diff --git a/.env.example b/.env.example index b4aa814..5e1c5db 100644 --- a/.env.example +++ b/.env.example @@ -2,19 +2,16 @@ 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_URL=redis://localhost:6379 -REDIS_TTL=3600 +REDIS_HOST=localhost +REDIS_PORT=6379 +# REDIS_USERNAME=redis +# REDIS_PASSWORD=redis # 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 deleted file mode 100644 index 15c990c..0000000 --- a/.forgejo/workflows/biomejs.yml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 4cfd4bc..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": [ - "mikestead.dotenv", - "EditorConfig.EditorConfig", - "biomejs.biome" - ] -} diff --git a/biome.json b/biome.json deleted file mode 100644 index 46ee8c9..0000000 --- a/biome.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$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 new file mode 100644 index 0000000..9370a4b --- /dev/null +++ b/config/environment.ts @@ -0,0 +1,37 @@ +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 deleted file mode 100644 index ad67386..0000000 --- a/config/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index e268a0c..0000000 --- a/config/jwt.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 8721478..0000000 --- a/config/redis.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 8f40a73..7047e24 100644 --- a/config/sql/avatars.ts +++ b/config/sql/avatars.ts @@ -1,18 +1,18 @@ -import { logger } from "@creations.works/logger"; +import { logger } from "@helpers/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 6; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation` + await reservation` 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,7 +28,17 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - activeReservation.release(); + reservation.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 844640d..da08db5 100644 --- a/config/sql/files.ts +++ b/config/sql/files.ts @@ -1,18 +1,18 @@ -import { logger } from "@creations.works/logger"; +import { logger } from "@helpers/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 5; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation` + await reservation` 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 activeReservation` + const functionExists: { exists: boolean }[] = await reservation` 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 activeReservation` + await reservation` 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 activeReservation` + const triggerExists: { exists: boolean }[] = await reservation` 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 activeReservation` + await reservation` 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) { - activeReservation.release(); + reservation.release(); } } } diff --git a/config/sql/folders.ts b/config/sql/folders.ts index 6329808..1946c4a 100644 --- a/config/sql/folders.ts +++ b/config/sql/folders.ts @@ -1,18 +1,18 @@ -import { logger } from "@creations.works/logger"; +import { logger } from "@helpers/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 4; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation` + await reservation` 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 activeReservation` + const functionExists: { exists: boolean }[] = await reservation` 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 activeReservation` + await reservation` 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 activeReservation` + const triggerExists: { exists: boolean }[] = await reservation` 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 activeReservation` + await reservation` 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) { - activeReservation.release(); + reservation.release(); } } } diff --git a/config/sql/invites.ts b/config/sql/invites.ts index fabbbf7..e098d32 100644 --- a/config/sql/invites.ts +++ b/config/sql/invites.ts @@ -1,18 +1,18 @@ -import { logger } from "@creations.works/logger"; +import { logger } from "@helpers/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 3; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation` + await reservation` 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) { - activeReservation.release(); + reservation.release(); } } } diff --git a/config/sql/settings.ts b/config/sql/settings.ts index 41d5fe7..0def8e8 100644 --- a/config/sql/settings.ts +++ b/config/sql/settings.ts @@ -1,4 +1,4 @@ -import { logger } from "@creations.works/logger"; +import { logger } from "@helpers/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 = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation` + await reservation` 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 activeReservation` + const functionExists: { exists: boolean }[] = await reservation` 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 activeReservation` + await reservation` 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 activeReservation` + const triggerExists: { exists: boolean }[] = await reservation` 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 activeReservation` + await reservation` 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 activeReservation` + await reservation` INSERT INTO settings ("key", "value") VALUES (${setting.key}, ${setting.value}) ON CONFLICT ("key") DO NOTHING; @@ -88,25 +88,27 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - activeReservation.release(); + reservation.release(); } } } +// * Validation functions + export async function getSetting( key: string, reservation?: ReservedSQL, ): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { const result: { value: string }[] = - await activeReservation`SELECT value FROM settings WHERE "key" = ${key};`; + await reservation`SELECT value FROM settings WHERE "key" = ${key};`; if (result.length === 0) { return null; @@ -118,7 +120,7 @@ export async function getSetting( throw error; } finally { if (selfReservation) { - activeReservation.release(); + reservation.release(); } } } @@ -128,15 +130,15 @@ export async function setSetting( value: string, reservation?: ReservedSQL, ): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation` + await reservation` INSERT INTO settings ("key", "value", updated_at) VALUES (${key}, ${value}, NOW()) ON CONFLICT ("key") @@ -146,7 +148,7 @@ export async function setSetting( throw error; } finally { if (selfReservation) { - activeReservation.release(); + reservation.release(); } } } @@ -155,21 +157,21 @@ export async function deleteSetting( key: string, reservation?: ReservedSQL, ): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation`DELETE FROM settings WHERE "key" = ${key};`; + await reservation`DELETE FROM settings WHERE "key" = ${key};`; } catch (error) { logger.error(["Could not delete the setting:", error as Error]); throw error; } finally { if (selfReservation) { - activeReservation.release(); + reservation.release(); } } } @@ -177,16 +179,16 @@ export async function deleteSetting( export async function getAllSettings( reservation?: ReservedSQL, ): Promise<{ key: string; value: string }[]> { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { const result: { key: string; value: string }[] = - await activeReservation`SELECT "key", "value" FROM settings;`; + await reservation`SELECT "key", "value" FROM settings;`; return result; } catch (error) { @@ -194,7 +196,7 @@ export async function getAllSettings( throw error; } finally { if (selfReservation) { - activeReservation.release(); + reservation.release(); } } } diff --git a/config/sql/users.ts b/config/sql/users.ts index 0cbf8c5..560b906 100644 --- a/config/sql/users.ts +++ b/config/sql/users.ts @@ -1,18 +1,18 @@ -import { logger } from "@creations.works/logger"; +import { logger } from "@helpers/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 1; export async function createTable(reservation?: ReservedSQL): Promise { - let selfReservation = false; - const activeReservation: ReservedSQL = reservation ?? (await sql.reserve()); + let selfReservation: boolean = false; if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { - await activeReservation` + await reservation` CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), authorization_token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), @@ -32,7 +32,122 @@ export async function createTable(reservation?: ReservedSQL): Promise { throw error; } finally { if (selfReservation) { - activeReservation.release(); + reservation.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 new file mode 100644 index 0000000..7442449 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,142 @@ +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 6a99842..0fc03b5 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,46 @@ { - "name": "atums.world", - "private": true, + "name": "bun_frontend_template", "module": "src/index.ts", "type": "module", "scripts": { "start": "bun run src/index.ts", - "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", + "dev": "bun run --watch src/index.ts --dev", + "lint": "eslint", + "lint:fix": "bun lint --fix", + "cleanup": "rm -rf logs node_modules bun.lockdb", "clearTable": "bun run src/helpers/commands/clearTable.ts" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@types/bun": "^1.2.13", + "@eslint/js": "^9.22.0", + "@types/bun": "^1.2.5", + "@types/ejs": "^3.1.5", "@types/fluent-ffmpeg": "^2.1.27", "@types/image-thumbnail": "^1.0.4", - "@types/luxon": "^3.6.2", - "globals": "16.0.0", - "prettier": "^3.5.3" + "@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" }, "peerDependencies": { - "typescript": "^5.8.3" + "typescript": "^5.7.3" }, "dependencies": { - "@creations.works/logger": "^1.0.3", - "eta": "^3.5.0", - "exiftool-vendored": "^30.0.0", - "fast-jwt": "6.0.1", + "ejs": "^3.1.10", + "exiftool-vendored": "^29.2.0", + "fast-jwt": "^5.0.5", "fluent-ffmpeg": "^2.1.3", "image-thumbnail": "^1.0.17", - "luxon": "^3.6.1" + "luxon": "^3.5.0", + "redis": "^4.7.0" } } diff --git a/public/css/auth/login.css b/public/css/auth/login.css new file mode 100644 index 0000000..4de920e --- /dev/null +++ b/public/css/auth/login.css @@ -0,0 +1,27 @@ +.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 fe28f4b..f550c18 100644 --- a/public/css/global.css +++ b/public/css/global.css @@ -1,48 +1,40 @@ [data-theme="dark"] { - --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); + --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); } /* 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/lib/auth.ts b/src/helpers/auth.ts similarity index 73% rename from src/lib/auth.ts rename to src/helpers/auth.ts index 1c9a311..1a3d91f 100644 --- a/src/lib/auth.ts +++ b/src/helpers/auth.ts @@ -1,13 +1,12 @@ -import { logger } from "@creations.works/logger"; -import { isUUID } from "@lib/char"; +import { isUUID } from "@helpers/char"; +import { logger } from "@helpers/logger"; import { type ReservedSQL, sql } from "bun"; export async function authByToken( request: ExtendedRequest, reservation?: ReservedSQL, ): Promise { - let selfReservation = false; - let activeReservation: ReservedSQL | undefined = reservation; + let selfReservation: boolean = false; const authorizationHeader: string | null = request.headers.get("Authorization"); @@ -18,14 +17,14 @@ export async function authByToken( const authorizationToken: string = authorizationHeader.slice(7).trim(); if (!authorizationToken || !isUUID(authorizationToken)) return null; - if (!activeReservation) { - activeReservation = await sql.reserve(); + if (!reservation) { + reservation = await sql.reserve(); selfReservation = true; } try { const result: User[] = - await activeReservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`; + await reservation`SELECT * FROM users WHERE authorization_token = ${authorizationToken};`; if (result.length === 0) return null; @@ -45,7 +44,7 @@ export async function authByToken( return null; } finally { if (selfReservation) { - activeReservation.release(); + reservation.release(); } } } diff --git a/src/lib/char.ts b/src/helpers/char.ts similarity index 83% rename from src/lib/char.ts rename to src/helpers/char.ts index ac89dcd..084386b 100644 --- a/src/lib/char.ts +++ b/src/helpers/char.ts @@ -2,8 +2,8 @@ import { DateTime } from "luxon"; export function timestampToReadable(timestamp?: number): string { const date: Date = - timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date(); - if (Number.isNaN(date.getTime())) return "Invalid Date"; + timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); + if (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 = Number.parseInt(match[1], 10); + const value: number = parseInt(match[1], 10); const unit: string = match[2]; switch (unit) { @@ -84,14 +84,18 @@ export function isValidTimezone(timezone: string): boolean { } export function generateRandomString(length?: number): string { - const finalLength: number = length ?? Math.floor(Math.random() * 10) + 5; + if (!length) { + length = length || Math.floor(Math.random() * 10) + 5; + } - const characters = + const characters: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; + let result: string = ""; - for (let i = 0; i < finalLength; i++) { - result += characters.charAt(Math.floor(Math.random() * characters.length)); + for (let i: number = 0; i < length; i++) { + result += characters.charAt( + Math.floor(Math.random() * characters.length), + ); } return result; @@ -168,7 +172,9 @@ 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 { @@ -200,22 +206,18 @@ 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 = 0; i < args.length; i++) { + for (let i: number = 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/lib/commands/clearTable.ts b/src/helpers/commands/clearTable.ts similarity index 89% rename from src/lib/commands/clearTable.ts rename to src/helpers/commands/clearTable.ts index dfd2906..cdb11a1 100644 --- a/src/lib/commands/clearTable.ts +++ b/src/helpers/commands/clearTable.ts @@ -1,4 +1,4 @@ -import { parseArgs } from "@lib/char"; +import { parseArgs } from "@helpers/char"; import { type ReservedSQL, sql } from "bun"; (async (): Promise => { @@ -24,7 +24,8 @@ import { type ReservedSQL, sql } from "bun"; error.message.includes("foreign key constraint") ) { console.error( - `Could not clear table "${table}" due to foreign key constraints.\nTry using --cascade if you want to remove dependent records.`, + `Could not clear table "${table}" due to foreign key constraints.\n` + + "Try using --cascade if you want to remove dependent records.", ); } else { console.error("Could not clear table:", error); diff --git a/src/helpers/ejs.ts b/src/helpers/ejs.ts new file mode 100644 index 0000000..6b03dd0 --- /dev/null +++ b/src/helpers/ejs.ts @@ -0,0 +1,26 @@ +import { renderFile } from "ejs"; +import { resolve } from "path"; + +export async function renderEjsTemplate( + viewName: string | string[], + data: EjsTemplateData, + headers?: Record, +): Promise { + let templatePath: string; + + if (Array.isArray(viewName)) { + templatePath = resolve("src", "views", ...viewName); + } else { + templatePath = resolve("src", "views", viewName); + } + + if (!templatePath.endsWith(".ejs")) { + templatePath += ".ejs"; + } + + const html: string = await renderFile(templatePath, data); + + return new Response(html, { + headers: { "Content-Type": "text/html", ...headers }, + }); +} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts new file mode 100644 index 0000000..16e1076 --- /dev/null +++ b/src/helpers/logger.ts @@ -0,0 +1,205 @@ +import { environment } from "@config/environment"; +import { timestampToReadable } from "@helpers/char"; +import type { Stats } from "fs"; +import { + createWriteStream, + existsSync, + mkdirSync, + statSync, + WriteStream, +} from "fs"; +import { EOL } from "os"; +import { basename, join } from "path"; + +class Logger { + private static instance: Logger; + private static log: string = join(__dirname, "../../logs"); + + public static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + + return Logger.instance; + } + + private writeToLog(logMessage: string): void { + if (environment.development) return; + + const date: Date = new Date(); + const logDir: string = Logger.log; + const logFile: string = join( + logDir, + `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`, + ); + + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }); + } + + let addSeparator: boolean = false; + + if (existsSync(logFile)) { + const fileStats: Stats = statSync(logFile); + if (fileStats.size > 0) { + const lastModified: Date = new Date(fileStats.mtime); + if ( + lastModified.getFullYear() === date.getFullYear() && + lastModified.getMonth() === date.getMonth() && + lastModified.getDate() === date.getDate() && + lastModified.getHours() !== date.getHours() + ) { + addSeparator = true; + } + } + } + + const stream: WriteStream = createWriteStream(logFile, { flags: "a" }); + + if (addSeparator) { + stream.write(`${EOL}${date.toISOString()}${EOL}`); + } + + stream.write(`${logMessage}${EOL}`); + stream.close(); + } + + private extractFileName(stack: string): string { + const stackLines: string[] = stack.split("\n"); + let callerFile: string = ""; + + for (let i: number = 2; i < stackLines.length; i++) { + const line: string = stackLines[i].trim(); + if (line && !line.includes("Logger.") && line.includes("(")) { + callerFile = line.split("(")[1]?.split(")")[0] || ""; + break; + } + } + + return basename(callerFile); + } + + private getCallerInfo(stack: unknown): { + filename: string; + timestamp: string; + } { + const filename: string = + typeof stack === "string" ? this.extractFileName(stack) : "unknown"; + + const readableTimestamp: string = timestampToReadable(); + + return { filename, timestamp: readableTimestamp }; + } + + public info(message: string | string[], breakLine: boolean = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[INFO]", color: "32" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public warn(message: string | string[], breakLine: boolean = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[WARN]", color: "33" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public error( + message: string | Error | 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 new file mode 100644 index 0000000..5cd1e54 --- /dev/null +++ b/src/helpers/redis.ts @@ -0,0 +1,204 @@ +import { redisConfig } from "@config/environment"; +import { logger } from "@helpers/logger"; +import { createClient, type RedisClientType } from "redis"; + +class RedisJson { + private static instance: RedisJson | null = null; + private client: RedisClientType | null = null; + + private constructor() {} + + public static async initialize(): Promise { + if (!RedisJson.instance) { + RedisJson.instance = new RedisJson(); + RedisJson.instance.client = createClient({ + socket: { + host: redisConfig.host, + port: redisConfig.port, + }, + username: redisConfig.username || undefined, + password: redisConfig.password || undefined, + }); + + RedisJson.instance.client.on("error", (err: Error) => { + logger.error([ + "Error connecting to Redis:", + err, + redisConfig.host, + ]); + + process.exit(1); + }); + + RedisJson.instance.client.on("connect", () => { + logger.info([ + "Connected to Redis on", + `${redisConfig.host}:${redisConfig.port}`, + ]); + }); + + await RedisJson.instance.client.connect(); + } + + return RedisJson.instance; + } + + public static getInstance(): RedisJson { + if (!RedisJson.instance || !RedisJson.instance.client) { + throw new Error( + "Redis instance not initialized. Call initialize() first.", + ); + } + return RedisJson.instance; + } + + public async disconnect(): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + process.exit(1); + } + try { + await this.client.disconnect(); + this.client = null; + logger.info("Redis disconnected successfully."); + } catch (error) { + logger.error("Error disconnecting Redis client:"); + logger.error(error as Error); + throw error; + } + } + + public async get( + type: "JSON" | "STRING", + key: string, + path?: string, + ): Promise< + string | number | boolean | Record | null | unknown + > { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + if (type === "JSON") { + const value: unknown = await this.client.json.get(key, { + path, + }); + + if (value instanceof Date) { + return value.toISOString(); + } + + return value; + } else if (type === "STRING") { + const value: string | null = await this.client.get(key); + return value; + } else { + throw new Error(`Invalid type: ${type}`); + } + } catch (error) { + logger.error(`Error getting value from Redis for key: ${key}`); + logger.error(error as Error); + throw error; + } + } + + public async set( + type: "JSON" | "STRING", + key: string, + value: unknown, + expiresInSeconds?: number, + path?: string, + ): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + if (type === "JSON") { + await this.client.json.set(key, path || "$", value as string); + + if (expiresInSeconds) { + await this.client.expire(key, expiresInSeconds); + } + } else if (type === "STRING") { + if (expiresInSeconds) { + await this.client.set(key, value as string, { + EX: expiresInSeconds, + }); + } else { + await this.client.set(key, value as string); + } + } else { + throw new Error(`Invalid type: ${type}`); + } + } catch (error) { + logger.error(`Error setting value in Redis for key: ${key}`); + logger.error(error as Error); + throw error; + } + } + + public async delete(type: "JSON" | "STRING", key: string): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + if (type === "JSON") { + await this.client.json.del(key); + } else if (type === "STRING") { + await this.client.del(key); + } else { + throw new Error(`Invalid type: ${type}`); + } + } catch (error) { + logger.error(`Error deleting value from Redis for key: ${key}`); + logger.error(error as Error); + throw error; + } + } + + public async expire(key: string, seconds: number): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + await this.client.expire(key, seconds); + } catch (error) { + logger.error([ + `Error expiring key in Redis: ${key}`, + error as Error, + ]); + throw error; + } + } + + public async keys(pattern: string): Promise { + if (!this.client) { + logger.error("Redis client is not initialized."); + throw new Error("Redis client is not initialized."); + } + try { + const keys: string[] = await this.client.keys(pattern); + return keys; + } catch (error) { + logger.error([ + `Error getting keys from Redis for pattern: ${pattern}`, + error as Error, + ]); + throw error; + } + } +} + +export const redis: { + initialize: () => Promise; + getInstance: () => RedisJson; +} = { + initialize: RedisJson.initialize, + getInstance: RedisJson.getInstance, +}; + +export { RedisJson }; diff --git a/src/helpers/sessions.ts b/src/helpers/sessions.ts new file mode 100644 index 0000000..05b5376 --- /dev/null +++ b/src/helpers/sessions.ts @@ -0,0 +1,194 @@ +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/lib/workers/thumbnails.ts b/src/helpers/workers/thumbnails.ts similarity index 75% rename from src/lib/workers/thumbnails.ts rename to src/helpers/workers/thumbnails.ts index c0c4605..45c9073 100644 --- a/src/lib/workers/thumbnails.ts +++ b/src/helpers/workers/thumbnails.ts @@ -1,11 +1,11 @@ -import { join, resolve } from "node:path"; -import { dataType } from "@config"; -import { logger } from "@creations.works/logger"; +import { dataType } from "@config/environment.ts"; +import { logger } from "@helpers/logger.ts"; import { type BunFile, s3, sql } from "bun"; import ffmpeg from "fluent-ffmpeg"; import imageThumbnail from "image-thumbnail"; +import { join, resolve } from "path"; -declare let self: Worker; +declare var self: Worker; async function generateVideoThumbnail( filePath: string, @@ -22,7 +22,10 @@ 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); }) @@ -47,34 +50,40 @@ async function generateImageThumbnail( thumbnailPath: string, ): Promise { return new Promise( - ( + async ( resolve: (value: ArrayBuffer) => void, reject: (reason: Error) => void, - ): void => { - const options = { - height: 320, - responseType: "buffer" as const, - jpegOptions: { - force: true, - quality: 60, - }, - }; + ) => { + try { + const options: { + responseType: "buffer"; + height: number; + jpegOptions: { + force: boolean; + quality: number; + }; + } = { + height: 320, + responseType: "buffer", + jpegOptions: { + force: true, + quality: 60, + }, + }; - 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); + 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); + } }, ); } @@ -95,14 +104,20 @@ 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; } } @@ -134,7 +149,10 @@ 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; } @@ -186,5 +204,5 @@ self.onmessage = async (event: MessageEvent): Promise => { }; self.onerror = (error: ErrorEvent): void => { - logger.error(["An error occurred in the thumbnail worker:", error.message]); + logger.error(error); }; diff --git a/src/index.ts b/src/index.ts index 07e2352..5af5367 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,14 @@ -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 { 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 { serverHandler } from "@/server"; +import { redis } from "./helpers/redis"; + async function initializeDatabase(): Promise { const sqlDir: string = resolve("config", "sql"); const files: string[] = await readdir(sqlDir); @@ -15,7 +17,9 @@ 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 }; }), ); @@ -36,69 +40,63 @@ async function initializeDatabase(): Promise { } async function main(): Promise { - verifyRequiredVariables(); - try { - await sql`SELECT 1;`; + try { + await sql`SELECT 1;`; - logger.info([ - "Connected to PostgreSQL on", - `${process.env.PGHOST}:${process.env.PGPORT}`, - ]); - } catch (error) { - logger.error([ - "Could not establish a connection to PostgreSQL:", - error as Error, - ]); - process.exit(1); - } + 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); + } - try { - await redis.connect(); + 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); + } + } - 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)) { + logger.info([ + "Using local datasource directory", + `${dataType.path}`, + ]); + } else { try { - mkdirSync(dataType.path); + 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 create datasource local directory", + "Could not establish a connection to S3 bucket:", error as Error, ]); process.exit(1); } } - 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); - } + await redis.initialize(); + serverHandler.initialize(); + await initializeDatabase(); + } catch (error) { + throw error; } - - logger.space(); - - serverHandler.initialize(); - await initializeDatabase(); } main().catch((error: Error) => { diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts deleted file mode 100644 index cf857a0..0000000 --- a/src/lib/jwt.ts +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index 89422ed..0000000 --- a/src/lib/validators/avatar.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index d5becad..0000000 --- a/src/lib/validators/email.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 0192c4a..0000000 --- a/src/lib/validators/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 6a31711..0000000 --- a/src/lib/validators/invite.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 1ccf292..0000000 --- a/src/lib/validators/name.ts +++ /dev/null @@ -1,31 +0,0 @@ -// ? 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 deleted file mode 100644 index 43dcb0e..0000000 --- a/src/lib/validators/password.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/routes/auth/email/verify/[code].ts b/src/routes/api/auth/email/verify/[code].ts similarity index 69% rename from src/routes/auth/email/verify/[code].ts rename to src/routes/api/auth/email/verify/[code].ts index ff92edb..0c9f627 100644 --- a/src/routes/auth/email/verify/[code].ts +++ b/src/routes/api/auth/email/verify/[code].ts @@ -1,8 +1,9 @@ -import { redis, sql } from "bun"; +import { sql } from "bun"; -import { sessionManager } from "@/lib/jwt"; -import { logger } from "@creations.works/logger"; -import { isUUID } from "@lib/char"; +import { isUUID } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; +import { redis } from "@/helpers/redis"; +import { sessionManager } from "@/helpers/sessions"; const routeDef: RouteDef = { method: "POST", @@ -36,9 +37,11 @@ async function handler(request: ExtendedRequest): Promise { } try { - const raw: string | null = await redis.get(`email:verify:${code}`); + const verificationData: unknown = await redis + .getInstance() + .get("JSON", `email:verify:${code}`); - if (!raw) { + if (!verificationData) { return Response.json( { success: false, @@ -49,24 +52,11 @@ async function handler(request: ExtendedRequest): Promise { ); } - let verificationData: { user_id: string }; + const { user_id: userId } = verificationData as { + user_id: string; + }; - 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 redis.getInstance().delete("JSON", `email:verify:${code}`); await sql` UPDATE users SET email_verified = true diff --git a/src/routes/auth/email/verify/request.ts b/src/routes/api/auth/email/verify/request.ts similarity index 87% rename from src/routes/auth/email/verify/request.ts rename to src/routes/api/auth/email/verify/request.ts index 477f42b..089301e 100644 --- a/src/routes/auth/email/verify/request.ts +++ b/src/routes/api/auth/email/verify/request.ts @@ -1,7 +1,7 @@ import { randomUUIDv7, sql } from "bun"; -import { logger } from "@creations.works/logger"; -import { redis } from "bun"; +import { logger } from "@/helpers/logger"; +import { redis } from "@/helpers/redis"; const routeDef: RouteDef = { method: "GET", @@ -51,9 +51,11 @@ async function handler(request: ExtendedRequest): Promise { } const code: string = randomUUIDv7(); - await redis.set( + await redis.getInstance().set( + "JSON", `email:verify:${code}`, - JSON.stringify({ user_id: request.session.id }), + { user_id: request.session.id }, + 60 * 60 * 2, // 2 hours ); // TODO: Send email when email service is implemented diff --git a/src/routes/api/auth/login.ts b/src/routes/api/auth/login.ts new file mode 100644 index 0000000..8cfa4fd --- /dev/null +++ b/src/routes/api/auth/login.ts @@ -0,0 +1,161 @@ +import { + isValidEmail, + isValidPassword, + isValidUsername, +} from "@config/sql/users"; +import { password as bunPassword, type ReservedSQL, sql } from "bun"; + +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + if (request.session) { + if ((request.session as ApiUserSession).is_api) { + return Response.json( + { + success: false, + code: 403, + error: "You cannot log in while using an authorization token", + }, + { status: 403 }, + ); + } + + return Response.json( + { + success: false, + code: 403, + error: "Already logged in", + }, + { status: 403 }, + ); + } + + const { username, email, password } = requestBody as { + username: string; + email: string; + password: string; + }; + + if (!password || (!username && !email)) { + return Response.json( + { + success: false, + code: 400, + error: "Expected username or email, and password", + }, + { status: 400 }, + ); + } + + const errors: string[] = []; + const validations: UserValidation[] = [ + { check: isValidUsername(username), field: "Username" }, + { check: isValidEmail(email), field: "Email" }, + { check: isValidPassword(password), field: "Password" }, + ]; + + validations.forEach(({ check }: UserValidation): void => { + if (!check.valid && check.error) { + errors.push(check.error); + } + }); + + if (errors.length > 0) { + return Response.json( + { + success: false, + code: 400, + errors, + }, + { status: 400 }, + ); + } + + const reservation: ReservedSQL = await sql.reserve(); + let user: User | null = null; + + try { + user = await reservation` + SELECT * FROM users + WHERE (username = ${username} OR email = ${email}) + LIMIT 1; + `.then((rows: User[]): User | null => rows[0]); + + if (!user) { + await bunPassword.verify("fake", await bunPassword.hash("fake")); + + return Response.json( + { + success: false, + code: 401, + error: "Invalid username, email, or password", + }, + { status: 401 }, + ); + } + + const passwordMatch: boolean = await bunPassword.verify( + password, + user.password, + ); + + if (!passwordMatch) { + return Response.json( + { + success: false, + code: 401, + error: "Invalid username, email, or password", + }, + { status: 401 }, + ); + } + } catch (error) { + logger.error(["Error logging in", error as Error]); + + return Response.json( + { + success: false, + code: 500, + error: "An error occurred while logging in", + }, + { status: 500 }, + ); + } finally { + if (reservation) reservation.release(); + } + + const sessionCookie: string = await sessionManager.createSession( + { + id: user.id, + username: user.username, + email: user.email, + email_verified: user.email_verified, + roles: user.roles[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/auth/logout.ts b/src/routes/api/auth/logout.ts similarity index 94% rename from src/routes/auth/logout.ts rename to src/routes/api/auth/logout.ts index 9103d5a..4ef42a1 100644 --- a/src/routes/auth/logout.ts +++ b/src/routes/api/auth/logout.ts @@ -1,4 +1,4 @@ -import { sessionManager } from "@/lib/jwt"; +import { sessionManager } from "@/helpers/sessions"; const routeDef: RouteDef = { method: "POST", diff --git a/src/routes/auth/register.ts b/src/routes/api/auth/register.ts similarity index 85% rename from src/routes/auth/register.ts rename to src/routes/api/auth/register.ts index 7e751f3..ca14995 100644 --- a/src/routes/auth/register.ts +++ b/src/routes/api/auth/register.ts @@ -4,12 +4,12 @@ import { isValidInvite, isValidPassword, isValidUsername, -} from "@lib/validators"; -import { type ReservedSQL, password as bunPassword, sql } from "bun"; +} from "@config/sql/users"; +import { password as bunPassword, type ReservedSQL, sql } from "bun"; -import { sessionManager } from "@/lib/jwt"; -import { logger } from "@creations.works/logger"; -import { isValidTimezone } from "@lib/char"; +import { isValidTimezone } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; const routeDef: RouteDef = { method: "POST", @@ -49,17 +49,17 @@ async function handler( { check: isValidPassword(password), field: "Password" }, ]; - for (const { check } of validations) { + validations.forEach(({ check }: UserValidation): void => { if (!check.valid && check.error) { errors.push(check.error); } - } + }); const normalizedUsername: string = username.normalize("NFC"); const reservation: ReservedSQL = await sql.reserve(); - let firstUser = false; + let firstUser: boolean = false; let inviteData: Invite | null = null; - const roles: string[] = []; + let roles: string[] = []; try { const registrationEnabled: boolean = @@ -69,16 +69,13 @@ 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); } @@ -92,12 +89,10 @@ async function handler( } roles.push("user"); - if (firstUser) { - roles.push("admin"); - roles.push("superadmin"); - } + if (firstUser) roles.push("admin"); - const [result] = await reservation` + const result: { usernameExists: boolean; emailExists: boolean }[] = + 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"; @@ -109,13 +104,15 @@ async function handler( errors.push("Username or email already exists"); } - if (invite && inviteValid && !firstUser) { - [inviteData] = + if (invite && !firstUser) { + const result: Invite[] = await reservation`SELECT * FROM invites WHERE id = ${invite};`; - if (!inviteData) { + if (!result || result.length === 0) { errors.push("Invalid invite"); } + + inviteData = result[0]; } } catch (error) { errors.push("An error occurred while checking for existing users"); @@ -143,13 +140,13 @@ async function handler( : (await getSetting("default_timezone", reservation)) || "UTC"; try { - [user] = await reservation` + const result: 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 (!user) { + if (result.length === 0) { logger.error("User was not created"); return Response.json( { @@ -161,6 +158,8 @@ async function handler( ); } + user = result[0]; + if (!user) { logger.error("User was not created"); return Response.json( @@ -186,7 +185,10 @@ 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/files/delete[query].ts b/src/routes/api/files/delete[query].ts similarity index 86% rename from src/routes/files/delete[query].ts rename to src/routes/api/files/delete[query].ts index a88c29d..cd2ccb8 100644 --- a/src/routes/files/delete[query].ts +++ b/src/routes/api/files/delete[query].ts @@ -1,9 +1,9 @@ -import { resolve } from "node:path"; -import { dataType } from "@config"; -import { type SQLQuery, s3, sql } from "bun"; +import { dataType } from "@config/environment"; +import { s3, sql, type SQLQuery } from "bun"; +import { resolve } from "path"; -import { logger } from "@creations.works/logger"; -import { isUUID } from "@lib/char"; +import { isUUID } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "DELETE", @@ -124,9 +124,7 @@ async function handler( ); } - const isAdmin: boolean = - request.session.roles.includes("admin") || - request.session.roles.includes("superadmin"); + const isAdmin: boolean = request.session.roles.includes("admin"); const { query: file } = request.params as { query: string }; let { files } = requestBody as { files: string[] | string }; // const { password } = request.query as { password: string }; @@ -136,14 +134,26 @@ 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/files/upload.ts b/src/routes/api/files/upload.ts similarity index 90% rename from src/routes/files/upload.ts rename to src/routes/api/files/upload.ts index 664879c..0d8b3d5 100644 --- a/src/routes/files/upload.ts +++ b/src/routes/api/files/upload.ts @@ -1,17 +1,16 @@ -import { resolve } from "node:path"; -import { dataType } from "@config"; +import { dataType } from "@config/environment"; 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, @@ -20,7 +19,8 @@ import { nameWithoutExtension, supportsExif, supportsThumbnail, -} from "@lib/char"; +} from "@/helpers/char"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "POST", @@ -97,7 +97,9 @@ 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) { @@ -159,9 +161,9 @@ async function processFile( }; const extension: string | null = getExtension(file.name); - const rawName: string | null = nameWithoutExtension(file.name); + let rawName: string | null = nameWithoutExtension(file.name); const maxViews: number | null = - Number.parseInt(user_provided_max_views, 10) || null; + parseInt(user_provided_max_views, 10) || null; if (!rawName) { failedFiles.push({ @@ -174,9 +176,13 @@ async function processFile( let hashedPassword: string | null = null; if (user_provided_password) { - hashedPassword = await bunPassword.hash(user_provided_password, { - algorithm: "argon2id", - }); + try { + hashedPassword = await bunPassword.hash(user_provided_password, { + algorithm: "argon2id", + }); + } catch (error) { + throw error; + } } const randomUUID: string = randomUUIDv7(); @@ -184,7 +190,7 @@ async function processFile( ? user_provided_tags : (user_provided_tags?.split(/[, ]+/).filter(Boolean) ?? []); - const uploadEntry: FileUpload = { + let uploadEntry: FileUpload = { id: randomUUID as UUID, owner: session.id as UUID, name: rawName, @@ -195,7 +201,9 @@ 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") { @@ -213,7 +221,7 @@ async function processFile( // ? Should work not sure about non-english characters const sanitizedFileName: string = rawName .normalize("NFD") - .replace(/\p{Mn}/gu, "") + .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-zA-Z0-9._-]/g, "_") .toLowerCase(); @@ -272,7 +280,7 @@ async function processFile( return; } } else { - path = `/uploads/${uuidWithExtension}`; + path = "/uploads/" + uuidWithExtension; try { await s3.write(path, fileBuffer); @@ -288,7 +296,7 @@ async function processFile( } try { - const [result] = await sql` + const result: FileUpload[] = 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}, @@ -300,7 +308,7 @@ async function processFile( RETURNING id; `; - if (!result) { + if (result.length === 0) { failedFiles.push({ reason: "Failed to create file entry", file: key, @@ -317,7 +325,7 @@ async function processFile( return; } - if (uploadEntry.password) uploadEntry.password = undefined; + if (uploadEntry.password) delete uploadEntry.password; uploadEntry.url = `${userHeaderOptions.domain}/raw/${uploadEntry.name}`; successfulFiles.push(uploadEntry); @@ -358,7 +366,9 @@ 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( @@ -432,16 +442,20 @@ 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", { - type: "module", - }); + const worker: Worker = new Worker( + "./src/helpers/workers/thumbnails.ts", + { + type: "module", + }, + ); worker.postMessage({ files: filesThatSupportThumbnails, }); diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts new file mode 100644 index 0000000..9461be4 --- /dev/null +++ b/src/routes/api/index.ts @@ -0,0 +1,15 @@ +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(): Promise { + // TODO: Put something useful here + + return Response.json({ + message: "Hello, World!", + }); +} + +export { handler, routeDef }; diff --git a/src/routes/invite/create.ts b/src/routes/api/invite/create.ts similarity index 85% rename from src/routes/invite/create.ts rename to src/routes/api/invite/create.ts index a27c239..23f9bd5 100644 --- a/src/routes/invite/create.ts +++ b/src/routes/api/invite/create.ts @@ -1,8 +1,8 @@ import { getSetting } from "@config/sql/settings"; import { sql } from "bun"; -import { logger } from "@creations.works/logger"; -import { generateRandomString, getNewTimeUTC } from "@lib/char"; +import { generateRandomString, getNewTimeUTC } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "POST", @@ -37,9 +37,7 @@ async function handler( ); } - const isAdmin: boolean = - request.session.roles.includes("admin") || - request.session.roles.includes("superadmin"); + const isAdmin: boolean = request.session.roles.includes("admin"); if (!isAdmin && !getSetting("allow_user_invites")) { return Response.json( @@ -69,19 +67,21 @@ 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 { - [invite] = await sql` + const result: Invite[] = await sql` INSERT INTO invites (created_by, expiration, max_uses, role, id) VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)}) RETURNING *; `; - if (!invite) { + if (result.length === 0) { logger.error("Invite failed to create"); return Response.json( @@ -93,6 +93,8 @@ async function handler( { status: 500 }, ); } + + invite = result[0]; } catch (error) { logger.error(["Error creating invite:", error as Error]); diff --git a/src/routes/invite/delete[invite].ts b/src/routes/api/invite/delete[invite].ts similarity index 86% rename from src/routes/invite/delete[invite].ts rename to src/routes/api/invite/delete[invite].ts index a9bf371..bf5617f 100644 --- a/src/routes/invite/delete[invite].ts +++ b/src/routes/api/invite/delete[invite].ts @@ -1,7 +1,7 @@ -import { isValidInvite } from "@lib/validators"; +import { isValidInvite } from "@config/sql/users"; import { type ReservedSQL, sql } from "bun"; -import { logger } from "@creations.works/logger"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "DELETE", @@ -21,9 +21,7 @@ async function handler(request: ExtendedRequest): Promise { ); } - const isAdmin: boolean = - request.session.roles.includes("admin") || - request.session.roles.includes("superadmin"); + const isAdmin: boolean = request.session.roles.includes("admin"); const { invite } = request.params as { invite: string }; if (!invite) { @@ -54,10 +52,10 @@ async function handler(request: ExtendedRequest): Promise { let inviteData: Invite | null = null; try { - [inviteData] = + const result: Invite[] = await reservation`SELECT * FROM invites WHERE id = ${invite};`; - if (!inviteData) { + if (result.length === 0) { return Response.json( { success: false, @@ -68,6 +66,8 @@ 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/settings/set.ts b/src/routes/api/settings/set.ts similarity index 90% rename from src/routes/settings/set.ts rename to src/routes/api/settings/set.ts index d667fab..61f1279 100644 --- a/src/routes/settings/set.ts +++ b/src/routes/api/settings/set.ts @@ -1,6 +1,6 @@ import { setSetting } from "@config/sql/settings"; -import { logger } from "@creations.works/logger"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "POST", @@ -53,8 +53,7 @@ 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/delete.ts b/src/routes/api/user/avatar/delete.ts similarity index 83% rename from src/routes/user/avatar/delete.ts rename to src/routes/api/user/avatar/delete.ts index 1948c23..29e0e35 100644 --- a/src/routes/user/avatar/delete.ts +++ b/src/routes/api/user/avatar/delete.ts @@ -1,9 +1,9 @@ -import { resolve } from "node:path"; -import { dataType } from "@config"; +import { dataType } from "@config/environment"; import { s3, sql } from "bun"; +import { resolve } from "path"; -import { sessionManager } from "@/lib/jwt"; -import { logger } from "@creations.works/logger"; +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; async function deleteAvatar( request: ExtendedRequest, @@ -21,7 +21,9 @@ 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}`); } @@ -59,9 +61,7 @@ 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") || - request.session.roles.includes("superadmin"); + const isAdmin: boolean = request.session.roles.includes("admin"); if (request.session.id !== userID && !isAdmin) { return Response.json( @@ -115,15 +115,16 @@ 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/user/avatar/set.ts b/src/routes/api/user/avatar/set.ts similarity index 87% rename from src/routes/user/avatar/set.ts rename to src/routes/api/user/avatar/set.ts index 653fa47..8dc5a7e 100644 --- a/src/routes/user/avatar/set.ts +++ b/src/routes/api/user/avatar/set.ts @@ -1,12 +1,12 @@ -import { resolve } from "node:path"; -import { dataType } from "@config"; +import { dataType } from "@config/environment"; +import { isValidTypeOrExtension } from "@config/sql/avatars"; import { getSetting } from "@config/sql/settings"; -import { isValidTypeOrExtension } from "@lib/validators"; import { s3, sql } from "bun"; +import { resolve } from "path"; -import { sessionManager } from "@/lib/jwt"; -import { logger } from "@creations.works/logger"; -import { getBaseUrl, getExtension } from "@lib/char"; +import { getBaseUrl, getExtension } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; async function processFile( file: File, @@ -50,7 +50,10 @@ 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, + ]); } } @@ -135,7 +138,9 @@ 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( @@ -201,16 +206,17 @@ 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/routes/info[query].ts b/src/routes/api/user/info[query].ts similarity index 88% rename from src/routes/info[query].ts rename to src/routes/api/user/info[query].ts index 7acc04b..96fb226 100644 --- a/src/routes/info[query].ts +++ b/src/routes/api/user/info[query].ts @@ -1,8 +1,8 @@ -import { isValidUsername } from "@lib/validators"; +import { isValidUsername } from "@config/sql/users"; import { type ReservedSQL, sql } from "bun"; -import { logger } from "@creations.works/logger"; -import { isUUID } from "@lib/char"; +import { isUUID } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "GET", @@ -28,7 +28,7 @@ async function handler(request: ExtendedRequest): Promise { } let user: GetUser | null = null; - let isSelf = false; + let isSelf: boolean = 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 { - [user] = isId + const result: GetUser[] = isId ? await reservation`SELECT * FROM users WHERE id = ${normalized}` : await reservation`SELECT * FROM users WHERE username = ${normalized}`; - if (!user) { + if (result.length === 0) { return Response.json( { success: false, @@ -64,6 +64,8 @@ async function handler(request: ExtendedRequest): Promise { ); } + user = result[0]; + isSelf = request.session ? user.id === request.session.id : false; const files: { count: bigint }[] = @@ -110,9 +112,9 @@ async function handler(request: ExtendedRequest): Promise { ); } - user.password = undefined; - user.authorization_token = undefined; - if (!isSelf) user.email = undefined; + delete user.password; + delete user.authorization_token; + if (!isSelf) delete user.email; user.roles = user.roles ? user.roles[0].split(",") : []; diff --git a/src/routes/auth/login.ts b/src/routes/auth/login.ts index 0beacc1..b7b4e68 100644 --- a/src/routes/auth/login.ts +++ b/src/routes/auth/login.ts @@ -1,166 +1,20 @@ -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"; +import { getSetting } from "@config/sql/settings"; +import { renderEjsTemplate } from "@helpers/ejs"; const routeDef: RouteDef = { - method: "POST", - accepts: "application/json", - returns: "application/json", - needsBody: "json", + method: "GET", + accepts: "*/*", + returns: "text/html", }; -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; +async function handler(): Promise { + const ejsTemplateData: EjsTemplateData = { + title: "Hello, World!", + instance_name: + (await getSetting("instance_name")) || "Unnamed Instance", }; - 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 } }, - ); + return await renderEjsTemplate("auth/login", ejsTemplateData); } export { handler, routeDef }; diff --git a/src/routes/auth/session.ts b/src/routes/auth/session.ts deleted file mode 100644 index a087358..0000000 --- a/src/routes/auth/session.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index a67f1fa..0000000 --- a/src/routes/files.ts +++ /dev/null @@ -1,181 +0,0 @@ -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/index.ts b/src/routes/index.ts index b29e27c..711c0f2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,4 @@ -import { frontendUrl } from "@config"; +import { renderEjsTemplate } from "@helpers/ejs"; const routeDef: RouteDef = { method: "GET", @@ -7,14 +7,15 @@ const routeDef: RouteDef = { }; async function handler(request: ExtendedRequest): Promise { - return Response.json( - { - success: true, - code: 200, - message: `This is the api for ${frontendUrl}`, - }, - { status: 200 }, - ); + if (!request.session) { + return Response.redirect("/auth/login"); + } + + const ejsTemplateData: EjsTemplateData = { + title: "Hello, World!", + }; + + return await renderEjsTemplate("index", ejsTemplateData); } export { handler, routeDef }; diff --git a/src/routes/raw/[query].ts b/src/routes/raw/[query].ts index c7c7b37..be23740 100644 --- a/src/routes/raw/[query].ts +++ b/src/routes/raw/[query].ts @@ -1,9 +1,9 @@ -import { resolve } from "node:path"; -import { dataType } from "@config"; +import { dataType } from "@config/environment"; import { type BunFile, type ReservedSQL, sql } from "bun"; +import { resolve } from "path"; -import { logger } from "@creations.works/logger"; -import { isUUID, nameWithoutExtension } from "@lib/char"; +import { isUUID, nameWithoutExtension } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "GET", @@ -115,7 +115,7 @@ async function handler(request: ExtendedRequest): Promise { } if (json === "true" || json === "1") { - fileData.password = undefined; + delete fileData.password; fileData.tags = fileData.tags = fileData.tags[0]?.trim() ? fileData.tags[0].split(",").filter((tag: string) => tag.trim()) : []; @@ -139,7 +139,9 @@ async function handler(request: ExtendedRequest): Promise { } else { path = resolve( dataType.path, - `${fileData.id}${fileData.extension ? `.${fileData.extension}` : ""}`, + `${fileData.id}${ + fileData.extension ? `.${fileData.extension}` : "" + }`, ); } } else { @@ -155,7 +157,9 @@ 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/user/avatar/[user].ts b/src/routes/user/avatar/[user].ts index c0f7ed7..9c8be7b 100644 --- a/src/routes/user/avatar/[user].ts +++ b/src/routes/user/avatar/[user].ts @@ -1,10 +1,10 @@ -import { resolve } from "node:path"; -import { dataType } from "@config"; -import { isValidUsername } from "@lib/validators"; +import { dataType } from "@config/environment"; +import { isValidUsername } from "@config/sql/users"; import { type BunFile, type ReservedSQL, sql } from "bun"; +import { resolve } from "path"; -import { logger } from "@creations.works/logger"; -import { getBaseUrl, isUUID, nameWithoutExtension } from "@lib/char"; +import { getBaseUrl, isUUID, nameWithoutExtension } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "GET", @@ -71,12 +71,11 @@ 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/server.ts b/src/server.ts index 6a381cf..fc40125 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,16 +1,17 @@ -import { resolve } from "node:path"; -import { environment } from "@config"; -import { logger } from "@creations.works/logger"; +import { environment } from "@config/environment"; +import { logger } from "@helpers/logger"; import { type BunFile, FileSystemRouter, type MatchedRoute, type Serve, } from "bun"; +import { resolve } from "path"; -import { sessionManager } from "@/lib/jwt"; import { webSocketHandler } from "@/websocket"; -import { authByToken } from "@lib/auth"; + +import { authByToken } from "./helpers/auth"; +import { sessionManager } from "./helpers/sessions"; class ServerHandler { private router: FileSystemRouter; @@ -40,7 +41,11 @@ class ServerHandler { maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon }); - logger.info(`Server running at ${environment.fqdn}`); + logger.info( + `Server running at http://${server.hostname}:${server.port}`, + true, + ); + this.logRoutes(); } @@ -58,15 +63,10 @@ class ServerHandler { } } - private async serveStaticFile( - request: ExtendedRequest, - pathname: string, - ip: string, - ): Promise { - let filePath: string; - let response: Response; - + private async serveStaticFile(pathname: string): Promise { try { + let filePath: string; + if (pathname === "/favicon.ico") { filePath = resolve("public", "assets", "favicon.ico"); } else { @@ -77,87 +77,47 @@ 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"; - response = new Response(fileContent, { + return new Response(fileContent, { headers: { "Content-Type": contentType }, }); } else { logger.warn(`File not found: ${filePath}`); - response = new Response("Not Found", { status: 404 }); + return new Response("Not Found", { status: 404 }); } } catch (error) { - logger.error([`Error serving static file: ${pathname}`, error as Error]); - response = new Response("Internal Server Error", { status: 500 }); + logger.error([ + `Error serving static file: ${pathname}`, + error as Error, + ]); + return 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: Request, + request: ExtendedRequest, server: BunServer, ): Promise { - 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"; - } + request.startPerf = performance.now(); const pathname: string = new URL(request.url).pathname; if (pathname.startsWith("/public") || pathname === "/favicon.ico") { - return await this.serveStaticFile(extendedRequest, pathname, ip); + return await this.serveStaticFile(pathname); } const match: MatchedRoute | null = this.router.match(request); let requestBody: unknown = {}; + let response: Response; if (match) { const { filePath, params, query } = match; try { const routeModule: RouteModule = await import(filePath); - const contentType: string | null = request.headers.get("Content-Type"); + const contentType: string | null = + request.headers.get("Content-Type"); const actualContentType: string | null = contentType ? contentType.split(";")[0].trim() : null; @@ -184,7 +144,9 @@ 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) ) { @@ -209,7 +171,9 @@ class ServerHandler { if (Array.isArray(expectedContentType)) { matchesAccepts = expectedContentType.includes("*/*") || - expectedContentType.includes(actualContentType || ""); + expectedContentType.includes( + actualContentType || "", + ); } else { matchesAccepts = expectedContentType === "*/*" || @@ -230,15 +194,19 @@ class ServerHandler { { status: 406 }, ); } else { - extendedRequest.params = params; - extendedRequest.query = query; - extendedRequest.actualContentType = actualContentType; + request.params = params; + request.query = query; + request.actualContentType = actualContentType; - extendedRequest.session = - (await authByToken(extendedRequest)) || + request.session = + (await authByToken(request)) || (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( @@ -249,7 +217,10 @@ 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( { @@ -271,22 +242,28 @@ class ServerHandler { ); } - 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", - ); + const headers: Headers = response.headers; + let ip: string | null = server.requestIP(request)?.address || null; + + if (!ip) { + ip = + headers.get("CF-Connecting-IP") || + headers.get("X-Real-IP") || + headers.get("X-Forwarded-For") || + null; } + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); + return response; } } diff --git a/src/views/auth/login.ejs b/src/views/auth/login.ejs new file mode 100644 index 0000000..b6f4f15 --- /dev/null +++ b/src/views/auth/login.ejs @@ -0,0 +1,28 @@ + + + + <%- include("../global", { styles: ["auth/login"], scripts: [] }) %> + + +
+
+

<%= instance_name %>

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + diff --git a/src/views/global.ejs b/src/views/global.ejs new file mode 100644 index 0000000..8815e98 --- /dev/null +++ b/src/views/global.ejs @@ -0,0 +1,31 @@ + + + + +<% 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 new file mode 100644 index 0000000..dfc2c62 --- /dev/null +++ b/src/views/index.ejs @@ -0,0 +1,9 @@ + + + + <%- include("global", { styles: [], scripts: [] }) %> + + + + + diff --git a/src/views/partials/header.ejs b/src/views/partials/header.ejs new file mode 100644 index 0000000..e69de29 diff --git a/src/websocket.ts b/src/websocket.ts index 7b65476..ce87fe8 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,5 +1,5 @@ -import { logger } from "@creations.works/logger"; -import type { ServerWebSocket } from "bun"; +import { logger } from "@helpers/logger"; +import { type ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { @@ -20,7 +20,11 @@ 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 new file mode 100644 index 0000000..ea5c5f1 --- /dev/null +++ b/stylelint.config.js @@ -0,0 +1,9 @@ +/** @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 391e2c9..ac5f2c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,15 +2,28 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@/*": ["src/*"], - "@config": ["config/index.ts"], - "@config/*": ["config/*"], - "@types/*": ["types/*"], - "@lib/*": ["src/lib/*"] + "@/*": [ + "src/*" + ], + "@config/*": [ + "config/*" + ], + "@types/*": [ + "types/*" + ], + "@helpers/*": [ + "src/helpers/*" + ] }, - "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", @@ -28,7 +41,11 @@ // 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 e65c147..322a951 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -2,8 +2,6 @@ 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 new file mode 100644 index 0000000..486a4a4 --- /dev/null +++ b/types/ejs.d.ts @@ -0,0 +1,3 @@ +interface EjsTemplateData { + [key: string]: string | number | boolean | object | undefined | null; +} diff --git a/types/logger.d.ts b/types/logger.d.ts new file mode 100644 index 0000000..ff6a601 --- /dev/null +++ b/types/logger.d.ts @@ -0,0 +1,9 @@ +type ILogMessagePart = { value: string; color: string }; + +type ILogMessageParts = { + level: ILogMessagePart; + filename: ILogMessagePart; + readableTimestamp: ILogMessagePart; + message: ILogMessagePart; + [key: string]: ILogMessagePart; +};