From 46c05ca3a98611df08ad67386fb15ddc5c3b8c05 Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 2 Mar 2025 17:36:45 -0500 Subject: [PATCH] start of the 50th remake of a file host doomed to never be finished --- .editorconfig | 12 ++ .env | 17 +++ .gitattributes | 1 + .gitignore | 2 + README.md | 3 + config/environment.ts | 27 ++++ config/sql/settings.ts | 177 +++++++++++++++++++++++ config/sql/users.ts | 174 ++++++++++++++++++++++ eslint.config.js | 132 +++++++++++++++++ package.json | 35 +++++ public/assets/favicon.ico | Bin 0 -> 15406 bytes src/helpers/auth.ts | 50 +++++++ src/helpers/char.ts | 13 ++ src/helpers/ejs.ts | 26 ++++ src/helpers/logger.ts | 205 ++++++++++++++++++++++++++ src/helpers/redis.ts | 204 ++++++++++++++++++++++++++ src/helpers/sessions.ts | 162 +++++++++++++++++++++ src/index.ts | 54 +++++++ src/routes/api/auth/login.ts | 161 +++++++++++++++++++++ src/routes/api/auth/logout.ts | 50 +++++++ src/routes/api/auth/register.ts | 197 +++++++++++++++++++++++++ src/routes/api/index.ts | 15 ++ src/routes/index.ts | 17 +++ src/server.ts | 247 ++++++++++++++++++++++++++++++++ src/views/index.ejs | 8 ++ src/websocket.ts | 34 +++++ tsconfig.json | 51 +++++++ types/bun.d.ts | 15 ++ types/config.d.ts | 10 ++ types/ejs.d.ts | 3 + types/logger.d.ts | 9 ++ types/routes.d.ts | 15 ++ types/session.d.ts | 29 ++++ 33 files changed, 2155 insertions(+) create mode 100644 .editorconfig create mode 100644 .env create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/environment.ts create mode 100644 config/sql/settings.ts create mode 100644 config/sql/users.ts create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 public/assets/favicon.ico create mode 100644 src/helpers/auth.ts create mode 100644 src/helpers/char.ts create mode 100644 src/helpers/ejs.ts create mode 100644 src/helpers/logger.ts create mode 100644 src/helpers/redis.ts create mode 100644 src/helpers/sessions.ts create mode 100644 src/index.ts create mode 100644 src/routes/api/auth/login.ts create mode 100644 src/routes/api/auth/logout.ts create mode 100644 src/routes/api/auth/register.ts create mode 100644 src/routes/api/index.ts create mode 100644 src/routes/index.ts create mode 100644 src/server.ts create mode 100644 src/views/index.ejs create mode 100644 src/websocket.ts create mode 100644 tsconfig.json create mode 100644 types/bun.d.ts create mode 100644 types/config.d.ts create mode 100644 types/ejs.d.ts create mode 100644 types/logger.d.ts create mode 100644 types/routes.d.ts create mode 100644 types/session.d.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..980ef21 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.env b/.env new file mode 100644 index 0000000..951e1cb --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +# NODE_ENV=development +HOST=0.0.0.0 +PORT=8080 + +PGHOST=10.0.0.40 +PGPORT=5432 +PGUSERNAME=postgres +PGPASSWORD=postgres +PGDATABASE=postgres + +REDIS_HOST=10.0.0.40 +REDIS_PORT=6379 +# REDIS_USERNAME=redis +# REDIS_PASSWORD=redis + +JWT_SECRET=467e5faeda7780bc442f41b8c421d645b5666543d1ae145ad4717f0aff890eb30b971e0700e2bccd3988d653413126b1c63793a49525770b586a6a10b604542e6627c8004090a8ddcd4cd02e9d829a9834850740c02631f7bbd95919be4228065d9aaf35f32eefa28af54470e0a0bc9338fccc22dd0896e3d434ded40f0bcc1c30c24aca39a11417e502e260167759d21fb54fa18dd74db8ec50c0f5f06365862e7f412c2075110f0a9718095e5b260942d38a06c744a0b18bcc11e2c7d730890d5ae933579e11b117f295b372832348849cf448c495424e5a6570e066bd33871aa9c374cc000a512d63e24463ba68554e8f0102de310f38905d794cf8b60424 +JWT_EXPIRES=1d diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58b5bae --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules +bun.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b56686 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# bun frontend template + +a simle bun frontend starting point i made and use diff --git a/config/environment.ts b/config/environment.ts new file mode 100644 index 0000000..7adf22f --- /dev/null +++ b/config/environment.ts @@ -0,0 +1,27 @@ +export const environment: Environment = { + port: parseInt(process.env.PORT || "8080", 10), + host: process.env.HOST || "0.0.0.0", + development: + process.env.NODE_ENV === "development" || + process.argv.includes("--dev"), +}; + +export const redisConfig: { + host: string; + port: number; + username?: string | undefined; + password?: string | undefined; +} = { + host: process.env.REDIS_HOST || "localhost", + port: parseInt(process.env.REDIS_PORT || "6379", 10), + username: process.env.REDIS_USERNAME || undefined, + password: process.env.REDIS_PASSWORD || undefined, +}; + +export const jwt: { + secret: string; + expiresIn: string; +} = { + secret: process.env.JWT_SECRET || "", + expiresIn: process.env.JWT_EXPIRES || "1d", +}; diff --git a/config/sql/settings.ts b/config/sql/settings.ts new file mode 100644 index 0000000..11c4748 --- /dev/null +++ b/config/sql/settings.ts @@ -0,0 +1,177 @@ +import { logger } from "@helpers/logger"; +import { type ReservedSQL, sql } from "bun"; + +const defaultSettings: { key: string; value: string }[] = [ + { key: "default_role", value: "user" }, + { key: "default_timezone", value: "UTC" }, + { key: "server_timezone", value: "UTC" }, + { key: "enable_registration", value: "false" }, + { key: "enable_invitations", value: "true" }, + { key: "allow_user_invites", value: "false" }, + { key: "require_email_verification", value: "false" }, +]; + +export async function createTable(reservation?: ReservedSQL): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation` + CREATE TABLE IF NOT EXISTS settings ( + "key" VARCHAR(64) PRIMARY KEY NOT NULL UNIQUE, + "value" TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + );`; + + for (const setting of defaultSettings) { + await reservation` + INSERT INTO settings ("key", "value") + VALUES (${setting.key}, ${setting.value}) + ON CONFLICT ("key") + DO NOTHING;`; + } + } catch (error) { + logger.error(["Could not create the settings table:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +export async function drop( + cascade: boolean, + reservation?: ReservedSQL, +): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation`DROP TABLE IF EXISTS settings ${cascade ? "CASCADE" : ""};`; + } catch (error) { + logger.error(["Could not drop the settings table:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +// * Validation functions + +export async function getSetting( + key: string, + reservation?: ReservedSQL, +): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + const result: { value: string }[] = + await reservation`SELECT value FROM settings WHERE "key" = ${key};`; + + if (result.length === 0) { + return null; + } + + return result[0].value; + } catch (error) { + logger.error(["Could not get the setting:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +export async function setSetting( + key: string, + value: string, + reservation?: ReservedSQL, +): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation` + INSERT INTO settings ("key", "value") + VALUES (${key}, ${value}) + ON CONFLICT ("key") + DO UPDATE SET "value" = ${value};`; + } catch (error) { + logger.error(["Could not set the setting:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +export async function deleteSetting( + key: string, + reservation?: ReservedSQL, +): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation`DELETE FROM settings WHERE "key" = ${key};`; + } catch (error) { + logger.error(["Could not delete the setting:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +export async function getAllSettings( + reservation?: ReservedSQL, +): Promise<{ key: string; value: string }[]> { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + const result: { key: string; value: string }[] = + await reservation`SELECT "key", "value" FROM settings;`; + + return result; + } catch (error) { + logger.error(["Could not get all settings:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} diff --git a/config/sql/users.ts b/config/sql/users.ts new file mode 100644 index 0000000..24f90ff --- /dev/null +++ b/config/sql/users.ts @@ -0,0 +1,174 @@ +import { logger } from "@helpers/logger"; +import { type ReservedSQL, sql } from "bun"; + +export async function createTable(reservation?: ReservedSQL): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + authorization_token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), + username VARCHAR(20) NOT NULL UNIQUE, + email VARCHAR(254) NOT NULL UNIQUE, + email_verified boolean NOT NULL DEFAULT false, + password TEXT NOT NULL, + avatar boolean NOT NULL DEFAULT false, + roles TEXT[] NOT NULL DEFAULT ARRAY['user'], + timezone VARCHAR(64) DEFAULT NULL, + invited_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + last_seen TIMESTAMPTZ DEFAULT NOW() NOT NULL + );`; + } catch (error) { + logger.error(["Could not create the users table:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +export async function drop( + cascade: boolean, + reservation?: ReservedSQL, +): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation`DROP TABLE IF EXISTS users ${cascade ? "CASCADE" : ""};`; + } catch (error) { + logger.error(["Could not drop the users table:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +// * Validation functions + +// ? should support non english characters but wont mess up the url +export const userNameRestrictions: { + length: { min: number; max: number }; + regex: RegExp; +} = { + length: { min: 3, max: 20 }, + regex: /^[\p{L}0-9._-]+$/u, +}; + +export const passwordRestrictions: { + length: { min: number; max: number }; + regex: RegExp; +} = { + length: { min: 12, max: 64 }, + regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u, +}; + +export const emailRestrictions: { regex: RegExp } = { + regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, +}; + +export const inviteRestrictions: { min: number; max: number; regex: RegExp } = { + min: 4, + max: 15, + regex: /^[a-zA-Z0-9]+$/, +}; + +export function isValidUsername(username: string): { + valid: boolean; + error?: string; +} { + if (username.length < userNameRestrictions.length.min) { + return { valid: false, error: "Username is too short" }; + } + + if (username.length > userNameRestrictions.length.max) { + return { valid: false, error: "Username is too long" }; + } + + if (!userNameRestrictions.regex.test(username)) { + return { valid: false, error: "Username contains invalid characters" }; + } + + return { valid: true }; +} + +export function isValidPassword(password: string): { + valid: boolean; + error?: string; +} { + if (password.length < passwordRestrictions.length.min) { + return { + valid: false, + error: `Password must be at least ${passwordRestrictions.length.min} characters long`, + }; + } + + if (password.length > passwordRestrictions.length.max) { + return { + valid: false, + error: `Password can't be longer than ${passwordRestrictions.length.max} characters`, + }; + } + + if (!passwordRestrictions.regex.test(password)) { + return { + valid: false, + error: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character", + }; + } + + return { valid: true }; +} + +export function isValidEmail(email: string): { + valid: boolean; + error?: string; +} { + if (!emailRestrictions.regex.test(email)) { + return { valid: false, error: "Invalid email address" }; + } + + return { valid: true }; +} + +export function isValidInvite(invite: string): { + valid: boolean; + error?: string; +} { + if (invite.length < inviteRestrictions.min) { + return { + valid: false, + error: `Invite code must be at least ${inviteRestrictions.min} characters long`, + }; + } + + if (invite.length > inviteRestrictions.max) { + return { + valid: false, + error: `Invite code can't be longer than ${inviteRestrictions.max} characters`, + }; + } + + if (!inviteRestrictions.regex.test(invite)) { + return { + valid: false, + error: "Invite code contains invalid characters", + }; + } + + return { valid: true }; +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d43df76 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,132 @@ +import pluginJs from "@eslint/js"; +import tseslintPlugin from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import promisePlugin from "eslint-plugin-promise"; +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import unicorn from "eslint-plugin-unicorn"; +import unusedImports from "eslint-plugin-unused-imports"; +import globals from "globals"; + +/** @type {import('eslint').Linter.FlatConfig[]} */ +export default [ + { + files: ["**/*.{js,mjs,cjs}"], + languageOptions: { + globals: globals.node, + }, + ...pluginJs.configs.recommended, + plugins: { + "simple-import-sort": simpleImportSort, + "unused-imports": unusedImports, + promise: promisePlugin, + prettier: prettier, + unicorn: unicorn, + }, + rules: { + "eol-last": ["error", "always"], + "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }], + "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + "promise/always-return": "error", + "promise/no-return-wrap": "error", + "promise/param-names": "error", + "promise/catch-or-return": "error", + "promise/no-nesting": "warn", + "promise/no-promise-in-callback": "warn", + "promise/no-callback-in-promise": "warn", + "prettier/prettier": [ + "error", + { + useTabs: true, + tabWidth: 4, + }, + ], + indent: ["error", "tab", { SwitchCase: 1 }], + "unicorn/filename-case": [ + "error", + { + case: "camelCase", + }, + ], + }, + }, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + globals: globals.node, + }, + plugins: { + "@typescript-eslint": tseslintPlugin, + "simple-import-sort": simpleImportSort, + "unused-imports": unusedImports, + promise: promisePlugin, + prettier: prettier, + unicorn: unicorn, + }, + rules: { + ...tseslintPlugin.configs.recommended.rules, + quotes: ["error", "double"], + "eol-last": ["error", "always"], + "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }], + "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + "promise/always-return": "error", + "promise/no-return-wrap": "error", + "promise/param-names": "error", + "promise/catch-or-return": "error", + "promise/no-nesting": "warn", + "promise/no-promise-in-callback": "warn", + "promise/no-callback-in-promise": "warn", + "prettier/prettier": [ + "error", + { + useTabs: true, + tabWidth: 4, + }, + ], + indent: ["error", "tab", { SwitchCase: 1 }], + "unicorn/filename-case": [ + "error", + { + case: "camelCase", + }, + ], + "@typescript-eslint/explicit-function-return-type": ["error"], + "@typescript-eslint/explicit-module-boundary-types": ["error"], + "@typescript-eslint/typedef": [ + "error", + { + arrowParameter: true, + variableDeclaration: true, + propertyDeclaration: true, + memberVariableDeclaration: true, + parameter: true, + }, + ], + }, + }, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d05b594 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "bun_frontend_template", + "module": "src/index.ts", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun run --watch src/index.ts --dev", + "lint": "eslint", + "lint:fix": "bun lint --fix", + "cleanup": "rm -rf logs node_modules bun.lockdb" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/bun": "^1.2.4", + "@types/ejs": "^3.1.5", + "@typescript-eslint/eslint-plugin": "^8.25.0", + "@typescript-eslint/parser": "^8.25.0", + "eslint": "^9.21.0", + "eslint-plugin-prettier": "^5.2.3", + "eslint-plugin-promise": "^7.2.1", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-unicorn": "^56.0.1", + "eslint-plugin-unused-imports": "^4.1.4", + "globals": "^15.15.0", + "prettier": "^3.5.2" + }, + "peerDependencies": { + "typescript": "^5.7.3" + }, + "dependencies": { + "ejs": "^3.1.10", + "fast-jwt": "^5.0.5", + "redis": "^4.7.0" + } +} diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..69ec50db0ae55e9b54fd9fbf88c34bb1c420fb63 GIT binary patch literal 15406 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 { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + const authorizationHeader: string | null = + request.headers.get("Authorization"); + + if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) + return null; + + const authorizationToken: string = authorizationHeader.slice(7).trim(); + if (!authorizationToken || !isUUID(authorizationToken)) return null; + + try { + const result: UserSession[] = + await reservation`SELECT id, username, email, roles avatar, timezone, authorization_token FROM users WHERE authorization_token = ${authorizationToken};`; + + if (result.length === 0) return null; + + return { + id: result[0].id, + username: result[0].username, + email: result[0].email, + email_verified: result[0].email_verified, + roles: result[0].roles, + avatar: result[0].avatar, + timezone: result[0].timezone, + authorization_token: result[0].authorization_token, + is_api: true, + }; + } catch (error) { + logger.error(["Could not authenticate by token:", error as Error]); + return null; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} diff --git a/src/helpers/char.ts b/src/helpers/char.ts new file mode 100644 index 0000000..7ec0bff --- /dev/null +++ b/src/helpers/char.ts @@ -0,0 +1,13 @@ +export function timestampToReadable(timestamp?: number): string { + const date: Date = + timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); + if (isNaN(date.getTime())) return "Invalid Date"; + return date.toISOString().replace("T", " ").replace("Z", ""); +} + +export function isUUID(uuid: string): boolean { + const regex: RegExp = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + + return regex.test(uuid); +} diff --git a/src/helpers/ejs.ts b/src/helpers/ejs.ts new file mode 100644 index 0000000..6b03dd0 --- /dev/null +++ b/src/helpers/ejs.ts @@ -0,0 +1,26 @@ +import { renderFile } from "ejs"; +import { resolve } from "path"; + +export async function renderEjsTemplate( + viewName: string | string[], + data: EjsTemplateData, + headers?: Record, +): Promise { + let templatePath: string; + + if (Array.isArray(viewName)) { + templatePath = resolve("src", "views", ...viewName); + } else { + templatePath = resolve("src", "views", viewName); + } + + if (!templatePath.endsWith(".ejs")) { + templatePath += ".ejs"; + } + + const html: string = await renderFile(templatePath, data); + + return new Response(html, { + headers: { "Content-Type": "text/html", ...headers }, + }); +} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts new file mode 100644 index 0000000..331be1d --- /dev/null +++ b/src/helpers/logger.ts @@ -0,0 +1,205 @@ +import { environment } from "@config/environment"; +import { timestampToReadable } from "@helpers/char"; +import type { Stats } from "fs"; +import { + createWriteStream, + existsSync, + mkdirSync, + statSync, + WriteStream, +} from "fs"; +import { EOL } from "os"; +import { basename, join } from "path"; + +class Logger { + private static instance: Logger; + private static log: string = join(__dirname, "../../logs"); + + public static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + + return Logger.instance; + } + + private writeToLog(logMessage: string): void { + if (environment.development) return; + + const date: Date = new Date(); + const logDir: string = Logger.log; + const logFile: string = join( + logDir, + `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`, + ); + + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }); + } + + let addSeparator: boolean = false; + + if (existsSync(logFile)) { + const fileStats: Stats = statSync(logFile); + if (fileStats.size > 0) { + const lastModified: Date = new Date(fileStats.mtime); + if ( + lastModified.getFullYear() === date.getFullYear() && + lastModified.getMonth() === date.getMonth() && + lastModified.getDate() === date.getDate() && + lastModified.getHours() !== date.getHours() + ) { + addSeparator = true; + } + } + } + + const stream: WriteStream = createWriteStream(logFile, { flags: "a" }); + + if (addSeparator) { + stream.write(`${EOL}${date.toISOString()}${EOL}`); + } + + stream.write(`${logMessage}${EOL}`); + stream.close(); + } + + private extractFileName(stack: string): string { + const stackLines: string[] = stack.split("\n"); + let callerFile: string = ""; + + for (let i: number = 2; i < stackLines.length; i++) { + const line: string = stackLines[i].trim(); + if (line && !line.includes("Logger.") && line.includes("(")) { + callerFile = line.split("(")[1]?.split(")")[0] || ""; + break; + } + } + + return basename(callerFile); + } + + private getCallerInfo(stack: unknown): { + filename: string; + timestamp: string; + } { + const filename: string = + typeof stack === "string" ? this.extractFileName(stack) : "unknown"; + + const readableTimestamp: string = timestampToReadable(); + + return { filename, timestamp: readableTimestamp }; + } + + public info(message: string | string[], breakLine: boolean = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[INFO]", color: "32" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public warn(message: string | string[], breakLine: boolean = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[WARN]", color: "33" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public error( + message: string | Error | (string | Error)[], + breakLine: boolean = false, + ): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const messages: (string | Error)[] = Array.isArray(message) + ? message + : [message]; + const joinedMessage: string = messages + .map((msg: string | Error): string => + typeof msg === "string" ? msg : msg.message, + ) + .join(" "); + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[ERROR]", color: "31" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public custom( + bracketMessage: string, + bracketMessage2: string, + message: string | string[], + color: string, + breakLine: boolean = false, + ): void { + const stack: string = new Error().stack || ""; + const { timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: bracketMessage, color }, + filename: { value: `${bracketMessage2}`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog( + `${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`, + ); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public space(): void { + console.log(); + } + + private writeConsoleMessageColored( + logMessageParts: ILogMessageParts, + breakLine: boolean = false, + ): void { + const logMessage: string = Object.keys(logMessageParts) + .map((key: string) => { + const part: ILogMessagePart = logMessageParts[key]; + return `\x1b[${part.color}m${part.value}\x1b[0m`; + }) + .join(" "); + console.log(logMessage + (breakLine ? EOL : "")); + } +} + +const logger: Logger = Logger.getInstance(); +export { logger }; diff --git a/src/helpers/redis.ts b/src/helpers/redis.ts new file mode 100644 index 0000000..5cd1e54 --- /dev/null +++ b/src/helpers/redis.ts @@ -0,0 +1,204 @@ +import { redisConfig } from "@config/environment"; +import { logger } from "@helpers/logger"; +import { createClient, type RedisClientType } from "redis"; + +class RedisJson { + private static instance: RedisJson | null = null; + private client: RedisClientType | null = null; + + private constructor() {} + + public static async initialize(): Promise { + if (!RedisJson.instance) { + RedisJson.instance = new RedisJson(); + RedisJson.instance.client = createClient({ + socket: { + host: redisConfig.host, + port: redisConfig.port, + }, + username: redisConfig.username || undefined, + password: redisConfig.password || undefined, + }); + + RedisJson.instance.client.on("error", (err: Error) => { + logger.error([ + "Error connecting to Redis:", + err, + redisConfig.host, + ]); + + process.exit(1); + }); + + RedisJson.instance.client.on("connect", () => { + logger.info([ + "Connected to Redis on", + `${redisConfig.host}:${redisConfig.port}`, + ]); + }); + + await RedisJson.instance.client.connect(); + } + + return RedisJson.instance; + } + + public static getInstance(): RedisJson { + if (!RedisJson.instance || !RedisJson.instance.client) { + throw new Error( + "Redis instance not initialized. Call initialize() first.", + ); + } + return RedisJson.instance; + } + + public async disconnect(): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + process.exit(1); + } + try { + await this.client.disconnect(); + this.client = null; + logger.info("Redis disconnected successfully."); + } catch (error) { + logger.error("Error disconnecting Redis client:"); + logger.error(error as Error); + throw error; + } + } + + public async get( + type: "JSON" | "STRING", + key: string, + path?: string, + ): Promise< + string | number | boolean | Record | null | unknown + > { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + if (type === "JSON") { + const value: unknown = await this.client.json.get(key, { + path, + }); + + if (value instanceof Date) { + return value.toISOString(); + } + + return value; + } else if (type === "STRING") { + const value: string | null = await this.client.get(key); + return value; + } else { + throw new Error(`Invalid type: ${type}`); + } + } catch (error) { + logger.error(`Error getting value from Redis for key: ${key}`); + logger.error(error as Error); + throw error; + } + } + + public async set( + type: "JSON" | "STRING", + key: string, + value: unknown, + expiresInSeconds?: number, + path?: string, + ): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + if (type === "JSON") { + await this.client.json.set(key, path || "$", value as string); + + if (expiresInSeconds) { + await this.client.expire(key, expiresInSeconds); + } + } else if (type === "STRING") { + if (expiresInSeconds) { + await this.client.set(key, value as string, { + EX: expiresInSeconds, + }); + } else { + await this.client.set(key, value as string); + } + } else { + throw new Error(`Invalid type: ${type}`); + } + } catch (error) { + logger.error(`Error setting value in Redis for key: ${key}`); + logger.error(error as Error); + throw error; + } + } + + public async delete(type: "JSON" | "STRING", key: string): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + if (type === "JSON") { + await this.client.json.del(key); + } else if (type === "STRING") { + await this.client.del(key); + } else { + throw new Error(`Invalid type: ${type}`); + } + } catch (error) { + logger.error(`Error deleting value from Redis for key: ${key}`); + logger.error(error as Error); + throw error; + } + } + + public async expire(key: string, seconds: number): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + await this.client.expire(key, seconds); + } catch (error) { + logger.error([ + `Error expiring key in Redis: ${key}`, + error as Error, + ]); + throw error; + } + } + + public async keys(pattern: string): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + const keys: string[] = await this.client.keys(pattern); + return keys; + } catch (error) { + logger.error([ + `Error getting keys from Redis for pattern: ${pattern}`, + error as Error, + ]); + throw error; + } + } +} + +export const redis: { + initialize: () => Promise; + getInstance: () => RedisJson; +} = { + initialize: RedisJson.initialize, + getInstance: RedisJson.getInstance, +}; + +export { RedisJson }; diff --git a/src/helpers/sessions.ts b/src/helpers/sessions.ts new file mode 100644 index 0000000..de686a2 --- /dev/null +++ b/src/helpers/sessions.ts @@ -0,0 +1,162 @@ +import { jwt } from "@config/environment"; +import { environment } from "@config/environment"; +import { redis } from "@helpers/redis"; +import { createDecoder, createSigner, createVerifier } from "fast-jwt"; + +type Signer = (payload: UserSession, options?: UserSession) => string; +type Verifier = (token: string, options?: UserSession) => UserSession; +type Decoder = (token: string, options?: UserSession) => UserSession; + +class SessionManager { + private signer: Signer; + private verifier: Verifier; + private decoder: Decoder; + + constructor() { + this.signer = createSigner({ + key: jwt.secret, + expiresIn: jwt.expiresIn, + }); + this.verifier = createVerifier({ key: jwt.secret }); + this.decoder = createDecoder(); + } + + public async createSession( + payload: UserSession, + userAgent: string, + ): Promise { + const token: string = this.signer(payload); + const sessionKey: string = `session:${payload.id}:${token}`; + + await redis + .getInstance() + .set( + "JSON", + sessionKey, + { ...payload, userAgent }, + this.getExpirationInSeconds(), + ); + + const cookie: string = this.generateCookie(token); + return cookie; + } + + public async getSession(request: Request): Promise { + const cookie: string | null = request.headers.get("Cookie"); + if (!cookie) return null; + + const token: string | null = + cookie.match(/session=([^;]+)/)?.[1] || null; + if (!token) return null; + + const userSessions: string[] = await redis + .getInstance() + .keys("session:*:" + token); + if (!userSessions.length) return null; + + const sessionData: unknown = await redis + .getInstance() + .get("JSON", userSessions[0]); + if (!sessionData) return null; + + const payload: UserSession & { userAgent: string } = + sessionData as UserSession & { userAgent: string }; + return payload; + } + + public async verifySession(token: string): Promise { + const userSessions: string[] = await redis + .getInstance() + .keys("session:*:" + token); + if (!userSessions.length) + throw new Error("Session not found or expired"); + + const sessionData: unknown = await redis + .getInstance() + .get("JSON", userSessions[0]); + if (!sessionData) throw new Error("Session not found or expired"); + + const payload: UserSession = this.verifier(token); + return payload; + } + + public async decodeSession(token: string): Promise { + const payload: UserSession = this.decoder(token); + return payload; + } + + public async invalidateSession(request: Request): Promise { + const cookie: string | null = request.headers.get("Cookie"); + if (!cookie) return; + + const token: string | null = + cookie.match(/session=([^;]+)/)?.[1] || null; + if (!token) return; + + const userSessions: string[] = await redis + .getInstance() + .keys("session:*:" + token); + if (!userSessions.length) return; + + await redis.getInstance().delete("JSON", userSessions[0]); + } + + private generateCookie( + token: string, + maxAge: number = this.getExpirationInSeconds(), + options?: { + secure?: boolean; + httpOnly?: boolean; + sameSite?: "Strict" | "Lax" | "None"; + path?: string; + domain?: string; + }, + ): string { + const { + secure = !environment.development, + httpOnly = true, + sameSite = environment.development ? "Lax" : "None", + path = "/", + domain, + } = options || {}; + + let cookie: string = `session=${encodeURIComponent(token)}; Path=${path}; Max-Age=${maxAge}`; + + if (httpOnly) cookie += "; HttpOnly"; + + if (secure) cookie += "; Secure"; + + if (sameSite) cookie += `; SameSite=${sameSite}`; + + if (domain) cookie += `; Domain=${domain}`; + + return cookie; + } + + private getExpirationInSeconds(): number { + const match: RegExpMatchArray | null = + jwt.expiresIn.match(/^(\d+)([smhd])$/); + if (!match) { + throw new Error("Invalid expiresIn format in jwt config"); + } + + const [, value, unit] = match; + const num: number = parseInt(value, 10); + + switch (unit) { + case "s": + return num; + case "m": + return num * 60; + case "h": + return num * 3600; + case "d": + return num * 86400; + default: + throw new Error("Invalid time unit in expiresIn"); + } + } +} + +const sessionManager: SessionManager = new SessionManager(); +export { sessionManager }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7cddd5d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,54 @@ +import { logger } from "@helpers/logger"; +import { type ReservedSQL, sql } from "bun"; +import { readdir } from "fs/promises"; +import { resolve } from "path"; + +import { serverHandler } from "@/server"; + +import { redis } from "./helpers/redis"; + +async function initializeDatabase(): Promise { + const sqlDir: string = resolve("config", "sql"); + const files: string[] = await readdir(sqlDir); + + const reservation: ReservedSQL = await sql.reserve(); + for (const file of files) { + if (file.endsWith(".ts")) { + const { createTable } = await import(resolve(sqlDir, file)); + + await createTable(reservation); + } + } + + reservation.release(); +} + +async function main(): Promise { + try { + try { + await sql`SELECT 1;`; + + logger.info([ + "Connected to PostgreSQL on", + `${process.env.PGHOST}:${process.env.PGPORT}`, + ]); + } catch (error) { + logger.error([ + "Could not establish a connection to PostgreSQL:", + error as Error, + ]); + process.exit(1); + } + + await redis.initialize(); + serverHandler.initialize(); + await initializeDatabase(); + } catch (error) { + throw error; + } +} + +main().catch((error: Error) => { + logger.error(["Error initializing the server:", error]); + process.exit(1); +}); diff --git a/src/routes/api/auth/login.ts b/src/routes/api/auth/login.ts new file mode 100644 index 0000000..e191151 --- /dev/null +++ b/src/routes/api/auth/login.ts @@ -0,0 +1,161 @@ +import { + isValidEmail, + isValidPassword, + isValidUsername, +} from "@config/sql/users"; +import { password as bunPassword, type ReservedSQL, sql } from "bun"; + +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + if (request.session) { + if ((request.session as ApiUserSession).is_api) { + return Response.json( + { + success: false, + code: 403, + error: "You cannot log in while using an authorization token", + }, + { status: 403 }, + ); + } + + return Response.json( + { + success: false, + code: 403, + error: "Already logged in", + }, + { status: 403 }, + ); + } + + const { username, email, password } = requestBody as { + username: string; + email: string; + password: string; + }; + + if (!password || (!username && !email)) { + return Response.json( + { + success: false, + code: 400, + error: "Expected username or email, and password", + }, + { status: 400 }, + ); + } + + const errors: string[] = []; + const validations: UserValidation[] = [ + { check: isValidUsername(username), field: "Username" }, + { check: isValidEmail(email), field: "Email" }, + { check: isValidPassword(password), field: "Password" }, + ]; + + validations.forEach(({ check }: UserValidation): void => { + if (!check.valid && check.error) { + errors.push(check.error); + } + }); + + if (errors.length > 0) { + return Response.json( + { + success: false, + code: 400, + errors, + }, + { status: 400 }, + ); + } + + const reservation: ReservedSQL = await sql.reserve(); + let user: User | null = null; + + try { + user = await reservation` + SELECT * FROM users + WHERE (username = ${username} OR email = ${email}) + LIMIT 1; + `.then((rows: User[]): User | null => rows[0]); + + if (!user) { + await bunPassword.verify("fake", await bunPassword.hash("fake")); + + return Response.json( + { + success: false, + code: 401, + error: "Invalid username, email, or password", + }, + { status: 401 }, + ); + } + + const passwordMatch: boolean = await bunPassword.verify( + password, + user.password, + ); + + if (!passwordMatch) { + return Response.json( + { + success: false, + code: 401, + error: "Invalid username, email, or password", + }, + { status: 401 }, + ); + } + } catch (error) { + logger.error(["Error logging in", error as Error]); + + return Response.json( + { + success: false, + code: 500, + error: "An error occurred while logging in", + }, + { status: 500 }, + ); + } finally { + if (reservation) reservation.release(); + } + + const sessionCookie: string = await sessionManager.createSession( + { + id: user.id, + username: user.username, + email: user.email, + email_verified: user.email_verified, + roles: user.roles, + avatar: user.avatar, + timezone: user.timezone, + authorization_token: user.authorization_token, + }, + request.headers.get("User-Agent") || "", + ); + + return Response.json( + { + success: true, + code: 200, + }, + { status: 200, headers: { "Set-Cookie": sessionCookie } }, + ); +} + +export { handler, routeDef }; diff --git a/src/routes/api/auth/logout.ts b/src/routes/api/auth/logout.ts new file mode 100644 index 0000000..4ef42a1 --- /dev/null +++ b/src/routes/api/auth/logout.ts @@ -0,0 +1,50 @@ +import { sessionManager } from "@/helpers/sessions"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 403, + error: "You are not logged in", + }, + { status: 403 }, + ); + } + + if ((request.session as ApiUserSession).is_api) { + return Response.json( + { + success: false, + code: 403, + error: "You cannot logout while using an authorization token", + }, + { status: 403 }, + ); + } + + sessionManager.invalidateSession(request); + + return Response.json( + { + success: true, + code: 200, + message: "Successfully logged out", + }, + { + status: 200, + headers: { + "Set-Cookie": + "session=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict", + }, + }, + ); +} + +export { handler, routeDef }; diff --git a/src/routes/api/auth/register.ts b/src/routes/api/auth/register.ts new file mode 100644 index 0000000..de6e16b --- /dev/null +++ b/src/routes/api/auth/register.ts @@ -0,0 +1,197 @@ +import { getSetting } from "@config/sql/settings"; +import { + isValidEmail, + isValidInvite, + isValidPassword, + isValidUsername, +} from "@config/sql/users"; +import { password as bunPassword, type ReservedSQL, sql } from "bun"; +import type { UUID } from "crypto"; + +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + const { username, email, password, invite } = requestBody as { + username: string; + email: string; + password: string; + invite?: string; + }; + + if (!username || !email || !password) { + return Response.json( + { + success: false, + code: 400, + error: "Expected username, email, and password", + }, + { status: 400 }, + ); + } + + const errors: string[] = []; + + const validations: UserValidation[] = [ + { check: isValidUsername(username), field: "Username" }, + { check: isValidEmail(email), field: "Email" }, + { check: isValidPassword(password), field: "Password" }, + ]; + + validations.forEach(({ check }: UserValidation): void => { + if (!check.valid && check.error) { + errors.push(check.error); + } + }); + + const reservation: ReservedSQL = await sql.reserve(); + let firstUser: boolean = false; + let invitedBy: UUID | null = null; + let roles: string[] = []; + + try { + const registrationEnabled: boolean = + (await getSetting("registrationEnabled", reservation)) === "true"; + const invitationsEnabled: boolean = + (await getSetting("invitationsEnabled", reservation)) === "true"; + + firstUser = + Number( + (await reservation`SELECT COUNT(*) AS count FROM users;`)[0] + ?.count, + ) === 0; + + if (!firstUser && invite) { + const inviteValidation: { valid: boolean; error?: string } = + isValidInvite(invite); + if (!inviteValidation.valid && inviteValidation.error) { + errors.push(inviteValidation.error); + } + } + + if ( + (!firstUser && !registrationEnabled && !invite) || + (!firstUser && invite && !invitationsEnabled) + ) { + errors.push("Registration is disabled"); + } + + roles.push("user"); + if (firstUser) { + roles.push("admin"); + } + + const { usernameExists, emailExists } = await reservation` + SELECT + EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${username})) AS usernameExists, + EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS emailExists; + `; + + if (usernameExists) errors.push("Username already exists"); + if (emailExists) errors.push("Email already exists"); + if (invite) { + invitedBy = ( + await reservation`SELECT user_id FROM invites WHERE invite = ${invite};` + )[0]?.id; + if (!invitedBy) errors.push("Invalid invite code"); + } + } catch (error) { + errors.push("An error occurred while checking for existing users"); + logger.error(["Error checking for existing users:", error as Error]); + } + + if (errors.length > 0) { + return Response.json( + { + success: false, + code: 400, + errors, + }, + { status: 400 }, + ); + } + + let user: User | null = null; + const hashedPassword: string = await bunPassword.hash(password, { + algorithm: "argon2id", + }); + const defaultTimezone: string = + (await getSetting("default_timezone", reservation)) || "UTC"; + + try { + user = ( + await reservation` + INSERT INTO users (username, email, password, invited_by, roles, timezone) + VALUES (${username}, ${email}, ${hashedPassword}, ${invitedBy}, ARRAY[${roles.join(",")}]::TEXT[], ${defaultTimezone}) + RETURNING *; + ` + )[0]; + + if (!user) { + logger.error("User was not created"); + return Response.json( + { + success: false, + code: 500, + error: "An error occurred with the user registration", + }, + { status: 500 }, + ); + } + + if (invitedBy) { + await reservation`DELETE FROM invites WHERE invite = ${invite};`; + } + } catch (error) { + logger.error([ + "Error inserting user into the database:", + error as Error, + ]); + return Response.json( + { + success: false, + code: 500, + error: "An error occurred while creating the user", + }, + { status: 500 }, + ); + } finally { + reservation.release(); + } + + const sessionCookie: string = await sessionManager.createSession( + { + id: user.id, + username: user.username, + email: user.email, + email_verified: user.email_verified, + roles: user.roles, + avatar: user.avatar, + timezone: user.timezone, + authorization_token: user.authorization_token, + }, + request.headers.get("User-Agent") || "", + ); + + return Response.json( + { + success: true, + code: 201, + message: "User Registered", + id: user.id, + }, + { status: 201, headers: { "Set-Cookie": sessionCookie } }, + ); +} + +export { handler, routeDef }; diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts new file mode 100644 index 0000000..9461be4 --- /dev/null +++ b/src/routes/api/index.ts @@ -0,0 +1,15 @@ +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(): Promise { + // TODO: Put something useful here + + return Response.json({ + message: "Hello, World!", + }); +} + +export { handler, routeDef }; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..9204e19 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,17 @@ +import { renderEjsTemplate } from "@helpers/ejs"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "text/html", +}; + +async function handler(): Promise { + const ejsTemplateData: EjsTemplateData = { + title: "Hello, World!", + }; + + return await renderEjsTemplate("index", ejsTemplateData); +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..6506490 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,247 @@ +import { environment } from "@config/environment"; +import { logger } from "@helpers/logger"; +import { + type BunFile, + FileSystemRouter, + type MatchedRoute, + type Serve, +} from "bun"; +import { resolve } from "path"; + +import { webSocketHandler } from "@/websocket"; + +import { authByToken } from "./helpers/auth"; +import { sessionManager } from "./helpers/sessions"; + +class ServerHandler { + private router: FileSystemRouter; + + constructor( + private port: number, + private host: string, + ) { + this.router = new FileSystemRouter({ + style: "nextjs", + dir: "./src/routes", + origin: `http://${this.host}:${this.port}`, + }); + } + + public initialize(): void { + const server: Serve = Bun.serve({ + port: this.port, + hostname: this.host, + fetch: this.handleRequest.bind(this), + websocket: { + open: webSocketHandler.handleOpen.bind(webSocketHandler), + message: webSocketHandler.handleMessage.bind(webSocketHandler), + close: webSocketHandler.handleClose.bind(webSocketHandler), + }, + }); + + logger.info( + `Server running at http://${server.hostname}:${server.port}`, + true, + ); + + this.logRoutes(); + } + + private logRoutes(): void { + logger.info("Available routes:"); + + const sortedRoutes: [string, string][] = Object.entries( + this.router.routes, + ).sort(([pathA]: [string, string], [pathB]: [string, string]) => + pathA.localeCompare(pathB), + ); + + for (const [path, filePath] of sortedRoutes) { + logger.info(`Route: ${path}, File: ${filePath}`); + } + } + + private async serveStaticFile(pathname: string): Promise { + try { + let filePath: string; + + if (pathname === "/favicon.ico") { + filePath = resolve("public", "assets", "favicon.ico"); + } else { + filePath = resolve(`.${pathname}`); + } + + const file: BunFile = Bun.file(filePath); + + if (await file.exists()) { + const fileContent: ArrayBuffer = await file.arrayBuffer(); + const contentType: string = + file.type || "application/octet-stream"; + + return new Response(fileContent, { + headers: { "Content-Type": contentType }, + }); + } else { + logger.warn(`File not found: ${filePath}`); + return new Response("Not Found", { status: 404 }); + } + } catch (error) { + logger.error([ + `Error serving static file: ${pathname}`, + error as Error, + ]); + return new Response("Internal Server Error", { status: 500 }); + } + } + + private async handleRequest( + request: ExtendedRequest, + server: BunServer, + ): Promise { + request.startPerf = performance.now(); + + const pathname: string = new URL(request.url).pathname; + if (pathname.startsWith("/public") || pathname === "/favicon.ico") { + return await this.serveStaticFile(pathname); + } + + const match: MatchedRoute | null = this.router.match(request); + let requestBody: unknown = {}; + let response: Response; + + if (match) { + const { filePath, params, query } = match; + + try { + const routeModule: RouteModule = await import(filePath); + const contentType: string | null = + request.headers.get("Content-Type"); + const actualContentType: string | null = contentType + ? contentType.split(";")[0].trim() + : null; + + if ( + routeModule.routeDef.needsBody === "json" && + actualContentType === "application/json" + ) { + try { + requestBody = await request.json(); + } catch { + requestBody = {}; + } + } else if ( + routeModule.routeDef.needsBody === "multipart" && + actualContentType === "multipart/form-data" + ) { + try { + requestBody = await request.formData(); + } catch { + requestBody = {}; + } + } + + if (routeModule.routeDef.method !== request.method) { + response = Response.json( + { + success: false, + code: 405, + error: `Method ${request.method} Not Allowed, expected ${routeModule.routeDef.method}`, + }, + { status: 405 }, + ); + } else { + const expectedContentType: string | null = + routeModule.routeDef.accepts; + + const matchesAccepts: boolean = + expectedContentType === "*/*" || + actualContentType === expectedContentType; + + if (!matchesAccepts) { + response = Response.json( + { + success: false, + code: 406, + error: `Content-Type ${contentType} Not Acceptable, expected ${expectedContentType}`, + }, + { status: 406 }, + ); + } else { + request.params = params; + request.query = query; + + request.session = + (await sessionManager.getSession(request)) || + (await authByToken(request)); + + response = await routeModule.handler( + request, + requestBody, + server, + ); + + if (routeModule.routeDef.returns !== "*/*") { + response.headers.set( + "Content-Type", + routeModule.routeDef.returns, + ); + } + } + } + } catch (error: unknown) { + logger.error([ + `Error handling route ${request.url}:`, + error as Error, + ]); + + response = Response.json( + { + success: false, + code: 500, + error: "Internal Server Error", + }, + { status: 500 }, + ); + } + } else { + response = Response.json( + { + success: false, + code: 404, + error: "Not Found", + }, + { status: 404 }, + ); + } + + const headers: Headers = response.headers; + let ip: string | null = server.requestIP(request)?.address || null; + + if (!ip) { + ip = + headers.get("CF-Connecting-IP") || + headers.get("X-Real-IP") || + headers.get("X-Forwarded-For") || + null; + } + + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); + + return response; + } +} +const serverHandler: ServerHandler = new ServerHandler( + environment.port, + environment.host, +); + +export { serverHandler }; diff --git a/src/views/index.ejs b/src/views/index.ejs new file mode 100644 index 0000000..e8f6e0b --- /dev/null +++ b/src/views/index.ejs @@ -0,0 +1,8 @@ + + + + + +

hello

+ + diff --git a/src/websocket.ts b/src/websocket.ts new file mode 100644 index 0000000..ce87fe8 --- /dev/null +++ b/src/websocket.ts @@ -0,0 +1,34 @@ +import { logger } from "@helpers/logger"; +import { type ServerWebSocket } from "bun"; + +class WebSocketHandler { + public handleMessage(ws: ServerWebSocket, message: string): void { + logger.info(`WebSocket received: ${message}`); + try { + ws.send(`You said: ${message}`); + } catch (error) { + logger.error(["WebSocket send error", error as Error]); + } + } + + public handleOpen(ws: ServerWebSocket): void { + logger.info("WebSocket connection opened."); + try { + ws.send("Welcome to the WebSocket server!"); + } catch (error) { + logger.error(["WebSocket send error", error as Error]); + } + } + + public handleClose( + ws: ServerWebSocket, + code: number, + reason: string, + ): void { + logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + } +} + +const webSocketHandler: WebSocketHandler = new WebSocketHandler(); + +export { webSocketHandler }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ac5f2c7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,51 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": [ + "src/*" + ], + "@config/*": [ + "config/*" + ], + "@types/*": [ + "types/*" + ], + "@helpers/*": [ + "src/helpers/*" + ] + }, + "typeRoots": [ + "./src/types", + "./node_modules/@types" + ], + // Enable latest features + "lib": [ + "ESNext", + "DOM" + ], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + }, + "include": [ + "src", + "types", + "config" + ], +} diff --git a/types/bun.d.ts b/types/bun.d.ts new file mode 100644 index 0000000..414c40f --- /dev/null +++ b/types/bun.d.ts @@ -0,0 +1,15 @@ +import type { Server } from "bun"; + +type Query = Record; +type Params = Record; + +declare global { + type BunServer = Server; + + type ExtendedRequest = Request & { + startPerf: number; + query: Query; + params: Params; + session: UserSession | ApiUserSession | null; + }; +} diff --git a/types/config.d.ts b/types/config.d.ts new file mode 100644 index 0000000..86a49f6 --- /dev/null +++ b/types/config.d.ts @@ -0,0 +1,10 @@ +type Environment = { + port: number; + host: string; + development: boolean; +}; + +type UserValidation = { + check: { valid: boolean; error?: string }; + field: string; +}; diff --git a/types/ejs.d.ts b/types/ejs.d.ts new file mode 100644 index 0000000..486a4a4 --- /dev/null +++ b/types/ejs.d.ts @@ -0,0 +1,3 @@ +interface EjsTemplateData { + [key: string]: string | number | boolean | object | undefined | null; +} diff --git a/types/logger.d.ts b/types/logger.d.ts new file mode 100644 index 0000000..ff6a601 --- /dev/null +++ b/types/logger.d.ts @@ -0,0 +1,9 @@ +type ILogMessagePart = { value: string; color: string }; + +type ILogMessageParts = { + level: ILogMessagePart; + filename: ILogMessagePart; + readableTimestamp: ILogMessagePart; + message: ILogMessagePart; + [key: string]: ILogMessagePart; +}; diff --git a/types/routes.d.ts b/types/routes.d.ts new file mode 100644 index 0000000..eb67a3c --- /dev/null +++ b/types/routes.d.ts @@ -0,0 +1,15 @@ +type RouteDef = { + method: string; + accepts: string | null; + returns: string; + needsBody?: "multipart" | "json"; +}; + +type RouteModule = { + handler: ( + request: Request, + requestBody: unknown, + server: BunServer, + ) => Promise | Response; + routeDef: RouteDef; +}; diff --git a/types/session.d.ts b/types/session.d.ts new file mode 100644 index 0000000..9f5d7b7 --- /dev/null +++ b/types/session.d.ts @@ -0,0 +1,29 @@ +type UserSession = { + id: string; + username: string; + email: string; + email_verified: boolean; + roles: string[]; + avatar: boolean; + timezone: string; + authorization_token: string; +}; + +type ApiUserSession = UserSession & { + is_api: boolean; +}; + +type User = { + id: UUID; + authorization_token: UUID; + username: string; + email: string; + email_verified: boolean; + password: string; + avatar: boolean; + roles: string[]; + timezone: string; + invited_by: UUID; + created_at: Date; + last_seen: Date; +};