diff --git a/.env.example b/.env.example index 5e1c5db..e3b8e92 100644 --- a/.env.example +++ b/.env.example @@ -2,16 +2,18 @@ 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 + PGHOST=localhost PGPORT=5432 PGUSERNAME=postgres PGPASSWORD=postgres PGDATABASE=postgres -REDIS_HOST=localhost -REDIS_PORT=6379 -# REDIS_USERNAME=redis -# REDIS_PASSWORD=redis +REDIS_URL=redis://localhost:6379 +REDIS_TTL=3600 # For sessions and cookies, can be generated using `openssl rand -base64 32` JWT_SECRET=your_jwt_secret diff --git a/biome.json b/biome.json index 921a7a5..46ee8c9 100644 --- a/biome.json +++ b/biome.json @@ -17,10 +17,19 @@ "organizeImports": { "enabled": true }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "correctness": { + "noUnusedImports": "error" + } } }, "javascript": { diff --git a/config/environment.ts b/config/environment.ts deleted file mode 100644 index 1130339..0000000 --- a/config/environment.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { resolve } from "node:path"; - -export 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"), -}; - -export const redisConfig: { - host: string; - port: number; - username?: string | undefined; - password?: string | undefined; -} = { - host: process.env.REDIS_HOST || "localhost", - port: Number.parseInt(process.env.REDIS_PORT || "6379", 10), - username: process.env.REDIS_USERNAME || undefined, - password: process.env.REDIS_PASSWORD || undefined, -}; - -export const jwt: { - secret: string; - expiresIn: string; -} = { - secret: process.env.JWT_SECRET || "", - expiresIn: process.env.JWT_EXPIRES || "1d", -}; - -export const dataType: { type: string; path: string | undefined } = { - type: process.env.DATASOURCE_TYPE || "local", - path: - process.env.DATASOURCE_TYPE === "local" - ? resolve(process.env.DATASOURCE_LOCAL_DIRECTORY || "./uploads") - : undefined, -}; diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 0000000..527c43c --- /dev/null +++ b/config/index.ts @@ -0,0 +1,61 @@ +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", +}; + +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, +}; + +function verifyRequiredVariables(): void { + const requiredVariables = [ + "HOST", + "PORT", + + "FQDN", + + "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 }; diff --git a/config/jwt.ts b/config/jwt.ts new file mode 100644 index 0000000..e268a0c --- /dev/null +++ b/config/jwt.ts @@ -0,0 +1,27 @@ +const allowedAlgorithms = [ + "HS256", + "RS256", + "HS384", + "HS512", + "RS384", + "RS512", +] as const; + +type AllowedAlgorithm = (typeof allowedAlgorithms)[number]; + +function getAlgorithm(envVar: string | undefined): AllowedAlgorithm { + if (allowedAlgorithms.includes(envVar as AllowedAlgorithm)) { + return envVar as AllowedAlgorithm; + } + return "HS256"; +} + +export const jwt: { + secret: string; + expiration: string; + algorithm: AllowedAlgorithm; +} = { + secret: process.env.JWT_SECRET || "", + expiration: process.env.JWT_EXPIRATION || "1h", + algorithm: getAlgorithm(process.env.JWT_ALGORITHM), +}; diff --git a/config/redis.ts b/config/redis.ts new file mode 100644 index 0000000..8721478 --- /dev/null +++ b/config/redis.ts @@ -0,0 +1,3 @@ +export const redisTtl: number = process.env.REDIS_TTL + ? Number.parseInt(process.env.REDIS_TTL, 10) + : 60 * 60 * 1; // 1 hour diff --git a/config/sql/avatars.ts b/config/sql/avatars.ts index cbaafb6..8f40a73 100644 --- a/config/sql/avatars.ts +++ b/config/sql/avatars.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 6; @@ -32,13 +32,3 @@ export async function createTable(reservation?: ReservedSQL): Promise { } } } - -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 6a35576..844640d 100644 --- a/config/sql/files.ts +++ b/config/sql/files.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 5; diff --git a/config/sql/folders.ts b/config/sql/folders.ts index bc7beae..6329808 100644 --- a/config/sql/folders.ts +++ b/config/sql/folders.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 4; diff --git a/config/sql/invites.ts b/config/sql/invites.ts index 23a25d3..fabbbf7 100644 --- a/config/sql/invites.ts +++ b/config/sql/invites.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 3; diff --git a/config/sql/settings.ts b/config/sql/settings.ts index 938b5da..41d5fe7 100644 --- a/config/sql/settings.ts +++ b/config/sql/settings.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 2; @@ -93,8 +93,6 @@ export async function createTable(reservation?: ReservedSQL): Promise { } } -// * Validation functions - export async function getSetting( key: string, reservation?: ReservedSQL, diff --git a/config/sql/users.ts b/config/sql/users.ts index b346b30..0cbf8c5 100644 --- a/config/sql/users.ts +++ b/config/sql/users.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type ReservedSQL, sql } from "bun"; export const order: number = 1; @@ -36,135 +36,3 @@ export async function createTable(reservation?: ReservedSQL): Promise { } } } - -// * 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) { - 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 }; -} - -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 }; -} - -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 }; -} - -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/package.json b/package.json index cea47bb..25496ce 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "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.lockdb", + "cleanup": "rm -rf logs node_modules bun.lock", "clearTable": "bun run src/helpers/commands/clearTable.ts" }, "devDependencies": { "@biomejs/biome": "^1.9.4", - "@types/bun": "^1.2.9", + "@types/bun": "^1.2.13", "@types/ejs": "^3.1.5", "@types/fluent-ffmpeg": "^2.1.27", "@types/image-thumbnail": "^1.0.4", @@ -22,16 +22,16 @@ "prettier": "^3.5.3" }, "peerDependencies": { - "typescript": "^5.8.2" + "typescript": "^5.8.3" }, "dependencies": { + "@creations.works/logger": "^1.0.3", "ejs": "^3.1.10", "eta": "^3.5.0", - "exiftool-vendored": "^29.3.0", + "exiftool-vendored": "^30.0.0", "fast-jwt": "6.0.1", "fluent-ffmpeg": "^2.1.3", "image-thumbnail": "^1.0.17", - "luxon": "^3.6.1", - "redis": "^4.7.0" + "luxon": "^3.6.1" } } diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts deleted file mode 100644 index 345fd75..0000000 --- a/src/helpers/logger.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Stats } from "node:fs"; -import { - type WriteStream, - createWriteStream, - existsSync, - mkdirSync, - statSync, -} from "node:fs"; -import { EOL } from "node:os"; -import { basename, join } from "node:path"; -import { environment } from "@config/environment"; -import { timestampToReadable } from "@helpers/char"; - -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 = 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 = ""; - - for (let i = 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 = 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 = 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 = 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 = 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 = false, - ): void { - const logMessage: string = Object.keys(logMessageParts) - .map((key: string) => { - const part: ILogMessagePart = logMessageParts[key]; - return `\x1b[${part.color}m${part.value}\x1b[0m`; - }) - .join(" "); - console.log(logMessage + (breakLine ? EOL : "")); - } -} - -const logger: Logger = Logger.getInstance(); -export { logger }; diff --git a/src/helpers/redis.ts b/src/helpers/redis.ts deleted file mode 100644 index a633580..0000000 --- a/src/helpers/redis.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { redisConfig } from "@config/environment"; -import { logger } from "@helpers/logger"; -import { type RedisClientType, createClient } 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; - } - if (type === "STRING") { - const value: string | null = await this.client.get(key); - return value; - } - throw new Error(`Invalid type: ${type}`); - } catch (error) { - logger.error(`Error getting value from Redis for key: ${key}`); - logger.error(error as Error); - throw error; - } - } - - public async set( - type: "JSON" | "STRING", - key: string, - value: unknown, - expiresInSeconds?: number, - path?: string, - ): Promise { - if (!this.client) { - logger.error("Redis client is not initialized."); - throw new Error("Redis client is not initialized."); - } - try { - if (type === "JSON") { - await this.client.json.set(key, path || "$", value as string); - - if (expiresInSeconds) { - await this.client.expire(key, expiresInSeconds); - } - } else if (type === "STRING") { - if (expiresInSeconds) { - await this.client.set(key, value as string, { - EX: expiresInSeconds, - }); - } else { - await this.client.set(key, value as string); - } - } else { - throw new Error(`Invalid type: ${type}`); - } - } catch (error) { - logger.error(`Error setting value in Redis for key: ${key}`); - logger.error(error as Error); - throw error; - } - } - - public async delete(type: "JSON" | "STRING", key: string): Promise { - if (!this.client) { - logger.error("Redis client is not initialized."); - throw new Error("Redis client is not initialized."); - } - try { - if (type === "JSON") { - await this.client.json.del(key); - } else if (type === "STRING") { - await this.client.del(key); - } else { - throw new Error(`Invalid type: ${type}`); - } - } catch (error) { - logger.error(`Error deleting value from Redis for key: ${key}`); - logger.error(error as Error); - throw error; - } - } - - public async expire(key: string, seconds: number): Promise { - if (!this.client) { - logger.error("Redis client is not initialized."); - throw new Error("Redis client is not initialized."); - } - try { - await this.client.expire(key, seconds); - } catch (error) { - logger.error([`Error expiring key in Redis: ${key}`, error as Error]); - throw error; - } - } - - public async keys(pattern: string): Promise { - if (!this.client) { - logger.error("Redis client is not initialized."); - throw new Error("Redis client is not initialized."); - } - try { - const keys: string[] = await this.client.keys(pattern); - return keys; - } catch (error) { - logger.error([ - `Error getting keys from Redis for pattern: ${pattern}`, - error as Error, - ]); - throw error; - } - } -} - -export const redis: { - initialize: () => Promise; - getInstance: () => RedisJson; -} = { - initialize: RedisJson.initialize, - getInstance: RedisJson.getInstance, -}; - -export { RedisJson }; diff --git a/src/helpers/sessions.ts b/src/helpers/sessions.ts deleted file mode 100644 index 67d7d27..0000000 --- a/src/helpers/sessions.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { jwt } from "@config/environment"; -import { environment } from "@config/environment"; -import { redis } from "@helpers/redis"; -import { createDecoder, createSigner, createVerifier } from "fast-jwt"; - -type Signer = (payload: UserSession, options?: UserSession) => string; -type Verifier = (token: string, options?: UserSession) => UserSession; -type Decoder = (token: string, options?: UserSession) => UserSession; - -class SessionManager { - private signer: Signer; - private verifier: Verifier; - private decoder: Decoder; - - constructor() { - this.signer = createSigner({ - key: jwt.secret, - expiresIn: jwt.expiresIn, - }); - this.verifier = createVerifier({ key: jwt.secret }); - this.decoder = createDecoder(); - } - - public async createSession( - payload: UserSession, - userAgent: string, - ): Promise { - const token: string = this.signer(payload); - const sessionKey: string = `session:${payload.id}:${token}`; - - await redis - .getInstance() - .set( - "JSON", - sessionKey, - { ...payload, userAgent }, - this.getExpirationInSeconds(), - ); - - const cookie: string = this.generateCookie(token); - return cookie; - } - - public async getSession(request: Request): Promise { - const cookie: string | null = request.headers.get("Cookie"); - if (!cookie) return null; - - const token: string | null = cookie.match(/session=([^;]+)/)?.[1] || null; - if (!token) return null; - - const userSessions: string[] = await redis - .getInstance() - .keys(`session:*:${token}`); - if (!userSessions.length) return null; - - const sessionData: unknown = await redis - .getInstance() - .get("JSON", userSessions[0]); - if (!sessionData) return null; - - const payload: UserSession & { userAgent: string } = - sessionData as UserSession & { userAgent: string }; - return payload; - } - - public async updateSession( - request: Request, - payload: UserSession, - userAgent: string, - ): Promise { - const cookie: string | null = request.headers.get("Cookie"); - if (!cookie) throw new Error("No session found in request"); - - const token: string | null = cookie.match(/session=([^;]+)/)?.[1] || null; - if (!token) throw new Error("Session token not found"); - - const userSessions: string[] = await redis - .getInstance() - .keys(`session:*:${token}`); - if (!userSessions.length) throw new Error("Session not found or expired"); - - const sessionKey: string = userSessions[0]; - - await redis - .getInstance() - .set( - "JSON", - sessionKey, - { ...payload, userAgent }, - this.getExpirationInSeconds(), - ); - - return this.generateCookie(token); - } - - public async verifySession(token: string): Promise { - const userSessions: string[] = await redis - .getInstance() - .keys(`session:*:${token}`); - if (!userSessions.length) throw new Error("Session not found or expired"); - - const sessionData: unknown = await redis - .getInstance() - .get("JSON", userSessions[0]); - if (!sessionData) throw new Error("Session not found or expired"); - - const payload: UserSession = this.verifier(token); - return payload; - } - - public async decodeSession(token: string): Promise { - const payload: UserSession = this.decoder(token); - return payload; - } - - public async invalidateSession(request: Request): Promise { - const cookie: string | null = request.headers.get("Cookie"); - if (!cookie) return; - - const token: string | null = cookie.match(/session=([^;]+)/)?.[1] || null; - if (!token) return; - - const userSessions: string[] = await redis - .getInstance() - .keys(`session:*:${token}`); - if (!userSessions.length) return; - - await redis.getInstance().delete("JSON", userSessions[0]); - } - - private generateCookie( - token: string, - maxAge: number = this.getExpirationInSeconds(), - options?: { - secure?: boolean; - httpOnly?: boolean; - sameSite?: "Strict" | "Lax" | "None"; - path?: string; - domain?: string; - }, - ): string { - const { - secure = !environment.development, - httpOnly = true, - sameSite = environment.development ? "Lax" : "None", - path = "/", - domain, - } = options || {}; - - let cookie = `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 = Number.parseInt(value, 10); - - switch (unit) { - case "s": - return num; - case "m": - return num * 60; - case "h": - return num * 3600; - case "d": - return num * 86400; - default: - throw new Error("Invalid time unit in expiresIn"); - } - } -} - -const sessionManager: SessionManager = new SessionManager(); -export { sessionManager }; diff --git a/src/index.ts b/src/index.ts index 923f860..07e2352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,12 @@ import { existsSync, mkdirSync } from "node:fs"; import { readdir } from "node:fs/promises"; import { resolve } from "node:path"; -import { dataType } from "@config/environment"; -import { logger } from "@helpers/logger"; -import { type ReservedSQL, s3, sql } from "bun"; +import { dataType, verifyRequiredVariables } from "@config"; +import { logger } from "@creations.works/logger"; +import { type ReservedSQL, redis, s3, sql } from "bun"; import { serverHandler } from "@/server"; -import { redis } from "./helpers/redis"; - async function initializeDatabase(): Promise { const sqlDir: string = resolve("config", "sql"); const files: string[] = await readdir(sqlDir); @@ -38,6 +36,8 @@ async function initializeDatabase(): Promise { } async function main(): Promise { + verifyRequiredVariables(); + try { await sql`SELECT 1;`; @@ -53,6 +53,19 @@ async function main(): Promise { process.exit(1); } + try { + await redis.connect(); + + const url = new URL(process.env.REDIS_URL || "redis://localhost:6379"); + const host = url.hostname; + const port = url.port || "6379"; + + logger.info(["Connected to Redis on", `${host}:${port}`]); + } catch (error) { + logger.error(["Redis connection failed:", error as Error]); + process.exit(1); + } + if (dataType.type === "local" && dataType.path) { if (!existsSync(dataType.path)) { try { @@ -82,7 +95,8 @@ async function main(): Promise { } } - await redis.initialize(); + logger.space(); + serverHandler.initialize(); await initializeDatabase(); } diff --git a/src/helpers/auth.ts b/src/lib/auth.ts similarity index 94% rename from src/helpers/auth.ts rename to src/lib/auth.ts index 5df1a3f..1c9a311 100644 --- a/src/helpers/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,5 @@ -import { isUUID } from "@helpers/char"; -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; import { type ReservedSQL, sql } from "bun"; export async function authByToken( diff --git a/src/helpers/char.ts b/src/lib/char.ts similarity index 96% rename from src/helpers/char.ts rename to src/lib/char.ts index 2ef3e03..ac89dcd 100644 --- a/src/helpers/char.ts +++ b/src/lib/char.ts @@ -200,6 +200,12 @@ 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); diff --git a/src/helpers/commands/clearTable.ts b/src/lib/commands/clearTable.ts similarity index 96% rename from src/helpers/commands/clearTable.ts rename to src/lib/commands/clearTable.ts index 0cd5269..dfd2906 100644 --- a/src/helpers/commands/clearTable.ts +++ b/src/lib/commands/clearTable.ts @@ -1,4 +1,4 @@ -import { parseArgs } from "@helpers/char"; +import { parseArgs } from "@lib/char"; import { type ReservedSQL, sql } from "bun"; (async (): Promise => { diff --git a/src/helpers/ejs.ts b/src/lib/ejs.ts similarity index 100% rename from src/helpers/ejs.ts rename to src/lib/ejs.ts diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..cf857a0 --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,145 @@ +import { environment, jwt } from "@config"; +import { redis } from "bun"; +import { createDecoder, createSigner, createVerifier } from "fast-jwt"; + +const signer = createSigner({ key: jwt.secret, expiresIn: jwt.expiration }); +const verifier = createVerifier({ key: jwt.secret }); +const decoder = createDecoder(); + +export async function createSession( + payload: UserSession, + userAgent: string, +): Promise { + const token = signer(payload); + const sessionKey = `session:${payload.id}:${token}`; + await redis.set(sessionKey, JSON.stringify({ ...payload, userAgent })); + await redis.expire(sessionKey, getExpirationInSeconds()); + return generateCookie(token); +} + +export async function getSession( + request: Request, +): Promise { + const token = extractToken(request); + if (!token) return null; + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) return null; + const raw = await redis.get(keys[0]); + return raw ? JSON.parse(raw) : null; +} + +export async function updateSession( + request: Request, + payload: UserSession, + userAgent: string, +): Promise { + const token = extractToken(request); + if (!token) throw new Error("Session token not found"); + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) throw new Error("Session not found or expired"); + await redis.set(keys[0], JSON.stringify({ ...payload, userAgent })); + await redis.expire(keys[0], getExpirationInSeconds()); + return generateCookie(token); +} + +export async function verifySession(token: string): Promise { + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) throw new Error("Session not found or expired"); + return verifier(token); +} + +export async function decodeSession(token: string): Promise { + return decoder(token); +} + +export async function invalidateSession(request: Request): Promise { + const token = extractToken(request); + if (!token) return; + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) return; + await redis.del(keys[0]); +} + +export async function invalidateSessionById( + sessionId: string, +): Promise { + const keys = await redis.keys(`session:*:${sessionId}`); + if (!keys.length) return false; + await redis.del(keys[0]); + return true; +} + +export async function invalidateAllSessionsForUser( + userId: string, +): Promise { + const keys = await redis.keys(`session:${userId}:*`); + if (keys.length === 0) return 0; + + for (const key of keys) { + await redis.del(key); + } + + return keys.length; +} + +// helpers +function extractToken(request: Request): string | null { + return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null; +} + +function generateCookie( + token: string, + maxAge = getExpirationInSeconds(), + options?: { + secure?: boolean; + httpOnly?: boolean; + sameSite?: "Strict" | "Lax" | "None"; + path?: string; + domain?: string; + }, +): string { + const { + secure = !environment.development, + httpOnly = true, + sameSite = environment.development ? "Lax" : "None", + path = "/", + domain, + } = options || {}; + + let cookie = `session=${encodeURIComponent(token)}; Path=${path}; Max-Age=${maxAge}`; + if (httpOnly) cookie += "; HttpOnly"; + if (secure) cookie += "; Secure"; + if (sameSite) cookie += `; SameSite=${sameSite}`; + if (domain) cookie += `; Domain=${domain}`; + return cookie; +} + +function getExpirationInSeconds(): number { + const match = jwt.expiration.match(/^(\d+)([smhd])$/); + if (!match) throw new Error("Invalid expiresIn format in jwt config"); + const [, value, unit] = match; + const num = Number(value); + switch (unit) { + case "s": + return num; + case "m": + return num * 60; + case "h": + return num * 3600; + case "d": + return num * 86400; + default: + throw new Error("Invalid time unit in expiresIn"); + } +} + +export const sessionManager = { + createSession, + getSession, + updateSession, + verifySession, + decodeSession, + invalidateSession, + invalidateSessionById, + invalidateAllSessionsForUser, +}; diff --git a/src/lib/validators/avatar.ts b/src/lib/validators/avatar.ts new file mode 100644 index 0000000..89422ed --- /dev/null +++ b/src/lib/validators/avatar.ts @@ -0,0 +1,9 @@ +export function isValidTypeOrExtension( + type: string, + extension: string, +): boolean { + return ( + ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) && + ["jpeg", "jpg", "png", "gif", "webp"].includes(extension) + ); +} diff --git a/src/lib/validators/email.ts b/src/lib/validators/email.ts new file mode 100644 index 0000000..d5becad --- /dev/null +++ b/src/lib/validators/email.ts @@ -0,0 +1,18 @@ +const emailRestrictions: { regex: RegExp } = { + regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, +}; + +export function isValidEmail(email: string): { + valid: boolean; + error?: string; +} { + if (!email) { + return { valid: false, error: "" }; + } + + if (!emailRestrictions.regex.test(email)) { + return { valid: false, error: "Invalid email address" }; + } + + return { valid: true }; +} diff --git a/src/lib/validators/index.ts b/src/lib/validators/index.ts new file mode 100644 index 0000000..0192c4a --- /dev/null +++ b/src/lib/validators/index.ts @@ -0,0 +1,5 @@ +export * from "@lib/validators/name"; +export * from "@lib/validators/email"; +export * from "@lib/validators/password"; +export * from "@lib/validators/invite"; +export * from "@lib/validators/avatar"; diff --git a/src/lib/validators/invite.ts b/src/lib/validators/invite.ts new file mode 100644 index 0000000..6a31711 --- /dev/null +++ b/src/lib/validators/invite.ts @@ -0,0 +1,37 @@ +const inviteRestrictions: { min: number; max: number; regex: RegExp } = { + min: 4, + max: 15, + regex: /^[a-zA-Z0-9]+$/, +}; + +export function isValidInvite(invite: string): { + valid: boolean; + error?: string; +} { + if (!invite) { + return { valid: false, error: "" }; + } + + if (invite.length < inviteRestrictions.min) { + return { + valid: false, + error: `Invite code must be at least ${inviteRestrictions.min} characters long`, + }; + } + + if (invite.length > inviteRestrictions.max) { + return { + valid: false, + error: `Invite code can't be longer than ${inviteRestrictions.max} characters`, + }; + } + + if (!inviteRestrictions.regex.test(invite)) { + return { + valid: false, + error: "Invite code contains invalid characters", + }; + } + + return { valid: true }; +} diff --git a/src/lib/validators/name.ts b/src/lib/validators/name.ts new file mode 100644 index 0000000..1ccf292 --- /dev/null +++ b/src/lib/validators/name.ts @@ -0,0 +1,31 @@ +// ? should support non english characters but won't mess up the url +export const userNameRestrictions: { + length: { min: number; max: number }; + regex: RegExp; +} = { + length: { min: 3, max: 20 }, + regex: /^[\p{L}\p{N}._-]+$/u, +}; + +export function isValidUsername(username: string): { + valid: boolean; + error?: string; +} { + if (!username) { + return { valid: false, error: "" }; + } + + if (username.length < userNameRestrictions.length.min) { + return { valid: false, error: "Username is too short" }; + } + + if (username.length > userNameRestrictions.length.max) { + return { valid: false, error: "Username is too long" }; + } + + if (!userNameRestrictions.regex.test(username)) { + return { valid: false, error: "Username contains invalid characters" }; + } + + return { valid: true }; +} diff --git a/src/lib/validators/password.ts b/src/lib/validators/password.ts new file mode 100644 index 0000000..43dcb0e --- /dev/null +++ b/src/lib/validators/password.ts @@ -0,0 +1,40 @@ +const passwordRestrictions: { + length: { min: number; max: number }; + regex: RegExp; +} = { + length: { min: 12, max: 64 }, + regex: /^(?=.*\p{Ll})(?=.*\p{Lu})(?=.*\d)(?=.*[^\w\s]).{12,64}$/u, +}; + +export function isValidPassword(password: string): { + valid: boolean; + error?: string; +} { + if (!password) { + return { valid: false, error: "" }; + } + + if (password.length < passwordRestrictions.length.min) { + return { + valid: false, + error: `Password must be at least ${passwordRestrictions.length.min} characters long`, + }; + } + + if (password.length > passwordRestrictions.length.max) { + return { + valid: false, + error: `Password can't be longer than ${passwordRestrictions.length.max} characters`, + }; + } + + if (!passwordRestrictions.regex.test(password)) { + return { + valid: false, + error: + "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character", + }; + } + + return { valid: true }; +} diff --git a/src/helpers/workers/thumbnails.ts b/src/lib/workers/thumbnails.ts similarity index 96% rename from src/helpers/workers/thumbnails.ts rename to src/lib/workers/thumbnails.ts index d3f0d7d..c0c4605 100644 --- a/src/helpers/workers/thumbnails.ts +++ b/src/lib/workers/thumbnails.ts @@ -1,6 +1,6 @@ import { join, resolve } from "node:path"; -import { dataType } from "@config/environment.ts"; -import { logger } from "@helpers/logger.ts"; +import { dataType } from "@config"; +import { logger } from "@creations.works/logger"; import { type BunFile, s3, sql } from "bun"; import ffmpeg from "fluent-ffmpeg"; import imageThumbnail from "image-thumbnail"; @@ -186,5 +186,5 @@ self.onmessage = async (event: MessageEvent): Promise => { }; self.onerror = (error: ErrorEvent): void => { - logger.error(error); + logger.error(["An error occurred in the thumbnail worker:", error.message]); }; diff --git a/src/routes/api/auth/email/verify/[code].ts b/src/routes/api/auth/email/verify/[code].ts index 0c9f627..ff92edb 100644 --- a/src/routes/api/auth/email/verify/[code].ts +++ b/src/routes/api/auth/email/verify/[code].ts @@ -1,9 +1,8 @@ -import { sql } from "bun"; +import { redis, sql } from "bun"; -import { isUUID } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; -import { redis } from "@/helpers/redis"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; const routeDef: RouteDef = { method: "POST", @@ -37,11 +36,9 @@ async function handler(request: ExtendedRequest): Promise { } try { - const verificationData: unknown = await redis - .getInstance() - .get("JSON", `email:verify:${code}`); + const raw: string | null = await redis.get(`email:verify:${code}`); - if (!verificationData) { + if (!raw) { return Response.json( { success: false, @@ -52,11 +49,24 @@ async function handler(request: ExtendedRequest): Promise { ); } - const { user_id: userId } = verificationData as { - user_id: string; - }; + let verificationData: { user_id: string }; - await redis.getInstance().delete("JSON", `email:verify:${code}`); + try { + verificationData = JSON.parse(raw); + } catch { + return Response.json( + { + success: false, + code: 400, + error: "Malformed verification data", + }, + { status: 500 }, + ); + } + + const { user_id: userId } = verificationData; + + await redis.del(`email:verify:${code}`); await sql` UPDATE users SET email_verified = true diff --git a/src/routes/api/auth/email/verify/request.ts b/src/routes/api/auth/email/verify/request.ts index 089301e..477f42b 100644 --- a/src/routes/api/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 "@/helpers/logger"; -import { redis } from "@/helpers/redis"; +import { logger } from "@creations.works/logger"; +import { redis } from "bun"; const routeDef: RouteDef = { method: "GET", @@ -51,11 +51,9 @@ async function handler(request: ExtendedRequest): Promise { } const code: string = randomUUIDv7(); - await redis.getInstance().set( - "JSON", + await redis.set( `email:verify:${code}`, - { user_id: request.session.id }, - 60 * 60 * 2, // 2 hours + JSON.stringify({ user_id: request.session.id }), ); // TODO: Send email when email service is implemented diff --git a/src/routes/api/auth/login.ts b/src/routes/api/auth/login.ts index 6ede556..0beacc1 100644 --- a/src/routes/api/auth/login.ts +++ b/src/routes/api/auth/login.ts @@ -2,11 +2,11 @@ import { isValidEmail, isValidPassword, isValidUsername, -} from "@config/sql/users"; +} from "@lib/validators"; import { type ReservedSQL, password as bunPassword, sql } from "bun"; -import { logger } from "@/helpers/logger"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; const routeDef: RouteDef = { method: "POST", diff --git a/src/routes/api/auth/logout.ts b/src/routes/api/auth/logout.ts index 4ef42a1..9103d5a 100644 --- a/src/routes/api/auth/logout.ts +++ b/src/routes/api/auth/logout.ts @@ -1,4 +1,4 @@ -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; const routeDef: RouteDef = { method: "POST", diff --git a/src/routes/api/auth/register.ts b/src/routes/api/auth/register.ts index 9c681b9..7e751f3 100644 --- a/src/routes/api/auth/register.ts +++ b/src/routes/api/auth/register.ts @@ -4,12 +4,12 @@ import { isValidInvite, isValidPassword, isValidUsername, -} from "@config/sql/users"; +} from "@lib/validators"; import { type ReservedSQL, password as bunPassword, sql } from "bun"; -import { isValidTimezone } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; +import { isValidTimezone } from "@lib/char"; const routeDef: RouteDef = { method: "POST", diff --git a/src/routes/api/files/delete[query].ts b/src/routes/api/files/delete[query].ts index 03166f4..a88c29d 100644 --- a/src/routes/api/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/environment"; +import { dataType } from "@config"; import { type SQLQuery, s3, sql } from "bun"; -import { isUUID } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; const routeDef: RouteDef = { method: "DELETE", diff --git a/src/routes/api/files/upload.ts b/src/routes/api/files/upload.ts index d9d9f89..664879c 100644 --- a/src/routes/api/files/upload.ts +++ b/src/routes/api/files/upload.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { dataType } from "@config/environment"; +import { dataType } from "@config"; import { getSetting } from "@config/sql/settings"; import { type SQLQuery, @@ -11,6 +11,7 @@ import { import { exiftool } from "exiftool-vendored"; import { DateTime } from "luxon"; +import { logger } from "@creations.works/logger"; import { generateRandomString, getBaseUrl, @@ -19,8 +20,7 @@ import { nameWithoutExtension, supportsExif, supportsThumbnail, -} from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +} from "@lib/char"; const routeDef: RouteDef = { method: "POST", @@ -439,7 +439,7 @@ async function handler(request: ExtendedRequest): Promise { filesThatSupportThumbnails.length > 0 ) { try { - const worker: Worker = new Worker("./src/helpers/workers/thumbnails.ts", { + const worker: Worker = new Worker("./src/helpers/workers/thumbnails", { type: "module", }); worker.postMessage({ diff --git a/src/routes/api/invite/create.ts b/src/routes/api/invite/create.ts index fbb5aaf..a27c239 100644 --- a/src/routes/api/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 { generateRandomString, getNewTimeUTC } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { generateRandomString, getNewTimeUTC } from "@lib/char"; const routeDef: RouteDef = { method: "POST", diff --git a/src/routes/api/invite/delete[invite].ts b/src/routes/api/invite/delete[invite].ts index 3672ae1..a9bf371 100644 --- a/src/routes/api/invite/delete[invite].ts +++ b/src/routes/api/invite/delete[invite].ts @@ -1,7 +1,7 @@ -import { isValidInvite } from "@config/sql/users"; +import { isValidInvite } from "@lib/validators"; import { type ReservedSQL, sql } from "bun"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; const routeDef: RouteDef = { method: "DELETE", diff --git a/src/routes/api/settings/set.ts b/src/routes/api/settings/set.ts index b84df90..d667fab 100644 --- a/src/routes/api/settings/set.ts +++ b/src/routes/api/settings/set.ts @@ -1,6 +1,6 @@ import { setSetting } from "@config/sql/settings"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; const routeDef: RouteDef = { method: "POST", diff --git a/src/routes/api/user/avatar/delete.ts b/src/routes/api/user/avatar/delete.ts index 6122eeb..1948c23 100644 --- a/src/routes/api/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/environment"; +import { dataType } from "@config"; import { s3, sql } from "bun"; -import { logger } from "@/helpers/logger"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; async function deleteAvatar( request: ExtendedRequest, diff --git a/src/routes/api/user/avatar/set.ts b/src/routes/api/user/avatar/set.ts index 0b9c90f..653fa47 100644 --- a/src/routes/api/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/environment"; -import { isValidTypeOrExtension } from "@config/sql/avatars"; +import { dataType } from "@config"; import { getSetting } from "@config/sql/settings"; +import { isValidTypeOrExtension } from "@lib/validators"; import { s3, sql } from "bun"; -import { getBaseUrl, getExtension } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; -import { sessionManager } from "@/helpers/sessions"; +import { sessionManager } from "@/lib/jwt"; +import { logger } from "@creations.works/logger"; +import { getBaseUrl, getExtension } from "@lib/char"; async function processFile( file: File, diff --git a/src/routes/api/user/files.ts b/src/routes/api/user/files.ts index ef5ae83..a67f1fa 100644 --- a/src/routes/api/user/files.ts +++ b/src/routes/api/user/files.ts @@ -1,7 +1,7 @@ import { type ReservedSQL, type SQLQuery, sql } from "bun"; -import { isUUID } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; function isValidSort(sortBy: string): boolean { const validSorts: string[] = [ diff --git a/src/routes/api/user/info[query].ts b/src/routes/api/user/info[query].ts index 62ff923..7acc04b 100644 --- a/src/routes/api/user/info[query].ts +++ b/src/routes/api/user/info[query].ts @@ -1,8 +1,8 @@ -import { isValidUsername } from "@config/sql/users"; +import { isValidUsername } from "@lib/validators"; import { type ReservedSQL, sql } from "bun"; -import { isUUID } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID } from "@lib/char"; const routeDef: RouteDef = { method: "GET", diff --git a/src/routes/index.ts b/src/routes/index.ts index 711c0f2..99beef0 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,4 @@ -import { renderEjsTemplate } from "@helpers/ejs"; +import { renderEjsTemplate } from "@lib/ejs"; const routeDef: RouteDef = { method: "GET", diff --git a/src/routes/raw/[query].ts b/src/routes/raw/[query].ts index b6ada16..c7c7b37 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/environment"; +import { dataType } from "@config"; import { type BunFile, type ReservedSQL, sql } from "bun"; -import { isUUID, nameWithoutExtension } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { isUUID, nameWithoutExtension } from "@lib/char"; const routeDef: RouteDef = { method: "GET", diff --git a/src/routes/user/avatar/[user].ts b/src/routes/user/avatar/[user].ts index d6d06c7..c0f7ed7 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/environment"; -import { isValidUsername } from "@config/sql/users"; +import { dataType } from "@config"; +import { isValidUsername } from "@lib/validators"; import { type BunFile, type ReservedSQL, sql } from "bun"; -import { getBaseUrl, isUUID, nameWithoutExtension } from "@/helpers/char"; -import { logger } from "@/helpers/logger"; +import { logger } from "@creations.works/logger"; +import { getBaseUrl, isUUID, nameWithoutExtension } from "@lib/char"; const routeDef: RouteDef = { method: "GET", diff --git a/src/server.ts b/src/server.ts index 15471ee..e9d7c15 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; -import { environment } from "@config/environment"; -import { logger } from "@helpers/logger"; +import { environment } from "@config"; +import { logger } from "@creations.works/logger"; import { type BunFile, FileSystemRouter, @@ -8,10 +8,9 @@ import { type Serve, } from "bun"; +import { sessionManager } from "@/lib/jwt"; import { webSocketHandler } from "@/websocket"; - -import { authByToken } from "./helpers/auth"; -import { sessionManager } from "./helpers/sessions"; +import { authByToken } from "@lib/auth"; class ServerHandler { private router: FileSystemRouter; @@ -41,15 +40,7 @@ class ServerHandler { maxRequestBodySize: 10 * 1024 * 1024 * 1024, // 10GB ? will be changed to env var soon }); - const accessUrls: string[] = [ - `http://${server.hostname}:${server.port}`, - `http://localhost:${server.port}`, - `http://127.0.0.1:${server.port}`, - ]; - - logger.info(`Server running at ${accessUrls[0]}`); - logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true); - + logger.info(`Server running at ${environment.fqdn}`); this.logRoutes(); } @@ -67,10 +58,15 @@ class ServerHandler { } } - private async serveStaticFile(pathname: string): Promise { - try { - let filePath: string; + private async serveStaticFile( + request: ExtendedRequest, + pathname: string, + ip: string, + ): Promise { + let filePath: string; + let response: Response; + try { if (pathname === "/favicon.ico") { filePath = resolve("public", "assets", "favicon.ico"); } else { @@ -83,16 +79,37 @@ class ServerHandler { const fileContent: ArrayBuffer = await file.arrayBuffer(); const contentType: string = file.type || "application/octet-stream"; - return new Response(fileContent, { + response = new Response(fileContent, { headers: { "Content-Type": contentType }, }); + } else { + logger.warn(`File not found: ${filePath}`); + response = new Response("Not Found", { status: 404 }); } - logger.warn(`File not found: ${filePath}`); - return new Response("Not Found", { status: 404 }); } catch (error) { logger.error([`Error serving static file: ${pathname}`, error as Error]); - return new Response("Internal Server Error", { status: 500 }); + response = new Response("Internal Server Error", { status: 500 }); } + + this.logRequest(request, response, ip); + return response; + } + + private logRequest( + request: ExtendedRequest, + response: Response, + ip: string | undefined, + ): void { + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); } private async handleRequest( @@ -102,14 +119,25 @@ class ServerHandler { const extendedRequest: ExtendedRequest = request as ExtendedRequest; extendedRequest.startPerf = performance.now(); + const headers = request.headers; + let ip = server.requestIP(request)?.address; + let response: Response; + + if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { + ip = + headers.get("CF-Connecting-IP")?.trim() || + headers.get("X-Real-IP")?.trim() || + headers.get("X-Forwarded-For")?.split(",")[0].trim() || + "unknown"; + } + const pathname: string = new URL(request.url).pathname; if (pathname.startsWith("/public") || pathname === "/favicon.ico") { - return await this.serveStaticFile(pathname); + return await this.serveStaticFile(extendedRequest, pathname, ip); } const match: MatchedRoute | null = this.router.match(request); let requestBody: unknown = {}; - let response: Response; if (match) { const { filePath, params, query } = match; @@ -230,28 +258,6 @@ class ServerHandler { ); } - const headers: Headers = response.headers; - let ip: string | null = server.requestIP(request)?.address || null; - - if (!ip) { - ip = - headers.get("CF-Connecting-IP") || - headers.get("X-Real-IP") || - headers.get("X-Forwarded-For") || - null; - } - - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - return response; } } diff --git a/src/websocket.ts b/src/websocket.ts index 99686e8..7b65476 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import type { ServerWebSocket } from "bun"; class WebSocketHandler { diff --git a/tsconfig.json b/tsconfig.json index 68a5a97..391e2c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,10 @@ "baseUrl": "./", "paths": { "@/*": ["src/*"], + "@config": ["config/index.ts"], "@config/*": ["config/*"], "@types/*": ["types/*"], - "@helpers/*": ["src/helpers/*"] + "@lib/*": ["src/lib/*"] }, "typeRoots": ["./src/types", "./node_modules/@types"], // Enable latest features diff --git a/types/config.d.ts b/types/config.d.ts index 322a951..2d0f7dd 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -2,6 +2,7 @@ type Environment = { port: number; host: string; development: boolean; + fqdn: string; }; type UserValidation = { diff --git a/types/logger.d.ts b/types/logger.d.ts deleted file mode 100644 index ff6a601..0000000 --- a/types/logger.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type ILogMessagePart = { value: string; color: string }; - -type ILogMessageParts = { - level: ILogMessagePart; - filename: ILogMessagePart; - readableTimestamp: ILogMessagePart; - message: ILogMessagePart; - [key: string]: ILogMessagePart; -};