diff --git a/.env.example b/.env.example index 5e1c5db..b4aa814 100644 --- a/.env.example +++ b/.env.example @@ -2,16 +2,19 @@ HOST=0.0.0.0 PORT=9090 +# Replace with your domain name or IP address +# If you are using a reverse proxy, set the FQDN to your domain name +FQDN=localhost:9090 +FRONTEND_URL=http://localhost:8080 + PGHOST=localhost PGPORT=5432 PGUSERNAME=postgres PGPASSWORD=postgres PGDATABASE=postgres -REDIS_HOST=localhost -REDIS_PORT=6379 -# REDIS_USERNAME=redis -# REDIS_PASSWORD=redis +REDIS_URL=redis://localhost:6379 +REDIS_TTL=3600 # For sessions and cookies, can be generated using `openssl rand -base64 32` JWT_SECRET=your_jwt_secret diff --git a/.forgejo/workflows/biomejs.yml b/.forgejo/workflows/biomejs.yml new file mode 100644 index 0000000..15c990c --- /dev/null +++ b/.forgejo/workflows/biomejs.yml @@ -0,0 +1,24 @@ +name: Code quality checks + +on: + push: + pull_request: + +jobs: + biome: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + echo "$BUN_INSTALL/bin" >> $GITHUB_PATH + + - name: Install Dependencies + run: bun install + + - name: Run Biome with verbose output + run: bunx biome ci . --verbose diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4cfd4bc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "mikestead.dotenv", + "EditorConfig.EditorConfig", + "biomejs.biome" + ] +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..46ee8c9 --- /dev/null +++ b/biome.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": true, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineEnding": "lf" + }, + "organizeImports": { + "enabled": true + }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "indentStyle": "tab", + "lineEnding": "lf", + "jsxQuoteStyle": "double", + "semicolons": "always" + } + } +} diff --git a/config/environment.ts b/config/environment.ts deleted file mode 100644 index 9370a4b..0000000 --- a/config/environment.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { resolve } from "path"; - -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", -}; - -export const dataType: { type: string; path: string | undefined } = { - type: process.env.DATASOURCE_TYPE || "local", - path: - process.env.DATASOURCE_TYPE === "local" - ? resolve(process.env.DATASOURCE_LOCAL_DIRECTORY || "./uploads") - : undefined, -}; diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 0000000..ad67386 --- /dev/null +++ b/config/index.ts @@ -0,0 +1,66 @@ +import { resolve } from "node:path"; +import { logger } from "@creations.works/logger"; +import { normalizeFqdn } from "@lib/char"; + +const environment: Environment = { + port: Number.parseInt(process.env.PORT || "8080", 10), + host: process.env.HOST || "0.0.0.0", + development: + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), + fqdn: normalizeFqdn(process.env.FQDN) || "http://localhost:8080", + frontendUrl: + normalizeFqdn(process.env.FRONTEND_URL) || "http://localhost:8080", +}; + +const dataType: { type: string; path: string | undefined } = { + type: process.env.DATASOURCE_TYPE || "local", + path: + process.env.DATASOURCE_TYPE === "local" + ? resolve(process.env.DATASOURCE_LOCAL_DIRECTORY || "./uploads") + : undefined, +}; + +const frontendUrl: string = process.env.FRONTEND_URL || "http://localhost:8080"; + +function verifyRequiredVariables(): void { + const requiredVariables = [ + "HOST", + "PORT", + + "FQDN", + "FRONTEND_URL", + + "PGHOST", + "PGPORT", + "PGUSERNAME", + "PGPASSWORD", + "PGDATABASE", + + "REDIS_URL", + "REDIS_TTL", + + "JWT_SECRET", + "JWT_EXPIRES", + + "DATASOURCE_TYPE", + ]; + + let hasError = false; + + for (const key of requiredVariables) { + const value = process.env[key]; + if (value === undefined || value.trim() === "") { + logger.error(`Missing or empty environment variable: ${key}`); + hasError = true; + } + } + + if (hasError) { + process.exit(1); + } +} + +export * from "@config/jwt"; +export * from "@config/redis"; + +export { environment, dataType, verifyRequiredVariables, frontendUrl }; diff --git a/config/jwt.ts b/config/jwt.ts new file mode 100644 index 0000000..e268a0c --- /dev/null +++ b/config/jwt.ts @@ -0,0 +1,27 @@ +const allowedAlgorithms = [ + "HS256", + "RS256", + "HS384", + "HS512", + "RS384", + "RS512", +] as const; + +type AllowedAlgorithm = (typeof allowedAlgorithms)[number]; + +function getAlgorithm(envVar: string | undefined): AllowedAlgorithm { + if (allowedAlgorithms.includes(envVar as AllowedAlgorithm)) { + return envVar as AllowedAlgorithm; + } + return "HS256"; +} + +export const jwt: { + secret: string; + expiration: string; + algorithm: AllowedAlgorithm; +} = { + secret: process.env.JWT_SECRET || "", + expiration: process.env.JWT_EXPIRATION || "1h", + algorithm: getAlgorithm(process.env.JWT_ALGORITHM), +}; diff --git a/config/redis.ts b/config/redis.ts new file mode 100644 index 0000000..8721478 --- /dev/null +++ b/config/redis.ts @@ -0,0 +1,3 @@ +export const redisTtl: number = process.env.REDIS_TTL + ? Number.parseInt(process.env.REDIS_TTL, 10) + : 60 * 60 * 1; // 1 hour diff --git a/config/sql/avatars.ts b/config/sql/avatars.ts index 7047e24..8f40a73 100644 --- a/config/sql/avatars.ts +++ b/config/sql/avatars.ts @@ -1,18 +1,18 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 6; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation` + await activeReservation` CREATE TABLE IF NOT EXISTS avatars ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -28,17 +28,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } - -export function isValidTypeOrExtension( - type: string, - extension: string, -): boolean { - return ( - ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) && - ["jpeg", "jpg", "png", "gif", "webp"].includes(extension) - ); -} diff --git a/config/sql/files.ts b/config/sql/files.ts index da08db5..844640d 100644 --- a/config/sql/files.ts +++ b/config/sql/files.ts @@ -1,18 +1,18 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 5; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation` + await activeReservation` CREATE TABLE IF NOT EXISTS files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -37,7 +37,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { ); `; - const functionExists: { exists: boolean }[] = await reservation` + const functionExists: { exists: boolean }[] = await activeReservation` SELECT EXISTS ( SELECT 1 FROM pg_proc JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid @@ -46,7 +46,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; if (!functionExists[0].exists) { - await reservation` + await activeReservation` CREATE FUNCTION update_files_updated_at() RETURNS TRIGGER AS $$ BEGIN @@ -57,7 +57,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; } - const triggerExists: { exists: boolean }[] = await reservation` + const triggerExists: { exists: boolean }[] = await activeReservation` SELECT EXISTS ( SELECT 1 FROM pg_trigger WHERE tgname = 'trigger_update_files_updated_at' @@ -65,7 +65,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; if (!triggerExists[0].exists) { - await reservation` + await activeReservation` CREATE TRIGGER trigger_update_files_updated_at BEFORE UPDATE ON files FOR EACH ROW @@ -80,7 +80,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } diff --git a/config/sql/folders.ts b/config/sql/folders.ts index 1946c4a..6329808 100644 --- a/config/sql/folders.ts +++ b/config/sql/folders.ts @@ -1,18 +1,18 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 4; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation` + await activeReservation` CREATE TABLE IF NOT EXISTS folders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -26,7 +26,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { ); `; - const functionExists: { exists: boolean }[] = await reservation` + const functionExists: { exists: boolean }[] = await activeReservation` SELECT EXISTS ( SELECT 1 FROM pg_proc JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid @@ -35,7 +35,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; if (!functionExists[0].exists) { - await reservation` + await activeReservation` CREATE FUNCTION update_folders_updated_at() RETURNS TRIGGER AS $$ BEGIN @@ -46,7 +46,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; } - const triggerExists: { exists: boolean }[] = await reservation` + const triggerExists: { exists: boolean }[] = await activeReservation` SELECT EXISTS ( SELECT 1 FROM pg_trigger WHERE tgname = 'trigger_update_folders_updated_at' @@ -54,7 +54,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; if (!triggerExists[0].exists) { - await reservation` + await activeReservation` CREATE TRIGGER trigger_update_folders_updated_at BEFORE UPDATE ON folders FOR EACH ROW @@ -69,7 +69,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } diff --git a/config/sql/invites.ts b/config/sql/invites.ts index e098d32..fabbbf7 100644 --- a/config/sql/invites.ts +++ b/config/sql/invites.ts @@ -1,18 +1,18 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 3; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation` + await activeReservation` CREATE TABLE IF NOT EXISTS invites ( id TEXT PRIMARY KEY NOT NULL UNIQUE, created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -27,7 +27,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } diff --git a/config/sql/settings.ts b/config/sql/settings.ts index 0def8e8..41d5fe7 100644 --- a/config/sql/settings.ts +++ b/config/sql/settings.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 2; @@ -20,15 +20,15 @@ const defaultSettings: Setting[] = [ ]; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation` + await activeReservation` CREATE TABLE IF NOT EXISTS settings ( "key" VARCHAR(64) PRIMARY KEY NOT NULL UNIQUE, "value" TEXT NOT NULL, @@ -37,7 +37,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { ); `; - const functionExists: { exists: boolean }[] = await reservation` + const functionExists: { exists: boolean }[] = await activeReservation` SELECT EXISTS ( SELECT 1 FROM pg_proc JOIN pg_namespace ON pg_proc.pronamespace = pg_namespace.oid @@ -46,7 +46,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; if (!functionExists[0].exists) { - await reservation` + await activeReservation` CREATE FUNCTION update_settings_updated_at() RETURNS TRIGGER AS $$ BEGIN @@ -57,7 +57,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; } - const triggerExists: { exists: boolean }[] = await reservation` + const triggerExists: { exists: boolean }[] = await activeReservation` SELECT EXISTS ( SELECT 1 FROM pg_trigger WHERE tgname = 'trigger_update_settings_updated_at' @@ -65,7 +65,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { `; if (!triggerExists[0].exists) { - await reservation` + await activeReservation` CREATE TRIGGER trigger_update_settings_updated_at BEFORE UPDATE ON settings FOR EACH ROW @@ -74,7 +74,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { } for (const setting of defaultSettings) { - await reservation` + await activeReservation` INSERT INTO settings ("key", "value") VALUES (${setting.key}, ${setting.value}) ON CONFLICT ("key") DO NOTHING; @@ -88,27 +88,25 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } -// * Validation functions - export async function getSetting( key: string, reservation?: ReservedSQL, ): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { const result: { value: string }[] = - await reservation`SELECT value FROM settings WHERE "key" = ${key};`; + await activeReservation`SELECT value FROM settings WHERE "key" = ${key};`; if (result.length === 0) { return null; @@ -120,7 +118,7 @@ export async function getSetting( throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } @@ -130,15 +128,15 @@ export async function setSetting( value: string, reservation?: ReservedSQL, ): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation` + await activeReservation` INSERT INTO settings ("key", "value", updated_at) VALUES (${key}, ${value}, NOW()) ON CONFLICT ("key") @@ -148,7 +146,7 @@ export async function setSetting( throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } @@ -157,21 +155,21 @@ export async function deleteSetting( key: string, reservation?: ReservedSQL, ): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation`DELETE FROM settings WHERE "key" = ${key};`; + await activeReservation`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(); + activeReservation.release(); } } } @@ -179,16 +177,16 @@ export async function deleteSetting( export async function getAllSettings( reservation?: ReservedSQL, ): Promise<{ key: string; value: string }[]> { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { const result: { key: string; value: string }[] = - await reservation`SELECT "key", "value" FROM settings;`; + await activeReservation`SELECT "key", "value" FROM settings;`; return result; } catch (error) { @@ -196,7 +194,7 @@ export async function getAllSettings( throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } diff --git a/config/sql/users.ts b/config/sql/users.ts index 560b906..0cbf8c5 100644 --- a/config/sql/users.ts +++ b/config/sql/users.ts @@ -1,18 +1,18 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 1; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); if (!reservation) { - reservation = await sql.reserve(); selfReservation = true; } try { - await reservation` + await activeReservation` CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), authorization_token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), @@ -32,122 +32,7 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } - -// * Validation functions - -// ? should support non english characters but won't mess up the url -export const userNameRestrictions: { - length: { min: number; max: number }; - regex: RegExp; -} = { - length: { min: 3, max: 20 }, - regex: /^[\p{L}\p{N}._-]+$/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 deleted file mode 100644 index 7442449..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,142 +0,0 @@ -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"; -import stylelintPlugin from "stylelint"; - -/** @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, - }, - ], - }, - }, - { - files: ["**/*.{css,scss,sass,less}"], - plugins: { - stylelint: stylelintPlugin, - }, - rules: { - "stylelint/rule-name": "error", - }, - }, -]; diff --git a/package.json b/package.json index 0fc03b5..6a99842 100644 --- a/package.json +++ b/package.json @@ -1,46 +1,35 @@ { - "name": "bun_frontend_template", + "name": "atums.world", + "private": true, "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", + "dev": "bun run --hot src/index.ts --dev", + "lint": "bunx biome check", + "lint:fix": "bunx biome check --fix", + "cleanup": "rm -rf logs node_modules bun.lock", "clearTable": "bun run src/helpers/commands/clearTable.ts" }, "devDependencies": { - "@eslint/js": "^9.22.0", - "@types/bun": "^1.2.5", - "@types/ejs": "^3.1.5", + "@biomejs/biome": "^1.9.4", + "@types/bun": "^1.2.13", "@types/fluent-ffmpeg": "^2.1.27", "@types/image-thumbnail": "^1.0.4", - "@types/luxon": "^3.4.2", - "@typescript-eslint/eslint-plugin": "^8.26.1", - "@typescript-eslint/parser": "^8.26.1", - "eslint": "^9.22.0", - "eslint-plugin-prettier": "^5.2.3", - "eslint-plugin-promise": "^7.2.1", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-stylelint": "^0.1.1", - "eslint-plugin-unicorn": "^56.0.1", - "eslint-plugin-unused-imports": "^4.1.4", - "globals": "^15.15.0", - "prettier": "^3.5.3", - "stylelint": "^16.16.0", - "stylelint-config-standard": "^37.0.0" + "@types/luxon": "^3.6.2", + "globals": "16.0.0", + "prettier": "^3.5.3" }, "peerDependencies": { - "typescript": "^5.7.3" + "typescript": "^5.8.3" }, "dependencies": { - "ejs": "^3.1.10", - "exiftool-vendored": "^29.2.0", - "fast-jwt": "^5.0.5", + "@creations.works/logger": "^1.0.3", + "eta": "^3.5.0", + "exiftool-vendored": "^30.0.0", + "fast-jwt": "6.0.1", "fluent-ffmpeg": "^2.1.3", "image-thumbnail": "^1.0.17", - "luxon": "^3.5.0", - "redis": "^4.7.0" + "luxon": "^3.6.1" } } diff --git a/public/css/auth/login.css b/public/css/auth/login.css deleted file mode 100644 index 4de920e..0000000 --- a/public/css/auth/login.css +++ /dev/null @@ -1,27 +0,0 @@ -.container { - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - height: 100vh; -} - -.content { - border: 1px solid var(--border); - background-color: var(--background-secondary); - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 2rem; - - width: clamp(200px, 50%, 300px); -} - -.content form { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} diff --git a/public/css/global.css b/public/css/global.css index f550c18..fe28f4b 100644 --- a/public/css/global.css +++ b/public/css/global.css @@ -1,40 +1,48 @@ [data-theme="dark"] { - --background: rgb(31, 30, 30); - --background-secondary: rgb(45, 45, 45); - --border: rgb(31, 30, 30); - --text: rgb(255, 255, 255); - --text-secondary: rgb(255, 255, 255); -} - -body { - font-family: "Ubuntu", sans-serif; - - margin: 0; - padding: 0; - box-sizing: border-box; - font-size: 16px; - - background-color: var(--background); + --background: rgb(31 30 30); + --background-secondary: rgb(45 45 45); + --border: rgb(70 70 70); + --text: rgb(255 255 255); + --svg-fill: rgb(255 255 255); + --text-secondary: rgb(200 200 200); + --accent: rgb(88 101 242); + --accent-hover: rgb(71 82 196); + --error: rgb(237 66 69); + --success: rgb(87 242 135); + --shadow: rgb(0 0 0 / 20%); + --card-shadow: 0 2px 10px 0 rgb(0 0 0 / 20%); + --input-background: rgb(55 55 55); } /* Fonts */ @font-face { - font-family: "Ubuntu"; + font-family: Ubuntu; src: url("/public/assets/fonts/Ubuntu/Ubuntu-Regular.ttf") format("truetype"); font-weight: normal; font-style: normal; } @font-face { - font-family: "Ubuntu Bold"; + font-family: Ubuntu Bold; src: url("/public/assets/fonts/Ubuntu/Ubuntu-Bold.ttf") format("truetype"); font-weight: bold; font-style: normal; } @font-face { - font-family: "Fira Code"; - src: url("/public/assets/fonts/Fira_code/FiraCode-Regular.ttf") format("truetype"); + font-family: Fira Code; + src: url("/public/assets/fonts/Fira_code/FiraCode-Regular.ttf") + format("truetype"); font-weight: normal; font-style: normal; } + +body { + font-family: Ubuntu, sans-serif; + margin: 0; + padding: 0; + box-sizing: border-box; + font-size: 16px; + background-color: var(--background); + color: var(--text); +} diff --git a/src/helpers/ejs.ts b/src/helpers/ejs.ts deleted file mode 100644 index 6b03dd0..0000000 --- a/src/helpers/ejs.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 16e1076..0000000 --- a/src/helpers/logger.ts +++ /dev/null @@ -1,205 +0,0 @@ -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 | ErrorEvent | (string | Error)[], - breakLine: boolean = false, - ): void { - const stack: string = new Error().stack || ""; - const { filename, timestamp } = this.getCallerInfo(stack); - - const messages: (string | Error | ErrorEvent)[] = Array.isArray(message) - ? message - : [message]; - const joinedMessage: string = messages - .map((msg: string | Error | ErrorEvent): 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 deleted file mode 100644 index 5cd1e54..0000000 --- a/src/helpers/redis.ts +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 05b5376..0000000 --- a/src/helpers/sessions.ts +++ /dev/null @@ -1,194 +0,0 @@ -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 updateSession( - request: Request, - payload: UserSession, - userAgent: string, - ): Promise { - const cookie: string | null = request.headers.get("Cookie"); - if (!cookie) throw new Error("No session found in request"); - - const token: string | null = - cookie.match(/session=([^;]+)/)?.[1] || null; - if (!token) throw new Error("Session token not found"); - - const userSessions: string[] = await redis - .getInstance() - .keys("session:*:" + token); - if (!userSessions.length) - throw new Error("Session not found or expired"); - - const sessionKey: string = userSessions[0]; - - await redis - .getInstance() - .set( - "JSON", - sessionKey, - { ...payload, userAgent }, - this.getExpirationInSeconds(), - ); - - return this.generateCookie(token); - } - - 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 index 5af5367..07e2352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,12 @@ -import { dataType } from "@config/environment"; -import { logger } from "@helpers/logger"; -import { type ReservedSQL, s3, sql } from "bun"; -import { existsSync, mkdirSync } from "fs"; -import { readdir } from "fs/promises"; -import { resolve } from "path"; +import { existsSync, mkdirSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { dataType, verifyRequiredVariables } from "@config"; +import { logger } from "@creations.works/logger"; +import { type ReservedSQL, redis, s3, sql } from "bun"; 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); @@ -17,9 +15,7 @@ async function initializeDatabase(): Promise { files .filter((file: string): boolean => file.endsWith(".ts")) .map(async (file: string): Promise => { - const module: Module["module"] = await import( - resolve(sqlDir, file) - ); + const module: Module["module"] = await import(resolve(sqlDir, file)); return { file, module }; }), ); @@ -40,63 +36,69 @@ async function initializeDatabase(): Promise { } async function main(): Promise { + verifyRequiredVariables(); + try { - try { - await sql`SELECT 1;`; + 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); - } + 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); + } - if (dataType.type === "local" && dataType.path) { - if (!existsSync(dataType.path)) { - try { - mkdirSync(dataType.path); - } catch (error) { - logger.error([ - "Could not create datasource local directory", - error as Error, - ]); - process.exit(1); - } - } + try { + await redis.connect(); - logger.info([ - "Using local datasource directory", - `${dataType.path}`, - ]); - } else { + const url = new URL(process.env.REDIS_URL || "redis://localhost:6379"); + const host = url.hostname; + const port = url.port || "6379"; + + logger.info(["Connected to Redis on", `${host}:${port}`]); + } catch (error) { + logger.error(["Redis connection failed:", error as Error]); + process.exit(1); + } + + if (dataType.type === "local" && dataType.path) { + if (!existsSync(dataType.path)) { try { - await s3.write("test", "test"); - await s3.delete("test"); - - logger.info([ - "Connected to S3 with bucket", - `${process.env.S3_BUCKET}`, - ]); + mkdirSync(dataType.path); } catch (error) { logger.error([ - "Could not establish a connection to S3 bucket:", + "Could not create datasource local directory", error as Error, ]); process.exit(1); } } - await redis.initialize(); - serverHandler.initialize(); - await initializeDatabase(); - } catch (error) { - throw error; + logger.info(["Using local datasource directory", `${dataType.path}`]); + } else { + try { + await s3.write("test", "test"); + await s3.delete("test"); + + logger.info(["Connected to S3 with bucket", `${process.env.S3_BUCKET}`]); + } catch (error) { + logger.error([ + "Could not establish a connection to S3 bucket:", + error as Error, + ]); + process.exit(1); + } } + + logger.space(); + + serverHandler.initialize(); + await initializeDatabase(); } main().catch((error: Error) => { diff --git a/src/helpers/auth.ts b/src/lib/auth.ts similarity index 73% rename from src/helpers/auth.ts rename to src/lib/auth.ts index 1a3d91f..1c9a311 100644 --- a/src/helpers/auth.ts +++ b/src/lib/auth.ts @@ -1,12 +1,13 @@ -import { isUUID } from "@helpers/char"; -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; import { type ReservedSQL, sql } from "bun"; export async function authByToken( request: ExtendedRequest, reservation?: ReservedSQL, ): Promise { - let selfReservation: boolean = false; + let selfReservation = false; + let activeReservation: ReservedSQL | undefined = reservation; const authorizationHeader: string | null = request.headers.get("Authorization"); @@ -17,14 +18,14 @@ export async function authByToken( const authorizationToken: string = authorizationHeader.slice(7).trim(); if (!authorizationToken || !isUUID(authorizationToken)) return null; - if (!reservation) { - reservation = await sql.reserve(); + if (!activeReservation) { + activeReservation = await sql.reserve(); selfReservation = true; } try { const result: User[] = - await reservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`; + await activeReservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`; if (result.length === 0) return null; @@ -44,7 +45,7 @@ export async function authByToken( return null; } finally { if (selfReservation) { - reservation.release(); + activeReservation.release(); } } } diff --git a/src/helpers/char.ts b/src/lib/char.ts similarity index 83% rename from src/helpers/char.ts rename to src/lib/char.ts index 084386b..ac89dcd 100644 --- a/src/helpers/char.ts +++ b/src/lib/char.ts @@ -2,8 +2,8 @@ import { DateTime } from "luxon"; export function timestampToReadable(timestamp?: number): string { const date: Date = - timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); - if (isNaN(date.getTime())) return "Invalid Date"; + timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date(); + if (Number.isNaN(date.getTime())) return "Invalid Date"; return date.toISOString().replace("T", " ").replace("Z", ""); } @@ -48,7 +48,7 @@ export function parseDuration(input: string): DurationObject { }; for (const match of matches) { - const value: number = parseInt(match[1], 10); + const value: number = Number.parseInt(match[1], 10); const unit: string = match[2]; switch (unit) { @@ -84,18 +84,14 @@ export function isValidTimezone(timezone: string): boolean { } export function generateRandomString(length?: number): string { - if (!length) { - length = length || Math.floor(Math.random() * 10) + 5; - } + const finalLength: number = length ?? Math.floor(Math.random() * 10) + 5; - const characters: string = + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result: string = ""; + let result = ""; - for (let i: number = 0; i < length; i++) { - result += characters.charAt( - Math.floor(Math.random() * characters.length), - ); + for (let i = 0; i < finalLength; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); } return result; @@ -172,9 +168,7 @@ export function nameWithoutExtension(fileName: string): string { if (lastDotIndex <= 0) return fileName; const ext: string = fileName.slice(lastDotIndex + 1).toLowerCase(); - return knownExtensions.has(ext) - ? fileName.slice(0, lastDotIndex) - : fileName; + return knownExtensions.has(ext) ? fileName.slice(0, lastDotIndex) : fileName; } export function supportsExif(mimeType: string, extension: string): boolean { @@ -206,18 +200,22 @@ export function supportsThumbnail(mimeType: string): boolean { return /^(image\/(?!svg+xml)|video\/)/i.test(mimeType); } +export function normalizeFqdn(value?: string): string | null { + if (!value) return null; + if (!/^https?:\/\//.test(value)) return `https://${value}`; + return value; +} + // Commands export function parseArgs(): Record { const args: string[] = process.argv.slice(2); const parsedArgs: Record = {}; - for (let i: number = 0; i < args.length; i++) { + for (let i = 0; i < args.length; i++) { if (args[i].startsWith("--")) { const key: string = args[i].slice(2); const value: string | boolean = - args[i + 1] && !args[i + 1].startsWith("--") - ? args[i + 1] - : true; + args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true; parsedArgs[key] = value; if (value !== true) i++; } diff --git a/src/helpers/commands/clearTable.ts b/src/lib/commands/clearTable.ts similarity index 89% rename from src/helpers/commands/clearTable.ts rename to src/lib/commands/clearTable.ts index cdb11a1..dfd2906 100644 --- a/src/helpers/commands/clearTable.ts +++ b/src/lib/commands/clearTable.ts @@ -1,4 +1,4 @@ -import { parseArgs } from "@helpers/char"; +import { parseArgs } from "@lib/char"; import { type ReservedSQL, sql } from "bun"; (async (): Promise => { @@ -24,8 +24,7 @@ import { type ReservedSQL, sql } from "bun"; error.message.includes("foreign key constraint") ) { console.error( - `Could not clear table "${table}" due to foreign key constraints.\n` + - "Try using --cascade if you want to remove dependent records.", + `Could not clear table "${table}" due to foreign key constraints.\nTry using --cascade if you want to remove dependent records.`, ); } else { console.error("Could not clear table:", error); diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..cf857a0 --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,145 @@ +import { environment, jwt } from "@config"; +import { redis } from "bun"; +import { createDecoder, createSigner, createVerifier } from "fast-jwt"; + +const signer = createSigner({ key: jwt.secret, expiresIn: jwt.expiration }); +const verifier = createVerifier({ key: jwt.secret }); +const decoder = createDecoder(); + +export async function createSession( + payload: UserSession, + userAgent: string, +): Promise { + const token = signer(payload); + const sessionKey = `session:${payload.id}:${token}`; + await redis.set(sessionKey, JSON.stringify({ ...payload, userAgent })); + await redis.expire(sessionKey, getExpirationInSeconds()); + return generateCookie(token); +} + +export async function getSession( + request: Request, +): Promise { + const token = extractToken(request); + if (!token) return null; + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) return null; + const raw = await redis.get(keys[0]); + return raw ? JSON.parse(raw) : null; +} + +export async function updateSession( + request: Request, + payload: UserSession, + userAgent: string, +): Promise { + const token = extractToken(request); + if (!token) throw new Error("Session token not found"); + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) throw new Error("Session not found or expired"); + await redis.set(keys[0], JSON.stringify({ ...payload, userAgent })); + await redis.expire(keys[0], getExpirationInSeconds()); + return generateCookie(token); +} + +export async function verifySession(token: string): Promise { + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) throw new Error("Session not found or expired"); + return verifier(token); +} + +export async function decodeSession(token: string): Promise { + return decoder(token); +} + +export async function invalidateSession(request: Request): Promise { + const token = extractToken(request); + if (!token) return; + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) return; + await redis.del(keys[0]); +} + +export async function invalidateSessionById( + sessionId: string, +): Promise { + const keys = await redis.keys(`session:*:${sessionId}`); + if (!keys.length) return false; + await redis.del(keys[0]); + return true; +} + +export async function invalidateAllSessionsForUser( + userId: string, +): Promise { + const keys = await redis.keys(`session:${userId}:*`); + if (keys.length === 0) return 0; + + for (const key of keys) { + await redis.del(key); + } + + return keys.length; +} + +// helpers +function extractToken(request: Request): string | null { + return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null; +} + +function generateCookie( + token: string, + maxAge = 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 = `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; +} + +function getExpirationInSeconds(): number { + const match = jwt.expiration.match(/^(\d+)([smhd])$/); + if (!match) throw new Error("Invalid expiresIn format in jwt config"); + const [, value, unit] = match; + const num = Number(value); + 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"); + } +} + +export const sessionManager = { + createSession, + getSession, + updateSession, + verifySession, + decodeSession, + invalidateSession, + invalidateSessionById, + invalidateAllSessionsForUser, +}; diff --git a/src/lib/validators/avatar.ts b/src/lib/validators/avatar.ts new file mode 100644 index 0000000..89422ed --- /dev/null +++ b/src/lib/validators/avatar.ts @@ -0,0 +1,9 @@ +export function isValidTypeOrExtension( + type: string, + extension: string, +): boolean { + return ( + ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) && + ["jpeg", "jpg", "png", "gif", "webp"].includes(extension) + ); +} diff --git a/src/lib/validators/email.ts b/src/lib/validators/email.ts new file mode 100644 index 0000000..d5becad --- /dev/null +++ b/src/lib/validators/email.ts @@ -0,0 +1,18 @@ +const emailRestrictions: { regex: RegExp } = { + regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, +}; + +export function isValidEmail(email: string): { + valid: boolean; + error?: string; +} { + if (!email) { + return { valid: false, error: "" }; + } + + if (!emailRestrictions.regex.test(email)) { + return { valid: false, error: "Invalid email address" }; + } + + return { valid: true }; +} diff --git a/src/lib/validators/index.ts b/src/lib/validators/index.ts new file mode 100644 index 0000000..0192c4a --- /dev/null +++ b/src/lib/validators/index.ts @@ -0,0 +1,5 @@ +export * from "@lib/validators/name"; +export * from "@lib/validators/email"; +export * from "@lib/validators/password"; +export * from "@lib/validators/invite"; +export * from "@lib/validators/avatar"; diff --git a/src/lib/validators/invite.ts b/src/lib/validators/invite.ts new file mode 100644 index 0000000..6a31711 --- /dev/null +++ b/src/lib/validators/invite.ts @@ -0,0 +1,37 @@ +const inviteRestrictions: { min: number; max: number; regex: RegExp } = { + min: 4, + max: 15, + regex: /^[a-zA-Z0-9]+$/, +}; + +export function isValidInvite(invite: string): { + valid: boolean; + error?: string; +} { + if (!invite) { + return { valid: false, error: "" }; + } + + 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/src/lib/validators/name.ts b/src/lib/validators/name.ts new file mode 100644 index 0000000..1ccf292 --- /dev/null +++ b/src/lib/validators/name.ts @@ -0,0 +1,31 @@ +// ? should support non english characters but won't mess up the url +export const userNameRestrictions: { + length: { min: number; max: number }; + regex: RegExp; +} = { + length: { min: 3, max: 20 }, + regex: /^[\p{L}\p{N}._-]+$/u, +}; + +export function isValidUsername(username: string): { + valid: boolean; + error?: string; +} { + if (!username) { + return { valid: false, error: "" }; + } + + 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 }; +} diff --git a/src/lib/validators/password.ts b/src/lib/validators/password.ts new file mode 100644 index 0000000..43dcb0e --- /dev/null +++ b/src/lib/validators/password.ts @@ -0,0 +1,40 @@ +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 function isValidPassword(password: string): { + valid: boolean; + error?: string; +} { + if (!password) { + return { valid: false, error: "" }; + } + + 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 }; +} diff --git a/src/helpers/workers/thumbnails.ts b/src/lib/workers/thumbnails.ts similarity index 75% rename from src/helpers/workers/thumbnails.ts rename to src/lib/workers/thumbnails.ts index 45c9073..c0c4605 100644 --- a/src/helpers/workers/thumbnails.ts +++ b/src/lib/workers/thumbnails.ts @@ -1,11 +1,11 @@ -import { dataType } from "@config/environment.ts"; -import { logger } from "@helpers/logger.ts"; +import { join, resolve } from "node:path"; +import { dataType } from "@config"; +import { logger } from "@creations.works/logger"; import { type BunFile, s3, sql } from "bun"; import ffmpeg from "fluent-ffmpeg"; import imageThumbnail from "image-thumbnail"; -import { join, resolve } from "path"; -declare var self: Worker; +declare let self: Worker; async function generateVideoThumbnail( filePath: string, @@ -22,10 +22,7 @@ async function generateVideoThumbnail( .format("mjpeg") .output(thumbnailPath) .on("error", (error: Error) => { - logger.error([ - "failed to generate thumbnail", - error as Error, - ]); + logger.error(["failed to generate thumbnail", error as Error]); reject(error); }) @@ -50,40 +47,34 @@ async function generateImageThumbnail( thumbnailPath: string, ): Promise { return new Promise( - async ( + ( resolve: (value: ArrayBuffer) => void, reject: (reason: Error) => void, - ) => { - try { - const options: { - responseType: "buffer"; - height: number; - jpegOptions: { - force: boolean; - quality: number; - }; - } = { - height: 320, - responseType: "buffer", - jpegOptions: { - force: true, - quality: 60, - }, - }; + ): void => { + const options = { + height: 320, + responseType: "buffer" as const, + jpegOptions: { + force: true, + quality: 60, + }, + }; - const thumbnailBuffer: Buffer = await imageThumbnail( - filePath, - options, - ); - - await Bun.write(thumbnailPath, thumbnailBuffer.buffer); - resolve(await Bun.file(thumbnailPath).arrayBuffer()); - - await Bun.file(filePath).unlink(); - await Bun.file(thumbnailPath).unlink(); - } catch (error) { - reject(error as Error); - } + imageThumbnail(filePath, options) + .then( + (thumbnailBuffer: Buffer): Promise => + Bun.write(thumbnailPath, thumbnailBuffer.buffer).then( + (): Promise => Bun.file(thumbnailPath).arrayBuffer(), + ), + ) + .then((arrayBuffer: ArrayBuffer) => { + resolve(arrayBuffer); + return Promise.all([ + Bun.file(filePath).unlink(), + Bun.file(thumbnailPath).unlink(), + ]); + }) + .catch(reject); }, ); } @@ -104,20 +95,14 @@ async function createThumbnails(files: FileEntry[]): Promise { try { fileArrayBuffer = await Bun.file(filePath).arrayBuffer(); } catch { - logger.error([ - "Could not generate thumbnail for file:", - fileName, - ]); + logger.error(["Could not generate thumbnail for file:", fileName]); continue; } } else { try { fileArrayBuffer = await s3.file(fileName).arrayBuffer(); } catch { - logger.error([ - "Could not generate thumbnail for file:", - fileName, - ]); + logger.error(["Could not generate thumbnail for file:", fileName]); continue; } } @@ -149,10 +134,7 @@ async function createThumbnails(files: FileEntry[]): Promise { : await generateImageThumbnail(tempFilePath, tempThumbnailPath); if (!thumbnailArrayBuffer) { - logger.error([ - "Could not generate thumbnail for file:", - fileName, - ]); + logger.error(["Could not generate thumbnail for file:", fileName]); continue; } @@ -204,5 +186,5 @@ self.onmessage = async (event: MessageEvent): Promise => { }; self.onerror = (error: ErrorEvent): void => { - logger.error(error); + logger.error(["An error occurred in the thumbnail worker:", error.message]); }; diff --git a/src/routes/api/auth/login.ts b/src/routes/api/auth/login.ts deleted file mode 100644 index 8cfa4fd..0000000 --- a/src/routes/api/auth/login.ts +++ /dev/null @@ -1,161 +0,0 @@ -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[0].split(","), - 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/index.ts b/src/routes/api/index.ts deleted file mode 100644 index 9461be4..0000000 --- a/src/routes/api/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/api/auth/email/verify/[code].ts b/src/routes/auth/email/verify/[code].ts similarity index 69% rename from src/routes/api/auth/email/verify/[code].ts rename to src/routes/auth/email/verify/[code].ts index 0c9f627..ff92edb 100644 --- a/src/routes/api/auth/email/verify/[code].ts +++ b/src/routes/auth/email/verify/[code].ts @@ -1,9 +1,8 @@ -import { sql } from "bun"; +import { redis, sql } from "bun"; -import { isUUID } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; -import { redis } from "@/helpers/redis"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; const routeDef: RouteDef = { method: "POST", @@ -37,11 +36,9 @@ async function handler(request: ExtendedRequest): Promise { } try { - const verificationData: unknown = await redis - .getInstance() - .get("JSON", `email:verify:${code}`); + const raw: string | null = await redis.get(`email:verify:${code}`); - if (!verificationData) { + if (!raw) { return Response.json( { success: false, @@ -52,11 +49,24 @@ async function handler(request: ExtendedRequest): Promise { ); } - const { user_id: userId } = verificationData as { - user_id: string; - }; + let verificationData: { user_id: string }; - await redis.getInstance().delete("JSON", `email:verify:${code}`); + try { + verificationData = JSON.parse(raw); + } catch { + return Response.json( + { + success: false, + code: 400, + error: "Malformed verification data", + }, + { status: 500 }, + ); + } + + const { user_id: userId } = verificationData; + + await redis.del(`email:verify:${code}`); await sql` UPDATE users SET email_verified = true diff --git a/src/routes/api/auth/email/verify/request.ts b/src/routes/auth/email/verify/request.ts similarity index 87% rename from src/routes/api/auth/email/verify/request.ts rename to src/routes/auth/email/verify/request.ts index 089301e..477f42b 100644 --- a/src/routes/api/auth/email/verify/request.ts +++ b/src/routes/auth/email/verify/request.ts @@ -1,7 +1,7 @@ import { randomUUIDv7, sql } from "bun"; -import { logger } from "@/helpers/logger"; -import { redis } from "@/helpers/redis"; +import { logger } from "@creations.works/logger"; +import { redis } from "bun"; const routeDef: RouteDef = { method: "GET", @@ -51,11 +51,9 @@ async function handler(request: ExtendedRequest): Promise { } const code: string = randomUUIDv7(); - await redis.getInstance().set( - "JSON", + await redis.set( `email:verify:${code}`, - { user_id: request.session.id }, - 60 * 60 * 2, // 2 hours + JSON.stringify({ user_id: request.session.id }), ); // TODO: Send email when email service is implemented diff --git a/src/routes/auth/login.ts b/src/routes/auth/login.ts index b7b4e68..0beacc1 100644 --- a/src/routes/auth/login.ts +++ b/src/routes/auth/login.ts @@ -1,20 +1,166 @@ -import { getSetting } from "@config/sql/settings"; -import { renderEjsTemplate } from "@helpers/ejs"; +import { + isValidEmail, + isValidPassword, + isValidUsername, +} from "@lib/validators"; +import { type ReservedSQL, password as bunPassword, sql } from "bun"; + +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; const routeDef: RouteDef = { - method: "GET", - accepts: "*/*", - returns: "text/html", + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", }; -async function handler(): Promise { - const ejsTemplateData: EjsTemplateData = { - title: "Hello, World!", - instance_name: - (await getSetting("instance_name")) || "Unnamed Instance", +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; }; - return await renderEjsTemplate("auth/login", ejsTemplateData); + 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[] = [ + username ? { check: isValidUsername(username), field: "Username" } : null, + email ? { check: isValidEmail(email), field: "Email" } : null, + password ? { check: isValidPassword(password), field: "Password" } : null, + ].filter(Boolean) as UserValidation[]; + + for (const { check } of validations) { + if (!check.valid && check.error) { + errors.push(check.error); + } + } + + if (!username && !email) { + errors.push("Either a username or an email is required."); + } + + 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; + `; + + 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[0].split(","), + 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/auth/logout.ts similarity index 94% rename from src/routes/api/auth/logout.ts rename to src/routes/auth/logout.ts index 4ef42a1..9103d5a 100644 --- a/src/routes/api/auth/logout.ts +++ b/src/routes/auth/logout.ts @@ -1,4 +1,4 @@ -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; const routeDef: RouteDef = { method: "POST", diff --git a/src/routes/api/auth/register.ts b/src/routes/auth/register.ts similarity index 85% rename from src/routes/api/auth/register.ts rename to src/routes/auth/register.ts index ca14995..7e751f3 100644 --- a/src/routes/api/auth/register.ts +++ b/src/routes/auth/register.ts @@ -4,12 +4,12 @@ import { isValidInvite, isValidPassword, isValidUsername, -} from "@config/sql/users"; -import { password as bunPassword, type ReservedSQL, sql } from "bun"; +} from "@lib/validators"; +import { type ReservedSQL, password as bunPassword, sql } from "bun"; -import { isValidTimezone } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; +import { isValidTimezone } from "@lib/char"; const routeDef: RouteDef = { method: "POST", @@ -49,17 +49,17 @@ async function handler( { check: isValidPassword(password), field: "Password" }, ]; - validations.forEach(({ check }: UserValidation): void => { + for (const { check } of validations) { if (!check.valid && check.error) { errors.push(check.error); } - }); + } const normalizedUsername: string = username.normalize("NFC"); const reservation: ReservedSQL = await sql.reserve(); - let firstUser: boolean = false; + let firstUser = false; let inviteData: Invite | null = null; - let roles: string[] = []; + const roles: string[] = []; try { const registrationEnabled: boolean = @@ -69,13 +69,16 @@ async function handler( firstUser = Number( - (await reservation`SELECT COUNT(*) AS count FROM users;`)[0] - ?.count, + (await reservation`SELECT COUNT(*) AS count FROM users;`)[0]?.count, ) === 0; + let inviteValid = true; if (!firstUser && invite) { const inviteValidation: { valid: boolean; error?: string } = isValidInvite(invite); + + inviteValid = inviteValidation.valid; + if (!inviteValidation.valid && inviteValidation.error) { errors.push(inviteValidation.error); } @@ -89,10 +92,12 @@ async function handler( } roles.push("user"); - if (firstUser) roles.push("admin"); + if (firstUser) { + roles.push("admin"); + roles.push("superadmin"); + } - const result: { usernameExists: boolean; emailExists: boolean }[] = - await reservation` + const [result] = await reservation` SELECT EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${normalizedUsername})) AS "usernameExists", EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS "emailExists"; @@ -104,15 +109,13 @@ async function handler( errors.push("Username or email already exists"); } - if (invite && !firstUser) { - const result: Invite[] = + if (invite && inviteValid && !firstUser) { + [inviteData] = await reservation`SELECT * FROM invites WHERE id = ${invite};`; - if (!result || result.length === 0) { + if (!inviteData) { errors.push("Invalid invite"); } - - inviteData = result[0]; } } catch (error) { errors.push("An error occurred while checking for existing users"); @@ -140,13 +143,13 @@ async function handler( : (await getSetting("default_timezone", reservation)) || "UTC"; try { - const result: User[] = await reservation` + [user] = await reservation` INSERT INTO users (username, email, password, invited_by, roles, timezone) VALUES (${normalizedUsername}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${setTimezone}) RETURNING *; `; - if (result.length === 0) { + if (!user) { logger.error("User was not created"); return Response.json( { @@ -158,8 +161,6 @@ async function handler( ); } - user = result[0]; - if (!user) { logger.error("User was not created"); return Response.json( @@ -185,10 +186,7 @@ async function handler( if (inviteData?.role) roles.push(inviteData.role); } } catch (error) { - logger.error([ - "Error inserting user into the database:", - error as Error, - ]); + logger.error(["Error inserting user into the database:", error as Error]); return Response.json( { success: false, diff --git a/src/routes/auth/session.ts b/src/routes/auth/session.ts new file mode 100644 index 0000000..a087358 --- /dev/null +++ b/src/routes/auth/session.ts @@ -0,0 +1,38 @@ +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 403, + error: "Not logged in", + }, + { status: 403 }, + ); + } + + const { session } = request; + + if ((session as ApiUserSession).is_api === true) { + return Response.json( + { + success: false, + code: 403, + error: "You cannot use this endpoint with an authorization token", + }, + { status: 403 }, + ); + } + + return Response.json({ + success: true, + session, + }); +} + +export { routeDef, handler }; diff --git a/src/routes/files.ts b/src/routes/files.ts new file mode 100644 index 0000000..a67f1fa --- /dev/null +++ b/src/routes/files.ts @@ -0,0 +1,181 @@ +import { type ReservedSQL, type SQLQuery, sql } from "bun"; + +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; + +function isValidSort(sortBy: string): boolean { + const validSorts: string[] = [ + "size", + "created_at", + "expires_at", + "views", + "name", + "original_name", + "mime_type", + "extension", + ]; + return validSorts.includes(sortBy); +} + +function validSortOrder(sortOrder: string): string { + const validSortOrder: { [key: string]: string } = { + asc: "ASC", + desc: "DESC", + ascending: "ASC", + descending: "DESC", + }; + + return validSortOrder[sortOrder.toLowerCase()] || "DESC"; +} + +const escapeLike: (value: string) => string = (value: string): string => + value.replace(/[%_\\]/g, "\\$&"); + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + const { + user: user_id, + count = "25", + page = "0", + sort_by = "created_at", + sort_order = "DESC", + search_value, + } = request.query as { + user: string; + count: string; + page: string; + sort_by: string; + sort_order: string; + search_value: string; + }; + + if (!isValidSort(sort_by)) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid sort_by value", + }, + { status: 400 }, + ); + } + + const userLookup: string | undefined = user_id || request.session?.id; + + if (!userLookup) { + return Response.json( + { + success: false, + code: 400, + error: "Please provide a user ID or log in", + }, + { status: 400 }, + ); + } + + const isId: boolean = isUUID(userLookup); + + if (!isId) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid user ID", + }, + { status: 400 }, + ); + } + + const isSelf: boolean = request.session?.id === userLookup; + const isAdmin: boolean = request.session + ? request.session.roles.includes("admin") + : false; + + if (!isSelf && !isAdmin) { + return Response.json( + { + success: false, + code: 403, + error: "Unauthorized", + }, + { status: 403 }, + ); + } + + const safeCount: number = Math.min(Number.parseInt(count) || 25, 100); + const safePage: number = Math.max(Number.parseInt(page) || 0, 0); + const offset: number = safePage * safeCount; + const sortColumn: string = sort_by || "created_at"; + const order: "ASC" | "DESC" = validSortOrder(sort_order) as "ASC" | "DESC"; + const safeSearchValue: string = escapeLike(search_value || ""); + + let files: FileEntry[]; + let totalPages = 0; + let totalFiles = 0; + const reservation: ReservedSQL = await sql.reserve(); + + try { + // ! i really dont understand why bun wont accept reservation(order)` + function orderBy(field_name: string, orderBy: "ASC" | "DESC"): SQLQuery { + return reservation`ORDER BY ${reservation(field_name)} ${orderBy === "ASC" ? reservation`ASC` : reservation`DESC`}`; + } + + files = await reservation` + SELECT + * FROM files + WHERE owner = ${userLookup} AND + (name ILIKE '%' || ${safeSearchValue} || '%' OR + original_name ILIKE '%' || ${safeSearchValue} || '%') + ${orderBy(sortColumn, order)} + LIMIT ${safeCount} OFFSET ${offset}; + `; + + if (!files.length) { + return Response.json( + { + success: false, + code: 404, + error: "No files found", + }, + { status: 404 }, + ); + } + + [{ count: totalFiles }] = await reservation` + SELECT COUNT(*)::int as count FROM files + WHERE owner = ${userLookup}; + `; + + totalPages = Math.ceil(totalFiles / safeCount); + } catch (error) { + logger.error(["Error fetching files", error as Error]); + return Response.json( + { + success: false, + code: 500, + error: "Internal server error", + }, + { status: 500 }, + ); + } finally { + reservation.release(); + } + + return Response.json( + { + success: true, + code: 200, + total_files: totalFiles, + total_pages: totalPages, + files, + }, + { status: 200 }, + ); +} + +export { handler, routeDef }; diff --git a/src/routes/api/files/delete[query].ts b/src/routes/files/delete[query].ts similarity index 86% rename from src/routes/api/files/delete[query].ts rename to src/routes/files/delete[query].ts index cd2ccb8..a88c29d 100644 --- a/src/routes/api/files/delete[query].ts +++ b/src/routes/files/delete[query].ts @@ -1,9 +1,9 @@ -import { dataType } from "@config/environment"; -import { s3, sql, type SQLQuery } from "bun"; -import { resolve } from "path"; +import { resolve } from "node:path"; +import { dataType } from "@config"; +import { type SQLQuery, s3, sql } from "bun"; -import { isUUID } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; const routeDef: RouteDef = { method: "DELETE", @@ -124,7 +124,9 @@ async function handler( ); } - const isAdmin: boolean = request.session.roles.includes("admin"); + const isAdmin: boolean = + request.session.roles.includes("admin") || + request.session.roles.includes("superadmin"); const { query: file } = request.params as { query: string }; let { files } = requestBody as { files: string[] | string }; // const { password } = request.query as { password: string }; @@ -134,26 +136,14 @@ async function handler( try { if (file && !(typeof file === "string" && file.length === 0)) { - await processFile( - request, - file, - isAdmin, - failedFiles, - successfulFiles, - ); + await processFile(request, file, isAdmin, failedFiles, successfulFiles); } else if (files) { files = Array.isArray(files) ? files : files.split(/[, ]+/).filter(Boolean); for (const file of files) { - await processFile( - request, - file, - isAdmin, - failedFiles, - successfulFiles, - ); + await processFile(request, file, isAdmin, failedFiles, successfulFiles); } } } catch (error) { diff --git a/src/routes/api/files/upload.ts b/src/routes/files/upload.ts similarity index 90% rename from src/routes/api/files/upload.ts rename to src/routes/files/upload.ts index 0d8b3d5..664879c 100644 --- a/src/routes/api/files/upload.ts +++ b/src/routes/files/upload.ts @@ -1,16 +1,17 @@ -import { dataType } from "@config/environment"; +import { resolve } from "node:path"; +import { dataType } from "@config"; import { getSetting } from "@config/sql/settings"; import { + type SQLQuery, password as bunPassword, randomUUIDv7, s3, sql, - type SQLQuery, } from "bun"; import { exiftool } from "exiftool-vendored"; import { DateTime } from "luxon"; -import { resolve } from "path"; +import { logger } from "@creations.works/logger"; import { generateRandomString, getBaseUrl, @@ -19,8 +20,7 @@ import { nameWithoutExtension, supportsExif, supportsThumbnail, -} from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +} from "@lib/char"; const routeDef: RouteDef = { method: "POST", @@ -97,9 +97,7 @@ async function removeExifData( LocationName: null, }; - await exiftool.write(tempInputPath, tagsToRemove, [ - "-overwrite_original", - ]); + await exiftool.write(tempInputPath, tagsToRemove, ["-overwrite_original"]); return await Bun.file(tempInputPath).arrayBuffer(); } catch (error) { @@ -161,9 +159,9 @@ async function processFile( }; const extension: string | null = getExtension(file.name); - let rawName: string | null = nameWithoutExtension(file.name); + const rawName: string | null = nameWithoutExtension(file.name); const maxViews: number | null = - parseInt(user_provided_max_views, 10) || null; + Number.parseInt(user_provided_max_views, 10) || null; if (!rawName) { failedFiles.push({ @@ -176,13 +174,9 @@ async function processFile( let hashedPassword: string | null = null; if (user_provided_password) { - try { - hashedPassword = await bunPassword.hash(user_provided_password, { - algorithm: "argon2id", - }); - } catch (error) { - throw error; - } + hashedPassword = await bunPassword.hash(user_provided_password, { + algorithm: "argon2id", + }); } const randomUUID: string = randomUUIDv7(); @@ -190,7 +184,7 @@ async function processFile( ? user_provided_tags : (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []); - let uploadEntry: FileUpload = { + const uploadEntry: FileUpload = { id: randomUUID as UUID, owner: session.id as UUID, name: rawName, @@ -201,9 +195,7 @@ async function processFile( password: hashedPassword, favorite: user_wants_favorite === "true" || user_wants_favorite === "1", tags: tags, - expires_at: delete_short_string - ? getNewTimeUTC(delete_short_string) - : null, + expires_at: delete_short_string ? getNewTimeUTC(delete_short_string) : null, }; if (name_format === "date") { @@ -221,7 +213,7 @@ async function processFile( // ? Should work not sure about non-english characters const sanitizedFileName: string = rawName .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") + .replace(/\p{Mn}/gu, "") .replace(/[^a-zA-Z0-9._-]/g, "_") .toLowerCase(); @@ -280,7 +272,7 @@ async function processFile( return; } } else { - path = "/uploads/" + uuidWithExtension; + path = `/uploads/${uuidWithExtension}`; try { await s3.write(path, fileBuffer); @@ -296,7 +288,7 @@ async function processFile( } try { - const result: FileUpload[] = await sql` + const [result] = await sql` INSERT INTO files ( id, owner, folder, name, original_name, mime_type, extension, size, max_views, password, favorite, tags, expires_at ) VALUES ( ${uploadEntry.id}, ${uploadEntry.owner}, ${folder_identifier}, ${uploadEntry.name}, @@ -308,7 +300,7 @@ async function processFile( RETURNING id; `; - if (result.length === 0) { + if (!result) { failedFiles.push({ reason: "Failed to create file entry", file: key, @@ -325,7 +317,7 @@ async function processFile( return; } - if (uploadEntry.password) delete uploadEntry.password; + if (uploadEntry.password) uploadEntry.password = undefined; uploadEntry.url = `${userHeaderOptions.domain}/raw/${uploadEntry.name}`; successfulFiles.push(uploadEntry); @@ -366,9 +358,7 @@ async function handler(request: ExtendedRequest): Promise { requestBody.append( "file", new Blob([body], { type: request.actualContentType }), - request.actualContentType === "text/plain" - ? "file.txt" - : "file.json", + request.actualContentType === "text/plain" ? "file.txt" : "file.json", ); } else { return Response.json( @@ -442,20 +432,16 @@ async function handler(request: ExtendedRequest): Promise { } const filesThatSupportThumbnails: FileUpload[] = successfulFiles.filter( - (file: FileUpload): boolean => - supportsThumbnail(file.mime_type as string), + (file: FileUpload): boolean => supportsThumbnail(file.mime_type as string), ); if ( (await getSetting("enable_thumbnails")) === "true" && filesThatSupportThumbnails.length > 0 ) { try { - const worker: Worker = new Worker( - "./src/helpers/workers/thumbnails.ts", - { - type: "module", - }, - ); + const worker: Worker = new Worker("./src/helpers/workers/thumbnails", { + type: "module", + }); worker.postMessage({ files: filesThatSupportThumbnails, }); diff --git a/src/routes/index.ts b/src/routes/index.ts index 711c0f2..b29e27c 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,4 @@ -import { renderEjsTemplate } from "@helpers/ejs"; +import { frontendUrl } from "@config"; const routeDef: RouteDef = { method: "GET", @@ -7,15 +7,14 @@ const routeDef: RouteDef = { }; async function handler(request: ExtendedRequest): Promise { - if (!request.session) { - return Response.redirect("/auth/login"); - } - - const ejsTemplateData: EjsTemplateData = { - title: "Hello, World!", - }; - - return await renderEjsTemplate("index", ejsTemplateData); + return Response.json( + { + success: true, + code: 200, + message: `This is the api for ${frontendUrl}`, + }, + { status: 200 }, + ); } export { handler, routeDef }; diff --git a/src/routes/api/user/info[query].ts b/src/routes/info[query].ts similarity index 88% rename from src/routes/api/user/info[query].ts rename to src/routes/info[query].ts index 96fb226..7acc04b 100644 --- a/src/routes/api/user/info[query].ts +++ b/src/routes/info[query].ts @@ -1,8 +1,8 @@ -import { isValidUsername } from "@config/sql/users"; +import { isValidUsername } from "@lib/validators"; import { type ReservedSQL, sql } from "bun"; -import { isUUID } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; const routeDef: RouteDef = { method: "GET", @@ -28,7 +28,7 @@ async function handler(request: ExtendedRequest): Promise { } let user: GetUser | null = null; - let isSelf: boolean = false; + let isSelf = false; const isId: boolean = isUUID(query); const normalized: string = isId ? query : query.normalize("NFC"); const isAdmin: boolean = request.session @@ -49,11 +49,11 @@ async function handler(request: ExtendedRequest): Promise { const reservation: ReservedSQL = await sql.reserve(); try { - const result: GetUser[] = isId + [user] = isId ? await reservation`SELECT * FROM users WHERE id = ${normalized}` : await reservation`SELECT * FROM users WHERE username = ${normalized}`; - if (result.length === 0) { + if (!user) { return Response.json( { success: false, @@ -64,8 +64,6 @@ async function handler(request: ExtendedRequest): Promise { ); } - user = result[0]; - isSelf = request.session ? user.id === request.session.id : false; const files: { count: bigint }[] = @@ -112,9 +110,9 @@ async function handler(request: ExtendedRequest): Promise { ); } - delete user.password; - delete user.authorization_token; - if (!isSelf) delete user.email; + user.password = undefined; + user.authorization_token = undefined; + if (!isSelf) user.email = undefined; user.roles = user.roles ? user.roles[0].split(",") : []; diff --git a/src/routes/api/invite/create.ts b/src/routes/invite/create.ts similarity index 85% rename from src/routes/api/invite/create.ts rename to src/routes/invite/create.ts index 23f9bd5..a27c239 100644 --- a/src/routes/api/invite/create.ts +++ b/src/routes/invite/create.ts @@ -1,8 +1,8 @@ import { getSetting } from "@config/sql/settings"; import { sql } from "bun"; -import { generateRandomString, getNewTimeUTC } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { generateRandomString, getNewTimeUTC } from "@lib/char"; const routeDef: RouteDef = { method: "POST", @@ -37,7 +37,9 @@ async function handler( ); } - const isAdmin: boolean = request.session.roles.includes("admin"); + const isAdmin: boolean = + request.session.roles.includes("admin") || + request.session.roles.includes("superadmin"); if (!isAdmin && !getSetting("allow_user_invites")) { return Response.json( @@ -67,21 +69,19 @@ async function handler( ); } - const expirationDate: string | null = expires - ? getNewTimeUTC(expires) - : null; + const expirationDate: string | null = expires ? getNewTimeUTC(expires) : null; const maxUses: number = Number(max_uses) || 1; const inviteRole: string = role || "user"; let invite: Invite | null = null; try { - const result: Invite[] = await sql` + [invite] = await sql` INSERT INTO invites (created_by, expiration, max_uses, role, id) VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)}) RETURNING *; `; - if (result.length === 0) { + if (!invite) { logger.error("Invite failed to create"); return Response.json( @@ -93,8 +93,6 @@ async function handler( { status: 500 }, ); } - - invite = result[0]; } catch (error) { logger.error(["Error creating invite:", error as Error]); diff --git a/src/routes/api/invite/delete[invite].ts b/src/routes/invite/delete[invite].ts similarity index 86% rename from src/routes/api/invite/delete[invite].ts rename to src/routes/invite/delete[invite].ts index bf5617f..a9bf371 100644 --- a/src/routes/api/invite/delete[invite].ts +++ b/src/routes/invite/delete[invite].ts @@ -1,7 +1,7 @@ -import { isValidInvite } from "@config/sql/users"; +import { isValidInvite } from "@lib/validators"; import { type ReservedSQL, sql } from "bun"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; const routeDef: RouteDef = { method: "DELETE", @@ -21,7 +21,9 @@ async function handler(request: ExtendedRequest): Promise { ); } - const isAdmin: boolean = request.session.roles.includes("admin"); + const isAdmin: boolean = + request.session.roles.includes("admin") || + request.session.roles.includes("superadmin"); const { invite } = request.params as { invite: string }; if (!invite) { @@ -52,10 +54,10 @@ async function handler(request: ExtendedRequest): Promise { let inviteData: Invite | null = null; try { - const result: Invite[] = + [inviteData] = await reservation`SELECT * FROM invites WHERE id = ${invite};`; - if (result.length === 0) { + if (!inviteData) { return Response.json( { success: false, @@ -66,8 +68,6 @@ async function handler(request: ExtendedRequest): Promise { ); } - inviteData = result[0]; - if (!isAdmin && inviteData.created_by !== request.session.id) { return Response.json( { diff --git a/src/routes/raw/[query].ts b/src/routes/raw/[query].ts index be23740..c7c7b37 100644 --- a/src/routes/raw/[query].ts +++ b/src/routes/raw/[query].ts @@ -1,9 +1,9 @@ -import { dataType } from "@config/environment"; +import { resolve } from "node:path"; +import { dataType } from "@config"; import { type BunFile, type ReservedSQL, sql } from "bun"; -import { resolve } from "path"; -import { isUUID, nameWithoutExtension } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID, nameWithoutExtension } from "@lib/char"; const routeDef: RouteDef = { method: "GET", @@ -115,7 +115,7 @@ async function handler(request: ExtendedRequest): Promise { } if (json === "true" || json === "1") { - delete fileData.password; + fileData.password = undefined; fileData.tags = fileData.tags = fileData.tags[0]?.trim() ? fileData.tags[0].split(",").filter((tag: string) => tag.trim()) : []; @@ -139,9 +139,7 @@ async function handler(request: ExtendedRequest): Promise { } else { path = resolve( dataType.path, - `${fileData.id}${ - fileData.extension ? `.${fileData.extension}` : "" - }`, + `${fileData.id}${fileData.extension ? `.${fileData.extension}` : ""}`, ); } } else { @@ -157,9 +155,7 @@ async function handler(request: ExtendedRequest): Promise { return new Response(bunStream, { headers: { - "Content-Type": shouldShowThumbnail - ? "image/jpeg" - : fileData.mime_type, + "Content-Type": shouldShowThumbnail ? "image/jpeg" : fileData.mime_type, "Content-Disposition": downloadFile === "true" || downloadFile === "1" ? `attachment; filename="${fileData.original_name || fileData.name}"` diff --git a/src/routes/api/settings/set.ts b/src/routes/settings/set.ts similarity index 90% rename from src/routes/api/settings/set.ts rename to src/routes/settings/set.ts index 61f1279..d667fab 100644 --- a/src/routes/api/settings/set.ts +++ b/src/routes/settings/set.ts @@ -1,6 +1,6 @@ import { setSetting } from "@config/sql/settings"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; const routeDef: RouteDef = { method: "POST", @@ -53,7 +53,8 @@ async function handler( { success: false, code: 400, - error: "Expected key to be a string and value to be a string, boolean, or number", + error: + "Expected key to be a string and value to be a string, boolean, or number", }, { status: 400 }, ); diff --git a/src/routes/user/avatar/[user].ts b/src/routes/user/avatar/[user].ts index 9c8be7b..c0f7ed7 100644 --- a/src/routes/user/avatar/[user].ts +++ b/src/routes/user/avatar/[user].ts @@ -1,10 +1,10 @@ -import { dataType } from "@config/environment"; -import { isValidUsername } from "@config/sql/users"; +import { resolve } from "node:path"; +import { dataType } from "@config"; +import { isValidUsername } from "@lib/validators"; import { type BunFile, type ReservedSQL, sql } from "bun"; -import { resolve } from "path"; -import { getBaseUrl, isUUID, nameWithoutExtension } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { getBaseUrl, isUUID, nameWithoutExtension } from "@lib/char"; const routeDef: RouteDef = { method: "GET", @@ -71,11 +71,12 @@ async function handler(request: ExtendedRequest): Promise { if (json === "true" || json === "1") { return Response.json( { - success: true, code: 200, + success: true, + code: 200, avatar: { ...avatar, url: `${getBaseUrl(request)}/user/avatar/${user.id}`, - } + }, }, { status: 200 }, ); diff --git a/src/routes/api/user/avatar/delete.ts b/src/routes/user/avatar/delete.ts similarity index 83% rename from src/routes/api/user/avatar/delete.ts rename to src/routes/user/avatar/delete.ts index 29e0e35..1948c23 100644 --- a/src/routes/api/user/avatar/delete.ts +++ b/src/routes/user/avatar/delete.ts @@ -1,9 +1,9 @@ -import { dataType } from "@config/environment"; +import { resolve } from "node:path"; +import { dataType } from "@config"; import { s3, sql } from "bun"; -import { resolve } from "path"; -import { logger } from "@/helpers/logger"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; async function deleteAvatar( request: ExtendedRequest, @@ -21,9 +21,7 @@ async function deleteAvatar( try { if (dataType.type === "local" && dataType.path) { - await Bun.file( - resolve(dataType.path, "avatars", fileName), - ).unlink(); + await Bun.file(resolve(dataType.path, "avatars", fileName)).unlink(); } else { await s3.delete(`/avatars/${fileName}`); } @@ -61,7 +59,9 @@ async function handler(request: ExtendedRequest): Promise { } const userID: UUID = (request.query.user as UUID) || request.session.id; - const isAdmin: boolean = request.session.roles.includes("admin"); + const isAdmin: boolean = + request.session.roles.includes("admin") || + request.session.roles.includes("superadmin"); if (request.session.id !== userID && !isAdmin) { return Response.json( @@ -115,16 +115,15 @@ async function handler(request: ExtendedRequest): Promise { }, }, ); - } else { - return Response.json( - { - success: true, - code: 200, - message: "Avatar deleted", - }, - { status: 200 }, - ); } + return Response.json( + { + success: true, + code: 200, + message: "Avatar deleted", + }, + { status: 200 }, + ); } catch (error) { logger.error(["Error processing delete request:", error as Error]); diff --git a/src/routes/api/user/avatar/set.ts b/src/routes/user/avatar/set.ts similarity index 87% rename from src/routes/api/user/avatar/set.ts rename to src/routes/user/avatar/set.ts index 8dc5a7e..653fa47 100644 --- a/src/routes/api/user/avatar/set.ts +++ b/src/routes/user/avatar/set.ts @@ -1,12 +1,12 @@ -import { dataType } from "@config/environment"; -import { isValidTypeOrExtension } from "@config/sql/avatars"; +import { resolve } from "node:path"; +import { dataType } from "@config"; import { getSetting } from "@config/sql/settings"; +import { isValidTypeOrExtension } from "@lib/validators"; import { s3, sql } from "bun"; -import { resolve } from "path"; -import { getBaseUrl, getExtension } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; +import { getBaseUrl, getExtension } from "@lib/char"; async function processFile( file: File, @@ -50,10 +50,7 @@ async function processFile( await s3.delete(`/avatars/${existingFileName}`); } } catch (error) { - logger.error([ - "Error deleting existing avatar file:", - error as Error, - ]); + logger.error(["Error deleting existing avatar file:", error as Error]); } } @@ -138,9 +135,7 @@ async function handler( } const file: File | null = - (formData.get("file") as File) || - (formData.get("avatar") as File) || - null; + (formData.get("file") as File) || (formData.get("avatar") as File) || null; if (!file.type || file.type === "") { return Response.json( @@ -206,17 +201,16 @@ async function handler( }, }, ); - } else { - return Response.json( - { - success: true, - code: 200, - message: "Avatar uploaded", - url: message, - }, - { status: 200 }, - ); } + return Response.json( + { + success: true, + code: 200, + message: "Avatar uploaded", + url: message, + }, + { status: 200 }, + ); } catch (error) { logger.error(["Error processing file:", error as Error]); diff --git a/src/server.ts b/src/server.ts index fc40125..6a381cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,17 +1,16 @@ -import { environment } from "@config/environment"; -import { logger } from "@helpers/logger"; +import { resolve } from "node:path"; +import { environment } from "@config"; +import { logger } from "@creations.works/logger"; import { type BunFile, FileSystemRouter, type MatchedRoute, type Serve, } from "bun"; -import { resolve } from "path"; +import { sessionManager } from "@/lib/jwt"; import { webSocketHandler } from "@/websocket"; - -import { authByToken } from "./helpers/auth"; -import { sessionManager } from "./helpers/sessions"; +import { authByToken } from "@lib/auth"; class ServerHandler { private router: FileSystemRouter; @@ -41,11 +40,7 @@ class ServerHandler { maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon }); - logger.info( - `Server running at http://${server.hostname}:${server.port}`, - true, - ); - + logger.info(`Server running at ${environment.fqdn}`); this.logRoutes(); } @@ -63,10 +58,15 @@ class ServerHandler { } } - private async serveStaticFile(pathname: string): Promise { - try { - let filePath: string; + private async serveStaticFile( + request: ExtendedRequest, + pathname: string, + ip: string, + ): Promise { + let filePath: string; + let response: Response; + try { if (pathname === "/favicon.ico") { filePath = resolve("public", "assets", "favicon.ico"); } else { @@ -77,47 +77,87 @@ class ServerHandler { if (await file.exists()) { const fileContent: ArrayBuffer = await file.arrayBuffer(); - const contentType: string = - file.type || "application/octet-stream"; + const contentType: string = file.type || "application/octet-stream"; - return new Response(fileContent, { + response = new Response(fileContent, { headers: { "Content-Type": contentType }, }); } else { logger.warn(`File not found: ${filePath}`); - return new Response("Not Found", { status: 404 }); + response = 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 }); + logger.error([`Error serving static file: ${pathname}`, error as Error]); + response = new Response("Internal Server Error", { status: 500 }); } + + this.logRequest(request, response, ip); + return response; + } + + private logRequest( + request: ExtendedRequest, + response: Response, + ip: string | undefined, + ): void { + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); } private async handleRequest( - request: ExtendedRequest, + request: Request, server: BunServer, ): Promise { - request.startPerf = performance.now(); + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": environment.frontendUrl, + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": + request.headers.get("Access-Control-Request-Headers") || "*", + }, + }); + } + + const extendedRequest: ExtendedRequest = request as ExtendedRequest; + extendedRequest.startPerf = performance.now(); + + const headers = request.headers; + let ip = server.requestIP(request)?.address; + let response: Response; + + if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { + ip = + headers.get("CF-Connecting-IP")?.trim() || + headers.get("X-Real-IP")?.trim() || + headers.get("X-Forwarded-For")?.split(",")[0].trim() || + "unknown"; + } const pathname: string = new URL(request.url).pathname; if (pathname.startsWith("/public") || pathname === "/favicon.ico") { - return await this.serveStaticFile(pathname); + return await this.serveStaticFile(extendedRequest, pathname, ip); } 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 contentType: string | null = request.headers.get("Content-Type"); const actualContentType: string | null = contentType ? contentType.split(";")[0].trim() : null; @@ -144,9 +184,7 @@ class ServerHandler { if ( (Array.isArray(routeModule.routeDef.method) && - !routeModule.routeDef.method.includes( - request.method, - )) || + !routeModule.routeDef.method.includes(request.method)) || (!Array.isArray(routeModule.routeDef.method) && routeModule.routeDef.method !== request.method) ) { @@ -171,9 +209,7 @@ class ServerHandler { if (Array.isArray(expectedContentType)) { matchesAccepts = expectedContentType.includes("*/*") || - expectedContentType.includes( - actualContentType || "", - ); + expectedContentType.includes(actualContentType || ""); } else { matchesAccepts = expectedContentType === "*/*" || @@ -194,19 +230,15 @@ class ServerHandler { { status: 406 }, ); } else { - request.params = params; - request.query = query; - request.actualContentType = actualContentType; + extendedRequest.params = params; + extendedRequest.query = query; + extendedRequest.actualContentType = actualContentType; - request.session = - (await authByToken(request)) || + extendedRequest.session = + (await authByToken(extendedRequest)) || (await sessionManager.getSession(request)); - response = await routeModule.handler( - request, - requestBody, - server, - ); + response = await routeModule.handler(request, requestBody, server); if (routeModule.routeDef.returns !== "*/*") { response.headers.set( @@ -217,10 +249,7 @@ class ServerHandler { } } } catch (error: unknown) { - logger.error([ - `Error handling route ${request.url}:`, - error as Error, - ]); + logger.error([`Error handling route ${request.url}:`, error as Error]); response = Response.json( { @@ -242,28 +271,22 @@ class ServerHandler { ); } - 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; + if (response?.headers) { + response.headers.set( + "Access-Control-Allow-Origin", + environment.frontendUrl, + ); + response.headers.set( + "Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS", + ); + response.headers.set("Access-Control-Allow-Credentials", "true"); + response.headers.set( + "Access-Control-Allow-Headers", + request.headers.get("Access-Control-Request-Headers") || "Content-Type", + ); } - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - request.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - return response; } } diff --git a/src/views/auth/login.ejs b/src/views/auth/login.ejs deleted file mode 100644 index b6f4f15..0000000 --- a/src/views/auth/login.ejs +++ /dev/null @@ -1,28 +0,0 @@ - - - - <%- include("../global", { styles: ["auth/login"], scripts: [] }) %> - - -
-
-

<%= instance_name %>

-
-
-
-
- - -
-
- - -
-
- -
-
-
-
- - diff --git a/src/views/global.ejs b/src/views/global.ejs deleted file mode 100644 index 8815e98..0000000 --- a/src/views/global.ejs +++ /dev/null @@ -1,31 +0,0 @@ - - - - -<% if (title) { %> - <%= title %> -<% } %> - - - -<% if (typeof styles !== "undefined") { %> - <% styles.forEach(style => { %> - - <% }) %> -<% } %> - -<% if (typeof scripts !== "undefined") { %> - <% scripts.forEach(script => { %> - <% if (typeof script === "string") { %> - - <% } else if (Array.isArray(script)) { %> - <% if (script[1]) { %> - - <% } else { %> - - <% } %> - <% } %> - <% }) %> -<% } %> - - diff --git a/src/views/index.ejs b/src/views/index.ejs deleted file mode 100644 index dfc2c62..0000000 --- a/src/views/index.ejs +++ /dev/null @@ -1,9 +0,0 @@ - - - - <%- include("global", { styles: [], scripts: [] }) %> - - - - - diff --git a/src/views/partials/header.ejs b/src/views/partials/header.ejs deleted file mode 100644 index e69de29..0000000 diff --git a/src/websocket.ts b/src/websocket.ts index ce87fe8..7b65476 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,5 +1,5 @@ -import { logger } from "@helpers/logger"; -import { type ServerWebSocket } from "bun"; +import { logger } from "@creations.works/logger"; +import type { ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { @@ -20,11 +20,7 @@ class WebSocketHandler { } } - public handleClose( - ws: ServerWebSocket, - code: number, - reason: string, - ): void { + public handleClose(ws: ServerWebSocket, code: number, reason: string): void { logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); } } diff --git a/stylelint.config.js b/stylelint.config.js deleted file mode 100644 index ea5c5f1..0000000 --- a/stylelint.config.js +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('stylelint').Config} */ -export default { - extends: ["stylelint-config-standard"], - rules: { - "color-function-notation": "modern", - "font-family-name-quotes": "always-where-required", - "declaration-empty-line-before": "never", - }, -}; diff --git a/tsconfig.json b/tsconfig.json index ac5f2c7..391e2c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,28 +2,15 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@/*": [ - "src/*" - ], - "@config/*": [ - "config/*" - ], - "@types/*": [ - "types/*" - ], - "@helpers/*": [ - "src/helpers/*" - ] + "@/*": ["src/*"], + "@config": ["config/index.ts"], + "@config/*": ["config/*"], + "@types/*": ["types/*"], + "@lib/*": ["src/lib/*"] }, - "typeRoots": [ - "./src/types", - "./node_modules/@types" - ], + "typeRoots": ["./src/types", "./node_modules/@types"], // Enable latest features - "lib": [ - "ESNext", - "DOM" - ], + "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", @@ -41,11 +28,7 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, + "noPropertyAccessFromIndexSignature": false }, - "include": [ - "src", - "types", - "config" - ], + "include": ["src", "types", "config"] } diff --git a/types/config.d.ts b/types/config.d.ts index 322a951..e65c147 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -2,6 +2,8 @@ type Environment = { port: number; host: string; development: boolean; + fqdn: string; + frontendUrl: string; }; type UserValidation = { diff --git a/types/ejs.d.ts b/types/ejs.d.ts deleted file mode 100644 index 486a4a4..0000000 --- a/types/ejs.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -interface EjsTemplateData { - [key: string]: string | number | boolean | object | undefined | null; -} diff --git a/types/logger.d.ts b/types/logger.d.ts deleted file mode 100644 index ff6a601..0000000 --- a/types/logger.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type ILogMessagePart = { value: string; color: string }; - -type ILogMessageParts = { - level: ILogMessagePart; - filename: ILogMessagePart; - readableTimestamp: ILogMessagePart; - message: ILogMessagePart; - [key: string]: ILogMessagePart; -};