commit b648cde8bcbd5268710b9af061a426aa3b8f13f5 Author: TZGyn Date: Fri Nov 10 23:50:54 2023 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6b38648 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..86b5966 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +host=0.0.0.0 +user=postgres +password=password +port=5432 +database=railway +FALLBACK_URL=https://shortener.tzgyn.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb291b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..9ffa4a8 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,15 @@ +--- +printWidth: 80 +tabWidth: 4 +useTabs: true +semi: false +singleQuote: true +quoteProps: consistent +jsxSingleQuote: true +trailingComma: es5 +bracketSpacing: true +bracketSameLine: true +arrowParens: always +htmlWhitespaceSensitivity: strict +vueIndentScriptAndStyle: false +singleAttributePerLine: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fd5aff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM docker.io/oven/bun + +RUN mkdir /shortener-backend +WORKDIR /shortener-backend + +COPY ./package.json ./ +COPY ./bun.lockb ./ + +RUN bun install --production + +COPY . . + +EXPOSE 3000 +ENV NODE_ENV production + +ENTRYPOINT [ "bun", "run", "./src/index.ts" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..688c87e --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Elysia with Bun runtime + +## Getting Started +To get started with this template, simply paste this command into your terminal: +```bash +bun create elysia ./elysia-example +``` + +## Development +To start the development server run: +```bash +bun run dev +``` + +Open http://localhost:3000/ with your browser to see the result. \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..f62cded Binary files /dev/null and b/bun.lockb differ diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..3d28cf9 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,15 @@ +--- +# docker-compose.yml +version: '3.9' +services: + app: + image: oven/bun + container_name: linkshortener_elysia_dev + # override default entrypoint allows us to do `bun install` before serving + entrypoint: [] + # execute bun install before we start the dev server in watch mode + command: /bin/sh -c 'bun install && bun run --watch src/index.ts' + # expose the right ports + ports: [3000:3000] + # setup a host mounted volume to sync changes to the container + volumes: [./:/home/bun/app] diff --git a/package.json b/package.json new file mode 100644 index 0000000..2b0287d --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "elysia", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "bun run --watch src/index.ts" + }, + "dependencies": { + "@elysiajs/cors": "^0.6.0", + "@types/pg": "^8.10.2", + "elysia": "latest", + "kysely": "^0.26.3", + "magic-regexp": "^0.7.0", + "nanoid": "^5.0.1", + "pg": "^8.11.3", + "zod": "^3.22.2" + }, + "devDependencies": { + "bun-types": "latest" + }, + "module": "src/index.js" +} \ No newline at end of file diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..8b29a2e --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,53 @@ +import { nanoid } from 'nanoid' +import { db } from './database' + +export const signup = async ( + email: string, + username: string, + password: string, + password_confirm: string +) => { + if (password !== password_confirm) { + return { error: 'password is not the same' } + } + + if (password.length < 8) { + return { error: 'password should be at least length 8' } + } + + try { + await db + .insertInto('user') + .values({ + uuid: nanoid(16), + email, + username, + password: await Bun.password.hash(password), + }) + .execute() + return { error: undefined } + } catch (error) { + console.log(error) + return { error: 'error' } + } +} + +export const login = async (email: string, password: string) => { + const userArray = await db + .selectFrom('user') + .selectAll() + .where('user.email', '=', email) + .execute() + + if (userArray.length < 1) { + return { error: 'Invalid User' } + } + + const user = userArray[0] + + if (await Bun.password.verify(password, user.password)) { + return { user } + } else { + return { error: 'Incorrect Credentials' } + } +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..183ee21 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,18 @@ +import { Database } from "./types"; +import { Pool } from "pg"; +import { Kysely, PostgresDialect } from "kysely"; + +const dialect = new PostgresDialect({ + pool: new Pool({ + database: Bun.env.database ?? "link-shortener", + host: Bun.env.host ?? "0.0.0.0", + user: Bun.env.user ?? "postgres", + password: Bun.env.password ?? "password", + port: parseInt(Bun.env.port ?? "") ?? 5432, + max: 10, + }), +}); + +export const db = new Kysely({ + dialect, +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..86218e9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,187 @@ +import { Elysia, t } from 'elysia' +import { nanoid } from 'nanoid' +import { db } from './database' +import { createLinkSchema } from './zodSchema' +import { cors } from '@elysiajs/cors' +import { jsonArrayFrom } from 'kysely/helpers/postgres' +import { login, signup } from './auth' + +const fallback_url = Bun.env.FALLBACK_URL ?? 'https://shortener.tzgyn.com' + +const app = new Elysia().use(cors()) + +app.get('/', () => 'Hello Elysia') +app.get('/invalid', () => 'Invalid Shortener') + +app.get('/link', async () => { + const shorteners = await db + .selectFrom('shortener') + .leftJoin('visitor', 'visitor.shortener_id', 'shortener.id') + .select(({ fn }) => [ + 'shortener.id', + 'shortener.link', + 'shortener.code', + 'shortener.created_at', + fn.count('visitor.id').as('visitor_count'), + ]) + .groupBy('shortener.id') + .execute() + + return { shorteners } +}) + +app.post('/link', async ({ body, set }) => { + const createLink = createLinkSchema.safeParse(body) + + if (!createLink.success) { + set.status = 400 + return { message: 'Invalid Link', body } + } + + const uuid = nanoid(10) + + await db + .insertInto('shortener') + .values({ + link: createLink.data.link, + code: uuid, + }) + .execute() + + return { message: 'Success' } +}) + +app.get( + '/:shortenerCode', + async ({ params: { shortenerCode }, set, request }) => { + const ip = request.headers.get('x-forwarded-for') + + const geolocation = await ( + await fetch(`https://api.ipbase.com/v2/info?ip=${ip}`) + ).json() + + try { + const shortener = await db + .selectFrom('shortener') + .selectAll() + .where('code', '=', shortenerCode) + .orderBy('created_at', 'desc') + .execute() + + const visitor_data = { + shortener_id: shortener[0].id, + country: geolocation.data.location.country.name as string, + country_code: geolocation.data.location.country + .alpha2 as string, + } + + await db.insertInto('visitor').values(visitor_data).execute() + + if (!shortener.length) { + set.redirect = '/invalid' + return + } + + set.redirect = shortener[0].link + } catch { + set.redirect = fallback_url + } + } +) + +app.get('/link/:shortenerCode', async ({ params: { shortenerCode } }) => { + try { + const shorteners = await db + .selectFrom('shortener') + .select((shortener) => [ + 'id', + 'code', + 'link', + 'created_at', + jsonArrayFrom( + shortener + .selectFrom('visitor') + .select([ + 'visitor.created_at as visited_at', + 'visitor.country_code', + ]) + .whereRef('visitor.shortener_id', '=', 'shortener.id') + ).as('visitors'), + ]) + .where('code', '=', shortenerCode) + .execute() + + const visitors = await db + .selectFrom('visitor') + .select(({ fn }) => [ + 'visitor.country_code', + 'visitor.country', + fn.count('visitor.id').as('visitor_count'), + ]) + .where('visitor.shortener_id', '=', shorteners[0].id) + .groupBy(['visitor.country_code', 'visitor.country']) + .execute() + + return { shorteners, visitors } + } catch { + return { error: true } + } +}) + +app.post( + '/signup', + async ({ body, set }) => { + const { email, username, password, password_confirm } = body + + const { error } = await signup( + email, + username, + password, + password_confirm + ) + + if (error) { + set.status = 400 + return { error } + } + + return { message: 'User Successfully Created' } + }, + { + body: t.Object({ + username: t.String(), + email: t.String(), + password: t.String(), + password_confirm: t.String(), + }), + } +) + +app.post( + '/login', + async ({ body, set }) => { + const { email, password } = body + const { user, error } = await login(email, password) + + if (error) { + set.status = 400 + return { error } + } else { + return user + } + }, + { + body: t.Object({ + email: t.String(), + password: t.String(), + }), + } +) + +app.listen(3000) + +console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` +) + +export type App = typeof app diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a405199 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,50 @@ +import { + ColumnType, + Generated, + Insertable, + Selectable, + Updateable, +} from 'kysely' + +export type Timestamp = ColumnType + +export interface Database { + shortener: ShortenerTable + visitor: VisitorTable + user: UserTable +} + +export interface ShortenerTable { + id: Generated + link: string + code: string + created_at: ColumnType +} + +export type Shortener = Selectable +export type NewShortener = Insertable +export type ShortenerUpdate = Updateable + +export interface VisitorTable { + id: Generated + shortener_id: number + country: string + country_code: string + created_at: ColumnType +} + +export type Visitor = Selectable +export type NewVisitor = Insertable + +export interface UserTable { + created_at: Generated + email: string + id: Generated + password: string + username: string + uuid: string +} + +export type User = Selectable +export type NewUser = Insertable +export type UserUpdate = Updateable diff --git a/src/zodSchema.ts b/src/zodSchema.ts new file mode 100644 index 0000000..e748de8 --- /dev/null +++ b/src/zodSchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { char, createRegExp, exactly, oneOrMore } from 'magic-regexp' + +const urlRegex = createRegExp( + exactly('https://'), + oneOrMore(oneOrMore(char), exactly('.')), + oneOrMore(char) +) + +export const createLinkSchema = z.object({ + link: z.string().regex(urlRegex), +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7b732ff --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,105 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": [ + "bun-types" + ] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}