├── src ├── schemas │ ├── index.ts │ └── shorten │ │ ├── index.ts │ │ ├── model.ts │ │ └── input.ts ├── server │ ├── repositories │ │ ├── index.ts │ │ └── shorten.repository.ts │ ├── database │ │ ├── index.ts │ │ ├── types │ │ │ └── database-client.ts │ │ └── notion.ts │ ├── middlewares │ │ ├── index.ts │ │ ├── validate.ts │ │ └── wrap-error.ts │ ├── models │ │ ├── index.ts │ │ └── shorten.model.ts │ ├── configs │ │ └── shorten.ts │ └── errors.ts ├── reducers │ ├── index.ts │ ├── verify-token-reducer.ts │ └── register-shorten-reducer.ts ├── utils │ ├── index.ts │ ├── random-integer.ts │ ├── copy-text-to-clipboard.ts │ └── base64.ts ├── components │ ├── Title.tsx │ ├── ShowItem.tsx │ ├── Footer.tsx │ ├── TokenAuthModal.tsx │ └── RegisterUrlForm.tsx ├── pages │ ├── _app.tsx │ ├── api │ │ ├── auth.ts │ │ └── shortens │ │ │ ├── [id].ts │ │ │ └── index.ts │ ├── [shortenUrlPath].tsx │ └── index.tsx └── constants │ └── index.ts ├── tests ├── constants │ └── index.ts ├── tsconfig.json ├── setup-env.ts └── api │ ├── auth.test.ts │ └── shortens │ ├── [id].test.ts │ └── index.test.ts ├── cypress ├── support │ ├── e2e.ts │ └── commands.ts ├── fixtures │ └── url.json ├── tsconfig.json └── e2e │ ├── auth.cy.ts │ └── shorten.cy.ts ├── public └── favicon.ico ├── next.config.cjs ├── next-env.d.ts ├── .editorconfig ├── vite.config.ts ├── .env.local.example ├── cypress.config.ts ├── tsconfig.json ├── .gitignore ├── LICENSE ├── package.json ├── .github └── workflows │ └── ci.yml ├── README.md └── xo.config.cjs /src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shorten'; 2 | -------------------------------------------------------------------------------- /tests/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const TIMEOUT = 1000 * 60 * 2; 2 | -------------------------------------------------------------------------------- /src/server/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shorten.repository'; 2 | -------------------------------------------------------------------------------- /src/schemas/shorten/index.ts: -------------------------------------------------------------------------------- 1 | export * from './input'; 2 | export * from './model'; 3 | -------------------------------------------------------------------------------- /src/server/database/index.ts: -------------------------------------------------------------------------------- 1 | export {default as NotionDBClient} from './notion'; 2 | -------------------------------------------------------------------------------- /src/server/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validate'; 2 | export * from './wrap-error'; 3 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unassigned-import */ 2 | import './commands'; 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/younho9/notion-url-shortener/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /cypress/fixtures/url.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "https://github.com/younho9/notion-url-shortener" 3 | } 4 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register-shorten-reducer'; 2 | export * from './verify-token-reducer'; 3 | -------------------------------------------------------------------------------- /next.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | }; 5 | -------------------------------------------------------------------------------- /src/server/models/index.ts: -------------------------------------------------------------------------------- 1 | export {default as ShortenModel} from './shorten.model'; 2 | export * from './shorten.model'; 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base64'; 2 | export * from './copy-text-to-clipboard'; 3 | export * from './random-integer'; 4 | -------------------------------------------------------------------------------- /src/utils/random-integer.ts: -------------------------------------------------------------------------------- 1 | export const randomInteger = (minimum = 0, maximum = minimum) => 2 | Math.floor(Math.random() * (maximum - minimum + 1) + minimum); 3 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "types": ["node"] 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /src/utils/copy-text-to-clipboard.ts: -------------------------------------------------------------------------------- 1 | export const copyTextToClipboard = async (text: string) => { 2 | try { 3 | await navigator.clipboard.writeText(text); 4 | 5 | return true; 6 | } catch { 7 | return document.execCommand('copy', true, text); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/schemas/shorten/model.ts: -------------------------------------------------------------------------------- 1 | import type {ShortenType} from './input'; 2 | 3 | export interface Shorten { 4 | id: number; 5 | shortenUrlPath: string; 6 | originalUrl: string; 7 | type: ShortenType; 8 | visits: number; 9 | createdAt: string; 10 | updatedAt: string; 11 | } 12 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | // be explicit about types included 6 | // to avoid clashing with Jest types 7 | "isolatedModules": false, 8 | "types": ["cypress", "node"] 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tests/setup-env.ts: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | 3 | import {loadEnvConfig} from '@next/env'; // eslint-disable-line import/no-extraneous-dependencies 4 | 5 | const setupEnv = async () => { 6 | process.env.TZ = 'UTC'; 7 | 8 | loadEnvConfig(process.env.PWD!); 9 | }; 10 | 11 | export default setupEnv; 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {fileURLToPath} from 'url'; 3 | 4 | import {defineConfig} from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | alias: { 8 | '@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src'), 9 | }, 10 | test: { 11 | globalSetup: ['./tests/setup-env'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import {Center, Heading, Icon} from '@chakra-ui/react'; 2 | import {SiNotion} from 'react-icons/si'; 3 | 4 | const Title = () => ( 5 |
6 | 7 | 8 | URL Shortener 9 | 10 |
11 | ); 12 | 13 | export default Title; 14 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_NOTION_DATABASE_URL="https://.notion.site/" # See https://developers.notion.com/docs#step-2-share-a-database-with-your-integration 2 | NOTION_API_TOKEN="" # See https://developers.notion.com/docs/getting-started#getting-started 3 | USE_TOKEN_AUTH=false 4 | MAXIMUM_ZERO_WIDTH_SHORTEN_LENGTH=8 5 | MAXIMUM_BASE64_SHORTEN_LENGTH=7 6 | MAXIMUM_GENERATION_ATTEMPTS=5 7 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | 3 | import {loadEnvConfig} from '@next/env'; // eslint-disable-line import/no-extraneous-dependencies 4 | import {defineConfig} from 'cypress'; 5 | 6 | export default defineConfig({ 7 | e2e: { 8 | defaultCommandTimeout: 8000, 9 | baseUrl: 'http://localhost:3000', 10 | env: loadEnvConfig(process.cwd()).combinedEnv, 11 | video: false, 12 | screenshotOnRunFailure: false, 13 | experimentalSessionAndOrigin: true, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/server/configs/shorten.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BASE64_CHARSET, 3 | MAXIMUM_BASE64_SHORTEN_LENGTH, 4 | MAXIMUM_GENERATION_ATTEMPTS, 5 | MAXIMUM_ZERO_WIDTH_SHORTEN_LENGTH, 6 | ZERO_WIDTH_CHARSET, 7 | } from '@/constants'; 8 | 9 | export const shortenConfig = { 10 | zeroWidth: { 11 | charset: ZERO_WIDTH_CHARSET, 12 | length: MAXIMUM_ZERO_WIDTH_SHORTEN_LENGTH, 13 | }, 14 | base64: { 15 | charset: BASE64_CHARSET, 16 | length: MAXIMUM_BASE64_SHORTEN_LENGTH, 17 | }, 18 | maximumGenerationAttempts: MAXIMUM_GENERATION_ATTEMPTS, 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "cypress.config.ts", "src"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /.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 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | 40 | # cypress 41 | /cypress/videos 42 | /cypress/screenshots 43 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import {ChakraProvider, extendTheme} from '@chakra-ui/react'; 2 | import type {AppProps} from 'next/app'; 3 | import '@fontsource/inter/400.css'; 4 | import '@fontsource/inter/700.css'; 5 | 6 | const theme = extendTheme({ 7 | styles: { 8 | global: { 9 | 'html, body': { 10 | backgroundColor: '#f7f6f3', 11 | margin: 0, 12 | padding: 0, 13 | height: '100%', 14 | }, 15 | }, 16 | }, 17 | fonts: { 18 | heading: 'Inter', 19 | body: 'Inter', 20 | }, 21 | }); 22 | 23 | const MyApp = ({Component, pageProps}: AppProps) => ( 24 | 25 | 26 | 27 | ); 28 | 29 | export default MyApp; 30 | -------------------------------------------------------------------------------- /src/server/database/types/database-client.ts: -------------------------------------------------------------------------------- 1 | export interface DatabaseClient { 2 | queryOne>( 3 | parameters?: unknown, 4 | ): Promise; 5 | 6 | queryAll>( 7 | parameters?: unknown, 8 | ): Promise; 9 | 10 | findById>( 11 | id: number, 12 | ): Promise; 13 | 14 | create>( 15 | properties: unknown, 16 | ): Promise; 17 | 18 | update>( 19 | id: number, 20 | properties: unknown, 21 | ): Promise; 22 | 23 | delete(id: number): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /src/server/middlewares/validate.ts: -------------------------------------------------------------------------------- 1 | import type {NextApiHandler, NextApiRequest, NextApiResponse} from 'next'; 2 | import {z} from 'zod'; 3 | 4 | import {BadRequestError, InvalidInputError} from '@/server/errors'; 5 | 6 | export function validate( 7 | schema: z.ZodType, 8 | handler: NextApiHandler, 9 | ) { 10 | return async (request: NextApiRequest, response: NextApiResponse) => { 11 | if (request.method === 'POST') { 12 | try { 13 | request.body = schema.parse(request.body); 14 | } catch (error: unknown) { 15 | if (error instanceof z.ZodError) { 16 | throw new InvalidInputError(error.issues); 17 | } 18 | 19 | throw new BadRequestError(); 20 | } 21 | } 22 | 23 | await handler(request, response); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/api/auth.ts: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest, NextApiResponse} from 'next'; 2 | 3 | import {NOTION_DATABASE_ID} from '@/constants'; 4 | import {NotionDBClient} from '@/server/database'; 5 | import {MethodNotAllowedError} from '@/server/errors'; 6 | import {wrapError} from '@/server/middlewares/wrap-error'; 7 | 8 | type Data = { 9 | message: string; 10 | }; 11 | 12 | const handler = async ( 13 | request: NextApiRequest, 14 | response: NextApiResponse, 15 | ) => { 16 | switch (request.method) { 17 | case 'GET': { 18 | const notionDatabase = new NotionDBClient({ 19 | auth: request.headers.authorization, 20 | databaseId: NOTION_DATABASE_ID, 21 | }); 22 | 23 | await notionDatabase.retrieve(); 24 | 25 | response.status(200).json({ 26 | message: 'OK', 27 | }); 28 | 29 | return; 30 | } 31 | 32 | default: { 33 | throw new MethodNotAllowedError(request.method); 34 | } 35 | } 36 | }; 37 | 38 | export default wrapError(handler); 39 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-empty-file */ 2 | 3 | // *********************************************** 4 | // This example commands.js shows you how to 5 | // create various custom commands and overwrite 6 | // existing commands. 7 | // 8 | // For more comprehensive examples of custom 9 | // commands please read more here: 10 | // https://on.cypress.io/custom-commands 11 | // *********************************************** 12 | // 13 | // 14 | // -- This is a parent command -- 15 | // Cypress.Commands.add('login', (email, password) => { ... }) 16 | // 17 | // 18 | // -- This is a child command -- 19 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 20 | // 21 | // 22 | // -- This is a dual command -- 23 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 24 | // 25 | // 26 | // -- This will overwrite an existing command -- 27 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 28 | -------------------------------------------------------------------------------- /src/components/ShowItem.tsx: -------------------------------------------------------------------------------- 1 | import is from '@sindresorhus/is'; 2 | import type {HTMLMotionProps} from 'framer-motion'; 3 | import {motion} from 'framer-motion'; 4 | 5 | interface ShowItemProps extends HTMLMotionProps<'div'> { 6 | direction: 'up' | 'down' | 'left' | 'right'; 7 | amount?: number; 8 | delay?: number; 9 | } 10 | 11 | export const SHOW_ITEM_DELAY_UNIT = 0.1; 12 | 13 | const ShowItem = ({ 14 | direction = 'up', 15 | amount = 20, 16 | delay = 0, 17 | children, 18 | }: ShowItemProps) => { 19 | const initialPositionProp = Object.fromEntries( 20 | [ 21 | direction === 'up' && (['y', amount] as const), 22 | direction === 'down' && (['y', -amount] as const), 23 | direction === 'left' && (['x', amount] as const), 24 | direction === 'right' && (['x', -amount] as const), 25 | ].filter(is.truthy), 26 | ); 27 | 28 | return ( 29 | 34 | {children} 35 | 36 | ); 37 | }; 38 | 39 | export default ShowItem; 40 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import {Center, Text, HStack, Icon} from '@chakra-ui/react'; 2 | import {SiNotion, SiGithub} from 'react-icons/si'; 3 | 4 | import {NOTION_DATABASE_URL} from '@/constants'; 5 | 6 | const Footer = () => ( 7 |
8 | 15 | Copyright © 2021 Younho Choo 16 | 17 | 18 | 19 | 27 | 28 | 29 | 37 | 38 | 39 |
40 | ); 41 | 42 | export default Footer; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2021 Younho Choo 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pages/api/shortens/[id].ts: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest, NextApiResponse} from 'next'; 2 | 3 | import {NOTION_DATABASE_ID} from '@/constants'; 4 | import {shortenRegisterInputSchema} from '@/schemas'; 5 | import {NotionDBClient} from '@/server/database'; 6 | import {MethodNotAllowedError} from '@/server/errors'; 7 | import {validate, wrapError} from '@/server/middlewares'; 8 | import {ShortenModel} from '@/server/models'; 9 | import {ShortenRepository} from '@/server/repositories/shorten.repository'; 10 | 11 | const handler = async ( 12 | request: NextApiRequest, 13 | response: NextApiResponse<{success: boolean}>, 14 | ) => { 15 | const {id} = request.query; 16 | 17 | switch (request.method) { 18 | case 'DELETE': { 19 | const notionDatabase = new NotionDBClient({ 20 | auth: request.headers.authorization, 21 | databaseId: NOTION_DATABASE_ID, 22 | }); 23 | 24 | const shortenModel = new ShortenModel(notionDatabase); 25 | const shortenRepository = new ShortenRepository(shortenModel); 26 | const isDeleted = await shortenRepository.unregister(Number(id)); 27 | 28 | response.status(200).json({success: isDeleted}); 29 | 30 | return; 31 | } 32 | 33 | default: { 34 | throw new MethodNotAllowedError(request.method); 35 | } 36 | } 37 | }; 38 | 39 | export default wrapError(validate(shortenRegisterInputSchema, handler)); 40 | -------------------------------------------------------------------------------- /src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | export interface CharsetIndex { 2 | byInt: Record; 3 | byChar: Record; 4 | length: number; 5 | } 6 | 7 | export const indexCharset = (charset: string[]): CharsetIndex => { 8 | const byInt: Record = {}; 9 | const byChar: Record = {}; 10 | 11 | for (const [i, char] of Array.from(charset.entries())) { 12 | byInt[i] = char; 13 | byChar[char] = i; 14 | } 15 | 16 | return {byInt, byChar, length: charset.length}; 17 | }; 18 | 19 | export const encode = (integer: number, {length: max, byInt}: CharsetIndex) => { 20 | if (integer === 0) { 21 | return byInt[0]; 22 | } 23 | 24 | let string_ = ''; 25 | 26 | while (integer > 0) { 27 | string_ = byInt[integer % max] + string_; 28 | integer = Math.floor(integer / max); 29 | } 30 | 31 | return string_; 32 | }; 33 | 34 | export const encodeWithLeftPad = ( 35 | integer: number, 36 | charsetIndex: CharsetIndex, 37 | maxLength: number, 38 | ) => encode(integer, charsetIndex).padStart(maxLength, encode(0, charsetIndex)); 39 | 40 | export const decode = ( 41 | string_: string, 42 | {length: max, byChar}: CharsetIndex, 43 | ) => { 44 | let integer = 0; 45 | 46 | for (const [i, char] of Array.from(string_.split('').entries())) { 47 | integer += (byChar[char] * max) ** (string_.length - i - 1); 48 | } 49 | 50 | return integer; 51 | }; 52 | -------------------------------------------------------------------------------- /src/server/middlewares/wrap-error.ts: -------------------------------------------------------------------------------- 1 | import {isNotionClientError} from '@notionhq/client/build/src'; 2 | import type {NextApiRequest, NextApiResponse, NextApiHandler} from 'next'; 3 | import type {SetReturnType} from 'type-fest'; 4 | 5 | import { 6 | getStatus, 7 | InvalidInputError, 8 | NotionUrlShortenerError, 9 | UnknownNotionUrlShortenerError, 10 | } from '@/server/errors'; 11 | 12 | export const wrapError = 13 | (handler: SetReturnType>) => 14 | async (request: NextApiRequest, response: NextApiResponse) => 15 | handler(request, response).catch((error: Error) => { 16 | if (isNotionClientError(error)) { 17 | response.status(getStatus(error)).json({ 18 | code: error.code, 19 | message: error.message, 20 | }); 21 | 22 | return; 23 | } 24 | 25 | if (error instanceof InvalidInputError) { 26 | response.status(error.status).json({ 27 | code: error.code, 28 | message: error.message, 29 | issues: error.issues, 30 | }); 31 | 32 | return; 33 | } 34 | 35 | if (error instanceof NotionUrlShortenerError) { 36 | response.status(error.status).json({ 37 | code: error.code, 38 | message: error.message, 39 | }); 40 | 41 | return; 42 | } 43 | 44 | const defaultError = new UnknownNotionUrlShortenerError(error); 45 | 46 | response.status(defaultError.status).json({ 47 | code: defaultError.code, 48 | message: defaultError.message, 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/schemas/shorten/input.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import {z} from 'zod'; 3 | 4 | export const GENERATED_SHORTEN_TYPE = { 5 | ZERO_WIDTH: 'zeroWidth', 6 | BASE64: 'base64', 7 | } as const; 8 | 9 | export const generatedShortenTypeSchema = z.nativeEnum(GENERATED_SHORTEN_TYPE); 10 | 11 | export type GeneratedShortenType = z.infer; 12 | 13 | export const SHORTEN_TYPE = { 14 | ...GENERATED_SHORTEN_TYPE, 15 | CUSTOM: 'custom', 16 | } as const; 17 | 18 | export const shortenTypeSchema = z.nativeEnum(SHORTEN_TYPE); 19 | 20 | export type ShortenType = z.infer; 21 | 22 | export const urlSchema = z.string().url().max(2048); 23 | 24 | export const customShortenRegisterInputSchema = z.object({ 25 | type: z.literal(SHORTEN_TYPE.CUSTOM), 26 | originalUrl: urlSchema, 27 | shortenUrlPath: z.string().max(100), 28 | }); 29 | 30 | export const generatedShortenRegisterInputSchema = z.object({ 31 | type: z.literal(SHORTEN_TYPE.ZERO_WIDTH).or(z.literal(SHORTEN_TYPE.BASE64)), 32 | originalUrl: urlSchema, 33 | shortenUrlPath: z.null().optional(), 34 | }); 35 | 36 | export const shortenRegisterInputSchema = z.union([ 37 | customShortenRegisterInputSchema, 38 | generatedShortenRegisterInputSchema, 39 | ]); 40 | 41 | export type CustomShortenRegisterInputSchema = z.infer< 42 | typeof customShortenRegisterInputSchema 43 | >; 44 | 45 | export type GeneratedShortenRegisterInputSchema = z.infer< 46 | typeof generatedShortenRegisterInputSchema 47 | >; 48 | 49 | export type ShortenRegisterInputSchema = z.infer< 50 | typeof shortenRegisterInputSchema 51 | >; 52 | -------------------------------------------------------------------------------- /src/pages/api/shortens/index.ts: -------------------------------------------------------------------------------- 1 | import is from '@sindresorhus/is'; 2 | import type {NextApiRequest, NextApiResponse} from 'next'; 3 | 4 | import { 5 | NOTION_API_TOKEN, 6 | NOTION_DATABASE_ID, 7 | USE_TOKEN_AUTH, 8 | } from '@/constants'; 9 | import type {Shorten} from '@/schemas'; 10 | import {shortenRegisterInputSchema} from '@/schemas'; 11 | import {NotionDBClient} from '@/server/database'; 12 | import { 13 | MethodNotAllowedError, 14 | UnknownNotionUrlShortenerError, 15 | } from '@/server/errors'; 16 | import {validate, wrapError} from '@/server/middlewares'; 17 | import {ShortenModel} from '@/server/models'; 18 | import {ShortenRepository} from '@/server/repositories/shorten.repository'; 19 | 20 | export interface ShortenResponse { 21 | shorten: Shorten; 22 | } 23 | 24 | const handler = async ( 25 | request: NextApiRequest, 26 | response: NextApiResponse, 27 | ) => { 28 | switch (request.method) { 29 | case 'POST': { 30 | const notionDatabase = new NotionDBClient({ 31 | auth: USE_TOKEN_AUTH ? request.headers.authorization : NOTION_API_TOKEN, 32 | databaseId: NOTION_DATABASE_ID, 33 | }); 34 | const shortenModel = new ShortenModel(notionDatabase); 35 | const shortenRepository = new ShortenRepository(shortenModel); 36 | const shorten = await shortenRepository.register(request.body); 37 | 38 | if (is.undefined(shorten)) { 39 | throw new UnknownNotionUrlShortenerError(); 40 | } 41 | 42 | response.status(200).json({shorten}); 43 | 44 | return; 45 | } 46 | 47 | default: { 48 | throw new MethodNotAllowedError(request.method); 49 | } 50 | } 51 | }; 52 | 53 | export default wrapError(validate(shortenRegisterInputSchema, handler)); 54 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | 3 | import {extractIdFromUrl} from '@narkdown/notion-utils'; 4 | 5 | export const ZERO_WIDTH_CHARSET = [ 6 | '\u200C', 7 | '\u200D', 8 | '\uDB40\uDC61', 9 | '\uDB40\uDC62', 10 | '\uDB40\uDC63', 11 | '\uDB40\uDC64', 12 | '\uDB40\uDC65', 13 | '\uDB40\uDC66', 14 | '\uDB40\uDC67', 15 | '\uDB40\uDC68', 16 | '\uDB40\uDC69', 17 | '\uDB40\uDC6A', 18 | '\uDB40\uDC6B', 19 | '\uDB40\uDC6C', 20 | '\uDB40\uDC6D', 21 | '\uDB40\uDC6E', 22 | '\uDB40\uDC6F', 23 | '\uDB40\uDC70', 24 | '\uDB40\uDC71', 25 | '\uDB40\uDC72', 26 | '\uDB40\uDC73', 27 | '\uDB40\uDC74', 28 | '\uDB40\uDC75', 29 | '\uDB40\uDC76', 30 | '\uDB40\uDC77', 31 | '\uDB40\uDC78', 32 | '\uDB40\uDC79', 33 | '\uDB40\uDC7A', 34 | '\uDB40\uDC7F', 35 | ]; 36 | 37 | export const BASE64_CHARSET = 38 | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split(''); 39 | 40 | export const NOTION_API_TOKEN = process.env.NOTION_API_TOKEN!; 41 | 42 | export const NOTION_API_TOKEN_STORAGE_KEY = 'NOTION_API_KEY'; 43 | 44 | export const NOTION_DATABASE_URL = process.env.NEXT_PUBLIC_NOTION_DATABASE_URL!; 45 | 46 | export const NOTION_DATABASE_ID = extractIdFromUrl(NOTION_DATABASE_URL); 47 | 48 | // eslint-disable-next-line @typescript-eslint/naming-convention 49 | export const USE_TOKEN_AUTH = process.env.USE_TOKEN_AUTH === 'true'; 50 | 51 | export const MAXIMUM_ZERO_WIDTH_SHORTEN_LENGTH = Number( 52 | process.env.MAXIMUM_ZERO_WIDTH_SHORTEN_LENGTH ?? 8, 53 | ); 54 | 55 | export const MAXIMUM_BASE64_SHORTEN_LENGTH = Number( 56 | process.env.MAXIMUM_BASE64_SHORTEN_LENGTH ?? 7, 57 | ); 58 | 59 | export const MAXIMUM_GENERATION_ATTEMPTS = Number( 60 | process.env.MAXIMUM_GENERATION_ATTEMPTS ?? 5, 61 | ); 62 | -------------------------------------------------------------------------------- /src/pages/[shortenUrlPath].tsx: -------------------------------------------------------------------------------- 1 | import is from '@sindresorhus/is'; 2 | import type {GetServerSideProps, NextPage} from 'next'; 3 | import Error from 'next/error'; 4 | 5 | import {NOTION_API_TOKEN, NOTION_DATABASE_ID} from '@/constants'; 6 | import {NotionDBClient} from '@/server/database'; 7 | import {NOTION_URL_SHORTENER_ERROR_STATUS_CODE} from '@/server/errors'; 8 | import ShortenModel from '@/server/models/shorten.model'; 9 | import {ShortenRepository} from '@/server/repositories/shorten.repository'; 10 | 11 | const ShortenUrlPath: NextPage<{ 12 | statusCode: number; 13 | }> = ({statusCode}) => ; 14 | 15 | export default ShortenUrlPath; 16 | 17 | export const getServerSideProps: GetServerSideProps = async ({query}) => { 18 | if (is.string(query.shortenUrlPath)) { 19 | try { 20 | const notionDatabase = new NotionDBClient({ 21 | auth: NOTION_API_TOKEN, 22 | databaseId: NOTION_DATABASE_ID, 23 | }); 24 | const shortenModel = new ShortenModel(notionDatabase); 25 | const shortenRepository = new ShortenRepository(shortenModel); 26 | 27 | const shorten = await shortenRepository.retrieveShortenUrlPath( 28 | query.shortenUrlPath, 29 | ); 30 | 31 | if (shorten) { 32 | return { 33 | redirect: { 34 | destination: new URL(shorten.originalUrl).href, 35 | permanent: false, 36 | }, 37 | }; 38 | } 39 | } catch { 40 | return { 41 | props: { 42 | statusCode: NOTION_URL_SHORTENER_ERROR_STATUS_CODE.URL_NOT_FOUND, 43 | }, 44 | }; 45 | } 46 | 47 | return { 48 | props: { 49 | statusCode: NOTION_URL_SHORTENER_ERROR_STATUS_CODE.URL_NOT_FOUND, 50 | }, 51 | }; 52 | } 53 | 54 | return { 55 | props: { 56 | statusCode: NOTION_URL_SHORTENER_ERROR_STATUS_CODE.INVALID_INPUT, 57 | }, 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-url-shortener", 3 | "private": true, 4 | "scripts": { 5 | "cypress": "cypress open", 6 | "cypress:headless": "cypress run", 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "format": "prettier --ignore-path ./node_modules/@younho9/prettier-config/.prettierignore", 11 | "lint": "xo", 12 | "test": "yarn test:unit --run && yarn test:e2e:headless", 13 | "test:unit": "vitest", 14 | "test:e2e": "NODE_ENV=test start-server-and-test dev http://localhost:3000 cypress", 15 | "test:e2e:headless": "NODE_ENV=test start-server-and-test dev http://localhost:3000 cypress:headless" 16 | }, 17 | "prettier": "@younho9/prettier-config", 18 | "dependencies": { 19 | "@chakra-ui/react": "1.7.3", 20 | "@emotion/react": "^11", 21 | "@emotion/styled": "^11", 22 | "@fontsource/inter": "4.5.1", 23 | "@narkdown/notion-utils": "0.1.0", 24 | "@notionhq/client": "^1.0.4", 25 | "@react-hookz/web": "^12.3.0", 26 | "@sindresorhus/is": "^4.5.0", 27 | "@younho9/object": "0.9.1", 28 | "framer-motion": "^4", 29 | "next": "12.0.7", 30 | "react": "17.0.2", 31 | "react-dom": "17.0.2", 32 | "react-icons": "4.3.1", 33 | "ts-extras": "^0.10.2", 34 | "zod": "3.11.6" 35 | }, 36 | "devDependencies": { 37 | "@next/eslint-plugin-next": "^12.1.6", 38 | "@types/node": "16.11.12", 39 | "@types/react": "17.0.37", 40 | "@younho9/prettier-config": "2.3.1", 41 | "cypress": "^10.0.2", 42 | "eslint-config-xo-react": "^0.27.0", 43 | "eslint-plugin-cypress": "^2.12.1", 44 | "eslint-plugin-react": "^7.30.0", 45 | "eslint-plugin-react-hooks": "^4.5.0", 46 | "node-mocks-http": "^1.11.0", 47 | "prettier": "2.5.1", 48 | "start-server-and-test": "^1.14.0", 49 | "type-fest": "2.8.0", 50 | "typescript": "^4.7.3", 51 | "vitest": "^0.14.1", 52 | "xo": "0.48.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | env: 8 | NEXT_PUBLIC_NOTION_DATABASE_URL: ${{ secrets.NEXT_PUBLIC_NOTION_DATABASE_URL }} 9 | NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }} 10 | USE_TOKEN_AUTH: ${{ secrets.USE_TOKEN_AUTH }} 11 | NODE_ENV: test 12 | 13 | concurrency: ${{ github.head_ref || github.ref_name }} 14 | 15 | jobs: 16 | unit: 17 | name: Unit test on Node.js 16 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-node@v2 22 | with: 23 | node-version: 16 24 | - run: yarn install 25 | - run: yarn test:unit 26 | 27 | chrome: 28 | name: E2E on Chrome 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions/setup-node@v2 33 | with: 34 | node-version: 16 35 | - uses: cypress-io/github-action@v2 36 | with: 37 | start: yarn dev 38 | command: npx cypress run 39 | wait-on: http://localhost:3000 40 | browser: chrome 41 | 42 | firefox: 43 | name: E2E on Firefox 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v2 47 | - uses: actions/setup-node@v2 48 | with: 49 | node-version: 16 50 | - uses: cypress-io/github-action@v2 51 | with: 52 | start: yarn dev 53 | command: npx cypress run 54 | wait-on: http://localhost:3000 55 | browser: firefox 56 | 57 | edge: 58 | name: E2E on Edge 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v2 62 | - uses: actions/setup-node@v2 63 | with: 64 | node-version: 16 65 | - uses: cypress-io/github-action@v2 66 | with: 67 | start: yarn dev 68 | command: npx cypress run 69 | wait-on: http://localhost:3000 70 | browser: edge 71 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import {Stack, Center, useDisclosure} from '@chakra-ui/react'; 2 | import {useLocalStorageValue} from '@react-hookz/web'; 3 | import type {GetServerSideProps, NextPage} from 'next'; 4 | import Head from 'next/head'; 5 | 6 | import Footer from '@/components/Footer'; 7 | import RegisterUrlForm from '@/components/RegisterUrlForm'; 8 | import ShowItem, {SHOW_ITEM_DELAY_UNIT} from '@/components/ShowItem'; 9 | import Title from '@/components/Title'; 10 | import TokenAuthModal from '@/components/TokenAuthModal'; 11 | import {USE_TOKEN_AUTH, NOTION_API_TOKEN_STORAGE_KEY} from '@/constants'; 12 | 13 | const Home: NextPage<{ 14 | useTokenAuth: boolean; 15 | }> = ({useTokenAuth}) => { 16 | const [token, setToken, removeToken] = useLocalStorageValue( 17 | NOTION_API_TOKEN_STORAGE_KEY, 18 | null, 19 | {initializeWithStorageValue: false}, 20 | ); 21 | const {isOpen, onOpen, onClose} = useDisclosure(); 22 | 23 | return ( 24 |
25 | 26 | Notion URL Shortener 27 | 28 | 29 | 30 | 31 |
38 | 39 | 40 | 41 | </ShowItem> 42 | <RegisterUrlForm 43 | token={token ?? undefined} 44 | onClickCapture={ 45 | useTokenAuth 46 | ? (event) => { 47 | if (!token) { 48 | event.stopPropagation(); 49 | event.preventDefault(); 50 | onOpen(); 51 | } 52 | } 53 | : undefined 54 | } 55 | /> 56 | </Stack> 57 | 58 | <ShowItem direction="down" delay={SHOW_ITEM_DELAY_UNIT * 4}> 59 | <Footer /> 60 | </ShowItem> 61 | </Center> 62 | 63 | {useTokenAuth && ( 64 | <TokenAuthModal 65 | token={token ?? undefined} 66 | setToken={setToken} 67 | removeToken={removeToken} 68 | isOpen={isOpen} 69 | onOpen={onOpen} 70 | onClose={onClose} 71 | /> 72 | )} 73 | </div> 74 | ); 75 | }; 76 | 77 | export default Home; 78 | 79 | export const getServerSideProps: GetServerSideProps = async () => ({ 80 | props: { 81 | useTokenAuth: USE_TOKEN_AUTH, 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /tests/api/auth.test.ts: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest, NextApiResponse} from 'next'; 2 | import type {MockResponse} from 'node-mocks-http'; 3 | import {createMocks} from 'node-mocks-http'; 4 | import {describe, it, expect} from 'vitest'; 5 | 6 | import {TIMEOUT} from '../constants'; 7 | 8 | import {NOTION_API_TOKEN} from '@/constants'; 9 | import authHandler from '@/pages/api/auth'; 10 | 11 | interface Mocks { 12 | req: NextApiRequest; 13 | res: MockResponse<NextApiResponse<{message: string}>>; 14 | } 15 | 16 | describe('/api/auth', () => { 17 | it( 18 | '[GET] Notion API Token가 authorization 헤더로 설정된 경우 200 OK 응답을 반환한다.', 19 | async () => { 20 | const {req, res}: Mocks = createMocks({method: 'GET'}); 21 | 22 | req.headers = { 23 | authorization: NOTION_API_TOKEN, 24 | }; 25 | 26 | await authHandler(req, res); 27 | 28 | const {message} = res._getJSONData() as {message: string}; 29 | 30 | expect(res.statusCode).toBe(200); 31 | expect(message).toBe('OK'); 32 | }, 33 | TIMEOUT, 34 | ); 35 | 36 | it( 37 | '[GET] Notion API Token가 유효하지 않은 경우 401 Unauthorized 응답을 반환한다.', 38 | async () => { 39 | const {req, res}: Mocks = createMocks({method: 'GET'}); 40 | 41 | req.headers = { 42 | authorization: 'invalid token', 43 | }; 44 | 45 | await authHandler(req, res); 46 | 47 | const data = res._getJSONData() as Record<string, unknown>; 48 | 49 | expect(res.statusCode).toBe(401); 50 | expect(data.code).toBe('unauthorized'); 51 | expect(data.message).toBe('API token is invalid.'); 52 | }, 53 | TIMEOUT, 54 | ); 55 | 56 | it( 57 | '[GET] authorization 헤더가 없는 경우 401 Unauthorized 응답을 반환한다.', 58 | async () => { 59 | const {req, res}: Mocks = createMocks({method: 'GET'}); 60 | 61 | await authHandler(req, res); 62 | 63 | const data = res._getJSONData() as Record<string, unknown>; 64 | 65 | expect(res.statusCode).toBe(401); 66 | expect(data.code).toBe('unauthorized'); 67 | expect(data.message).toBe('API token is invalid.'); 68 | }, 69 | TIMEOUT, 70 | ); 71 | 72 | it('지원하지 않는 메소드에 대해 405 Method Not Allowed 에러를 반환한다.', async () => { 73 | const {req, res}: Mocks = createMocks({method: 'POST'}); 74 | 75 | await authHandler(req, res); 76 | 77 | const data = res._getJSONData() as Record<string, unknown>; 78 | 79 | expect(res.statusCode).toBe(405); 80 | expect(data.code).toBe('method_not_allowed'); 81 | expect(data.message).toBe('POST method is not allowed'); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/reducers/verify-token-reducer.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {assertError} from 'ts-extras'; 3 | 4 | type VerifyTokenState = 5 | | { 6 | status: 'IDLE'; 7 | error?: undefined; 8 | } 9 | | { 10 | status: 'PENDING'; 11 | error?: undefined; 12 | } 13 | | { 14 | status: 'VERIFIED'; 15 | error?: undefined; 16 | } 17 | | { 18 | status: 'REJECTED'; 19 | error: string; 20 | }; 21 | 22 | type VerifyTokenAction = 23 | | { 24 | type: 'SUBMIT'; 25 | } 26 | | { 27 | type: 'VERIFY'; 28 | } 29 | | { 30 | type: 'REJECT'; 31 | payload: string; 32 | }; 33 | 34 | const verifyTokenReducer = ( 35 | state: VerifyTokenState, 36 | action: VerifyTokenAction, 37 | ): VerifyTokenState => { 38 | switch (action.type) { 39 | case 'SUBMIT': { 40 | return { 41 | status: 'PENDING', 42 | }; 43 | } 44 | 45 | case 'VERIFY': { 46 | return { 47 | status: 'VERIFIED', 48 | }; 49 | } 50 | 51 | case 'REJECT': { 52 | return { 53 | status: 'REJECTED', 54 | error: action.payload, 55 | }; 56 | } 57 | 58 | default: 59 | return state; 60 | } 61 | }; 62 | 63 | export const getIsVerified = async ( 64 | token: string, 65 | ): Promise<{isVerified: true} | {isVerified: false; error: Error}> => { 66 | try { 67 | const response = await fetch(`/api/auth`, { 68 | headers: new Headers({ 69 | 'authorization': token, 70 | 'content-type': 'application/json', 71 | }), 72 | }); 73 | 74 | if (!response.ok) { 75 | const {message} = (await response.json()) as {message: string}; 76 | 77 | throw new Error(message); 78 | } 79 | 80 | return {isVerified: true}; 81 | } catch (error: unknown) { 82 | assertError(error); 83 | 84 | return {isVerified: false, error}; 85 | } 86 | }; 87 | 88 | export const useVerifyTokenReducer = (): { 89 | status: VerifyTokenState['status']; 90 | error?: string; 91 | verifyToken: (token: string) => Promise<boolean>; 92 | } => { 93 | const [state, dispatch] = React.useReducer(verifyTokenReducer, { 94 | status: 'IDLE', 95 | }); 96 | 97 | const verifyToken = async (token: string) => { 98 | dispatch({type: 'SUBMIT'}); 99 | const response = await getIsVerified(token); 100 | 101 | if (response.isVerified) { 102 | dispatch({type: 'VERIFY'}); 103 | return true; 104 | } 105 | 106 | dispatch({type: 'REJECT', payload: response.error.message}); 107 | return false; 108 | }; 109 | 110 | return { 111 | status: state.status, 112 | error: state.error, 113 | verifyToken, 114 | }; 115 | }; 116 | -------------------------------------------------------------------------------- /src/server/models/shorten.model.ts: -------------------------------------------------------------------------------- 1 | import is from '@sindresorhus/is'; 2 | import type {Simplify} from 'type-fest'; 3 | 4 | import type {Shorten, ShortenType} from '@/schemas'; 5 | import type {DatabaseClient} from '@/server/database/types/database-client'; 6 | import {DuplicateShortenUrlPathError} from '@/server/errors'; 7 | 8 | type Model = Simplify<Shorten>; 9 | 10 | export default class ShortenModel { 11 | private readonly db: DatabaseClient; 12 | 13 | public constructor(db: DatabaseClient) { 14 | this.db = db; 15 | } 16 | 17 | public async findByShortenUrlPath(shortenUrlPath: string) { 18 | const result = await this.db.queryOne<Model>({ 19 | filter: { 20 | property: 'shortenUrlPath', 21 | // eslint-disable-next-line @typescript-eslint/naming-convention 22 | rich_text: { 23 | equals: shortenUrlPath, 24 | }, 25 | }, 26 | }); 27 | 28 | return result; 29 | } 30 | 31 | public async isUnique(shortenUrlPath: string) { 32 | return is.undefined(await this.findByShortenUrlPath(shortenUrlPath)); 33 | } 34 | 35 | public async getCurrentId() { 36 | const result = await this.db.queryOne<Model>({ 37 | sorts: [ 38 | { 39 | property: 'id', 40 | direction: 'descending', 41 | }, 42 | ], 43 | }); 44 | 45 | return (result?.id ?? 0) + 1; 46 | } 47 | 48 | public async createShorten({ 49 | type, 50 | originalUrl, 51 | shortenUrlPath, 52 | }: { 53 | type: ShortenType; 54 | originalUrl: string; 55 | shortenUrlPath: string; 56 | }) { 57 | const isUnique = await this.isUnique(shortenUrlPath); 58 | 59 | if (!isUnique) { 60 | throw new DuplicateShortenUrlPathError(shortenUrlPath); 61 | } 62 | 63 | const currentId = await this.getCurrentId(); 64 | 65 | return this.db.create<Model>({ 66 | id: { 67 | type: 'number', 68 | number: currentId, 69 | }, 70 | shortenUrlPath: { 71 | type: 'title', 72 | title: [ 73 | { 74 | type: 'text', 75 | text: { 76 | content: shortenUrlPath, 77 | }, 78 | }, 79 | ], 80 | }, 81 | originalUrl: { 82 | type: 'url', 83 | url: originalUrl, 84 | }, 85 | type: { 86 | type: 'select', 87 | select: { 88 | name: type, 89 | }, 90 | }, 91 | visits: { 92 | type: 'number', 93 | number: 0, 94 | }, 95 | }); 96 | } 97 | 98 | public async incrementVisits(id: number) { 99 | const shorten = await this.db.findById<Model>(id); 100 | 101 | if (shorten) { 102 | return this.db.update(id, { 103 | visits: { 104 | type: 'number', 105 | number: shorten.visits + 1, 106 | }, 107 | }); 108 | } 109 | } 110 | 111 | public async deleteShorten(id: number) { 112 | return this.db.delete(id); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /cypress/e2e/auth.cy.ts: -------------------------------------------------------------------------------- 1 | const NOTION_API_TOKEN = Cypress.env('NOTION_API_TOKEN') as string; 2 | const NOTION_API_TOKEN_STORAGE_KEY = 'NOTION_API_KEY'; 3 | const INVALID_NOTION_API_TOKEN = 'INVALID_TOKEN'; 4 | 5 | beforeEach(() => { 6 | cy.visit('http://localhost:3000'); 7 | cy.intercept('GET', '/api/auth').as('auth'); 8 | 9 | window.localStorage.clear(); 10 | }); 11 | 12 | describe('무효한 토큰이 저장되어 있는 경우', () => { 13 | beforeEach(() => { 14 | window.localStorage.setItem( 15 | NOTION_API_TOKEN_STORAGE_KEY, 16 | `"${INVALID_NOTION_API_TOKEN}"`, 17 | ); 18 | }); 19 | 20 | it('모달을 연다', () => { 21 | cy.wait('@auth').get('[name="token"]').should('exist'); 22 | }); 23 | }); 24 | 25 | describe('유효한 토큰이 저장되어 있는 경우', () => { 26 | beforeEach(() => { 27 | window.localStorage.setItem( 28 | NOTION_API_TOKEN_STORAGE_KEY, 29 | `"${NOTION_API_TOKEN}"`, 30 | ); 31 | }); 32 | 33 | it('모달을 열지 않는다', () => { 34 | cy.wait('@auth').get('[name="token"]').should('not.exist'); 35 | }); 36 | }); 37 | 38 | describe('토큰이 저장되어 있지 않은 경우', () => { 39 | /** @see https://docs.cypress.io/faq/questions/using-cypress-faq#Can-I-check-that-a-form-s-HTML-form-validation-is-shown-when-an-input-is-invalid */ 40 | it('토큰을 입력하지 않고 제출한 경우, HTML 폼 유효성 에러 메시지를 표시한다', () => { 41 | cy.get('.chakra-portal', {timeout: 8000}).should('exist'); 42 | 43 | cy.get('[type="submit"]').contains('Verify').click(); 44 | 45 | cy.get('[name="token"]').then(($input) => { 46 | expect(($input[0] as HTMLInputElement).validationMessage).to.eq( 47 | 'Please fill out this field.', 48 | ); 49 | }); 50 | }); 51 | 52 | it('토큰을 입력하고 제출한 경우, 응답을 기다리는 동안 로딩 스피너를 표시한다', () => { 53 | cy.get('.chakra-portal', {timeout: 8000}).should('exist'); 54 | 55 | cy.get('[name="token"]') 56 | .type(INVALID_NOTION_API_TOKEN) 57 | .should('have.value', INVALID_NOTION_API_TOKEN); 58 | 59 | cy.get('[type="submit"]') 60 | .contains('Verify') 61 | .click() 62 | .should('have.attr', 'data-loading'); 63 | }); 64 | 65 | it('무효한 토큰을 입력할 경우, 에러 메시지를 표시한다', () => { 66 | cy.get('.chakra-portal', {timeout: 8000}).should('exist'); 67 | 68 | cy.get('[name="token"]') 69 | .type(INVALID_NOTION_API_TOKEN) 70 | .should('have.value', INVALID_NOTION_API_TOKEN); 71 | 72 | cy.get('[type="submit"]').contains('Verify').click(); 73 | 74 | cy.get('[name="token"]') 75 | .should('have.attr', 'aria-invalid', 'true') 76 | .next() 77 | .should('have.text', 'API token is invalid.'); 78 | }); 79 | 80 | it('유효한 토큰을 입력한 경우, 로컬스토리지에 토큰을 저장하고, 모달을 닫는다', () => { 81 | cy.get('.chakra-portal', {timeout: 8000}).should('exist'); 82 | 83 | cy.get('[name="token"]') 84 | .type(NOTION_API_TOKEN) 85 | .should('have.value', NOTION_API_TOKEN); 86 | 87 | cy.get('[type="submit"]') 88 | .contains('Verify') 89 | .click() 90 | .should(() => { 91 | expect(localStorage.getItem(NOTION_API_TOKEN_STORAGE_KEY)).to.equal( 92 | `"${NOTION_API_TOKEN}"`, 93 | ); 94 | }); 95 | 96 | cy.get('[name="token"]').should('not.exist'); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/components/TokenAuthModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Center, 3 | Button, 4 | Input, 5 | Modal, 6 | ModalBody, 7 | ModalContent, 8 | ModalFooter, 9 | ModalHeader, 10 | ModalCloseButton, 11 | ModalOverlay, 12 | Text, 13 | FormControl, 14 | FormErrorMessage, 15 | } from '@chakra-ui/react'; 16 | import type React from 'react'; 17 | import {useEffect, useState} from 'react'; 18 | 19 | import {getIsVerified, useVerifyTokenReducer} from '@/reducers'; 20 | 21 | interface TokenAuthModalProps { 22 | token?: string; 23 | setToken: (token: string) => void; 24 | removeToken: () => void; 25 | isOpen: boolean; 26 | onOpen: () => void; 27 | onClose: () => void; 28 | } 29 | 30 | const TokenAuthModal = ({ 31 | token, 32 | setToken, 33 | removeToken, 34 | isOpen, 35 | onOpen, 36 | onClose, 37 | }: TokenAuthModalProps) => { 38 | const {status, error, verifyToken} = useVerifyTokenReducer(); 39 | const [tokenInput, setTokenInput] = useState(''); 40 | 41 | const handleSaveTokenForm: React.FormEventHandler<HTMLFormElement> = async ( 42 | event, 43 | ) => { 44 | event.preventDefault(); 45 | 46 | if (status !== 'PENDING' && status !== 'VERIFIED') { 47 | const isVerified = await verifyToken(tokenInput); 48 | 49 | if (isVerified) { 50 | setToken(tokenInput); 51 | setTokenInput(''); 52 | } 53 | } 54 | }; 55 | 56 | useEffect(() => { 57 | if (!token) { 58 | onOpen(); 59 | return; 60 | } 61 | 62 | const checkToken = async () => { 63 | const {isVerified} = await getIsVerified(token); 64 | 65 | if (isVerified) { 66 | onClose(); 67 | } else { 68 | removeToken(); 69 | } 70 | }; 71 | 72 | void checkToken(); 73 | }, [token, onOpen, onClose, removeToken]); 74 | 75 | return ( 76 | <Modal isCentered isOpen={!token && isOpen} onClose={onClose}> 77 | <ModalOverlay /> 78 | <ModalContent w={['xs', 'md']}> 79 | <ModalHeader fontSize={['2xl', '3xl']} pb={2}> 80 | <Center>👋 Welcome!</Center> 81 | </ModalHeader> 82 | <ModalCloseButton /> 83 | <form onSubmit={handleSaveTokenForm}> 84 | <ModalBody> 85 | <Text ml={2} mb={2} fontSize="sm" color="gray.500"> 86 | Notion API token for this database is required to register a new 87 | URL. 88 | </Text> 89 | <FormControl isInvalid={status === 'REJECTED'}> 90 | <Input 91 | isRequired 92 | boxShadow="sm" 93 | id="token" 94 | type="text" 95 | name="token" 96 | placeholder="Enter your Notion API token" 97 | value={tokenInput} 98 | autoComplete="off" 99 | onChange={(event) => { 100 | setTokenInput(event.target.value); 101 | }} 102 | /> 103 | {error && <FormErrorMessage ml={2}>{error}</FormErrorMessage>} 104 | </FormControl> 105 | </ModalBody> 106 | 107 | <ModalFooter> 108 | <Button 109 | isLoading={status === 'PENDING'} 110 | colorScheme="blue" 111 | boxShadow="sm" 112 | size="md" 113 | type="submit" 114 | > 115 | Verify 116 | </Button> 117 | </ModalFooter> 118 | </form> 119 | </ModalContent> 120 | </Modal> 121 | ); 122 | }; 123 | 124 | export default TokenAuthModal; 125 | -------------------------------------------------------------------------------- /src/reducers/register-shorten-reducer.ts: -------------------------------------------------------------------------------- 1 | import is from '@sindresorhus/is'; 2 | import React from 'react'; 3 | import {assertError} from 'ts-extras'; 4 | 5 | import type {ShortenResponse} from '@/pages/api/shortens'; 6 | import type { 7 | CustomShortenRegisterInputSchema, 8 | GeneratedShortenRegisterInputSchema, 9 | Shorten, 10 | } from '@/schemas'; 11 | 12 | type RegisterShortenState = 13 | | { 14 | status: 'IDLE'; 15 | shorten?: undefined; 16 | error?: undefined; 17 | } 18 | | { 19 | status: 'PENDING'; 20 | shorten?: undefined; 21 | error?: undefined; 22 | } 23 | | { 24 | status: 'RESOLVED'; 25 | shorten: Shorten; 26 | error?: undefined; 27 | } 28 | | { 29 | status: 'REJECTED'; 30 | shorten?: undefined; 31 | error: string; 32 | }; 33 | 34 | type RegisterShortenAction = 35 | | { 36 | type: 'SUBMIT'; 37 | } 38 | | { 39 | type: 'RESOLVE'; 40 | payload: Shorten; 41 | } 42 | | { 43 | type: 'REJECT'; 44 | payload: string; 45 | } 46 | | { 47 | type: 'RETRY'; 48 | }; 49 | 50 | const registerShortenReducer = ( 51 | state: RegisterShortenState, 52 | action: RegisterShortenAction, 53 | ): RegisterShortenState => { 54 | switch (action.type) { 55 | case 'SUBMIT': { 56 | return { 57 | status: 'PENDING', 58 | }; 59 | } 60 | 61 | case 'RESOLVE': { 62 | return { 63 | status: 'RESOLVED', 64 | shorten: action.payload, 65 | }; 66 | } 67 | 68 | case 'REJECT': { 69 | return { 70 | status: 'REJECTED', 71 | error: action.payload, 72 | }; 73 | } 74 | 75 | case 'RETRY': { 76 | return { 77 | status: 'IDLE', 78 | }; 79 | } 80 | 81 | default: 82 | return state; 83 | } 84 | }; 85 | 86 | export const useRegisterShortenReducer = (): { 87 | state: RegisterShortenState; 88 | startRegisterShorten: ( 89 | shortenRequest: 90 | | CustomShortenRegisterInputSchema 91 | | GeneratedShortenRegisterInputSchema, 92 | token?: string, 93 | ) => Promise<void>; 94 | retryRegisterShorten: () => void; 95 | } => { 96 | const [state, dispatch] = React.useReducer(registerShortenReducer, { 97 | status: 'IDLE', 98 | }); 99 | 100 | const startRegisterShorten = async ( 101 | shortenRequest: 102 | | CustomShortenRegisterInputSchema 103 | | GeneratedShortenRegisterInputSchema, 104 | token?: string, 105 | ) => { 106 | dispatch({type: 'SUBMIT'}); 107 | 108 | try { 109 | const headers = new Headers( 110 | Object.fromEntries( 111 | [ 112 | token && ['Authorization', token], 113 | ['content-type', 'application/json'], 114 | ].filter(is.truthy), 115 | ), 116 | ); 117 | 118 | const response = await fetch(`/api/shortens`, { 119 | method: 'POST', 120 | headers, 121 | body: JSON.stringify(shortenRequest), 122 | }); 123 | 124 | if (!response.ok) { 125 | const {message} = (await response.json()) as {message: string}; 126 | 127 | throw new Error(message); 128 | } 129 | 130 | const data: ShortenResponse = (await response.json()) as ShortenResponse; 131 | 132 | dispatch({type: 'RESOLVE', payload: data.shorten}); 133 | } catch (error: unknown) { 134 | assertError(error); 135 | 136 | dispatch({type: 'REJECT', payload: error.message}); 137 | } 138 | }; 139 | 140 | const retryRegisterShorten = () => { 141 | dispatch({type: 'RETRY'}); 142 | }; 143 | 144 | return { 145 | state, 146 | startRegisterShorten, 147 | retryRegisterShorten, 148 | }; 149 | }; 150 | -------------------------------------------------------------------------------- /tests/api/shortens/[id].test.ts: -------------------------------------------------------------------------------- 1 | import is from '@sindresorhus/is'; 2 | import type {NextApiRequest, NextApiResponse} from 'next'; 3 | import type {MockResponse} from 'node-mocks-http'; 4 | import {createMocks} from 'node-mocks-http'; 5 | import {describe, it, expect, beforeEach, afterAll} from 'vitest'; 6 | 7 | import {TIMEOUT} from '../../constants'; 8 | 9 | import {NOTION_API_TOKEN, NOTION_DATABASE_ID} from '@/constants'; 10 | import type {ShortenResponse} from '@/pages/api/shortens'; 11 | import shortenIdHandler from '@/pages/api/shortens/[id]'; 12 | import {NotionDBClient} from '@/server/database'; 13 | import {ShortenModel} from '@/server/models'; 14 | import {ShortenRepository} from '@/server/repositories/shorten.repository'; 15 | 16 | interface Mocks { 17 | req: NextApiRequest; 18 | res: MockResponse<NextApiResponse<ShortenResponse>>; 19 | } 20 | 21 | describe('/api/shortens/[id]', () => { 22 | const generatedShortenIds: number[] = []; 23 | const notionDatabase = new NotionDBClient({ 24 | auth: NOTION_API_TOKEN, 25 | databaseId: NOTION_DATABASE_ID, 26 | }); 27 | const shortenModel = new ShortenModel(notionDatabase); 28 | const shortenRepository = new ShortenRepository(shortenModel); 29 | 30 | beforeEach(async () => { 31 | const shorten = await shortenRepository.register({ 32 | type: 'zeroWidth', 33 | originalUrl: 'https://github.com/younho9/notion-url-shortener', 34 | }); 35 | 36 | if (!is.undefined(shorten)) { 37 | generatedShortenIds.push(shorten.id); 38 | } 39 | }, TIMEOUT); 40 | 41 | afterAll(async () => { 42 | for await (const id of generatedShortenIds) { 43 | await notionDatabase.delete(id); 44 | } 45 | }, TIMEOUT); 46 | 47 | it( 48 | '[DELETE] id가 존재하면 200 OK 응답을 반환한다.', 49 | async () => { 50 | const {req, res}: Mocks = createMocks({method: 'DELETE'}); 51 | 52 | req.headers = { 53 | authorization: NOTION_API_TOKEN, 54 | }; 55 | 56 | req.query = { 57 | id: generatedShortenIds[0].toString(), 58 | }; 59 | 60 | await shortenIdHandler(req, res); 61 | 62 | const data = res._getJSONData() as Record<string, unknown>; 63 | 64 | expect(res.statusCode).toBe(200); 65 | expect(data.success).toBe(true); 66 | }, 67 | TIMEOUT, 68 | ); 69 | 70 | it( 71 | '[DELETE] id가 존재하지 않으면 404 Not Found 응답을 반환한다.', 72 | async () => { 73 | const {req, res}: Mocks = createMocks({method: 'DELETE'}); 74 | 75 | req.headers = { 76 | authorization: NOTION_API_TOKEN, 77 | }; 78 | 79 | const currentId = await shortenModel.getCurrentId(); 80 | 81 | req.query = { 82 | id: currentId.toString(), 83 | }; 84 | 85 | await shortenIdHandler(req, res); 86 | 87 | const data = res._getJSONData() as Record<string, unknown>; 88 | 89 | expect(res.statusCode).toBe(404); 90 | expect(data.code).toBe('id_not_found'); 91 | expect(data.message).toBe( 92 | `Shorten id '${currentId.toString()}' is not found`, 93 | ); 94 | }, 95 | TIMEOUT, 96 | ); 97 | 98 | it('[DELETE] authorization 헤더가 없는 경우 401 Unauthorized 에러를 반환한다.', async () => { 99 | const {req, res}: Mocks = createMocks({method: 'DELETE'}); 100 | 101 | req.body = { 102 | type: 'zeroWidth', 103 | originalUrl: 'https://github.com/younho9', 104 | }; 105 | req.query = { 106 | id: generatedShortenIds[0].toString(), 107 | }; 108 | 109 | await shortenIdHandler(req, res); 110 | 111 | const data = res._getJSONData() as Record<string, unknown>; 112 | 113 | expect(res.statusCode).toBe(401); 114 | expect(data.code).toBe('unauthorized'); 115 | expect(data.message).toBe('API token is invalid.'); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /cypress/e2e/shorten.cy.ts: -------------------------------------------------------------------------------- 1 | import url from '../fixtures/url.json'; 2 | 3 | import type {Shorten} from '@/schemas'; 4 | 5 | const NOTION_API_TOKEN = Cypress.env('NOTION_API_TOKEN') as string; 6 | const NOTION_API_TOKEN_STORAGE_KEY = 'NOTION_API_KEY'; 7 | 8 | beforeEach(() => { 9 | cy.visit('http://localhost:3000', { 10 | onBeforeLoad(window: Window): void { 11 | if (window.navigator.clipboard) { 12 | cy.spy(window.navigator.clipboard, 'writeText').as('copy'); 13 | } 14 | }, 15 | }); 16 | cy.intercept('POST', '/api/shortens').as('shorten'); 17 | localStorage.clear(); 18 | }); 19 | 20 | describe('인증되지 않은 경우', () => { 21 | it('모달을 닫고 폼을 클릭하면, 모달을 다시 연다.', () => { 22 | cy.get('[aria-label="Close"]').click(); 23 | cy.get('.chakra-portal').should('not.exist'); 24 | cy.get('[name="originalUrl"]').type(url.repository, {force: true}); 25 | cy.get('.chakra-portal').should('exist'); 26 | }); 27 | }); 28 | 29 | describe('인증된 경우', () => { 30 | const generatedShortenIds: number[] = []; 31 | 32 | beforeEach(() => { 33 | localStorage.setItem(NOTION_API_TOKEN_STORAGE_KEY, `"${NOTION_API_TOKEN}"`); 34 | 35 | cy.get('[name="originalUrl"]') 36 | .type(url.repository) 37 | .should('have.value', url.repository) 38 | .get('[type="submit"]') 39 | .contains('Shorten') 40 | .click(); 41 | }); 42 | 43 | after(() => { 44 | for (const shortenId of generatedShortenIds) { 45 | cy.request({ 46 | url: `/api/shortens/${shortenId}`, 47 | method: 'DELETE', 48 | headers: { 49 | authorization: NOTION_API_TOKEN, 50 | }, 51 | }); 52 | } 53 | }); 54 | 55 | it('유효한 URL을 입력하고 제출하면 성공 얼럿 메시지를 표시한다', () => { 56 | cy.wait('@shorten') 57 | .its('response.body') 58 | .then(({shorten}: {shorten: Shorten}) => { 59 | generatedShortenIds.push(shorten.id); 60 | }); 61 | 62 | cy.get('[role="alert"]').should('have.text', 'Success!'); 63 | }); 64 | 65 | it('유효한 URL을 입력하고 제출하면 단축된 URL을 표시한다', () => { 66 | cy.wait('@shorten') 67 | .its('response.body') 68 | .then(({shorten}: {shorten: Shorten}) => { 69 | generatedShortenIds.push(shorten.id); 70 | 71 | cy.get('[name="shortenUrl"]').should( 72 | 'have.value', 73 | `${window.location.origin}/${shorten.shortenUrlPath}`, 74 | ); 75 | }); 76 | }); 77 | 78 | it( 79 | 'Copy 버튼을 클릭 시, 클립보드에 URL 주소가 복사된다.', 80 | {browser: 'chrome'}, 81 | () => { 82 | cy.wait('@shorten') 83 | .its('response.body') 84 | .then(({shorten}: {shorten: Shorten}) => { 85 | generatedShortenIds.push(shorten.id); 86 | }); 87 | 88 | cy.get('[name="shortenUrl"]', {timeout: 8000}).should('exist'); 89 | cy.get('button').contains('Copy').click(); 90 | cy.get('[name="shortenUrl"]').then(($input) => { 91 | const shortenUrl = $input.val(); 92 | 93 | cy.get('@copy').should('be.calledWithExactly', shortenUrl); 94 | }); 95 | }, 96 | ); 97 | 98 | it('단축된 URL로 이동 시, 원본 URL로 리다이렉트된다', () => { 99 | cy.wait('@shorten') 100 | .its('response.body') 101 | .then(({shorten}: {shorten: Shorten}) => { 102 | generatedShortenIds.push(shorten.id); 103 | 104 | cy.get('[name="shortenUrl"]').then(($input) => { 105 | const shortenUrl = $input.val() as string; 106 | 107 | cy.origin( 108 | new URL(url.repository).origin, 109 | {args: {shorten, shortenUrl}}, 110 | // eslint-disable-next-line max-nested-callbacks 111 | ({shorten, shortenUrl}) => { 112 | cy.visit(encodeURI(shortenUrl)).then( 113 | // eslint-disable-next-line max-nested-callbacks 114 | () => cy.url().should('eq', shorten.originalUrl), 115 | ); 116 | }, 117 | ); 118 | }); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion URL Shortener 2 | 3 | [![CI](https://github.com/younho9/notion-url-shortener/actions/workflows/ci.yml/badge.svg)](https://github.com/younho9/notion-url-shortener/actions/workflows/ci.yml) 4 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) 5 | [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) 6 | 7 | > Uses Notion database as personal URL shortener. 8 | 9 | ## Features 10 | 11 | - Get your personal URL shortener with just a Notion. 12 | - Support `custom`, [`zero width`](https://zws.im/), `base64` type. 13 | - Private mode : Notion API token based authorization. 14 | - Count visits. 15 | 16 | ## Demo 17 | 18 | - [Public](https://notion-url-shortener.vercel.app/) 19 | - [Private](https://yh9.page/) 20 | 21 | ## Getting Started 22 | 23 | ### Prep work 24 | 25 | 1. [Create a Notion account](https://www.notion.so/signup) 26 | 2. [Duplicate this Notion database template](https://younho9.notion.site/0382396e66cd4575901bd3ba0959fdb9?v=8dc11ef9545f494bbc4bb2380b926d0e) 27 | 3. [Create a Notion API integration & Get Token](https://developers.notion.com/docs#step-1-create-an-integration) 28 | 4. [Share a database with your integration](https://developers.notion.com/docs#step-2-share-a-database-with-your-integration) 29 | 5. [Deploy on Vercel](<https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fyounho9%2Fnotion-url-shortener%2Ftree%2Fmain&env=NEXT_PUBLIC_NOTION_DATABASE_URL,NOTION_API_TOKEN&envDescription=Notion%20Database%20%26%20Notion%20API%20token%20is%20required.&envLink=https%3A%2F%2Fgithub.com%2Fyounho9%2Fnotion-url-shortener%23environment-variables&project-name=notion-url-shortener&repo-name=notion-url-shortener&demo-title=Notion%20URL%20Shortener&demo-description=Notion%20URL%20Shortener%20(Public)&demo-url=https%3A%2F%2Fnotion-url-shortener.vercel.app%2F>) 30 | 6. Set `NEXT_PUBLIC_NOTION_DATABASE_URL` to your database URL, `NOTION_API_TOKEN` to your token obtained in step 3. 31 | 32 | ## Deploy on Vercel 33 | 34 | [![Deploy with Vercel](https://vercel.com/button)](<https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fyounho9%2Fnotion-url-shortener%2Ftree%2Fmain&env=NEXT_PUBLIC_NOTION_DATABASE_URL,NOTION_API_TOKEN&envDescription=Notion%20Database%20%26%20Notion%20API%20token%20is%20required.&envLink=https%3A%2F%2Fgithub.com%2Fyounho9%2Fnotion-url-shortener%23environment-variables&project-name=notion-url-shortener&repo-name=notion-url-shortener&demo-title=Notion%20URL%20Shortener&demo-description=Notion%20URL%20Shortener%20(Public)&demo-url=https%3A%2F%2Fnotion-url-shortener.vercel.app%2F>) 35 | 36 | ## Environment Variables 37 | 38 | ### `NEXT_PUBLIC_NOTION_DATABASE_URL` 39 | 40 | **Required** Notion database page URL. 41 | 42 | ``` 43 | https://www.notion.so/<your-workspace>/a8aec43384f447ed84390e8e42c2e089 44 | # or 45 | https://<your-workspace>.notion.site/a8aec43384f447ed84390e8e42c2e089 46 | ``` 47 | 48 | ### `NOTION_API_TOKEN` 49 | 50 | **Required** Notion API Key. 51 | 52 | > [How to get Notion API Key](https://developers.notion.com/docs) 53 | 54 | <details> 55 | <summary>Show advanced options</summary> 56 | 57 | ### `USE_TOKEN_AUTH` 58 | 59 | If set to `true`, visitors without tokens cannot submit new URLs. 60 | 61 | _Default_ `false` 62 | 63 | ### `MAXIMUM_ZERO_WIDTH_SHORTEN_LENGTH` 64 | 65 | Maximum length of URL path with zero width shorten. 66 | 67 | _Default_ `8` 68 | 69 | ### `MAXIMUM_BASE64_SHORTEN_LENGTH` 70 | 71 | Maximum length of URL path with base64 shorten. 72 | 73 | _Default_ `7` 74 | 75 | ### `MAXIMUM_GENERATION_ATTEMPTS` 76 | 77 | Maximum number of times to retry when the generated URL path conflicts with the already registered URL path. 78 | 79 | _Default_ `5` 80 | 81 | </details> 82 | 83 | ## License 84 | 85 | [MIT](LICENSE) 86 | -------------------------------------------------------------------------------- /src/server/repositories/shorten.repository.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Shorten, 3 | CustomShortenRegisterInputSchema, 4 | GeneratedShortenRegisterInputSchema, 5 | GeneratedShortenType, 6 | } from '@/schemas'; 7 | import {SHORTEN_TYPE} from '@/schemas'; 8 | import {shortenConfig} from '@/server/configs/shorten'; 9 | import { 10 | IdNotFoundError, 11 | OverflowMaximumAttemptError, 12 | OverflowMaximumCountError, 13 | UrlNotFoundError, 14 | } from '@/server/errors'; 15 | import type ShortenModel from '@/server/models/shorten.model'; 16 | import type {CharsetIndex} from '@/utils'; 17 | import {encodeWithLeftPad, indexCharset, randomInteger} from '@/utils'; 18 | 19 | interface EncodedPathConfing { 20 | charset: string[]; 21 | length: number; 22 | } 23 | 24 | interface ShortenRepositoryConfig 25 | extends Record<GeneratedShortenType, EncodedPathConfing> { 26 | maximumGenerationAttempts: number; 27 | } 28 | 29 | export class ShortenRepository { 30 | private readonly shortenModel: ShortenModel; 31 | 32 | private readonly availableCount: { 33 | readonly zeroWidth: number; 34 | readonly base64: number; 35 | }; 36 | 37 | private readonly charsetIndex: { 38 | readonly zeroWidth: CharsetIndex; 39 | readonly base64: CharsetIndex; 40 | }; 41 | 42 | private readonly length: { 43 | readonly zeroWidth: number; 44 | readonly base64: number; 45 | }; 46 | 47 | private readonly maximumAvailableCount: number; 48 | private readonly maximumGenerationAttempts: number; 49 | 50 | public constructor( 51 | shortenModel: ShortenModel, 52 | config: ShortenRepositoryConfig = shortenConfig, 53 | ) { 54 | this.shortenModel = shortenModel; 55 | 56 | this.availableCount = { 57 | zeroWidth: config.zeroWidth.charset.length ** config.zeroWidth.length, 58 | base64: config.base64.charset.length ** config.base64.length, 59 | }; 60 | 61 | this.charsetIndex = { 62 | zeroWidth: indexCharset(config.zeroWidth.charset), 63 | base64: indexCharset(config.base64.charset), 64 | }; 65 | 66 | this.length = { 67 | zeroWidth: config.zeroWidth.length, 68 | base64: config.base64.length, 69 | }; 70 | 71 | this.maximumAvailableCount = Math.min( 72 | this.availableCount.zeroWidth, 73 | this.availableCount.base64, 74 | ); 75 | 76 | this.maximumGenerationAttempts = config.maximumGenerationAttempts; 77 | } 78 | 79 | async retrieveShortenUrlPath(shortenUrlPath: string) { 80 | const response = await this.shortenModel.findByShortenUrlPath( 81 | shortenUrlPath, 82 | ); 83 | 84 | if (response) { 85 | await this.shortenModel.incrementVisits(response.id); 86 | 87 | return response; 88 | } 89 | 90 | throw new UrlNotFoundError(shortenUrlPath); 91 | } 92 | 93 | async register( 94 | parameters: 95 | | CustomShortenRegisterInputSchema 96 | | GeneratedShortenRegisterInputSchema, 97 | ) { 98 | const isOverflowMaximum = await this.isOverflowMaximum(); 99 | 100 | if (isOverflowMaximum) { 101 | throw new OverflowMaximumCountError(); 102 | } 103 | 104 | if (parameters.type === SHORTEN_TYPE.CUSTOM) { 105 | return this.shortenModel.createShorten(parameters); 106 | } 107 | 108 | let attempts = 0; 109 | let created: Shorten | undefined; 110 | 111 | do { 112 | if (attempts++ > this.maximumGenerationAttempts) { 113 | throw new OverflowMaximumAttemptError(attempts); 114 | } 115 | 116 | const shortenUrlPath = encodeWithLeftPad( 117 | randomInteger(0, this.availableCount[parameters.type]), 118 | this.charsetIndex[parameters.type], 119 | this.length[parameters.type], 120 | ); 121 | 122 | // eslint-disable-next-line no-await-in-loop 123 | created = await this.shortenModel.createShorten({ 124 | ...parameters, 125 | shortenUrlPath, 126 | }); 127 | } while (!created); 128 | 129 | return created; 130 | } 131 | 132 | async unregister(id: number) { 133 | const hasDeleted = await this.shortenModel.deleteShorten(id); 134 | 135 | if (!hasDeleted) { 136 | throw new IdNotFoundError(id); 137 | } 138 | 139 | return hasDeleted; 140 | } 141 | 142 | async isOverflowMaximum() { 143 | const currentId = await this.shortenModel.getCurrentId(); 144 | 145 | return currentId === this.maximumAvailableCount; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /xo.config.cjs: -------------------------------------------------------------------------------- 1 | const getNamingConventionRule = ({isTsx}) => ({ 2 | '@typescript-eslint/naming-convention': [ 3 | 'error', 4 | { 5 | /// selector: ['variableLike', 'memberLike', 'property', 'method'], 6 | // Note: Leaving out `parameter` and `typeProperty` because of the mentioned known issues. 7 | // Note: We are intentionally leaving out `enumMember` as it's usually pascal-case or upper-snake-case. 8 | selector: [ 9 | 'variable', 10 | 'function', 11 | 'classProperty', 12 | 'objectLiteralProperty', 13 | 'parameterProperty', 14 | 'classMethod', 15 | 'objectLiteralMethod', 16 | 'typeMethod', 17 | 'accessor', 18 | ], 19 | format: ['strictCamelCase', isTsx && 'StrictPascalCase'].filter(Boolean), 20 | // We allow double underscore because of GraphQL type names and some React names. 21 | leadingUnderscore: 'allowSingleOrDouble', 22 | trailingUnderscore: 'allow', 23 | // Ignore `{'Retry-After': retryAfter}` type properties. 24 | // filter: { 25 | // regex: '[- ]', 26 | // match: false, 27 | // }, 28 | }, 29 | { 30 | selector: 'typeLike', 31 | format: ['StrictPascalCase'], 32 | }, 33 | { 34 | selector: 'variable', 35 | types: ['boolean'], 36 | format: ['StrictPascalCase'], 37 | prefix: ['is', 'has', 'can', 'should', 'will', 'did'], 38 | }, 39 | // Allow UPPER_CASE in global constants. 40 | { 41 | selector: 'variable', 42 | modifiers: ['const', 'global'], 43 | format: [ 44 | 'strictCamelCase', 45 | isTsx && 'StrictPascalCase', 46 | 'UPPER_CASE', 47 | ].filter(Boolean), 48 | }, 49 | { 50 | selector: 'variable', 51 | types: ['boolean'], 52 | modifiers: ['const', 'global'], 53 | format: ['UPPER_CASE'], 54 | prefix: ['IS_', 'HAS_', 'CAN_', 'SHOULD_', 'WILL_', 'DID_'], 55 | filter: '[A-Z]{2,}', 56 | }, 57 | { 58 | // Interface name should not be prefixed with `I`. 59 | selector: 'interface', 60 | filter: /^(?!I)[A-Z]/.source, 61 | format: ['StrictPascalCase'], 62 | }, 63 | { 64 | // Type parameter name should either be `T` or a descriptive name. 65 | selector: 'typeParameter', 66 | filter: /^T$|^[A-Z][a-zA-Z]+$/.source, 67 | format: ['StrictPascalCase'], 68 | }, 69 | // Allow these in non-camel-case when quoted. 70 | { 71 | selector: ['classProperty', 'objectLiteralProperty'], 72 | format: null, 73 | modifiers: ['requiresQuotes'], 74 | }, 75 | ], 76 | }); 77 | 78 | /** @type {import('xo').Options & {overrides?: Array<{files: string} & import('xo').Options>}} */ 79 | module.exports = { 80 | prettier: true, 81 | extends: [ 82 | 'xo-react', 83 | /** @see https://github.com/yannickcr/eslint-plugin-react#configuration */ 84 | 'plugin:react/jsx-runtime', 85 | 'plugin:cypress/recommended', 86 | 'plugin:@next/next/core-web-vitals', 87 | ], 88 | ignores: ['next-env.d.ts', 'yarn.lock'], 89 | rules: { 90 | 'import/extensions': 'off', 91 | 'import/order': [ 92 | 'warn', 93 | { 94 | 'newlines-between': 'always', 95 | 'alphabetize': { 96 | order: 'asc', 97 | caseInsensitive: true, 98 | }, 99 | }, 100 | ], 101 | 'react/function-component-definition': [ 102 | 2, 103 | { 104 | namedComponents: ['arrow-function'], 105 | }, 106 | ], 107 | /** @see https://github.com/sindresorhus/eslint-plugin-unicorn/issues/781 */ 108 | 'unicorn/no-array-callback-reference': 'off', 109 | /** @see https://github.com/webpack/webpack/issues/13290 */ 110 | 'unicorn/prefer-node-protocol': 'off', 111 | }, 112 | overrides: [ 113 | { 114 | files: '**/*.{ts,tsx}', 115 | rules: { 116 | ...getNamingConventionRule({isTsx: false}), 117 | '@typescript-eslint/consistent-type-imports': [ 118 | 'error', 119 | { 120 | prefer: 'type-imports', 121 | }, 122 | ], 123 | }, 124 | }, 125 | { 126 | files: '**/*.tsx', 127 | rules: { 128 | ...getNamingConventionRule({isTsx: true}), 129 | /** @see https://github.com/typescript-eslint/typescript-eslint/issues/1184 */ 130 | '@typescript-eslint/no-floating-promises': [ 131 | 'error', 132 | { 133 | ignoreVoid: true, 134 | }, 135 | ], 136 | 'react/prop-types': 'off', 137 | 'unicorn/filename-case': [ 138 | 'error', 139 | { 140 | cases: { 141 | camelCase: true, 142 | pascalCase: true, 143 | }, 144 | }, 145 | ], 146 | }, 147 | }, 148 | ], 149 | }; 150 | -------------------------------------------------------------------------------- /tests/api/shortens/index.test.ts: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest, NextApiResponse} from 'next'; 2 | import type {MockResponse} from 'node-mocks-http'; 3 | import {createMocks} from 'node-mocks-http'; 4 | import {describe, it, expect, beforeEach, afterAll} from 'vitest'; 5 | 6 | import {TIMEOUT} from '../../constants'; 7 | 8 | import {NOTION_API_TOKEN, NOTION_DATABASE_ID} from '@/constants'; 9 | import shortenHandler from '@/pages/api/shortens'; 10 | import type {ShortenResponse} from '@/pages/api/shortens'; 11 | import type { 12 | CustomShortenRegisterInputSchema, 13 | GeneratedShortenRegisterInputSchema, 14 | } from '@/schemas'; 15 | import {NotionDBClient} from '@/server/database'; 16 | 17 | interface Mocks { 18 | req: NextApiRequest; 19 | res: MockResponse<NextApiResponse<ShortenResponse>>; 20 | } 21 | 22 | describe('/api/shortens', () => { 23 | const generatedShortenIds: number[] = []; 24 | let now: Date; 25 | 26 | beforeEach(() => { 27 | now = new Date(); 28 | now.setSeconds(0, 0); // Remove seconds and milliseconds 29 | }); 30 | 31 | afterAll(async () => { 32 | const notionDatabase = new NotionDBClient({ 33 | auth: NOTION_API_TOKEN, 34 | databaseId: NOTION_DATABASE_ID, 35 | }); 36 | 37 | for await (const id of generatedShortenIds) { 38 | await notionDatabase.delete(id); 39 | } 40 | }, TIMEOUT); 41 | 42 | it( 43 | '[POST] GeneratedShortenRegisterInputSchema에 따라 요청한 경우 200 OK 응답을 반환한다.', 44 | async () => { 45 | const {req, res}: Mocks = createMocks({method: 'POST'}); 46 | 47 | req.headers = { 48 | authorization: NOTION_API_TOKEN, 49 | }; 50 | 51 | (req.body as GeneratedShortenRegisterInputSchema) = { 52 | type: 'zeroWidth', 53 | originalUrl: 'https://github.com/younho9/notion-url-shortener', 54 | }; 55 | 56 | await shortenHandler(req, res); 57 | 58 | const {shorten} = res._getJSONData() as ShortenResponse; 59 | generatedShortenIds.push(shorten.id); 60 | 61 | expect(res.statusCode).toBe(200); 62 | }, 63 | TIMEOUT, 64 | ); 65 | 66 | it( 67 | '[POST] CustomShortenRegisterInputSchema에 따라 요청한 경우 200 OK 응답을 반환한다.', 68 | async () => { 69 | const {req, res}: Mocks = createMocks({method: 'POST'}); 70 | 71 | req.headers = { 72 | authorization: NOTION_API_TOKEN, 73 | }; 74 | 75 | (req.body as CustomShortenRegisterInputSchema) = { 76 | type: 'custom', 77 | originalUrl: 'https://github.com/younho9/notion-url-shortener', 78 | shortenUrlPath: 'notion-url-shortener', 79 | }; 80 | 81 | await shortenHandler(req, res); 82 | 83 | const {shorten} = res._getJSONData() as ShortenResponse; 84 | generatedShortenIds.push(shorten.id); 85 | 86 | expect(res.statusCode).toBe(200); 87 | }, 88 | TIMEOUT, 89 | ); 90 | 91 | it( 92 | '[POST] shortenRegisterInputSchema에 따르지 않고 요청한 경우 400 Bad Request 응답을 반환한다.', 93 | async () => { 94 | const {req, res}: Mocks = createMocks({method: 'POST'}); 95 | 96 | req.headers = { 97 | authorization: NOTION_API_TOKEN, 98 | }; 99 | 100 | req.body = { 101 | type: 'base64', 102 | originalUrl: 'hello-world', 103 | }; 104 | 105 | await shortenHandler(req, res); 106 | 107 | const data = res._getJSONData() as Record<string, unknown>; 108 | 109 | expect(res.statusCode).toBe(400); 110 | expect(data.code).toBe('invalid_input'); 111 | expect(data.message).toBe('invalid input'); 112 | }, 113 | TIMEOUT, 114 | ); 115 | 116 | it( 117 | '[POST] 200 OK 응답의 페이로드는 ShortenResponse 타입이다.', 118 | async () => { 119 | const {req, res}: Mocks = createMocks({method: 'POST'}); 120 | 121 | req.headers = { 122 | authorization: NOTION_API_TOKEN, 123 | }; 124 | 125 | req.body = { 126 | type: 'base64', 127 | originalUrl: 'https://github.com/younho9/notion-url-shortener', 128 | }; 129 | 130 | await shortenHandler(req, res); 131 | 132 | const {shorten} = res._getJSONData() as ShortenResponse; 133 | generatedShortenIds.push(shorten.id); 134 | 135 | expect(res.statusCode).toBe(200); 136 | expect(typeof shorten.id).toBe('number'); 137 | expect(typeof shorten.shortenUrlPath).toBe('string'); 138 | expect(shorten.originalUrl).toBe(req.body.originalUrl); 139 | expect(shorten.type).toBe(req.body.type); 140 | expect(shorten.visits).toBe(0); 141 | expect(new Date(shorten.createdAt).getTime()).toBeGreaterThanOrEqual(now.getTime()); // prettier-ignore 142 | expect(new Date(shorten.updatedAt).getTime()).toBeGreaterThanOrEqual(now.getTime()); // prettier-ignore 143 | }, 144 | TIMEOUT, 145 | ); 146 | 147 | it('[POST] authorization 헤더가 없는 경우 401 Unauthorized 에러를 반환한다.', async () => { 148 | const {req, res}: Mocks = createMocks({method: 'POST'}); 149 | 150 | req.body = { 151 | type: 'zeroWidth', 152 | originalUrl: 'https://github.com/younho9', 153 | }; 154 | 155 | await shortenHandler(req, res); 156 | 157 | const data = res._getJSONData() as Record<string, unknown>; 158 | 159 | expect(res.statusCode).toBe(401); 160 | expect(data.code).toBe('unauthorized'); 161 | expect(data.message).toBe('API token is invalid.'); 162 | }); 163 | 164 | it('지원하지 않는 메소드에 대해 405 Method Not Allowed 에러를 반환한다.', async () => { 165 | const {req, res}: Mocks = createMocks({method: 'GET'}); 166 | 167 | await shortenHandler(req, res); 168 | 169 | const data = res._getJSONData() as Record<string, unknown>; 170 | 171 | expect(res.statusCode).toBe(405); 172 | expect(data.code).toBe('method_not_allowed'); 173 | expect(data.message).toBe('GET method is not allowed'); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/server/errors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import is from '@sindresorhus/is'; 3 | import {get} from '@younho9/object'; 4 | import {z} from 'zod'; 5 | 6 | export const NOTION_URL_SHORTENER_ERROR_CODE = { 7 | OVERFLOW_MAXIMUM_COUNT: 'overflow_maximum_count', 8 | OVERFLOW_MAXIMUM_ATTEMPT: 'overflow_maximum_attempt', 9 | BAD_REQUEST: 'bad_request', 10 | INVALID_INPUT: 'invalid_input', 11 | METHOD_NOT_ALLOWED: 'method_not_allowed', 12 | DUPLICATE_SHORTEN_URL_PATH: 'duplicate_shorten_url_path', 13 | URL_NOT_FOUND: 'url_not_found', 14 | ID_NOT_FOUND: 'id_not_found', 15 | UNKNOWN_NOTION_URL_SHORTENER_ERROR: 'unknown_notion_url_shortener_error', 16 | } as const; 17 | 18 | export const notionUrlShortenerErrorCodeSchema = z.nativeEnum( 19 | NOTION_URL_SHORTENER_ERROR_CODE, 20 | ); 21 | 22 | export type NotionUrlShortenerErrorCode = z.infer< 23 | typeof notionUrlShortenerErrorCodeSchema 24 | >; 25 | 26 | export const NOTION_URL_SHORTENER_ERROR_STATUS_CODE = { 27 | OVERFLOW_MAXIMUM_COUNT: 500, // Internal Server Error 28 | OverflowMaximumAttempt: 408, // Request Timeout 29 | BAD_REQUEST: 400, // Bad Request 30 | INVALID_INPUT: 400, // Bad Request 31 | METHOD_NOT_ALLOWED: 405, // Method Not Allowed 32 | DUPLICATE_SHORTEN_URL_PATH: 409, // Conflict 33 | URL_NOT_FOUND: 404, // Not Found 34 | ID_NOT_FOUND: 404, // Not Found 35 | UNKNOWN_NOTION_URL_SHORTENER_ERROR: 500, // Internal Server Error 36 | } as const; 37 | 38 | export const notionUrlShortenerErrorStatusCodeSchema = z.nativeEnum( 39 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE, 40 | ); 41 | 42 | export type NotionUrlShortenerErrorStatusCode = z.infer< 43 | typeof notionUrlShortenerErrorStatusCodeSchema 44 | >; 45 | 46 | export class NotionUrlShortenerError extends Error { 47 | constructor( 48 | public readonly code: NotionUrlShortenerErrorCode, 49 | public readonly status: NotionUrlShortenerErrorStatusCode, 50 | public readonly message: string, 51 | ) { 52 | super(message); 53 | } 54 | 55 | toString() { 56 | return `${this.name} [${this.code}]: ${this.message}` as const; 57 | } 58 | } 59 | 60 | export class UnknownNotionUrlShortenerError extends NotionUrlShortenerError { 61 | constructor(error?: Error) { 62 | super( 63 | NOTION_URL_SHORTENER_ERROR_CODE.UNKNOWN_NOTION_URL_SHORTENER_ERROR, 64 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.UNKNOWN_NOTION_URL_SHORTENER_ERROR, 65 | error?.message ?? 'Unkown internal notion url shortener server error', 66 | ); 67 | } 68 | } 69 | 70 | export class OverflowMaximumCountError extends NotionUrlShortenerError { 71 | constructor() { 72 | super( 73 | NOTION_URL_SHORTENER_ERROR_CODE.OVERFLOW_MAXIMUM_COUNT, 74 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.OVERFLOW_MAXIMUM_COUNT, 75 | 'The maximum number of shorten url has been reached', 76 | ); 77 | } 78 | } 79 | 80 | export class OverflowMaximumAttemptError extends NotionUrlShortenerError { 81 | constructor(attempts: number) { 82 | super( 83 | NOTION_URL_SHORTENER_ERROR_CODE.OVERFLOW_MAXIMUM_ATTEMPT, 84 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.OverflowMaximumAttempt, 85 | `The maximum number of generation attempts(${attempts}) has been eached`, 86 | ); 87 | } 88 | } 89 | 90 | export class BadRequestError extends NotionUrlShortenerError { 91 | constructor() { 92 | super( 93 | NOTION_URL_SHORTENER_ERROR_CODE.BAD_REQUEST, 94 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.BAD_REQUEST, 95 | 'bad request', 96 | ); 97 | } 98 | } 99 | 100 | export class InvalidInputError extends NotionUrlShortenerError { 101 | constructor(public readonly issues: z.ZodIssue[]) { 102 | super( 103 | NOTION_URL_SHORTENER_ERROR_CODE.INVALID_INPUT, 104 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.INVALID_INPUT, 105 | 'invalid input', 106 | ); 107 | } 108 | } 109 | 110 | export class MethodNotAllowedError extends NotionUrlShortenerError { 111 | constructor(method?: string) { 112 | super( 113 | NOTION_URL_SHORTENER_ERROR_CODE.METHOD_NOT_ALLOWED, 114 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.METHOD_NOT_ALLOWED, 115 | method ? `${method} method is not allowed` : 'Method is not allowed', 116 | ); 117 | } 118 | } 119 | 120 | export class DuplicateShortenUrlPathError extends NotionUrlShortenerError { 121 | constructor(shortenUrlPath: string) { 122 | super( 123 | NOTION_URL_SHORTENER_ERROR_CODE.DUPLICATE_SHORTEN_URL_PATH, 124 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.DUPLICATE_SHORTEN_URL_PATH, 125 | `'${shortenUrlPath}' is duplicated with already registered shorten url path`, 126 | ); 127 | } 128 | } 129 | 130 | export class UrlNotFoundError extends NotionUrlShortenerError { 131 | constructor(shortenUrlPath: string) { 132 | super( 133 | NOTION_URL_SHORTENER_ERROR_CODE.URL_NOT_FOUND, 134 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.URL_NOT_FOUND, 135 | `'${shortenUrlPath}' is not found`, 136 | ); 137 | } 138 | } 139 | 140 | export class IdNotFoundError extends NotionUrlShortenerError { 141 | constructor(id: number) { 142 | super( 143 | NOTION_URL_SHORTENER_ERROR_CODE.ID_NOT_FOUND, 144 | NOTION_URL_SHORTENER_ERROR_STATUS_CODE.ID_NOT_FOUND, 145 | `Shorten id '${id}' is not found`, 146 | ); 147 | } 148 | } 149 | 150 | export const getName = ( 151 | error: unknown, 152 | defaultName = 'UnknownNotionUrlShortenerError', 153 | ): string => { 154 | const name = get(error, 'name'); 155 | 156 | return is.string(name) ? name : defaultName; 157 | }; 158 | 159 | export const getStatus = (error: unknown, defaultStatus = 500): number => { 160 | const status = get(error, 'status'); 161 | 162 | return is.number(status) ? status : defaultStatus; 163 | }; 164 | 165 | export const getMessage = ( 166 | error: unknown, 167 | defaultMessage = 'Internal Server Error', 168 | ): string => { 169 | const message = get(error, 'message'); 170 | 171 | return is.string(message) ? message : defaultMessage; 172 | }; 173 | -------------------------------------------------------------------------------- /src/server/database/notion.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import {Client} from '@notionhq/client'; 3 | import type { 4 | CreatePageParameters, 5 | QueryDatabaseParameters, 6 | QueryDatabaseResponse, 7 | UpdatePageParameters, 8 | } from '@notionhq/client/build/src/api-endpoints'; 9 | import type {ClientOptions} from '@notionhq/client/build/src/Client'; 10 | import is from '@sindresorhus/is'; 11 | import {isDefined, objectHasOwn} from 'ts-extras'; 12 | import type {Except, IterableElement, ValueOf} from 'type-fest'; 13 | 14 | import type {DatabaseClient} from '@/server/database/types/database-client'; 15 | 16 | export type NotionRow = Extract< 17 | IterableElement<QueryDatabaseResponse['results']>, 18 | {properties: unknown} 19 | >['properties']; 20 | 21 | export default class NotionDBClient extends Client implements DatabaseClient { 22 | private readonly DEFAULT_FILTER = { 23 | and: [ 24 | { 25 | timestamp: 'created_time', 26 | created_time: { 27 | is_not_empty: true, 28 | }, 29 | } as const, 30 | ], 31 | }; 32 | 33 | private readonly databaseId: string; 34 | 35 | public constructor(options: ClientOptions & {databaseId: string}) { 36 | super(options); 37 | this.databaseId = options.databaseId; 38 | } 39 | 40 | public async queryAll<Type extends Record<string, unknown>>( 41 | parameters?: Except<QueryDatabaseParameters, 'database_id'>, 42 | ) { 43 | const results = await this._query(parameters); 44 | 45 | return this._parseQueryResults<Type>(results); 46 | } 47 | 48 | public async queryOne<Type extends Record<string, unknown>>( 49 | parameters?: Except<QueryDatabaseParameters, 'database_id'>, 50 | ) { 51 | const results = await this.queryAll<Type>(parameters); 52 | 53 | return results.at(0); 54 | } 55 | 56 | public async findById<Type extends Record<string, unknown>>(id: number) { 57 | return this.queryOne<Type>({ 58 | filter: { 59 | property: 'id', 60 | number: { 61 | equals: id, 62 | }, 63 | }, 64 | }); 65 | } 66 | 67 | public async retrieve() { 68 | return this.databases.retrieve({ 69 | database_id: this.databaseId, 70 | }); 71 | } 72 | 73 | public async create<Type extends Record<string, unknown>>( 74 | properties: Extract< 75 | CreatePageParameters, 76 | Record<'parent', Record<'database_id', unknown>> 77 | >['properties'], 78 | ) { 79 | const response = await this.pages.create({ 80 | parent: { 81 | database_id: this.databaseId, 82 | }, 83 | children: [], 84 | properties, 85 | }); 86 | 87 | if (objectHasOwn(response, 'properties')) { 88 | return this._parseRow<Type>(response.properties); 89 | } 90 | } 91 | 92 | public async update<Type extends Record<string, unknown>>( 93 | id: number, 94 | properties: Extract< 95 | UpdatePageParameters, 96 | Record<'parent', Record<'database_id', unknown>> 97 | >['properties'], 98 | ) { 99 | const result = await this._findById(id); 100 | 101 | if (is.undefined(result)) { 102 | return; 103 | } 104 | 105 | const pageId = result.id; 106 | 107 | const response = await this.pages.update({ 108 | page_id: pageId, 109 | properties, 110 | }); 111 | 112 | if (objectHasOwn(response, 'properties')) { 113 | return this._parseRow<Type>(response.properties); 114 | } 115 | } 116 | 117 | public async delete(id: number) { 118 | const result = await this._findById(id); 119 | 120 | if (result) { 121 | const blockId = result.id; 122 | 123 | await this.blocks.delete({ 124 | block_id: blockId, 125 | }); 126 | 127 | return true; 128 | } 129 | 130 | return false; 131 | } 132 | 133 | private async _query( 134 | parameters?: Except<QueryDatabaseParameters, 'database_id'>, 135 | ) { 136 | const {results} = await this.databases.query({ 137 | ...parameters, 138 | filter: parameters?.filter ?? this.DEFAULT_FILTER, 139 | database_id: this.databaseId, 140 | }); 141 | 142 | return results; 143 | } 144 | 145 | private async _findById(id: number) { 146 | const results = await this._query({ 147 | filter: { 148 | property: 'id', 149 | number: { 150 | equals: id, 151 | }, 152 | }, 153 | }); 154 | 155 | return results.at(0); 156 | } 157 | 158 | // eslint-disable-next-line @typescript-eslint/ban-types 159 | private _parseRow<Type extends object>(row: NotionRow) { 160 | return Object.fromEntries( 161 | Object.entries(row).map(([key, value]) => [ 162 | key, 163 | this._parseNotionPropertyValue(value), 164 | ]), 165 | ) as Type; 166 | } 167 | 168 | // eslint-disable-next-line @typescript-eslint/ban-types 169 | private _parseQueryResults<Type extends object>( 170 | results: QueryDatabaseResponse['results'], 171 | ) { 172 | return results 173 | .map((result) => 174 | objectHasOwn(result, 'properties') ? result.properties : undefined, 175 | ) 176 | .filter(isDefined) 177 | .map((row) => this._parseRow<Type>(row)); 178 | } 179 | 180 | private _parseNotionPropertyValue(value: ValueOf<NotionRow>) { 181 | const title = (value: Extract<ValueOf<NotionRow>, {type: 'title'}>) => 182 | value.title.map(({plain_text}) => plain_text).join(''); 183 | 184 | const number = ({number}: Extract<ValueOf<NotionRow>, {type: 'number'}>) => 185 | number ?? 0; 186 | 187 | const url = ({url}: Extract<ValueOf<NotionRow>, {type: 'url'}>) => 188 | url ?? ''; 189 | 190 | const select = ({select}: Extract<ValueOf<NotionRow>, {type: 'select'}>) => 191 | select?.name ?? ''; 192 | 193 | const createdTime = ({ 194 | created_time, 195 | }: Extract<ValueOf<NotionRow>, {type: 'created_time'}>) => created_time; 196 | 197 | const lastEditedTime = ({ 198 | last_edited_time, 199 | }: Extract<ValueOf<NotionRow>, {type: 'last_edited_time'}>) => 200 | last_edited_time; 201 | 202 | switch (value.type) { 203 | case 'title': 204 | return title(value); 205 | case 'number': 206 | return number(value); 207 | case 'url': 208 | return url(value); 209 | case 'select': 210 | return select(value); 211 | case 'created_time': 212 | return createdTime(value); 213 | case 'last_edited_time': 214 | return lastEditedTime(value); 215 | default: 216 | return null; 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/components/RegisterUrlForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | AlertIcon, 4 | AlertTitle, 5 | InputGroup, 6 | Input, 7 | InputRightElement, 8 | Button, 9 | RadioGroup, 10 | Radio, 11 | Stack, 12 | Text, 13 | Center, 14 | HStack, 15 | } from '@chakra-ui/react'; 16 | import React from 'react'; 17 | 18 | import ShowItem, {SHOW_ITEM_DELAY_UNIT} from '@/components/ShowItem'; 19 | import {useRegisterShortenReducer} from '@/reducers'; 20 | import type {ShortenType} from '@/schemas'; 21 | import {SHORTEN_TYPE} from '@/schemas'; 22 | import {copyTextToClipboard} from '@/utils'; 23 | 24 | interface RegisterUrlFormProps { 25 | token?: string; 26 | onClickCapture?: React.MouseEventHandler<HTMLFormElement>; 27 | } 28 | 29 | const RegisterUrlForm = ({token, onClickCapture}: RegisterUrlFormProps) => { 30 | const [originalUrl, setOriginalUrl] = React.useState(''); 31 | const [shortenType, setShortenType] = React.useState<ShortenType>(SHORTEN_TYPE.ZERO_WIDTH); // prettier-ignore 32 | const [customShortenUrlPath, setCustomShortenUrlPath] = React.useState(''); 33 | const [isCopied, setIsCopied] = React.useState(false); 34 | const {state, startRegisterShorten, retryRegisterShorten} = useRegisterShortenReducer(); // prettier-ignore 35 | 36 | const isIdle = state.status === 'IDLE'; 37 | const isPending = state.status === 'PENDING'; 38 | const isResolved = state.status === 'RESOLVED'; 39 | const isRejected = state.status === 'REJECTED'; 40 | const shortenUrl = state.shorten ? `${window.location.origin}/${state.shorten.shortenUrlPath}` : ''; // prettier-ignore 41 | 42 | const handleSubmitForm: React.FormEventHandler<HTMLFormElement> = async ( 43 | event, 44 | ) => { 45 | event.preventDefault(); 46 | 47 | if (isIdle) { 48 | const shortenRequest = 49 | shortenType === SHORTEN_TYPE.CUSTOM 50 | ? { 51 | type: shortenType, 52 | originalUrl, 53 | shortenUrlPath: customShortenUrlPath, 54 | } 55 | : { 56 | type: shortenType, 57 | originalUrl, 58 | }; 59 | 60 | await startRegisterShorten(shortenRequest, token); 61 | } 62 | }; 63 | 64 | const handleRetryButtonClick = () => { 65 | retryRegisterShorten(); 66 | 67 | if (isResolved) { 68 | setOriginalUrl(''); 69 | setCustomShortenUrlPath(''); 70 | } 71 | }; 72 | 73 | const handleoriginalUrlInputChange: React.ChangeEventHandler< 74 | HTMLInputElement 75 | > = (event) => { 76 | setOriginalUrl(event.target.value); 77 | }; 78 | 79 | const handleCustomShortenUrlPathInputChange: React.ChangeEventHandler< 80 | HTMLInputElement 81 | > = (event) => { 82 | setCustomShortenUrlPath(event.target.value); 83 | }; 84 | 85 | const handleCopyButtonClick: React.MouseEventHandler< 86 | HTMLButtonElement 87 | > = async () => { 88 | const isSuccess = await copyTextToClipboard(shortenUrl); 89 | 90 | if (isSuccess) { 91 | setIsCopied(true); 92 | 93 | setTimeout(() => { 94 | setIsCopied(false); 95 | }, 1500); 96 | } 97 | }; 98 | 99 | return ( 100 | <form onSubmit={handleSubmitForm} onClickCapture={onClickCapture}> 101 | {(isPending || isIdle) && ( 102 | <Stack> 103 | <ShowItem direction="down" delay={SHOW_ITEM_DELAY_UNIT}> 104 | <Input 105 | isRequired 106 | boxShadow="sm" 107 | id="originalUrl" 108 | type="url" 109 | name="originalUrl" 110 | placeholder="Enter a URL to Shorten" 111 | value={originalUrl} 112 | isDisabled={isPending} 113 | onChange={handleoriginalUrlInputChange} 114 | /> 115 | </ShowItem> 116 | 117 | {shortenType === SHORTEN_TYPE.CUSTOM && ( 118 | <ShowItem direction="down"> 119 | <Input 120 | isRequired 121 | boxShadow="sm" 122 | id="customShortenUrlPath" 123 | type="text" 124 | name="customShortenUrlPath" 125 | placeholder="Enter a custom pathname" 126 | value={customShortenUrlPath} 127 | isDisabled={isPending} 128 | onChange={handleCustomShortenUrlPathInputChange} 129 | /> 130 | </ShowItem> 131 | )} 132 | 133 | <ShowItem direction="down" delay={SHOW_ITEM_DELAY_UNIT * 2}> 134 | <Center> 135 | <RadioGroup 136 | as={HStack} 137 | spacing={4} 138 | py={3} 139 | value={shortenType} 140 | isDisabled={isPending} 141 | onChange={ 142 | setShortenType as React.Dispatch<React.SetStateAction<string>> 143 | } 144 | > 145 | {Object.values(SHORTEN_TYPE).map((shortenType) => ( 146 | <Radio 147 | key={shortenType} 148 | boxShadow="sm" 149 | id={shortenType} 150 | name={shortenType} 151 | value={shortenType} 152 | > 153 | <Text fontSize="sm" textTransform="capitalize"> 154 | {shortenType} 155 | </Text> 156 | </Radio> 157 | ))} 158 | </RadioGroup> 159 | </Center> 160 | </ShowItem> 161 | 162 | <ShowItem direction="down" delay={SHOW_ITEM_DELAY_UNIT * 3}> 163 | <Button 164 | colorScheme="blue" 165 | boxShadow="sm" 166 | w="full" 167 | size="md" 168 | type="submit" 169 | isLoading={isPending} 170 | > 171 | Shorten 172 | </Button> 173 | </ShowItem> 174 | </Stack> 175 | )} 176 | 177 | {(isResolved || isRejected) && ( 178 | <Stack> 179 | <ShowItem direction="down" delay={SHOW_ITEM_DELAY_UNIT}> 180 | <Alert 181 | status={isResolved ? 'success' : 'error'} 182 | variant="left-accent" 183 | > 184 | <AlertIcon /> 185 | <AlertTitle>{isResolved ? 'Success!' : state.error}</AlertTitle> 186 | </Alert> 187 | </ShowItem> 188 | {isResolved && ( 189 | <ShowItem direction="up" delay={SHOW_ITEM_DELAY_UNIT * 2}> 190 | <InputGroup size="md"> 191 | <Input 192 | readOnly 193 | boxShadow="sm" 194 | id="shortenUrl" 195 | type="url" 196 | name="shortenUrl" 197 | placeholder="Shornted URL" 198 | value={shortenUrl} 199 | /> 200 | <InputRightElement width="4.5rem"> 201 | <Button h="1.75rem" size="sm" onClick={handleCopyButtonClick}> 202 | {isCopied ? 'Copied!' : 'Copy'} 203 | </Button> 204 | </InputRightElement> 205 | </InputGroup> 206 | </ShowItem> 207 | )} 208 | <ShowItem direction="up" delay={SHOW_ITEM_DELAY_UNIT * 3}> 209 | <Button 210 | colorScheme="blue" 211 | boxShadow="sm" 212 | w="full" 213 | size="md" 214 | onClick={handleRetryButtonClick} 215 | > 216 | Retry 217 | </Button> 218 | </ShowItem> 219 | </Stack> 220 | )} 221 | </form> 222 | ); 223 | }; 224 | 225 | export default RegisterUrlForm; 226 | --------------------------------------------------------------------------------