├── 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 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 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 | 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 | [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) 4 | 5 | 6 | ## Contributors 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
Silvano Marques
Silvano Marques

🤔 💻
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 | 4 | 8 | 9 | 10 | 12 | 14 | 15 | 16 | 18 | 20 | 21 | 22 | 23 | 24 | 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 | Example of poll with Markdown Poll 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 | {title} 28 | 29 |
  • 30 | ); 31 | }) 32 | 33 | const markdownOptionsList = poll.options.map(({index, title, votes}: any) => { 34 | return ` 35 | ${title} 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 |
      52 | {optionsList} 53 |
    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 |
    18 | 54 | 55 |
    56 | 57 |
    58 | 59 | Your Company 60 | Markdown Poll 66 | 67 | 75 |
    76 |
    77 |
    78 |
    79 | {navigation.map((item) => ( 80 | 85 | {item.name} 86 | 87 | ))} 88 |
    89 | {/*
    */} 90 | {/* */} 94 | {/* Log in*/} 95 | {/* */} 96 | {/*
    */} 97 |
    98 |
    99 |
    100 |
    101 |
    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 | 115 |
    116 |
    117 | 120 |
    121 | 129 |
    130 |
    131 |
    132 | 135 | {optionsList} 136 | 144 |
    145 |
    146 |
    147 | 165 |
    166 | 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 | --------------------------------------------------------------------------------