├── migrations.log
├── .eslintrc.json
├── commitlint.config.js
├── public
├── demo.webp
└── md-poll-black.svg
├── src
├── app
│ ├── favicon.ico
│ ├── api
│ │ └── polls
│ │ │ ├── [id]
│ │ │ └── options
│ │ │ │ └── [index]
│ │ │ │ ├── img
│ │ │ │ ├── check.svg
│ │ │ │ └── route.tsx
│ │ │ │ └── vote
│ │ │ │ └── route.tsx
│ │ │ └── route.tsx
│ ├── polls
│ │ ├── create
│ │ │ ├── page.tsx
│ │ │ ├── form.module.css
│ │ │ └── form.tsx
│ │ ├── page.tsx
│ │ └── [id]
│ │ │ ├── vote
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── infra
│ ├── migrations
│ │ ├── 1697815297293_make-votes-unique-by-option-and-ip.js
│ │ ├── 1697815297291_add-ip-fields.js
│ │ ├── 1697815297292_add-poll-id-to-votes-table.js
│ │ ├── 1697484314643_create-poll-option-votes.js
│ │ ├── 1697815297293_remake-votes-unique-by-option-and-ip.js
│ │ ├── 1697484148976_create-polls-table.js
│ │ └── 1697484308165_create-poll-options.js
│ ├── webserver.tsx
│ ├── logger.js
│ ├── errors
│ │ └── index.tsx
│ └── database.js
├── ui
│ ├── copy-button.tsx
│ └── header.tsx
└── models
│ ├── poll.tsx
│ └── validator.js
├── .husky
└── commit-msg
├── next.config.js
├── postcss.config.js
├── .idea
├── .gitignore
├── codeStyles
│ └── codeStyleConfig.xml
├── vcs.xml
├── misc.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── modules.xml
└── markdown-poll.iml
├── .env.sample
├── environment
└── dev
│ └── docker-compose.yml
├── .gitignore
├── tailwind.config.ts
├── .github
└── workflows
│ └── commitlint.yml
├── tsconfig.json
├── .all-contributorsrc
├── LICENSE
├── README.md
└── package.json
/migrations.log:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {extends: ['@commitlint/config-conventional']}
2 |
--------------------------------------------------------------------------------
/public/demo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iget-master/markdown-poll/HEAD/public/demo.webp
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iget-master/markdown-poll/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx --no -- commitlint --edit ${1}
5 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | module.exports = nextConfig
5 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/src/app/api/polls/[id]/options/[index]/img/check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | POSTGRES_USER=local_user
2 | POSTGRES_PASSWORD=local_password
3 | POSTGRES_DB=markdown_poll
4 | POSTGRES_HOST=localhost
5 | POSTGRES_PORT=54320
6 | DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB
7 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/environment/dev/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2.4'
2 | services:
3 | postgres_dev:
4 | container_name: 'postgres-dev'
5 | image: 'postgres:14.1-alpine'
6 | env_file:
7 | - ../../.env
8 | ports:
9 | - '54320:5432'
10 | volumes:
11 | - postgres_data:/data/postgres
12 | restart: unless-stopped
13 | volumes:
14 | postgres_data:
15 |
--------------------------------------------------------------------------------
/src/app/polls/create/page.tsx:
--------------------------------------------------------------------------------
1 | import Form from "@/app/polls/create/form";
2 | import Header from "@/ui/header";
3 |
4 | export default function Page() {
5 | return (
6 | <>
7 |
8 |
9 |
Create your Poll
10 | Takes just one minute to create a poll!
11 |
12 |
13 | >
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/src/infra/migrations/1697815297293_make-votes-unique-by-option-and-ip.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | exports.shorthands = undefined;
4 |
5 | exports.up = pgm => {
6 | pgm.createIndex('poll_option_votes', ['poll_option_id', 'creator_ip'], {
7 | name: 'poll_option_votes_option_id_ip_unique_index',
8 | unique: true,
9 | where: 'creator_ip IS NOT NULL'
10 | });
11 | };
12 |
13 | exports.down = false
14 |
--------------------------------------------------------------------------------
/src/infra/migrations/1697815297291_add-ip-fields.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | exports.shorthands = undefined;
4 |
5 | exports.up = pgm => {
6 | pgm.addColumns('polls', {
7 | creator_ip: {
8 | type: 'inet',
9 | notNull: false,
10 | }
11 | });
12 |
13 | pgm.addColumns('poll_option_votes', {
14 | creator_ip: {
15 | type: 'inet',
16 | notNull: false,
17 | }
18 | });
19 | };
20 |
21 | exports.down = false
22 |
--------------------------------------------------------------------------------
/.idea/markdown-poll.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 | .env
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/src/infra/migrations/1697815297292_add-poll-id-to-votes-table.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | exports.shorthands = undefined;
4 |
5 | exports.up = pgm => {
6 | pgm.addColumn('poll_option_votes', {
7 | poll_id: {
8 | type: 'uuid',
9 | notNull: false,
10 | }
11 | });
12 |
13 | pgm.sql(`
14 | UPDATE poll_option_votes
15 | SET poll_id = poll_options.poll_id
16 | FROM poll_options
17 | WHERE poll_option_votes.poll_option_id = poll_options.id
18 | `)
19 | };
20 |
21 | exports.down = false
22 |
--------------------------------------------------------------------------------
/src/infra/migrations/1697484314643_create-poll-option-votes.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | exports.shorthands = undefined;
4 |
5 | exports.up = async pgm => {
6 | await pgm.createTable('poll_option_votes', {
7 | id: {
8 | type: 'uuid',
9 | default: pgm.func('gen_random_uuid()'),
10 | notNull: true,
11 | primaryKey: true,
12 | },
13 | poll_option_id: {
14 | type: 'uuid',
15 | notNull: true,
16 | }
17 | })
18 |
19 | await pgm.createIndex('poll_option_votes', ['poll_option_id']);
20 | };
21 |
22 | exports.down = false;
23 |
--------------------------------------------------------------------------------
/src/infra/migrations/1697815297293_remake-votes-unique-by-option-and-ip.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | exports.shorthands = undefined;
4 |
5 | exports.up = pgm => {
6 | // index was created using wrong column `poll_option_id` instead of `poll_id`
7 | pgm.dropIndex('poll_option_votes', [], {
8 | name:'poll_option_votes_option_id_ip_unique_index'
9 | });
10 |
11 | pgm.createIndex('poll_option_votes', ['poll_id', 'creator_ip'], {
12 | name: 'poll_option_votes_option_id_ip_unique_index',
13 | unique: true,
14 | where: 'creator_ip IS NOT NULL'
15 | });
16 | };
17 |
18 | exports.down = false
19 |
--------------------------------------------------------------------------------
/src/infra/webserver.tsx:
--------------------------------------------------------------------------------
1 | const isServerlessRuntime = !!process.env.NEXT_PUBLIC_VERCEL_ENV;
2 |
3 | const isBuildTime = !!process.env.CI;
4 |
5 | const isProduction = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production';
6 |
7 | const host = isProduction
8 | ? `https://${process.env.NEXT_PUBLIC_WEBSERVER_HOST}`
9 | : isServerlessRuntime
10 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
11 | : `http://${process.env.NEXT_PUBLIC_WEBSERVER_HOST}:${process.env.NEXT_PUBLIC_WEBSERVER_PORT}`;
12 |
13 | export default Object.freeze({
14 | host,
15 | isBuildTime,
16 | isProduction,
17 | isServerlessRuntime,
18 | });
19 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config: Config = {
4 | content: [
5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
8 | './src/ui/**/*.{js,ts,jsx,tsx,mdx}',
9 | ],
10 | theme: {
11 | extend: {
12 | backgroundImage: {
13 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
14 | 'gradient-conic':
15 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
16 | },
17 | },
18 | },
19 | plugins: [],
20 | }
21 | export default config
22 |
--------------------------------------------------------------------------------
/src/app/polls/create/form.module.css:
--------------------------------------------------------------------------------
1 | .label {
2 | @apply mt-3 block text-sm font-medium leading-6 text-slate-900 dark:text-white
3 | }
4 |
5 | .inputWrapper {
6 | @apply mt-1 flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600 sm:max-w-md dark:bg-slate-700
7 | }
8 |
9 | .inputWrapper > input {
10 | @apply block flex-1 border-0 bg-transparent py-1.5 pl-2 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 sm:text-sm sm:leading-6 focus:outline-0
11 | }
12 |
13 | .formBody {
14 | @apply mt-2 border-b dark:border-slate-700 border-slate-900/10 pb-12 grid grid-cols-1 gap-x-6
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/commitlint.yml:
--------------------------------------------------------------------------------
1 | name: Run Commitlint on Pull Request
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 |
8 | run-commitlint-on-pr:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 |
13 | - uses: actions/checkout@v2
14 | with:
15 | fetch-depth: 0
16 |
17 | - name: Setup Node
18 | uses: actions/setup-node@v2
19 | with:
20 | node-version: 16.x
21 |
22 | - name: Install dependencies
23 | run: npm install
24 |
25 | - name: Validate all commits from PR
26 | run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "files": [
3 | "README.md"
4 | ],
5 | "imageSize": 100,
6 | "commit": false,
7 | "commitType": "docs",
8 | "commitConvention": "angular",
9 | "contributors": [
10 | {
11 | "login": "SilvanoGPM",
12 | "name": "Silvano Marques",
13 | "avatar_url": "https://avatars.githubusercontent.com/u/59753526?v=4",
14 | "profile": "https://silvanomarques.vercel.app/",
15 | "contributions": [
16 | "ideas",
17 | "code"
18 | ]
19 | }
20 | ],
21 | "contributorsPerLine": 7,
22 | "skipCi": true,
23 | "repoType": "github",
24 | "repoHost": "https://github.com",
25 | "projectName": "markdown-poll",
26 | "projectOwner": "iget-master"
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | min-height: 100vh;
21 | color: rgb(var(--foreground-rgb));
22 | }
23 |
24 | a {
25 | @apply text-blue-800 dark:text-blue-400
26 | }
27 |
28 | h1 {
29 | @apply text-3xl text-slate-900 dark:text-white font-semibold
30 | }
31 |
32 | h2 {
33 | @apply text-xl text-slate-800 dark:text-gray-100
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/polls/page.tsx:
--------------------------------------------------------------------------------
1 | import {getStatistics} from "@/models/poll";
2 |
3 | export const dynamic = 'force-dynamic'
4 | export default async function Page() {
5 | const {
6 | pollsCount,
7 | optionsCount,
8 | votesCount,
9 | } = await getStatistics()
10 |
11 | return (<>
12 | Statistics
13 |
14 | - Polls created: {pollsCount}
15 | - Average options per poll: {optionsCount / pollsCount}
16 | - Averate votes per poll: {(votesCount / pollsCount).toPrecision(2)}
17 | - Total votes: {votesCount}
18 |
19 | >)
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/api/polls/route.tsx:
--------------------------------------------------------------------------------
1 | import {create} from "@/models/poll";
2 | import {ValidationError} from "@/infra/errors";
3 | import {headers} from "next/headers";
4 | export async function POST(request: Request) {
5 | const payload = await request.json();
6 | const creator_ip = headers().get('x-forwarded-for');
7 |
8 | try {
9 | return Response.json(await create(payload, creator_ip))
10 | } catch (error) {
11 | if (error instanceof ValidationError) {
12 | // @todo: parse validation error to create proper response
13 | return Response.json({
14 | name: 'ValidationError',
15 | message: error.message,
16 | }, {status: 400})
17 | } else {
18 | throw error;
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/ui/copy-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {ClipboardDocumentIcon} from "@heroicons/react/24/solid";
4 |
5 | export type CopyButtonProps = {
6 | text: string;
7 | }
8 | export default function CopyButton({ text }: CopyButtonProps) {
9 | const copy = async () => {
10 | await navigator.clipboard.writeText(text)
11 | }
12 |
13 | return ()
22 | }
23 |
--------------------------------------------------------------------------------
/src/infra/migrations/1697484148976_create-polls-table.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | exports.shorthands = undefined;
4 |
5 | exports.up = async pgm => {
6 | await pgm.createTable('polls', {
7 | id: {
8 | type: 'uuid',
9 | default: pgm.func('gen_random_uuid()'),
10 | notNull: true,
11 | primaryKey: true,
12 | },
13 |
14 | title: {
15 | type: 'varchar(255)',
16 | notNull: true,
17 | },
18 |
19 | created_at: {
20 | type: 'timestamp with time zone',
21 | notNull: true,
22 | default: pgm.func("(now() at time zone 'utc')"),
23 | },
24 |
25 | updated_at: {
26 | type: 'timestamp with time zone',
27 | notNull: true,
28 | default: pgm.func("(now() at time zone 'utc')"),
29 | },
30 | })
31 | };
32 |
33 | exports.down = async pgm => {
34 | await pgm.dropTable('polls');
35 | };
36 |
--------------------------------------------------------------------------------
/src/app/polls/[id]/vote/page.tsx:
--------------------------------------------------------------------------------
1 | import {computeVoteByOptionId, findOneById} from "@/models/poll";
2 | import {redirect} from "next/navigation";
3 | import {headers} from 'next/headers'
4 |
5 | type PollVotePageProps = {
6 | params: {
7 | id: string
8 | },
9 | searchParams: {
10 | option: string|undefined
11 | close: string|undefined
12 | }
13 | }
14 | export default async function Page({params: {id}, searchParams: {option: optionIndex, close: shouldClose}}: PollVotePageProps) {
15 | const poll = await findOneById(id);
16 | const ip = headers().get('x-forwarded-for');
17 | if (!optionIndex?.match(/^\d+$/)) {
18 | // @todo: throw a correct error
19 | throw 'Invalid option index';
20 | }
21 | const option = poll.options.find((option: any) => option.index === parseInt(optionIndex))
22 |
23 | if (!option) {
24 | return (<>Option not found.>)
25 | }
26 |
27 | const voted = await computeVoteByOptionId(option.id, ip);
28 |
29 | redirect(`/polls/${id}` + ((shouldClose !== undefined) ? '?close' : ''));
30 | }
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 We are IGET
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import { Inter } from 'next/font/google'
3 | import './globals.css'
4 | import { Analytics } from '@vercel/analytics/react';
5 |
6 | const inter = Inter({ subsets: ['latin'] })
7 |
8 | export const metadata: Metadata = {
9 | title: 'Markdown Poll',
10 | description: 'Embed polls in markdown in 1 minute!',
11 | }
12 |
13 | export default function RootLayout({
14 | children,
15 | }: {
16 | children: React.ReactNode
17 | }) {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | {/* We've used 3xl here, but feel free to try other max-widths based on your needs */}
25 |
{children}
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Markdown Poll
2 |
3 | [](#contributors-)
4 |
5 |
6 | ## Contributors
7 |
8 |
9 |
10 |
11 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/infra/migrations/1697484308165_create-poll-options.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 |
3 | exports.shorthands = undefined;
4 |
5 | exports.up = async pgm => {
6 | await pgm.createTable('poll_options', {
7 | id: {
8 | type: 'uuid',
9 | default: pgm.func('gen_random_uuid()'),
10 | notNull: true,
11 | primaryKey: true,
12 | unique: true,
13 | },
14 |
15 | poll_id: {
16 | type: 'uuid',
17 | notNull: true,
18 | },
19 |
20 | index: {
21 | type: 'integer',
22 | notNull: true,
23 | },
24 |
25 | title: {
26 | type: 'varchar(255)',
27 | notNull: true,
28 | },
29 |
30 | created_at: {
31 | type: 'timestamp with time zone',
32 | notNull: true,
33 | default: pgm.func("(now() at time zone 'utc')"),
34 | },
35 |
36 | updated_at: {
37 | type: 'timestamp with time zone',
38 | notNull: true,
39 | default: pgm.func("(now() at time zone 'utc')"),
40 | },
41 | });
42 |
43 | await pgm.createIndex('poll_options', ['poll_id']);
44 | };
45 |
46 | exports.down = false
47 |
--------------------------------------------------------------------------------
/src/infra/logger.js:
--------------------------------------------------------------------------------
1 | import pino from 'pino';
2 |
3 | function getLogger() {
4 | if (['preview', 'production'].includes(process.env.VERCEL_ENV)) {
5 | const pinoLogger = pino({
6 | base: {
7 | environment: process.env.VERCEL_ENV,
8 | },
9 | nestedKey: 'payload',
10 | redact: [
11 | 'headers.cookie',
12 | 'password',
13 | 'email',
14 | 'body.password',
15 | 'body.email',
16 | 'context.user.password',
17 | 'context.user.email',
18 | 'context.session.token',
19 | ],
20 | });
21 |
22 | return pinoLogger;
23 | }
24 |
25 | // TODO: reimplement this in a more
26 | // sofisticated way.
27 | const consoleLogger = {
28 | trace: console.trace,
29 | debug: console.debug,
30 | info: ignore,
31 | warn: console.warn,
32 | error: console.error,
33 | fatal: console.error,
34 | };
35 |
36 | if (process.env.LOG_LEVEL === 'info') {
37 | consoleLogger.info = console.log;
38 | }
39 |
40 | return consoleLogger;
41 | }
42 |
43 | function ignore() {}
44 |
45 | export default getLogger();
46 |
--------------------------------------------------------------------------------
/public/md-poll-black.svg:
--------------------------------------------------------------------------------
1 |
2 |
25 |
--------------------------------------------------------------------------------
/src/app/api/polls/[id]/options/[index]/vote/route.tsx:
--------------------------------------------------------------------------------
1 | import {createCanvas} from "canvas";
2 | import {NextRequest, NextResponse} from "next/server";
3 | import {computeVoteByOptionId, findOneById, PollOption} from '@/models/poll';
4 | import {cookies, headers} from "next/headers";
5 | import {redirect} from "next/navigation";
6 |
7 | type PollOptionImageProps = {
8 | params: {
9 | id: string;
10 | index: string;
11 | },
12 | }
13 | export async function GET(request: NextRequest, { params: {id, index} }: PollOptionImageProps) {
14 | const url = new URL(request.url);
15 | const shouldClose = url.searchParams.get('close')
16 | const poll = await findOneById(id);
17 | const ip = headers().get('x-forwarded-for');
18 |
19 | if (!index?.match(/^\d+$/)) {
20 | // @todo: throw a correct error
21 | throw 'Invalid option index';
22 | }
23 |
24 | const option = poll.options.find((option: any) => option.index === parseInt(index))
25 |
26 | if (!option) {
27 | throw 'Option not found';
28 | }
29 |
30 | const voted = await computeVoteByOptionId(option.id, ip);
31 |
32 | if (voted) {
33 | cookies().set(
34 | `poll-` + poll.id,
35 | option.index.toString(10),
36 | {
37 | sameSite: 'none',
38 | secure: true
39 | }
40 | );
41 | }
42 |
43 | redirect(`/polls/${id}` + ((shouldClose !== undefined) ? '?close' : ''));
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import { redirect } from 'next/navigation'
3 | import Header from "@/ui/header";
4 | import Link from "next/link";
5 |
6 | export default function Home() {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 | Add interactivity to your Markdown!
14 |
15 |
16 | Create Polls to interact with your audience in just one minute!
17 |
18 |
19 |
20 |
27 |
31 | Create your poll!
32 |
33 |
Takes only one minute to create your first poll
34 | * No signup required!
35 |
36 |
37 | >
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "markdown-poll",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "up": "docker compose -f environment/dev/docker-compose.yml up -d",
11 | "down": "docker compose -f environment/dev/docker-compose.yml down",
12 | "migration:create": "node-pg-migrate --migrations-dir src/infra/migrations create",
13 | "migration:run": "node-pg-migrate up --envPath ./.env -m src/infra/migrations/",
14 | "migration:dry-run": "node-pg-migrate up --dry-run --envPath ./.env -m src/infra/migrations",
15 | "migration:seed": "node -r dotenv-expand/config src/infra/scripts/seed-database.js",
16 | "vercel-build": "yum install gcc-c++ cairo-devel libjpeg-turbo-devel pango-devel giflib-devel -y"
17 | },
18 | "dependencies": {
19 | "@headlessui/react": "^1.7.17",
20 | "@heroicons/react": "^2.0.18",
21 | "@vercel/analytics": "^1.1.1",
22 | "async-retry": "^1.3.3",
23 | "canvas": "~2.8.0",
24 | "dotenv": "^16.3.1",
25 | "dotenv-expand": "^10.0.0",
26 | "heroicons": "^2.0.18",
27 | "joi": "^17.11.0",
28 | "jsdom": "19.0.0",
29 | "next": "13.5.5",
30 | "node-pg-migrate": "^6.2.2",
31 | "pg": "^8.11.3",
32 | "pino": "^8.16.0",
33 | "react": "^18",
34 | "react-dom": "^18",
35 | "snakeize": "^0.1.0",
36 | "uuid": "^9.0.1",
37 | "validate-color": "^2.2.4"
38 | },
39 | "devDependencies": {
40 | "@commitlint/cli": "^17.8.0",
41 | "@commitlint/config-conventional": "^17.8.0",
42 | "@types/node": "^20",
43 | "@types/react": "^18",
44 | "@types/react-dom": "^18",
45 | "@types/uuid": "^9.0.5",
46 | "autoprefixer": "^10",
47 | "eslint": "^8",
48 | "eslint-config-next": "13.5.5",
49 | "husky": "^8.0.3",
50 | "postcss": "^8",
51 | "tailwindcss": "^3",
52 | "typescript": "^5"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/app/polls/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { findOneById } from '@/models/poll';
2 | import Link from "next/link";
3 | import CopyButton from "@/ui/copy-button";
4 | import Header from "@/ui/header";
5 | import Image from "next/image";
6 | export const dynamic = 'force-dynamic'
7 |
8 | type PollPageProps = {
9 | params: {
10 | id: string
11 | }
12 | searchParams: { [key: string]: string | string[] | undefined },
13 | }
14 |
15 | const BASE_URL = process.env.BASE_URL;
16 | export default async function Page(props: PollPageProps) {
17 | const { params: {id}, searchParams } = props;
18 | const justCreated = searchParams.justcreated !== undefined;
19 | const close = searchParams.close !== undefined;
20 |
21 | const poll = await findOneById(id, true);
22 |
23 | const optionsList = poll.options.map(({index, title, votes}: any) => {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 | );
31 | })
32 |
33 | const markdownOptionsList = poll.options.map(({index, title, votes}: any) => {
34 | return `
35 |
36 | `
37 | }).join(`\n
\n`);
38 |
39 | const markdown = `${poll.title}
40 |
41 | ${markdownOptionsList}
42 |
43 | Click on the option you want to vote.
Poll created with md-poll`
44 |
45 | return (
46 |
47 |
Poll preview
48 |
Your poll will show like this on markdown:
49 |
50 | {poll.title}
51 |
54 | Click on the option you want to vote.
55 |
56 | Poll created with md-poll
57 |
58 |
59 | {/* if close flag was sent, we try to close the popup tab */}
60 | {close &&
61 |
62 | }
63 |
64 |
65 | Copy and paste this html into your markdown
66 |
67 |
68 |
69 |
70 |
71 | {markdown}
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/api/polls/[id]/options/[index]/img/route.tsx:
--------------------------------------------------------------------------------
1 | import {createCanvas, loadImage} from "canvas";
2 | import {NextRequest, NextResponse} from "next/server";
3 | import {findOneById, PollOption} from '@/models/poll';
4 | import {cookies} from "next/headers";
5 | import validateColor, { validateHTMLColorHex } from 'validate-color';
6 |
7 | type PollOptionImageProps = {
8 | params: {
9 | id: string;
10 | index: string;
11 | }
12 | }
13 |
14 | const DPI_SCALE = 2;
15 | const CHECK_ICON = "PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgZmlsbD0iIzRiZDM3YiIgcj0iMzAiLz48cGF0aCBkPSJtNDYgMTQtMjEgMjEuNi03LTcuMi03IDcuMiAxNCAxNC40IDI4LTI4Ljh6IiBmaWxsPSIjZmZmIi8+PC9zdmc+";
16 | const ICON_SIZE = 24 * DPI_SCALE;
17 | const ICON_MARGIN = 4;
18 | const WIDTH = 300 * DPI_SCALE;
19 | const HEIGHT = 34 * DPI_SCALE;
20 | const TEXT_MARGIN = 4 * DPI_SCALE;
21 | const MAX_TEXT_SIZE = (HEIGHT - (2 * TEXT_MARGIN)) * DPI_SCALE;
22 |
23 | const DEFAULT_THEME = {
24 | bg: '#e0f2fe',
25 | fg: '#0ea5e9',
26 | text: 'black',
27 | textSize: 16
28 | }
29 |
30 | function parseTheme(request: NextRequest) {
31 | const searchParams = request.nextUrl.searchParams
32 |
33 | const getColorParam = (name: string, fallback: string) => {
34 | let value = searchParams.get(name);
35 |
36 | const hexPattern = /^([0-9A-F]{3}){1,2}$/i;
37 | if (value?.match(hexPattern)) {
38 | value = `#${value}`;
39 | }
40 |
41 | return (value && validateColor(value)) ? value : fallback
42 | }
43 |
44 | const getSizeParam = (name: string, min = 0, max = Infinity, fallback: number) => {
45 | const value = searchParams.get(name);
46 |
47 | if (value?.match(/^\d+$/)) {
48 | return Math.min(Math.max(min, parseInt(value)), max);
49 | } else {
50 | return fallback;
51 | }
52 | }
53 |
54 | return {
55 | bg: getColorParam('bg', DEFAULT_THEME.bg),
56 | fg: getColorParam('fg', DEFAULT_THEME.fg),
57 | text: getColorParam('text', DEFAULT_THEME.text),
58 | textSize: getSizeParam('textSize', 8, MAX_TEXT_SIZE, DEFAULT_THEME.textSize)
59 | }
60 | }
61 |
62 | export async function GET(request: NextRequest, { params: {id, index} }: PollOptionImageProps) {
63 | const styles = parseTheme(request);
64 | const poll = await findOneById(id, true);
65 | const option = poll.options.find((option: any) => option.index === parseInt(index))
66 |
67 | if (!option) {
68 | return new NextResponse('Option not found.', {status: 404})
69 | }
70 |
71 | const votedOption = cookies().get(`poll-${id}`);
72 | const voted = votedOption && votedOption.value === index;
73 |
74 | const totalVotes = poll.options.reduce((acc: number, current: PollOption) => acc + (current.votes ?? 0), 0)
75 |
76 | const optionVotes = option.votes ?? 0;
77 | const percentage = optionVotes ? Math.round(100*(optionVotes / totalVotes)) : 0;
78 |
79 | const text = `${option.title} - ${percentage}%`;
80 |
81 | const canvas = createCanvas(WIDTH, HEIGHT);
82 | const ctx = canvas.getContext('2d');
83 |
84 | ctx.fillStyle = styles.bg
85 | ctx.fillRect(0, 0, WIDTH, HEIGHT);
86 |
87 | ctx.fillStyle = styles.fg;
88 | ctx.fillRect(0, 0, (optionVotes / totalVotes) * WIDTH, HEIGHT);
89 |
90 | ctx.fillStyle = styles.text;
91 | ctx.font = styles.textSize * 2 + 'px Helvetica';
92 | ctx.fillText(text, 8, ((HEIGHT + styles.textSize) / 2), 250 * 2);
93 |
94 | if (voted) {
95 | const image = await loadImage(`data:image/png;base64,${CHECK_ICON}`);
96 | ctx.drawImage(image, WIDTH - ICON_SIZE - ICON_MARGIN, (HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE);
97 | }
98 |
99 | const buffer = canvas.toBuffer('image/png');
100 |
101 | const response = new NextResponse(buffer, {})
102 | response.headers.set('content-type', 'image/png');
103 |
104 | return response;
105 | }
106 |
--------------------------------------------------------------------------------
/src/ui/header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react'
4 | import { Dialog } from '@headlessui/react'
5 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'
6 | import Image from 'next/image'
7 | import Link from "next/link";
8 |
9 | const navigation = [
10 | { name: 'Create your Poll', href: '/polls/create' },
11 | { name: 'GitHub', href: 'https://github.com/iget-master/markdown-poll' },
12 | ]
13 | export default function Header() {
14 | const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
15 |
16 | return (
17 |
102 | )
103 | }
104 |
--------------------------------------------------------------------------------
/src/models/poll.tsx:
--------------------------------------------------------------------------------
1 | import { NotFoundError, ValidationError } from '../infra/errors';
2 | import database from '@/infra/database.js';
3 | import validator from '@/models/validator.js';
4 |
5 | export type PollData = {
6 | title: string,
7 | options: string[]
8 | }
9 |
10 | export type PollOption = {
11 | id: string,
12 | poll_id: string,
13 | title: string,
14 | index: number,
15 | votes?: number,
16 | };
17 |
18 | export type Poll = {
19 | id: string,
20 | title: string,
21 | options: Array
22 | };
23 | function validatePostSchema(postedPollData: PollData) {
24 | return validator(postedPollData, {
25 | title: 'required',
26 | options: 'required',
27 | });
28 | }
29 | export async function create(postedPollData: PollData, creator_ip: string|null) {
30 | const validPollData = validatePostSchema(postedPollData);
31 |
32 | return await runInsertQuery(validPollData);
33 |
34 | async function runInsertQuery(validPollData: PollData) {
35 | const transaction = await database.transaction();
36 |
37 | try {
38 | await transaction.query('BEGIN');
39 |
40 | const poll = (await transaction.query({
41 | text: 'INSERT INTO polls (title, creator_ip) VALUES ($1, $2) RETURNING *;',
42 | values: [validPollData.title, creator_ip]
43 | })).rows[0];
44 |
45 | poll.options = [];
46 |
47 | for (let [index, title] of validPollData.options.entries()) {
48 | const option = (await transaction.query({
49 | text: 'INSERT INTO poll_options (poll_id, index, title) VALUES ($1, $2, $3) RETURNING poll_id, index, title;',
50 | values: [
51 | poll.id,
52 | index,
53 | title
54 | ]
55 | })).rows[0]
56 |
57 | poll.options.push(option);
58 | }
59 | await transaction.query('COMMIT');
60 | await transaction.release();
61 |
62 | return poll;
63 |
64 | } catch (error) {
65 | await transaction.query('ROLLBACK');
66 | await transaction.release();
67 | throw error;
68 | }
69 | }
70 | }
71 | export async function findOneById(id: string, computeVotes: boolean = false, options = {}): Promise {
72 | const pollResults = (await database.query({
73 | text: "SELECT * FROM polls WHERE id = $1 LIMIT 1",
74 | values: [id],
75 | }, options));
76 |
77 | if (pollResults.rowCount === 0) {
78 | throw new NotFoundError({
79 | message: `O "uuid" informado não foi encontrado no sistema.`,
80 | action: 'Verifique se o "uuid" está digitado corretamente.',
81 | stack: new Error().stack,
82 | errorLocationCode: 'MODEL:POLL:FIND_ONE_BY_UUID:NOT_FOUND',
83 | key: 'uuid',
84 | });
85 | }
86 |
87 | let optionsResults;
88 |
89 | if (computeVotes) {
90 | optionsResults = (await database.query({
91 | text: `SELECT poll_options.*, COUNT(poll_option_votes) as votes FROM poll_options
92 | LEFT JOIN poll_option_votes ON poll_options.id = poll_option_votes.poll_option_id
93 | WHERE poll_options.poll_id = $1
94 | GROUP BY poll_options.id
95 | ORDER BY index
96 | LIMIT 5`,
97 | values: [id],
98 | }, options));
99 | } else {
100 | optionsResults = (await database.query({
101 | text: "SELECT * FROM poll_options WHERE poll_id = $1 ORDER BY index LIMIT 5",
102 | values: [id],
103 | }, options));
104 | }
105 |
106 |
107 | return {
108 | ...pollResults.rows[0],
109 | options: optionsResults.rows.map((option: any) => ({
110 | ...option,
111 | votes: typeof option.votes === 'string' ? parseInt(option.votes) : option.votes
112 | }))
113 | }
114 | }
115 |
116 | export async function computeVoteByOptionId(id: string, creator_ip: string|null, options = {}) {
117 | const poll_id = (await database.query({
118 | text: "SELECT poll_id FROM poll_options WHERE id = $1 LIMIT 1",
119 | values: [id],
120 | }, options)).rows[0]?.poll_id;
121 |
122 | if (!poll_id) {
123 | throw new NotFoundError({
124 | message: `O "uuid" informado não foi encontrado no sistema.`,
125 | action: 'Verifique se o "uuid" está digitado corretamente.',
126 | stack: new Error().stack,
127 | errorLocationCode: 'MODEL:POLL:VOTE_BY_OPTION_ID:NOT_FOUND',
128 | key: 'uuid',
129 | });
130 | }
131 |
132 | try {
133 | console.log([id, poll_id, creator_ip]);
134 | await database.query({
135 | text: "INSERT INTO poll_option_votes (poll_option_id, poll_id, creator_ip) VALUES ($1, $2, $3)",
136 | values: [id, poll_id, creator_ip]
137 | })
138 | return true;
139 | } catch (error) {
140 | return false;
141 | }
142 | }
143 |
144 | export async function getStatistics() {
145 | const pollsCount = (await database.query({
146 | text: "SELECT count(*) as count FROM polls;"
147 | })).rows[0].count;
148 |
149 | const optionsCount = (await database.query({
150 | text: "SELECT count(*) as count FROM poll_options;"
151 | })).rows[0].count;
152 |
153 | const votesCount = (await database.query({
154 | text: "SELECT count(*) as count FROM poll_option_votes;"
155 | })).rows[0].count;
156 |
157 | return {
158 | pollsCount,
159 | optionsCount,
160 | votesCount,
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/src/infra/errors/index.tsx:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from 'uuid';
2 |
3 | class BaseError extends Error {
4 | private action: any;
5 | private statusCode: any;
6 | private errorId: any;
7 | private requestId: any;
8 | private context: any;
9 | private errorLocationCode: any;
10 | private key: any;
11 | private type: any;
12 | private databaseErrorCode: any;
13 | constructor({
14 | message,
15 | stack,
16 | action,
17 | statusCode,
18 | errorId,
19 | requestId,
20 | context,
21 | errorLocationCode,
22 | key,
23 | type,
24 | databaseErrorCode,
25 | }: any) {
26 | super();
27 | this.name = this.constructor.name;
28 | this.message = message;
29 | this.action = action;
30 | this.statusCode = statusCode || 500;
31 | this.errorId = errorId || uuid();
32 | this.requestId = requestId;
33 | this.context = context;
34 | this.stack = stack;
35 | this.errorLocationCode = errorLocationCode;
36 | this.key = key;
37 | this.type = type;
38 | this.databaseErrorCode = databaseErrorCode;
39 | }
40 | }
41 |
42 | export class InternalServerError extends BaseError {
43 | constructor({ message, action, requestId, errorId, statusCode, stack, errorLocationCode }: any) {
44 | super({
45 | message: message || 'Um erro interno não esperado aconteceu.',
46 | action: action || "Informe ao suporte o valor encontrado no campo 'error_id'.",
47 | statusCode: statusCode || 500,
48 | requestId: requestId,
49 | errorId: errorId,
50 | stack: stack,
51 | errorLocationCode: errorLocationCode,
52 | });
53 | }
54 | }
55 |
56 | export class NotFoundError extends BaseError {
57 | constructor({ message, action, requestId, errorId, stack, errorLocationCode, key }: any) {
58 | super({
59 | message: message || 'Não foi possível encontrar este recurso no sistema.',
60 | action: action || 'Verifique se o caminho (PATH) e o método (GET, POST, PUT, DELETE) estão corretos.',
61 | statusCode: 404,
62 | requestId: requestId,
63 | errorId: errorId,
64 | stack: stack,
65 | errorLocationCode: errorLocationCode,
66 | key: key,
67 | });
68 | }
69 | }
70 |
71 | export class ServiceError extends BaseError {
72 | constructor({ message, action, stack, context, statusCode, errorLocationCode, databaseErrorCode }: any) {
73 | super({
74 | message: message || 'Serviço indisponível no momento.',
75 | action: action || 'Verifique se o serviço está disponível.',
76 | stack: stack,
77 | statusCode: statusCode || 503,
78 | context: context,
79 | errorLocationCode: errorLocationCode,
80 | databaseErrorCode: databaseErrorCode,
81 | });
82 | }
83 | }
84 |
85 | export class ValidationError extends BaseError {
86 | constructor({ message, action, stack, statusCode, context, errorLocationCode, key, type }: any) {
87 | super({
88 | message: message || 'Um erro de validação ocorreu.',
89 | action: action || 'Ajuste os dados enviados e tente novamente.',
90 | statusCode: statusCode || 400,
91 | stack: stack,
92 | context: context,
93 | errorLocationCode: errorLocationCode,
94 | key: key,
95 | type: type,
96 | });
97 | }
98 | }
99 |
100 | export class UnauthorizedError extends BaseError {
101 | constructor({ message, action, requestId, stack, errorLocationCode }: any) {
102 | super({
103 | message: message || 'Usuário não autenticado.',
104 | action: action || 'Verifique se você está autenticado com uma sessão ativa e tente novamente.',
105 | requestId: requestId,
106 | statusCode: 401,
107 | stack: stack,
108 | errorLocationCode: errorLocationCode,
109 | });
110 | }
111 | }
112 |
113 | export class ForbiddenError extends BaseError {
114 | constructor({ message, action, requestId, stack, errorLocationCode }: any) {
115 | super({
116 | message: message || 'Você não possui permissão para executar esta ação.',
117 | action: action || 'Verifique se você possui permissão para executar esta ação.',
118 | requestId: requestId,
119 | statusCode: 403,
120 | stack: stack,
121 | errorLocationCode: errorLocationCode,
122 | });
123 | }
124 | }
125 |
126 | export class TooManyRequestsError extends BaseError {
127 | constructor({ message, action, context, stack, errorLocationCode }: any) {
128 | super({
129 | message: message || 'Você realizou muitas requisições recentemente.',
130 | action: action || 'Tente novamente mais tarde ou contate o suporte caso acredite que isso seja um erro.',
131 | statusCode: 429,
132 | context: context,
133 | stack: stack,
134 | errorLocationCode: errorLocationCode,
135 | });
136 | }
137 | }
138 |
139 | export class UnprocessableEntityError extends BaseError {
140 | constructor({ message, action, stack, errorLocationCode }: any) {
141 | super({
142 | message: message || 'Não foi possível realizar esta operação.',
143 | action: action || 'Os dados enviados estão corretos, porém não foi possível realizar esta operação.',
144 | statusCode: 422,
145 | stack: stack,
146 | errorLocationCode: errorLocationCode,
147 | });
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/infra/database.js:
--------------------------------------------------------------------------------
1 | import retry from 'async-retry';
2 | import { Client, Pool } from 'pg';
3 | import snakeize from 'snakeize';
4 |
5 | import { ServiceError } from '@/infra/errors';
6 | import logger from '@/infra/logger.js';
7 | import webserver from '@/infra/webserver.tsx';
8 |
9 | const configurations = {
10 | user: process.env.POSTGRES_USER,
11 | host: process.env.POSTGRES_HOST,
12 | database: process.env.POSTGRES_DB,
13 | password: process.env.POSTGRES_PASSWORD,
14 | port: process.env.POSTGRES_PORT,
15 | connectionTimeoutMillis: 2000,
16 | idleTimeoutMillis: 30000,
17 | max: 1,
18 | ssl: {
19 | rejectUnauthorized: false,
20 | },
21 | allowExitOnIdle: true,
22 | };
23 |
24 | if (!webserver.isServerlessRuntime) {
25 | configurations.max = 30;
26 |
27 | // https://github.com/filipedeschamps/tabnews.com.br/issues/84
28 | delete configurations.ssl;
29 |
30 | configurations.dialectOptions = {ssl: true}
31 | }
32 |
33 | const cache = {
34 | pool: null,
35 | maxConnections: null,
36 | reservedConnections: null,
37 | openedConnections: null,
38 | openedConnectionsLastUpdate: null,
39 | };
40 |
41 | async function query(query, options = {}) {
42 | let client;
43 |
44 | try {
45 | client = options.transaction ? options.transaction : await tryToGetNewClientFromPool();
46 | return await client.query(query);
47 | } catch (error) {
48 | throw parseQueryErrorAndLog(error, query);
49 | } finally {
50 | if (client && !options.transaction) {
51 | const tooManyConnections = await checkForTooManyConnections(client);
52 |
53 | client.release();
54 | if (tooManyConnections && webserver.isServerlessRuntime) {
55 | await cache.pool.end();
56 | cache.pool = null;
57 | }
58 | }
59 | }
60 | }
61 |
62 | async function tryToGetNewClientFromPool() {
63 | const clientFromPool = await retry(newClientFromPool, {
64 | retries: webserver.isBuildTime ? 12 : 1,
65 | minTimeout: 150,
66 | maxTimeout: 5000,
67 | factor: 2,
68 | });
69 |
70 | return clientFromPool;
71 |
72 | async function newClientFromPool() {
73 | if (!cache.pool) {
74 | cache.pool = new Pool(configurations);
75 | }
76 |
77 | return await cache.pool.connect();
78 | }
79 | }
80 |
81 | async function checkForTooManyConnections(client) {
82 | if (webserver.isBuildTime) return false;
83 |
84 | const currentTime = new Date().getTime();
85 | const openedConnectionsMaxAge = 5000;
86 | const maxConnectionsTolerance = 0.8;
87 |
88 | if (cache.maxConnections === null || cache.reservedConnections === null) {
89 | const [maxConnections, reservedConnections] = await getConnectionLimits();
90 | cache.maxConnections = maxConnections;
91 | cache.reservedConnections = reservedConnections;
92 | }
93 |
94 | if (cache.openedConnections === null || currentTime - cache.openedConnectionsLastUpdate > openedConnectionsMaxAge) {
95 | const openedConnections = await getOpenedConnections();
96 | cache.openedConnections = openedConnections;
97 | cache.openedConnectionsLastUpdate = currentTime;
98 | }
99 |
100 | if (cache.openedConnections > (cache.maxConnections - cache.reservedConnections) * maxConnectionsTolerance) {
101 | return true;
102 | }
103 |
104 | return false;
105 |
106 | async function getConnectionLimits() {
107 | const [maxConnectionsResult, reservedConnectionResult] = await client.query(
108 | 'SHOW max_connections; SHOW superuser_reserved_connections;'
109 | );
110 | return [
111 | maxConnectionsResult.rows[0].max_connections,
112 | reservedConnectionResult.rows[0].superuser_reserved_connections,
113 | ];
114 | }
115 |
116 | async function getOpenedConnections() {
117 | const openConnectionsResult = await client.query({
118 | text: 'SELECT numbackends as opened_connections FROM pg_stat_database where datname = $1',
119 | values: [process.env.POSTGRES_DB],
120 | });
121 | return openConnectionsResult.rows[0].opened_connections;
122 | }
123 | }
124 |
125 | async function getNewClient() {
126 | try {
127 | const client = await tryToGetNewClient();
128 | return client;
129 | } catch (error) {
130 | const errorObject = new ServiceError({
131 | message: error.message,
132 | errorLocationCode: 'INFRA:DATABASE:GET_NEW_CONNECTED_CLIENT',
133 | stack: new Error().stack,
134 | });
135 | logger.error(snakeize(errorObject));
136 | throw errorObject;
137 | }
138 | }
139 |
140 | async function tryToGetNewClient() {
141 | const client = await retry(newClient, {
142 | retries: 50,
143 | minTimeout: 0,
144 | factor: 2,
145 | });
146 |
147 | return client;
148 |
149 | // You need to close the client when you are done with it
150 | // using the client.end() method.
151 | async function newClient() {
152 | const client = new Client(configurations);
153 | await client.connect();
154 | return client;
155 | }
156 | }
157 |
158 | const UNIQUE_CONSTRAINT_VIOLATION = '23505';
159 | const SERIALIZATION_FAILURE = '40001';
160 | const UNDEFINED_FUNCTION = '42883';
161 |
162 | function parseQueryErrorAndLog(error, query) {
163 | const expectedErrorsCode = [UNIQUE_CONSTRAINT_VIOLATION, SERIALIZATION_FAILURE];
164 |
165 | if (!webserver.isServerlessRuntime) {
166 | expectedErrorsCode.push(UNDEFINED_FUNCTION);
167 | }
168 |
169 | const errorToReturn = new ServiceError({
170 | message: error.message,
171 | context: {
172 | query: query.text,
173 | },
174 | errorLocationCode: 'INFRA:DATABASE:QUERY',
175 | databaseErrorCode: error.code,
176 | });
177 |
178 | if (!expectedErrorsCode.includes(error.code)) {
179 | logger.error(snakeize(errorToReturn));
180 | }
181 |
182 | return errorToReturn;
183 | }
184 |
185 | async function transaction() {
186 | return await tryToGetNewClientFromPool();
187 | }
188 |
189 | export default Object.freeze({
190 | query,
191 | getNewClient,
192 | transaction,
193 | errorCodes: {
194 | UNIQUE_CONSTRAINT_VIOLATION,
195 | SERIALIZATION_FAILURE,
196 | UNDEFINED_FUNCTION,
197 | },
198 | });
199 |
--------------------------------------------------------------------------------
/src/app/polls/create/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {ChangeEvent, FormEvent, useCallback, useMemo, useState} from "react";
4 | import {useRouter} from "next/navigation";
5 | import styles from "./form.module.css"
6 | import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline'
7 |
8 | const validateString = ((value: string) => (value.length >= 1) && (value.length <= 255));
9 | export default function Form() {
10 | const [loading, setLoading] = useState(false);
11 | const [value, setValue] = useState({
12 | title: '',
13 | options: ['', '']
14 | });
15 |
16 | const router = useRouter();
17 |
18 | const valid = useMemo(() => {
19 | return validateString(value.title) && value.options.every(validateString)
20 | }, [value]);
21 |
22 | const handleChange = useCallback((event: ChangeEvent) => {
23 | const name = event.target.name;
24 | const value = event.target.value;
25 |
26 | if (name.startsWith('option-')) {
27 | setValue((prev) => {
28 | const index = parseInt(name.split('-')[1]);
29 | const options = [...prev.options];
30 | options[index] = value;
31 |
32 | return {
33 | title: prev.title,
34 | options: options
35 | }
36 | })
37 | } else if (name === 'title') {
38 | setValue((prev) => {
39 | return {
40 | ...prev,
41 | title: value,
42 | }
43 | })
44 | }
45 | }, [])
46 |
47 | const submitHandler = useCallback(async (event: FormEvent) => {
48 | event.preventDefault();
49 |
50 | if (loading) {
51 | return;
52 | }
53 |
54 | setLoading(true);
55 | const response = await fetch('/api/polls', {
56 | method: 'POST',
57 | body: JSON.stringify(value),
58 | headers: {
59 | Accept: 'application/json',
60 | 'Content-Type': 'application/json',
61 | },
62 | });
63 |
64 | const poll = await response.json();
65 |
66 | router.push(`/polls/${poll.id}?justcreated`);
67 | }, [loading, router, value])
68 |
69 | const addOption = () => {
70 | setValue((prev) => {
71 | return {
72 | ...prev,
73 | options: [...prev.options, '']
74 | }
75 | });
76 | }
77 |
78 | const removeOption = (index: number) => {
79 | setValue((prev) => {
80 | const options = [...prev.options]
81 | options.splice(index, 1);
82 | return {...prev, options}
83 | })
84 | }
85 |
86 | const optionsList = useMemo(() => value.options.map((option, index) => {
87 | return (
88 |
89 | {index + 1}.
90 |
98 | {(index > 1) &&
99 |
107 | }
108 |
109 | )
110 | }), [value])
111 |
112 |
113 | return (
114 |
167 | )
168 | }
169 |
--------------------------------------------------------------------------------
/src/models/validator.js:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 |
3 | import { ValidationError } from 'src/infra/errors';
4 | import webserver from '@/infra/webserver';
5 |
6 | export default function validator(object, keys) {
7 | // Force the cleanup of "undefined" values since JSON
8 | // doesn't support them and Joi doesn't clean
9 | // them up. Also handles the case where the
10 | // "object" is not a valid JSON.
11 | try {
12 | object = JSON.parse(JSON.stringify(object));
13 | } catch (error) {
14 | throw new ValidationError({
15 | message: 'Não foi possível interpretar o valor enviado.',
16 | action: 'Verifique se o valor enviado é um JSON válido.',
17 | errorLocationCode: 'MODEL:VALIDATOR:ERROR_PARSING_JSON',
18 | stack: new Error().stack,
19 | key: 'object',
20 | });
21 | }
22 |
23 | let finalSchema = Joi.object().required().min(1).messages({
24 | 'object.base': `Body enviado deve ser do tipo Object.`,
25 | 'object.min': `Objeto enviado deve ter no mínimo uma chave.`,
26 | });
27 |
28 | for (const key of Object.keys(keys)) {
29 | const keyValidationFunction = schemas[key];
30 | finalSchema = finalSchema.concat(keyValidationFunction());
31 | }
32 |
33 | const { error, value } = finalSchema.validate(object, {
34 | escapeHtml: true,
35 | stripUnknown: true,
36 | context: {
37 | required: keys,
38 | },
39 | });
40 |
41 | if (error) {
42 | throw new ValidationError({
43 | message: error.details[0].message,
44 | key: error.details[0].context.key || error.details[0].context.type || 'object',
45 | errorLocationCode: 'MODEL:VALIDATOR:FINAL_SCHEMA',
46 | stack: new Error().stack,
47 | type: error.details[0].type,
48 | });
49 | }
50 |
51 | return value;
52 | }
53 |
54 | const schemas = {
55 | options: function () {
56 | return Joi.object({
57 | options: Joi.array()
58 | .required()
59 | .min(2)
60 | .max(5)
61 | .unique()
62 | .items(
63 | Joi
64 | .string()
65 | .max(255)
66 | )
67 | })
68 | },
69 | // - Tabnews schemas, remove unecessary
70 | // |
71 | // \ /
72 | id: function () {
73 | return Joi.object({
74 | id: Joi.string()
75 | .allow(null)
76 | .trim()
77 | .guid({ version: 'uuidv4' })
78 | .when('$required.id', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
79 | .messages({
80 | 'any.required': `"id" é um campo obrigatório.`,
81 | 'string.empty': `"id" não pode estar em branco.`,
82 | 'string.base': `"id" deve ser do tipo String.`,
83 | 'string.guid': `"id" deve possuir um token UUID na versão 4.`,
84 | }),
85 | });
86 | },
87 |
88 | username: function () {
89 | return Joi.object({
90 | username: Joi.string()
91 | .alphanum()
92 | .min(3)
93 | .max(30)
94 | .trim()
95 | .invalid(null)
96 | .custom(checkReservedUsernames, 'check if username is reserved')
97 | .when('$required.username', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
98 | .messages({
99 | 'any.required': `"username" é um campo obrigatório.`,
100 | 'string.empty': `"username" não pode estar em branco.`,
101 | 'string.base': `"username" deve ser do tipo String.`,
102 | 'string.alphanum': `"username" deve conter apenas caracteres alfanuméricos.`,
103 | 'string.min': `"username" deve conter no mínimo {#limit} caracteres.`,
104 | 'string.max': `"username" deve conter no máximo {#limit} caracteres.`,
105 | 'any.invalid': `"username" possui o valor inválido "null".`,
106 | 'username.reserved': `Este nome de usuário não está disponível para uso.`,
107 | }),
108 | });
109 | },
110 |
111 | owner_username: function () {
112 | return Joi.object({
113 | owner_username: Joi.string()
114 | .alphanum()
115 | .min(3)
116 | .max(30)
117 | .trim()
118 | .invalid(null)
119 | .custom(checkReservedUsernames, 'check if username is reserved')
120 | .when('$required.owner_username', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
121 | .messages({
122 | 'any.required': `"owner_username" é um campo obrigatório.`,
123 | 'string.empty': `"owner_username" não pode estar em branco.`,
124 | 'string.base': `"owner_username" deve ser do tipo String.`,
125 | 'string.alphanum': `"owner_username" deve conter apenas caracteres alfanuméricos.`,
126 | 'string.min': `"owner_username" deve conter no mínimo {#limit} caracteres.`,
127 | 'string.max': `"owner_username" deve conter no máximo {#limit} caracteres.`,
128 | 'any.invalid': `"owner_username" possui o valor inválido "null".`,
129 | 'username.reserved': `Este nome de usuário não está disponível para uso.`,
130 | }),
131 | });
132 | },
133 |
134 | email: function () {
135 | return Joi.object({
136 | email: Joi.string()
137 | .email()
138 | .min(7)
139 | .max(254)
140 | .lowercase()
141 | .trim()
142 | .invalid(null)
143 | .when('$required.email', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
144 | .messages({
145 | 'any.required': `"email" é um campo obrigatório.`,
146 | 'string.empty': `"email" não pode estar em branco.`,
147 | 'string.base': `"email" deve ser do tipo String.`,
148 | 'string.email': `"email" deve conter um email válido.`,
149 | 'any.invalid': `"email" possui o valor inválido "null".`,
150 | }),
151 | });
152 | },
153 |
154 | password: function () {
155 | return Joi.object({
156 | // Why 72 in max length? https://security.stackexchange.com/a/39851
157 | password: Joi.string()
158 | .min(8)
159 | .max(72)
160 | .trim()
161 | .invalid(null)
162 | .when('$required.password', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
163 | .messages({
164 | 'any.required': `"password" é um campo obrigatório.`,
165 | 'string.empty': `"password" não pode estar em branco.`,
166 | 'string.base': `"password" deve ser do tipo String.`,
167 | 'string.min': `"password" deve conter no mínimo {#limit} caracteres.`,
168 | 'string.max': `"password" deve conter no máximo {#limit} caracteres.`,
169 | 'any.invalid': `"password" possui o valor inválido "null".`,
170 | }),
171 | });
172 | },
173 |
174 | description: function () {
175 | return Joi.object({
176 | description: Joi.string()
177 | .replace(/(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0)+$|\u0000/gsu, '')
178 | .max(5000)
179 | .invalid(null)
180 | .allow('')
181 | .when('$required.description', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
182 | .messages({
183 | 'any.required': `"description" é um campo obrigatório.`,
184 | 'string.base': `"description" deve ser do tipo String.`,
185 | 'string.max': `"description" deve conter no máximo {#limit} caracteres.`,
186 | 'any.invalid': `"description" possui o valor inválido "null".`,
187 | }),
188 | });
189 | },
190 |
191 | notifications: function () {
192 | return Joi.object({
193 | notifications: Joi.boolean()
194 | .invalid(null)
195 | .when('$required.notifications', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
196 | .messages({
197 | 'any.required': `"notifications" é um campo obrigatório.`,
198 | 'string.empty': `"notifications" não pode estar em branco.`,
199 | 'boolean.base': `"notifications" deve ser do tipo Boolean.`,
200 | 'any.invalid': `"notifications" possui o valor inválido "null".`,
201 | }),
202 | });
203 | },
204 |
205 | token_id: function () {
206 | return Joi.object({
207 | token_id: Joi.string()
208 | .trim()
209 | .guid({ version: 'uuidv4' })
210 | .when('$required.token_id', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
211 | .messages({
212 | 'any.required': `"token_id" é um campo obrigatório.`,
213 | 'string.empty': `"token_id" não pode estar em branco.`,
214 | 'string.base': `"token_id" deve ser do tipo String.`,
215 | 'string.guid': `"token_id" deve possuir um token UUID na versão 4.`,
216 | }),
217 | });
218 | },
219 |
220 | session_id: function () {
221 | return Joi.object({
222 | session_id: Joi.string()
223 | .length(96)
224 | .alphanum()
225 | .when('$required.session_id', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
226 | .messages({
227 | 'any.required': `"session_id" é um campo obrigatório.`,
228 | 'string.empty': `"session_id" não pode estar em branco.`,
229 | 'string.base': `"session_id" deve ser do tipo String.`,
230 | 'string.length': `"session_id" deve possuir {#limit} caracteres.`,
231 | 'string.alphanum': `"session_id" deve conter apenas caracteres alfanuméricos.`,
232 | }),
233 | });
234 | },
235 |
236 | parent_id: function () {
237 | return Joi.object({
238 | parent_id: Joi.string()
239 | .allow(null)
240 | .trim()
241 | .guid({ version: 'uuidv4' })
242 | .when('$required.parent_id', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
243 | .messages({
244 | 'any.required': `"parent_id" é um campo obrigatório.`,
245 | 'string.empty': `"parent_id" não pode estar em branco.`,
246 | 'string.base': `"parent_id" deve ser do tipo String.`,
247 | 'string.guid': `"parent_id" deve possuir um token UUID na versão 4.`,
248 | }),
249 | });
250 | },
251 |
252 | slug: function () {
253 | return Joi.object({
254 | slug: Joi.string()
255 | .min(1)
256 | .max(255, 'utf8')
257 | .trim()
258 | .truncate()
259 | .invalid(null)
260 | .pattern(/^[a-z0-9](-?[a-z0-9])*$/)
261 | .when('$required.slug', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
262 | .messages({
263 | 'any.required': `"slug" é um campo obrigatório.`,
264 | 'string.empty': `"slug" não pode estar em branco.`,
265 | 'string.base': `"slug" deve ser do tipo String.`,
266 | 'string.min': `"slug" deve conter no mínimo {#limit} caractere.`,
267 | 'string.pattern.base': `"slug" está no formato errado.`,
268 | 'any.invalid': `"slug" possui o valor inválido "null".`,
269 | }),
270 | });
271 | },
272 |
273 | title: function () {
274 | return Joi.object({
275 | title: Joi.string()
276 | .replace(
277 | /^(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0)+|(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0)+$|\u0000/gu,
278 | ''
279 | )
280 | .allow(null)
281 | .min(1)
282 | .max(255)
283 | .when('$required.title', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
284 | .messages({
285 | 'any.required': `"title" é um campo obrigatório.`,
286 | 'string.empty': `"title" não pode estar em branco.`,
287 | 'string.base': `"title" deve ser do tipo String.`,
288 | 'string.min': `"title" deve conter no mínimo {#limit} caracteres.`,
289 | 'string.max': `"title" deve conter no máximo {#limit} caracteres.`,
290 | }),
291 | });
292 | },
293 |
294 | body: function () {
295 | return Joi.object({
296 | body: Joi.string()
297 | .pattern(/^(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0).*$/su, { invert: true })
298 | .replace(/(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0)+$|\u0000/gsu, '')
299 | .min(1)
300 | .max(20000)
301 | .invalid(null)
302 | .custom(withoutMarkdown, 'check if is empty without markdown')
303 | .when('$required.body', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
304 | .messages({
305 | 'any.required': `"body" é um campo obrigatório.`,
306 | 'string.empty': `"body" não pode estar em branco.`,
307 | 'string.base': `"body" deve ser do tipo String.`,
308 | 'string.min': `"body" deve conter no mínimo {#limit} caracteres.`,
309 | 'string.max': `"body" deve conter no máximo {#limit} caracteres.`,
310 | 'any.invalid': `"body" possui o valor inválido "null".`,
311 | 'string.pattern.invert.base': `"body" deve começar com caracteres visíveis.`,
312 | 'markdown.empty': `Markdown deve conter algum texto`,
313 | }),
314 | });
315 | },
316 |
317 | status: function () {
318 | return Joi.object({
319 | status: Joi.string()
320 | .trim()
321 | .valid('draft', 'published', 'deleted')
322 | .invalid(null)
323 | .when('$required.status', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
324 | .messages({
325 | 'any.required': `"status" é um campo obrigatório.`,
326 | 'string.empty': `"status" não pode estar em branco.`,
327 | 'string.base': `"status" deve ser do tipo String.`,
328 | 'string.min': `"status" deve conter no mínimo {#limit} caracteres.`,
329 | 'string.max': `"status" deve conter no máximo {#limit} caracteres.`,
330 | 'any.invalid': `"status" possui o valor inválido "null".`,
331 | 'any.only': `"status" deve possuir um dos seguintes valores: "draft", "published" ou "deleted".`,
332 | }),
333 | });
334 | },
335 |
336 | source_url: function () {
337 | return Joi.object({
338 | source_url: Joi.string()
339 | .allow(null)
340 | .replace(/\u0000/g, '')
341 | .trim()
342 | .max(2000)
343 | .pattern(/^https?:\/\/([-\p{Ll}\d_]{1,255}\.)+[-a-z0-9]{2,24}(:[0-9]{1,5})?([\/?#]\S*)?$/u)
344 | .when('$required.source_url', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
345 | .messages({
346 | 'any.required': `"source_url" é um campo obrigatório.`,
347 | 'string.empty': `"source_url" não pode estar em branco.`,
348 | 'string.base': `"source_url" deve ser do tipo String.`,
349 | 'string.max': `"source_url" deve conter no máximo {#limit} caracteres.`,
350 | 'any.invalid': `"source_url" possui o valor inválido "null".`,
351 | 'string.pattern.base': `"source_url" deve possuir uma URL válida e utilizando os protocolos HTTP ou HTTPS.`,
352 | }),
353 | });
354 | },
355 |
356 | owner_id: function () {
357 | return Joi.object({
358 | owner_id: Joi.string()
359 | .allow(null)
360 | .trim()
361 | .guid({ version: 'uuidv4' })
362 | .when('$required.owner_id', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
363 | .messages({
364 | 'any.required': `"owner_id" é um campo obrigatório.`,
365 | 'string.empty': `"owner_id" não pode estar em branco.`,
366 | 'string.base': `"owner_id" deve ser do tipo String.`,
367 | 'string.guid': `"owner_id" deve possuir um token UUID na versão 4.`,
368 | }),
369 | });
370 | },
371 |
372 | created_at: function () {
373 | return Joi.object({
374 | created_at: Joi.date()
375 | .when('$required.created_at', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
376 | .messages({
377 | 'any.required': `"created_at" é um campo obrigatório.`,
378 | 'string.empty': `"created_at" não pode estar em branco.`,
379 | 'string.base': `"created_at" deve ser do tipo Date.`,
380 | }),
381 | });
382 | },
383 |
384 | updated_at: function () {
385 | return Joi.object({
386 | updated_at: Joi.date()
387 | .when('$required.updated_at', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
388 | .messages({
389 | 'any.required': `"updated_at" é um campo obrigatório.`,
390 | 'string.empty': `"updated_at" não pode estar em branco.`,
391 | 'string.base': `"updated_at" deve ser do tipo Date.`,
392 | }),
393 | });
394 | },
395 |
396 | published_at: function () {
397 | return Joi.object({
398 | published_at: Joi.date()
399 | .allow(null)
400 | .when('$required.published_at', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
401 | .messages({
402 | 'any.required': `"published_at" é um campo obrigatório.`,
403 | 'string.empty': `"published_at" não pode estar em branco.`,
404 | 'string.base': `"published_at" deve ser do tipo Date.`,
405 | }),
406 | });
407 | },
408 |
409 | deleted_at: function () {
410 | return Joi.object({
411 | deleted_at: Joi.date()
412 | .allow(null)
413 | .when('$required.deleted_at', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
414 | .messages({
415 | 'any.required': `"deleted_at" é um campo obrigatório.`,
416 | 'string.empty': `"deleted_at" não pode estar em branco.`,
417 | 'string.base': `"deleted_at" deve ser do tipo Date.`,
418 | }),
419 | });
420 | },
421 |
422 | expires_at: function () {
423 | return Joi.object({
424 | expires_at: Joi.date()
425 | .allow(null)
426 | .when('$required.expires_at', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
427 | .messages({
428 | 'any.required': `"expires_at" é um campo obrigatório.`,
429 | 'string.empty': `"expires_at" não pode estar em branco.`,
430 | 'string.base': `"expires_at" deve ser do tipo Date.`,
431 | }),
432 | });
433 | },
434 |
435 | used: function () {
436 | return Joi.object({
437 | used: Joi.boolean()
438 | .allow(false)
439 | .when('$required.used', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
440 | .messages({
441 | 'any.required': `"used" é um campo obrigatório.`,
442 | 'string.empty': `"used" não pode estar em branco.`,
443 | 'boolean.base': `"used" deve ser do tipo Boolean.`,
444 | }),
445 | });
446 | },
447 |
448 | page: function () {
449 | return Joi.object({
450 | page: Joi.number()
451 | .integer()
452 | .min(1)
453 | .max(9007199254740990)
454 | .default(1)
455 | .when('$required.page', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
456 | .messages({
457 | 'any.required': `"page" é um campo obrigatório.`,
458 | 'string.empty': `"page" não pode estar em branco.`,
459 | 'number.base': `"page" deve ser do tipo Number.`,
460 | 'number.integer': `"page" deve ser um Inteiro.`,
461 | 'number.min': `"page" deve possuir um valor mínimo de 1.`,
462 | 'number.max': `"page" deve possuir um valor máximo de 9007199254740990.`,
463 | 'number.unsafe': `"page" deve possuir um valor máximo de 9007199254740990.`,
464 | }),
465 | });
466 | },
467 |
468 | per_page: function () {
469 | return Joi.object({
470 | per_page: Joi.number()
471 | .integer()
472 | .min(1)
473 | .max(100)
474 | .default(30)
475 | .when('$required.per_page', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
476 | .messages({
477 | 'any.required': `"per_page" é um campo obrigatório.`,
478 | 'string.empty': `"per_page" não pode estar em branco.`,
479 | 'number.base': `"per_page" deve ser do tipo Number.`,
480 | 'number.integer': `"per_page" deve ser um Inteiro.`,
481 | 'number.min': `"per_page" deve possuir um valor mínimo de 1.`,
482 | 'number.max': `"per_page" deve possuir um valor máximo de 100.`,
483 | 'number.unsafe': `"per_page" deve possuir um valor máximo de 100.`,
484 | }),
485 | });
486 | },
487 |
488 | strategy: function () {
489 | return Joi.object({
490 | strategy: Joi.string()
491 | .trim()
492 | .valid('new', 'old', 'relevant')
493 | .default('relevant')
494 | .invalid(null)
495 | .when('$required.strategy', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
496 | .messages({
497 | 'any.required': `"strategy" é um campo obrigatório.`,
498 | 'string.empty': `"strategy" não pode estar em branco.`,
499 | 'string.base': `"strategy" deve ser do tipo String.`,
500 | 'any.invalid': `"strategy" possui o valor inválido "null".`,
501 | 'any.only': `"strategy" deve possuir um dos seguintes valores: "new", "old" ou "relevant".`,
502 | }),
503 | });
504 | },
505 |
506 | // TODO: refactor this in the future for
507 | // an Array just like Sequelize.
508 | order: function () {
509 | return Joi.object({
510 | order: Joi.string()
511 | .trim()
512 | .valid('created_at DESC', 'created_at ASC', 'published_at DESC', 'published_at ASC')
513 | .when('$required.order', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
514 | .messages({
515 | 'any.required': `"order" é um campo obrigatório.`,
516 | 'string.empty': `"order" não pode estar em branco.`,
517 | 'string.base': `"order" deve ser do tipo String.`,
518 | 'any.only': `"order" deve possuir um dos seguintes valores: "created_at DESC", "created_at ASC", "published_at DESC" ou "published_at ASC".`,
519 | }),
520 | });
521 | },
522 |
523 | where: function () {
524 | let whereSchema = Joi.object({}).optional().min(1).messages({
525 | 'object.base': `"where" deve ser do tipo Object.`,
526 | });
527 |
528 | for (const key of [
529 | 'id',
530 | 'parent_id',
531 | 'slug',
532 | 'title',
533 | 'body',
534 | 'status',
535 | 'source_url',
536 | 'owner_id',
537 | 'username',
538 | 'owner_username',
539 | '$or',
540 | 'attributes',
541 | ]) {
542 | const keyValidationFunction = schemas[key];
543 | whereSchema = whereSchema.concat(keyValidationFunction());
544 | }
545 |
546 | return Joi.object({
547 | where: whereSchema,
548 | });
549 | },
550 |
551 | limit: function () {
552 | return Joi.object({
553 | limit: Joi.number()
554 | .integer()
555 | .min(1)
556 | .max(9007199254740990)
557 | .default(null)
558 | .when('$required.limit', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
559 | .messages({
560 | 'any.required': `"limit" é um campo obrigatório.`,
561 | 'string.empty': `"limit" não pode estar em branco.`,
562 | 'number.base': `"limit" deve ser do tipo Number.`,
563 | 'number.integer': `"limit" deve ser um Inteiro.`,
564 | 'number.min': `"limit" deve possuir um valor mínimo de 1.`,
565 | 'number.max': `"limit" deve possuir um valor máximo de 9007199254740990.`,
566 | 'number.unsafe': `"limit" deve possuir um valor máximo de 9007199254740990.`,
567 | }),
568 | });
569 | },
570 |
571 | $or: function () {
572 | const statusSchemaWithId = schemas.status().id('status');
573 |
574 | return Joi.object({
575 | $or: Joi.array()
576 | .optional()
577 | .items(Joi.link('#status'))
578 | .messages({
579 | 'array.base': `"#or" deve ser do tipo Array.`,
580 | })
581 | .shared(statusSchemaWithId),
582 | });
583 | },
584 |
585 | attributes: function () {
586 | return Joi.object({
587 | attributes: Joi.object({
588 | exclude: Joi.array().items(Joi.string().valid('body')),
589 | }),
590 | });
591 | },
592 |
593 | count: function () {
594 | return Joi.object({
595 | count: Joi.boolean()
596 | .default(false)
597 | .when('$required.count', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
598 | .messages({
599 | 'any.required': `"count" é um campo obrigatório.`,
600 | 'string.empty': `"count" não pode estar em branco.`,
601 | 'boolean.base': `"count" deve ser do tipo Boolean.`,
602 | }),
603 | });
604 | },
605 |
606 | children_deep_count: function () {
607 | return Joi.object({
608 | children_deep_count: Joi.number()
609 | .when('$required.children_deep_count', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
610 | .messages({
611 | 'any.required': `"children_deep_count" é um campo obrigatório.`,
612 | 'number.integer': `"children_deep_count" deve ser um Inteiro.`,
613 | }),
614 | });
615 | },
616 |
617 | content: function () {
618 | let contentSchema = Joi.object({
619 | children: Joi.array().optional().items(Joi.link('#content')).messages({
620 | 'array.base': `"children" deve ser do tipo Array.`,
621 | }),
622 | })
623 | .required()
624 | .min(1)
625 | .messages({
626 | 'object.base': `Body deve ser do tipo Object.`,
627 | })
628 | .id('content');
629 |
630 | for (const key of [
631 | 'id',
632 | 'owner_id',
633 | 'parent_id',
634 | 'slug',
635 | 'title',
636 | 'body',
637 | 'status',
638 | 'source_url',
639 | 'created_at',
640 | 'updated_at',
641 | 'published_at',
642 | 'deleted_at',
643 | 'owner_username',
644 | 'children_deep_count',
645 | 'tabcoins',
646 | ]) {
647 | const keyValidationFunction = schemas[key];
648 | contentSchema = contentSchema.concat(keyValidationFunction());
649 | }
650 |
651 | return contentSchema;
652 | },
653 |
654 | event: function () {
655 | return Joi.object({
656 | type: Joi.string()
657 | .valid(
658 | 'create:user',
659 | 'ban:user',
660 | 'create:content:text_root',
661 | 'create:content:text_child',
662 | 'update:content:text_root',
663 | 'update:content:text_child',
664 | 'update:content:tabcoins',
665 | 'firewall:block_users',
666 | 'firewall:block_contents:text_root',
667 | 'firewall:block_contents:text_child',
668 | 'reward:user:tabcoins',
669 | 'system:update:tabcoins'
670 | )
671 | .messages({
672 | 'any.required': `"type" é um campo obrigatório.`,
673 | 'string.empty': `"type" não pode estar em branco.`,
674 | 'string.base': `"type" deve ser do tipo String.`,
675 | 'any.only': `"type" não possui um valor válido.`,
676 | }),
677 | originatorUserId: Joi.string().guid({ version: 'uuidv4' }).optional().messages({
678 | 'string.empty': `"originatorId" não pode estar em branco.`,
679 | 'string.base': `"originatorId" deve ser do tipo String.`,
680 | 'string.guid': `"originatorId" deve possuir um token UUID na versão 4.`,
681 | }),
682 | originatorIp: Joi.string()
683 | .ip({
684 | version: ['ipv4', 'ipv6'],
685 | })
686 | .optional()
687 | .messages({
688 | 'string.empty': `"originatorIp" não pode estar em branco.`,
689 | 'string.base': `"originatorIp" deve ser do tipo String.`,
690 | 'string.ip': `"originatorIp" deve possuir um IP válido`,
691 | }),
692 | metadata: Joi.when('type', [
693 | {
694 | is: 'create:user',
695 | then: Joi.object({
696 | id: Joi.string().required(),
697 | }),
698 | },
699 | {
700 | is: 'create:content:text_root',
701 | then: Joi.object({
702 | id: Joi.string().required(),
703 | }),
704 | },
705 | {
706 | is: 'create:content:text_child',
707 | then: Joi.object({
708 | id: Joi.string().required(),
709 | }),
710 | },
711 | {
712 | is: 'update:content:text_root',
713 | then: Joi.object({
714 | id: Joi.string().required(),
715 | }),
716 | },
717 | {
718 | is: 'update:content:text_child',
719 | then: Joi.object({
720 | id: Joi.string().required(),
721 | }),
722 | },
723 | {
724 | is: 'firewall:block_users',
725 | then: Joi.object({
726 | from_rule: Joi.string().required(),
727 | users: Joi.array().required(),
728 | }),
729 | },
730 | {
731 | is: 'firewall:block_contents:text_root',
732 | then: Joi.object({
733 | from_rule: Joi.string().required(),
734 | contents: Joi.array().required(),
735 | }),
736 | },
737 | {
738 | is: 'firewall:block_contents:text_child',
739 | then: Joi.object({
740 | from_rule: Joi.string().required(),
741 | contents: Joi.array().required(),
742 | }),
743 | },
744 | ]),
745 | });
746 | },
747 |
748 | tabcoins: function () {
749 | return Joi.object({
750 | tabcoins: Joi.number()
751 | .integer()
752 | .min(-2147483648)
753 | .max(2147483647)
754 | .when('$required.tabcoins', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
755 | .messages({
756 | 'any.required': `"tabcoins" é um campo obrigatório.`,
757 | 'string.empty': `"tabcoins" não pode estar em branco.`,
758 | 'number.base': `"tabcoins" deve ser do tipo Number.`,
759 | 'number.integer': `"tabcoins" deve ser um Inteiro.`,
760 | 'number.min': `"tabcoins" deve possuir um valor mínimo de -2147483648.`,
761 | 'number.max': `"tabcoins" deve possuir um valor máximo de 2147483647.`,
762 | 'number.unsafe': `"tabcoins" deve possuir um valor máximo de 2147483647.`,
763 | }),
764 | });
765 | },
766 |
767 | tabcash: function () {
768 | return Joi.object({
769 | tabcash: Joi.number()
770 | .integer()
771 | .min(-2147483648)
772 | .max(2147483647)
773 | .when('$required.tabcash', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
774 | .messages({
775 | 'any.required': `"tabcash" é um campo obrigatório.`,
776 | 'string.empty': `"tabcash" não pode estar em branco.`,
777 | 'number.base': `"tabcash" deve ser do tipo Number.`,
778 | 'number.integer': `"tabcash" deve ser um Inteiro.`,
779 | 'number.min': `"tabcash" deve possuir um valor mínimo de -2147483648.`,
780 | 'number.max': `"tabcash" deve possuir um valor máximo de 2147483647.`,
781 | 'number.unsafe': `"tabcash" deve possuir um valor máximo de 2147483647.`,
782 | }),
783 | });
784 | },
785 |
786 | transaction_type: function () {
787 | return Joi.object({
788 | transaction_type: Joi.string()
789 | .trim()
790 | .valid('credit', 'debit')
791 | .invalid(null)
792 | .when('$required.transaction_type', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
793 | .messages({
794 | 'any.required': `"transaction_type" é um campo obrigatório.`,
795 | 'string.empty': `"transaction_type" não pode estar em branco.`,
796 | 'string.base': `"transaction_type" deve ser do tipo String.`,
797 | 'any.invalid': `"transaction_type" possui o valor inválido "null".`,
798 | 'any.only': `"transaction_type" deve possuir um dos seguintes valores: "credit" e "debit".`,
799 | }),
800 | });
801 | },
802 |
803 | ban_type: function () {
804 | return Joi.object({
805 | ban_type: Joi.string()
806 | .trim()
807 | .valid('nuke')
808 | .invalid(null)
809 | .when('$required.ban_type', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
810 | .messages({
811 | 'any.required': `"ban_type" é um campo obrigatório.`,
812 | 'string.empty': `"ban_type" não pode estar em branco.`,
813 | 'string.base': `"ban_type" deve ser do tipo String.`,
814 | 'any.invalid': `"ban_type" possui o valor inválido "null".`,
815 | 'any.only': `"ban_type" deve possuir um dos seguintes valores: "nuke".`,
816 | }),
817 | });
818 | },
819 | };
820 |
821 | function checkReservedUsernames(username, helpers) {
822 | if (
823 | (webserver.isServerlessRuntime && reservedDevUsernames.includes(username.toLowerCase())) ||
824 | reservedUsernames.includes(username.toLowerCase()) ||
825 | reservedUsernamesStartingWith.find((reserved) => username.toLowerCase().startsWith(reserved))
826 | ) {
827 | return helpers.error('username.reserved');
828 | }
829 | return username;
830 | }
831 |
832 | const reservedDevUsernames = ['admin', 'user'];
833 | const reservedUsernamesStartingWith = ['favicon', 'manifest'];
834 | const reservedUsernames = [
835 | 'account',
836 | 'administracao',
837 | 'administrador',
838 | 'administradora',
839 | 'administradores',
840 | 'administrator',
841 | 'afiliado',
842 | 'afiliados',
843 | 'ajuda',
844 | 'alerta',
845 | 'alertas',
846 | 'analytics',
847 | 'anonymous',
848 | 'anunciar',
849 | 'anuncie',
850 | 'anuncio',
851 | 'anuncios',
852 | 'api',
853 | 'app',
854 | 'apps',
855 | 'autenticacao',
856 | 'auth',
857 | 'authentication',
858 | 'autorizacao',
859 | 'avatar',
860 | 'backup',
861 | 'banner',
862 | 'banners',
863 | 'beta',
864 | 'blog',
865 | 'cadastrar',
866 | 'cadastro',
867 | 'carrinho',
868 | 'categoria',
869 | 'categorias',
870 | 'categories',
871 | 'category',
872 | 'ceo',
873 | 'cfo',
874 | 'checkout',
875 | 'comentario',
876 | 'comentarios',
877 | 'comunidade',
878 | 'comunidades',
879 | 'config',
880 | 'configuracao',
881 | 'configuracoes',
882 | 'configurar',
883 | 'configure',
884 | 'conta',
885 | 'contas',
886 | 'contato',
887 | 'contatos',
888 | 'contrato',
889 | 'convite',
890 | 'convites',
891 | 'create',
892 | 'criar',
893 | 'css',
894 | 'cto',
895 | 'cultura',
896 | 'curso',
897 | 'cursos',
898 | 'dados',
899 | 'dashboard',
900 | 'desconectar',
901 | 'descricao',
902 | 'description',
903 | 'deslogar',
904 | 'diretrizes',
905 | 'discussao',
906 | 'docs',
907 | 'documentacao',
908 | 'download',
909 | 'downloads',
910 | 'draft',
911 | 'edit',
912 | 'editar',
913 | 'editor',
914 | 'email',
915 | 'estatisticas',
916 | 'eu',
917 | 'faq',
918 | 'features',
919 | 'gerente',
920 | 'grupo',
921 | 'grupos',
922 | 'guest',
923 | 'guidelines',
924 | 'hoje',
925 | 'imagem',
926 | 'imagens',
927 | 'init',
928 | 'interface',
929 | 'licenca',
930 | 'log',
931 | 'login',
932 | 'logout',
933 | 'loja',
934 | 'me',
935 | 'membership',
936 | 'moderacao',
937 | 'moderador',
938 | 'moderadora',
939 | 'moderadoras',
940 | 'moderadores',
941 | 'museu',
942 | 'news',
943 | 'newsletter',
944 | 'newsletters',
945 | 'notificacoes',
946 | 'notification',
947 | 'notifications',
948 | 'ontem',
949 | 'pagina',
950 | 'password',
951 | 'perfil',
952 | 'pesquisa',
953 | 'popular',
954 | 'post',
955 | 'postar',
956 | 'posts',
957 | 'preferencias',
958 | 'public',
959 | 'publicar',
960 | 'publish',
961 | 'rascunho',
962 | 'recentes',
963 | 'register',
964 | 'registration',
965 | 'regras',
966 | 'relatorio',
967 | 'relatorios',
968 | 'replies',
969 | 'reply',
970 | 'resetar-senha',
971 | 'resetar',
972 | 'resposta',
973 | 'respostas',
974 | 'root',
975 | 'rootuser',
976 | 'rss',
977 | 'sair',
978 | 'senha',
979 | 'sobre',
980 | 'status',
981 | 'sudo',
982 | 'superuser',
983 | 'suporte',
984 | 'support',
985 | 'swr',
986 | 'sysadmin',
987 | 'tabnew',
988 | 'tabnews',
989 | 'tag',
990 | 'tags',
991 | 'termos-de-uso',
992 | 'termos',
993 | 'terms',
994 | 'toc',
995 | 'trending',
996 | 'upgrade',
997 | 'username',
998 | 'users',
999 | 'usuario',
1000 | 'usuarios',
1001 | 'va',
1002 | 'vagas',
1003 | 'videos',
1004 | ];
1005 |
--------------------------------------------------------------------------------