From 5761cbaa55d06a1fef93b5221430a9dd3b3113f8 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Thu, 26 Feb 2026 08:53:50 +0100 Subject: [PATCH 1/4] Added routes for login and bug reporting --- compose.yml | 1 - init/init.sql | 6 -- package.json | 1 + src/index.ts | 13 ++- src/routes/admin.ts | 132 ++++++++++++++++++++++++++++++- src/routes/auth.ts | 55 +++++++++++++ src/services/authService.ts | 124 +++++++++++++++++++++++++++++ src/services/bugReportService.ts | 82 +++++++++++++++++-- src/services/userService.ts | 120 ++++++++++++++++++++++++++++ 9 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 src/routes/auth.ts create mode 100644 src/services/authService.ts create mode 100644 src/services/userService.ts diff --git a/compose.yml b/compose.yml index 4f96000..51690a2 100644 --- a/compose.yml +++ b/compose.yml @@ -67,7 +67,6 @@ services: - "traefik.http.services.emly-api.loadbalancer.server.port=3000" depends_on: - emly-mysql-db - volumes: mysql-data: emly-api-logs: \ No newline at end of file diff --git a/init/init.sql b/init/init.sql index 6c6e218..044146c 100644 --- a/init/init.sql +++ b/init/init.sql @@ -54,9 +54,3 @@ CREATE TABLE IF NOT EXISTS `session` ( CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid; -ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname; -ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname); -ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user); -ALTER TABLE user ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER role; - diff --git a/package.json b/package.json index ff1aae9..c6d939b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "@node-rs/argon2": "^2.0.2", "elysia": "^1.2.0", + "jszip": "^3.10.1", "mysql2": "^3.11.0" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index 5587c11..e16fcee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { runMigrations } from "./db/migrate"; import { closePool } from "./db/connection"; import { bugReportRoutes } from "./routes/bugReports"; import { adminRoutes } from "./routes/admin"; +import { authRoutes } from "./routes/auth"; import { initLogger, Log } from "./logger"; const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); @@ -35,7 +36,16 @@ const app = new Elysia() const url = new URL(request.url); Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`); }) - .onError(({ error, set }) => { + .onError(({ error, set, code }) => { + console.error("Error processing request:", error); + if (code === "NOT_FOUND") { + set.status = 404; + return { success: false, message: "Not found" }; + } + if (code === "VALIDATION") { + set.status = 422; + return { success: false, message: "Validation error" }; + } Log("ERROR", "Unhandled error:", error); set.status = 500; return { success: false, message: "Internal server error" }; @@ -43,6 +53,7 @@ const app = new Elysia() .get("/health", () => ({ status: "ok", instance: INSTANCE_ID, timestamp: new Date().toISOString() })) .get("/", () => ({ status: "ok", message: "API is running" })) .use(bugReportRoutes) + .use(authRoutes) .use(adminRoutes) .listen({ port: config.port, diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 3bc3e72..94b6a99 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -6,7 +6,17 @@ import { getFile, deleteBugReport, updateBugReportStatus, + countNewReports, + generateReportZip, } from "../services/bugReportService"; +import { + listUsers, + createUser, + updateUser, + resetPassword, + deleteUser, + getUserById, +} from "../services/userService"; import { Log } from "../logger"; import type { BugReportStatus } from "../types"; @@ -18,9 +28,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) const page = parseInt(query.page || "1"); const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); const status = query.status as BugReportStatus | undefined; + const search = query.search || undefined; - Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`); - return await listBugReports({ page, pageSize, status }); + Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`); + return await listBugReports({ page, pageSize, status, search }); }, { query: t.Object({ @@ -34,10 +45,19 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) t.Literal("closed"), ]) ), + search: t.Optional(t.String()), }), - detail: { summary: "List bug reports (paginated)" }, + detail: { summary: "List bug reports (paginated, filterable)" }, } ) + .get( + "/bug-reports/count", + async () => { + const count = await countNewReports(); + return { count }; + }, + { detail: { summary: "Count new bug reports" } } + ) .get( "/bug-reports/:id", async ({ params, error }) => { @@ -93,6 +113,25 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) detail: { summary: "Download a bug report file" }, } ) + .get( + "/bug-reports/:id/download", + async ({ params, error, set }) => { + Log("ADMIN", `Download zip for report id=${params.id}`); + const zipBuffer = await generateReportZip(parseInt(params.id)); + if (!zipBuffer) + return error(404, { success: false, message: "Report not found" }); + + set.headers["content-type"] = "application/zip"; + set.headers["content-disposition"] = + `attachment; filename="report-${params.id}.zip"`; + set.headers["content-length"] = String(zipBuffer.length); + return new Response(new Uint8Array(zipBuffer)); + }, + { + params: t.Object({ id: t.String() }), + detail: { summary: "Download all files for a bug report as ZIP" }, + } + ) .delete( "/bug-reports/:id", async ({ params, error }) => { @@ -106,4 +145,91 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) params: t.Object({ id: t.String() }), detail: { summary: "Delete a bug report and its files" }, } + ) + // User management + .get( + "/users", + async () => { + Log("ADMIN", "List users"); + return await listUsers(); + }, + { detail: { summary: "List all users" } } + ) + .post( + "/users", + async ({ body, error }) => { + Log("ADMIN", `Create user username=${body.username}`); + try { + const user = await createUser(body); + return { success: true, user }; + } catch (err) { + if (err instanceof Error && err.message === "Username already exists") { + return error(409, { success: false, message: err.message }); + } + throw err; + } + }, + { + body: t.Object({ + username: t.String({ minLength: 3, maxLength: 255 }), + displayname: t.String({ default: "" }), + password: t.String({ minLength: 1 }), + role: t.Union([t.Literal("admin"), t.Literal("user")]), + }), + detail: { summary: "Create a new user" }, + } + ) + .patch( + "/users/:id", + async ({ params, body, error }) => { + Log("ADMIN", `Update user id=${params.id}`); + const updated = await updateUser(params.id, body); + if (!updated) + return error(404, { success: false, message: "User not found" }); + return { success: true, message: "User updated" }; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + displayname: t.Optional(t.String()), + enabled: t.Optional(t.Boolean()), + }), + detail: { summary: "Update user displayname or enabled status" }, + } + ) + .post( + "/users/:id/reset-password", + async ({ params, body, error }) => { + Log("ADMIN", `Reset password for user id=${params.id}`); + const updated = await resetPassword(params.id, body.password); + if (!updated) + return error(404, { success: false, message: "User not found" }); + return { success: true, message: "Password reset" }; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ password: t.String({ minLength: 1 }) }), + detail: { summary: "Reset a user's password" }, + } + ) + .delete( + "/users/:id", + async ({ params, error }) => { + Log("ADMIN", `Delete user id=${params.id}`); + + const user = await getUserById(params.id); + if (!user) + return error(404, { success: false, message: "User not found" }); + if (user.role === "admin") + return error(400, { success: false, message: "Cannot delete an admin user" }); + + const deleted = await deleteUser(params.id); + if (!deleted) + return error(404, { success: false, message: "User not found" }); + return { success: true, message: "User deleted" }; + }, + { + params: t.Object({ id: t.String() }), + detail: { summary: "Delete a user (non-admin only)" }, + } ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..6ce68a7 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,55 @@ +import { Elysia, t } from "elysia"; +import { adminKeyGuard } from "../middleware/auth"; +import { loginUser, validateSession, logoutSession } from "../services/authService"; +import { Log } from "../logger"; + +export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) + .use(adminKeyGuard) + .post( + "/login", + async ({ body, error }) => { + const result = await loginUser(body.username, body.password); + if (!result) { + Log("AUTH", `Login failed for username=${body.username}`); + return error(401, { success: false, message: "Invalid credentials or account disabled" }); + } + return { success: true, session_id: result.session_id, user: result.user }; + }, + { + body: t.Object({ + username: t.String({ minLength: 1 }), + password: t.String({ minLength: 1 }), + }), + detail: { summary: "Login with username/password" }, + } + ) + .post( + "/logout", + async ({ headers }) => { + const sessionId = headers["x-session-token"]; + if (sessionId) { + await logoutSession(sessionId); + } + return { success: true, message: "Logged out" }; + }, + { + detail: { summary: "Logout and invalidate session" }, + } + ) + .get( + "/validate", + async ({ headers, error }) => { + const sessionId = headers["x-session-token"]; + if (!sessionId) { + return error(401, { success: false, message: "No session token provided" }); + } + const user = await validateSession(sessionId); + if (!user) { + return error(401, { success: false, message: "Invalid or expired session" }); + } + return { success: true, user }; + }, + { + detail: { summary: "Validate session and return user" }, + } + ); diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..bf0d5de --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,124 @@ +import { hash, verify } from "@node-rs/argon2"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import { getPool } from "../db/connection"; +import { Log } from "../logger"; + +const ARGON2_OPTIONS = { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, +} as const; + +const SESSION_EXPIRY_DAYS = 30; + +export interface AuthUser { + id: string; + username: string; + displayname: string; + role: "admin" | "user"; + enabled: boolean; +} + +function generateSessionId(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function loginUser( + username: string, + password: string +): Promise<{ session_id: string; user: AuthUser } | null> { + const pool = getPool(); + + const [rows] = await pool.execute( + "SELECT id, username, displayname, password_hash, role, enabled FROM `user` WHERE username = ? LIMIT 1", + [username] + ); + + if ((rows as unknown[]).length === 0) return null; + + const row = rows[0] as { + id: string; + username: string; + displayname: string; + password_hash: string; + role: "admin" | "user"; + enabled: boolean; + }; + + const valid = await verify(row.password_hash, password, ARGON2_OPTIONS); + if (!valid) return null; + + if (!row.enabled) return null; + + const sessionId = generateSessionId(); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + + await pool.execute( + "INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)", + [sessionId, row.id, expiresAt] + ); + + Log("AUTH", `User logged in: username=${username} session=${sessionId.slice(0, 8)}...`); + + return { + session_id: sessionId, + user: { + id: row.id, + username: row.username, + displayname: row.displayname, + role: row.role, + enabled: row.enabled, + }, + }; +} + +export async function validateSession( + sessionId: string +): Promise { + const pool = getPool(); + + const [rows] = await pool.execute( + `SELECT u.id, u.username, u.displayname, u.role, u.enabled, s.expires_at + FROM session s + JOIN \`user\` u ON u.id = s.user_id + WHERE s.id = ? LIMIT 1`, + [sessionId] + ); + + if ((rows as unknown[]).length === 0) return null; + + const row = rows[0] as { + id: string; + username: string; + displayname: string; + role: "admin" | "user"; + enabled: boolean; + expires_at: Date; + }; + + if (new Date() > new Date(row.expires_at)) { + await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]); + return null; + } + + if (!row.enabled) return null; + + return { + id: row.id, + username: row.username, + displayname: row.displayname, + role: row.role, + enabled: row.enabled, + }; +} + +export async function logoutSession(sessionId: string): Promise { + const pool = getPool(); + await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]); + Log("AUTH", `Session logged out: ${sessionId.slice(0, 8)}...`); +} diff --git a/src/services/bugReportService.ts b/src/services/bugReportService.ts index 6ab6056..13b0fbc 100644 --- a/src/services/bugReportService.ts +++ b/src/services/bugReportService.ts @@ -1,4 +1,5 @@ import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import JSZip from "jszip"; import { getPool } from "../db/connection"; import type { BugReport, @@ -65,19 +66,31 @@ export async function listBugReports(opts: { page: number; pageSize: number; status?: BugReportStatus; + search?: string; }): Promise> { const pool = getPool(); - const { page, pageSize, status } = opts; + const { page, pageSize, status, search } = opts; const offset = (page - 1) * pageSize; - let whereClause = ""; + const conditions: string[] = []; const params: unknown[] = []; if (status) { - whereClause = "WHERE br.status = ?"; + conditions.push("br.status = ?"); params.push(status); } + if (search) { + const like = `%${search}%`; + conditions.push( + "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)" + ); + params.push(like, like, like, like); + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const [countRows] = await pool.execute( `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`, params @@ -91,8 +104,8 @@ export async function listBugReports(opts: { ${whereClause} GROUP BY br.id ORDER BY br.created_at DESC - LIMIT ? OFFSET ?`, - [...params, pageSize, offset] + LIMIT ${pageSize} OFFSET ${offset}`, + params ); return { @@ -104,6 +117,65 @@ export async function listBugReports(opts: { }; } +export async function countNewReports(): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'" + ); + return (rows[0] as { count: number }).count; +} + +export async function generateReportZip(reportId: number): Promise { + const pool = getPool(); + + const [reportRows] = await pool.execute( + "SELECT * FROM bug_reports WHERE id = ?", + [reportId] + ); + if ((reportRows as unknown[]).length === 0) return null; + + const report = reportRows[0] as BugReport; + + const [fileRows] = await pool.execute( + "SELECT * FROM bug_report_files WHERE report_id = ?", + [reportId] + ); + const files = fileRows as BugReportFile[]; + + const zip = new JSZip(); + + const reportText = [ + `Bug Report #${report.id}`, + `========================`, + ``, + `Name: ${report.name}`, + `Email: ${report.email}`, + `Hostname: ${report.hostname}`, + `OS User: ${report.os_user}`, + `HWID: ${report.hwid}`, + `IP: ${report.submitter_ip}`, + `Status: ${report.status}`, + `Created: ${report.created_at.toISOString()}`, + `Updated: ${report.updated_at.toISOString()}`, + ``, + `Description:`, + `------------`, + report.description, + ``, + ...(report.system_info + ? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)] + : []), + ].join("\n"); + + zip.file("report.txt", reportText); + + for (const file of files) { + zip.file(`${file.file_role}/${file.filename}`, file.data as Buffer); + } + + return zip.generateAsync({ type: "nodebuffer" }) as Promise; +} + export async function getBugReport( id: number ): Promise<{ report: BugReport; files: Omit[] } | null> { diff --git a/src/services/userService.ts b/src/services/userService.ts new file mode 100644 index 0000000..fe4972e --- /dev/null +++ b/src/services/userService.ts @@ -0,0 +1,120 @@ +import { hash } from "@node-rs/argon2"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import { randomUUID } from "crypto"; +import { getPool } from "../db/connection"; + +const ARGON2_OPTIONS = { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, +} as const; + +export interface User { + id: string; + username: string; + displayname: string; + role: "admin" | "user"; + enabled: boolean; + created_at: Date; +} + +export async function listUsers(): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT id, username, displayname, role, enabled, created_at FROM `user` ORDER BY created_at ASC" + ); + return rows as User[]; +} + +export async function createUser(data: { + username: string; + displayname: string; + password: string; + role: "admin" | "user"; +}): Promise { + const pool = getPool(); + + // Check for duplicate username + const [existing] = await pool.execute( + "SELECT id FROM `user` WHERE username = ? LIMIT 1", + [data.username] + ); + if ((existing as unknown[]).length > 0) { + throw new Error("Username already exists"); + } + + const passwordHash = await hash(data.password, ARGON2_OPTIONS); + const id = randomUUID(); + + await pool.execute( + "INSERT INTO `user` (id, username, displayname, password_hash, role) VALUES (?, ?, ?, ?, ?)", + [id, data.username, data.displayname, passwordHash, data.role] + ); + + const [rows] = await pool.execute( + "SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ?", + [id] + ); + return (rows as User[])[0]; +} + +export async function updateUser( + id: string, + data: { displayname?: string; enabled?: boolean } +): Promise { + const pool = getPool(); + + const fields: string[] = []; + const params: unknown[] = []; + + if (data.displayname !== undefined) { + fields.push("displayname = ?"); + params.push(data.displayname); + } + if (data.enabled !== undefined) { + fields.push("enabled = ?"); + params.push(data.enabled); + } + + if (fields.length === 0) return false; + + params.push(id); + const [result] = await pool.execute( + `UPDATE \`user\` SET ${fields.join(", ")} WHERE id = ?`, + params + ); + return result.affectedRows > 0; +} + +export async function resetPassword( + id: string, + newPassword: string +): Promise { + const pool = getPool(); + const passwordHash = await hash(newPassword, ARGON2_OPTIONS); + const [result] = await pool.execute( + "UPDATE `user` SET password_hash = ? WHERE id = ?", + [passwordHash, id] + ); + return result.affectedRows > 0; +} + +export async function deleteUser(id: string): Promise { + const pool = getPool(); + const [result] = await pool.execute( + "DELETE FROM `user` WHERE id = ?", + [id] + ); + return result.affectedRows > 0; +} + +export async function getUserById(id: string): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ? LIMIT 1", + [id] + ); + if ((rows as unknown[]).length === 0) return null; + return (rows as User[])[0]; +} From 3f15edae758bd1cc494b6980d53de77b41363b36 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Mon, 2 Mar 2026 19:30:59 +0100 Subject: [PATCH 2/4] Refactor middleware and routes to use onRequest for API key and admin key guards; update dependencies and improve logging for error handling --- package.json | 7 +++-- src/index.ts | 1 + src/logger.ts | 14 ++++++++- src/middleware/auth.ts | 60 ++++++++++++++++++------------------- src/middleware/rateLimit.ts | 2 +- src/routes/admin.ts | 42 +++++++++++++------------- src/routes/auth.ts | 2 +- src/routes/bugReports.ts | 2 +- 8 files changed, 72 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index c6d939b..ebcafd3 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,18 @@ "private": true, "scripts": { "dev": "bun run --watch src/index.ts", + "dev:wait": "bun run --watch src/wait-for-mysql.ts", "start": "bun run src/index.ts", "start:wait": "bun run src/wait-for-mysql.ts" }, "dependencies": { "@node-rs/argon2": "^2.0.2", - "elysia": "^1.2.0", + "elysia": "^1.4.27", "jszip": "^3.10.1", - "mysql2": "^3.11.0" + "mysql2": "^3.18.2" }, "devDependencies": { "@types/bun": "latest", - "typescript": "^5.0.0" + "typescript": "^5.9.3" } } diff --git a/src/index.ts b/src/index.ts index e16fcee..bc67232 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ const app = new Elysia() }) .onError(({ error, set, code }) => { console.error("Error processing request:", error); + console.log(code) if (code === "NOT_FOUND") { set.status = 404; return { success: false, message: "Not found" }; diff --git a/src/logger.ts b/src/logger.ts index bc953a4..0aa489d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -25,7 +25,19 @@ export function Log(source: string, ...args: unknown[]): void { const date = now.toISOString().slice(0, 10); const time = now.toTimeString().slice(0, 8); const msg = args - .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))) + .map((a) => { + if (a instanceof Error) { + return `${a.message}${a.stack ? "\n" + a.stack : ""}`; + } + if (typeof a === "object") { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + } + return String(a); + }) .join(" "); const line = `[${date}] - [${time}] - [${source}] - ${msg}`; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index df1e2b6..0a36488 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,35 +1,35 @@ -import { Elysia } from "elysia"; import { config } from "../config"; import { Log } from "../logger"; -export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive( - { as: "scoped" }, - ({ headers, error, request }) => { - const key = headers["x-api-key"]; - if (!key || key !== config.apiKey) { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown"; - Log("AUTH", `Invalid API key from ip=${ip}`); - return error(401, { success: false, message: "Invalid or missing API key" }); - } - return {}; - } -); +// simple middleware functions that enforce API or admin keys +export function apiKeyGuard(ctx: { request?: Request; set: any }) { + const request = ctx.request; + if (!request) return; // nothing to validate at setup time -export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive( - { as: "scoped" }, - ({ headers, error, request }) => { - const key = headers["x-admin-key"]; - if (!key || key !== config.adminKey) { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown"; - Log("AUTH", `Invalid admin key from ip=${ip}`); - return error(401, { success: false, message: "Invalid or missing admin key" }); - } - return {}; + const key = request.headers.get("x-api-key"); + if (!key || key !== config.apiKey) { + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + Log("AUTH", `Invalid API key from ip=${ip}`); + ctx.set.status = 401; + return { success: false, message: "Invalid or missing API key" }; } -); +} + +export function adminKeyGuard(ctx: { request?: Request; set: any }) { + const request = ctx.request; + if (!request) return; + + const key = request.headers.get("x-admin-key"); + if (!key || key !== config.adminKey) { + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + Log("AUTH", `Invalid admin key from ip=${ip}`); + ctx.set.status = 401; + return { success: false, message: "Invalid or missing admin key" }; + } +} diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 70921d8..cb16ed7 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -8,7 +8,7 @@ const excludedHwids = new Set([ "95e025d1-7567-462e-9354-ac88b965cd22", ]); -export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive( +export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHandle( { as: "scoped" }, // @ts-ignore async ({ body, error }) => { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 94b6a99..4b6461c 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -21,7 +21,7 @@ import { Log } from "../logger"; import type { BugReportStatus } from "../types"; export const adminRoutes = new Elysia({ prefix: "/api/admin" }) - .use(adminKeyGuard) + .onRequest(adminKeyGuard) .get( "/bug-reports", async ({ query }) => { @@ -60,10 +60,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .get( "/bug-reports/:id", - async ({ params, error }) => { + async ({ params, status }) => { Log("ADMIN", `Get bug report id=${params.id}`); const result = await getBugReport(parseInt(params.id)); - if (!result) return error(404, { success: false, message: "Report not found" }); + if (!result) return status(404, { success: false, message: "Report not found" }); return result; }, { @@ -73,14 +73,14 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .patch( "/bug-reports/:id/status", - async ({ params, body, error }) => { + async ({ params, body, status }) => { Log("ADMIN", `Update status id=${params.id} status=${body.status}`); const updated = await updateBugReportStatus( parseInt(params.id), body.status ); if (!updated) - return error(404, { success: false, message: "Report not found" }); + return status(404, { success: false, message: "Report not found" }); return { success: true, message: "Status updated" }; }, { @@ -98,10 +98,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .get( "/bug-reports/:id/files/:fileId", - async ({ params, error, set }) => { + async ({ params, status, set }) => { const file = await getFile(parseInt(params.id), parseInt(params.fileId)); if (!file) - return error(404, { success: false, message: "File not found" }); + return status(404, { success: false, message: "File not found" }); set.headers["content-type"] = file.mime_type; set.headers["content-disposition"] = @@ -115,11 +115,11 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .get( "/bug-reports/:id/download", - async ({ params, error, set }) => { + async ({ params, status, set }) => { Log("ADMIN", `Download zip for report id=${params.id}`); const zipBuffer = await generateReportZip(parseInt(params.id)); if (!zipBuffer) - return error(404, { success: false, message: "Report not found" }); + return status(404, { success: false, message: "Report not found" }); set.headers["content-type"] = "application/zip"; set.headers["content-disposition"] = @@ -134,11 +134,11 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .delete( "/bug-reports/:id", - async ({ params, error }) => { + async ({ params, status }) => { Log("ADMIN", `Delete bug report id=${params.id}`); const deleted = await deleteBugReport(parseInt(params.id)); if (!deleted) - return error(404, { success: false, message: "Report not found" }); + return status(404, { success: false, message: "Report not found" }); return { success: true, message: "Report deleted" }; }, { @@ -157,14 +157,14 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .post( "/users", - async ({ body, error }) => { + async ({ body, status }) => { Log("ADMIN", `Create user username=${body.username}`); try { const user = await createUser(body); return { success: true, user }; } catch (err) { if (err instanceof Error && err.message === "Username already exists") { - return error(409, { success: false, message: err.message }); + return status(409, { success: false, message: err.message }); } throw err; } @@ -181,11 +181,11 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .patch( "/users/:id", - async ({ params, body, error }) => { + async ({ params, body, status }) => { Log("ADMIN", `Update user id=${params.id}`); const updated = await updateUser(params.id, body); if (!updated) - return error(404, { success: false, message: "User not found" }); + return status(404, { success: false, message: "User not found" }); return { success: true, message: "User updated" }; }, { @@ -199,11 +199,11 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .post( "/users/:id/reset-password", - async ({ params, body, error }) => { + async ({ params, body, status }) => { Log("ADMIN", `Reset password for user id=${params.id}`); const updated = await resetPassword(params.id, body.password); if (!updated) - return error(404, { success: false, message: "User not found" }); + return status(404, { success: false, message: "User not found" }); return { success: true, message: "Password reset" }; }, { @@ -214,18 +214,18 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ) .delete( "/users/:id", - async ({ params, error }) => { + async ({ params, status }) => { Log("ADMIN", `Delete user id=${params.id}`); const user = await getUserById(params.id); if (!user) - return error(404, { success: false, message: "User not found" }); + throw status(404, { success: false, message: "User not found" }); if (user.role === "admin") - return error(400, { success: false, message: "Cannot delete an admin user" }); + return status(400, { success: false, message: "Cannot delete an admin user" }); const deleted = await deleteUser(params.id); if (!deleted) - return error(404, { success: false, message: "User not found" }); + return status(404, { success: false, message: "User not found" }); return { success: true, message: "User deleted" }; }, { diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 6ce68a7..4e85aea 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -4,7 +4,7 @@ import { loginUser, validateSession, logoutSession } from "../services/authServi import { Log } from "../logger"; export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) - .use(adminKeyGuard) + .onRequest(adminKeyGuard) .post( "/login", async ({ body, error }) => { diff --git a/src/routes/bugReports.ts b/src/routes/bugReports.ts index 23fb604..0b05ce3 100644 --- a/src/routes/bugReports.ts +++ b/src/routes/bugReports.ts @@ -13,7 +13,7 @@ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ ]; export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) - .use(apiKeyGuard) + .onRequest(apiKeyGuard) .use(hwidRateLimit) .post( "/", From 9458d1e8ad475a6c8f9ce2fcfcc8f6313132eec2 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Mon, 2 Mar 2026 23:15:15 +0100 Subject: [PATCH 3/4] Introduces configurable test database for bug reports Enables switching between production and testing MySQL databases based on the `ENABLE_TEST_DB` environment variable and an `X-DB-ENV` request header. Applies this dual database functionality primarily to bug report submission and administration features. New `TESTING_MYSQL_` environment variables are added for defining test database credentials. Refines HTTP request logging by excluding health checks and admin session validation endpoints to reduce noise. Allows `/health` endpoints to bypass API and Admin key guards. Temporarily disables HWID-based rate limiting for bug report submissions. --- .env.example | 18 ++++- src/config.ts | 13 +++- src/db/connection.ts | 31 +++++++- src/index.ts | 22 ++++-- src/middleware/auth.ts | 9 ++- src/middleware/rateLimit.ts | 15 ++-- src/routes/admin.ts | 98 ++++++++++++++++-------- src/routes/auth.ts | 37 ++++++--- src/routes/bugReports.ts | 64 ++++++++++------ src/services/bugReportService.ts | 126 ++++++++++++++++++------------- src/types/index.ts | 2 + 11 files changed, 297 insertions(+), 138 deletions(-) diff --git a/.env.example b/.env.example index 8f91e16..f05d2ee 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# MySQL +# MySQL Production DB MYSQL_HOST=mysql MYSQL_PORT=3306 MYSQL_USER=emly @@ -6,10 +6,21 @@ MYSQL_PASSWORD=change_me_in_production MYSQL_DATABASE=emly_bugreports MYSQL_ROOT_PASSWORD=change_root_password +# MySQL Testing DB (if ENABLE_TEST_DB is true) +TESTING_MYSQL_HOST=mysql +TESTING_MYSQL_PORT=3306 +TESTING_MYSQL_USER=emly +TESTING_MYSQL_PASSWORD=change_me_in_production +TESTING_MYSQL_DATABASE=emly_bugreports +TESTING_MYSQL_ROOT_PASSWORD=change_root_password + # API Keys API_KEY=change_me_client_key ADMIN_KEY=change_me_admin_key +# Hostname +HOSTNAME=amazing-kobold + # Server PORT=3000 DASHBOARD_PORT=3001 @@ -18,6 +29,9 @@ DASHBOARD_PORT=3001 RATE_LIMIT_MAX=5 RATE_LIMIT_WINDOW_HOURS=24 +# Test DB flag +ENABLE_TEST_DB = false + # Cloudflare Tunnel CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token -CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token \ No newline at end of file +CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token diff --git a/src/config.ts b/src/config.ts index ec1932b..badb743 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,13 @@ export const config = { password: process.env.MYSQL_PASSWORD || "", database: process.env.MYSQL_DATABASE || "emly_bugreports", }, + testing_mysql: { + host: process.env.TESTING_MYSQL_HOST || "localhost", + port: parseInt(process.env.TESTING_MYSQL_PORT || "3306"), + user: process.env.TESTING_MYSQL_USER || "emly", + password: process.env.TESTING_MYSQL_PASSWORD || "", + database: process.env.TESTING_MYSQL_DATABASE || "emly_bugreports", + }, apiKey: process.env.API_KEY || "", adminKey: process.env.ADMIN_KEY || "", port: parseInt(process.env.PORT || "3000"), @@ -13,12 +20,14 @@ export const config = { max: parseInt(process.env.RATE_LIMIT_MAX || "5"), windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"), }, + enableTestDB: process.env.ENABLE_TEST_DB === "true", } as const; // Validate required config on startup export function validateConfig(): void { if (!config.apiKey) throw new Error("API_KEY is required"); if (!config.adminKey) throw new Error("ADMIN_KEY is required"); - if (!config.mysql.password) - throw new Error("MYSQL_PASSWORD is required"); + if (!config.mysql.password) throw new Error("MYSQL_PASSWORD is required"); + if (!config.testing_mysql.password && config.enableTestDB) + throw new Error("TESTING_MYSQL_PASSWORD is required"); } diff --git a/src/db/connection.ts b/src/db/connection.ts index 6966d0a..2c24447 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -1,10 +1,25 @@ import mysql from "mysql2/promise"; import { config } from "../config"; +import { Log } from "../logger"; let pool: mysql.Pool | null = null; -export function getPool(): mysql.Pool { +export function getPool(useTestDb?: boolean): mysql.Pool { if (!pool) { + if (useTestDb && config.enableTestDB) { + Log("db", "using test db"); + return mysql.createPool({ + host: config.testing_mysql.host, + port: config.testing_mysql.port, + user: config.testing_mysql.user, + password: config.testing_mysql.password, + database: config.testing_mysql.database, + waitForConnections: true, + connectionLimit: 10, + maxIdle: 5, + idleTimeout: 60000, + }); + } pool = mysql.createPool({ host: config.mysql.host, port: config.mysql.port, @@ -17,6 +32,20 @@ export function getPool(): mysql.Pool { idleTimeout: 60000, }); } + if (useTestDb && config.enableTestDB) { + Log("db", "using test db"); + return mysql.createPool({ + host: config.testing_mysql.host, + port: config.testing_mysql.port, + user: config.testing_mysql.user, + password: config.testing_mysql.password, + database: config.testing_mysql.database, + waitForConnections: true, + connectionLimit: 10, + maxIdle: 5, + idleTimeout: 60000, + }); + } return pool; } diff --git a/src/index.ts b/src/index.ts index bc67232..3c75bed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import { adminRoutes } from "./routes/admin"; import { authRoutes } from "./routes/auth"; import { initLogger, Log } from "./logger"; -const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); +const INSTANCE_ID = + process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); // Initialize logger initLogger(); @@ -30,15 +31,20 @@ const app = new Elysia() request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; - Log("HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`); + if (url.pathname !== "/api/admin/auth/validate") + Log( + "HTTP", + `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`, + ); }) .onAfterResponse(({ request, set }) => { const url = new URL(request.url); - Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`); + if (url.pathname !== "/api/admin/auth/validate") + Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`); }) .onError(({ error, set, code }) => { console.error("Error processing request:", error); - console.log(code) + console.log(code); if (code === "NOT_FOUND") { set.status = 404; return { success: false, message: "Not found" }; @@ -51,7 +57,11 @@ const app = new Elysia() set.status = 500; return { success: false, message: "Internal server error" }; }) - .get("/health", () => ({ status: "ok", instance: INSTANCE_ID, timestamp: new Date().toISOString() })) + .get("/health", () => ({ + status: "ok", + instance: INSTANCE_ID, + timestamp: new Date().toISOString(), + })) .get("/", () => ({ status: "ok", message: "API is running" })) .use(bugReportRoutes) .use(authRoutes) @@ -64,7 +74,7 @@ const app = new Elysia() Log( "SERVER", - `EMLy Bug Report API running on http://localhost:${app.server?.port}` + `EMLy Bug Report API running on http://localhost:${app.server?.port}`, ); // Graceful shutdown diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 0a36488..aa8b80a 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -6,13 +6,15 @@ export function apiKeyGuard(ctx: { request?: Request; set: any }) { const request = ctx.request; if (!request) return; // nothing to validate at setup time + if (request.url.includes("/health")) return; + const key = request.headers.get("x-api-key"); if (!key || key !== config.apiKey) { const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; - Log("AUTH", `Invalid API key from ip=${ip}`); + Log("AUTH-API-KEYGUARD", `Invalid API key from ip=${ip}`); ctx.set.status = 401; return { success: false, message: "Invalid or missing API key" }; } @@ -22,13 +24,16 @@ export function adminKeyGuard(ctx: { request?: Request; set: any }) { const request = ctx.request; if (!request) return; + if (request.url.includes("/health")) return; + if (request.url.includes("/bug-reports")) return; + const key = request.headers.get("x-admin-key"); if (!key || key !== config.adminKey) { const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; - Log("AUTH", `Invalid admin key from ip=${ip}`); + Log("AUTH-ADMIN-KEYGUARD", `Invalid admin key from ip=${ip}`); ctx.set.status = 401; return { success: false, message: "Invalid or missing admin key" }; } diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index cb16ed7..674a4b2 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -6,9 +6,12 @@ import { Log } from "../logger"; const excludedHwids = new Set([ // Add HWIDs here for development testing "95e025d1-7567-462e-9354-ac88b965cd22", + "50973d98-7dce-4496-9f9a-fee21655d38a", ]); -export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHandle( +export const hwidRateLimit = new Elysia({ + name: "hwid-rate-limit", +}).onBeforeHandle( { as: "scoped" }, // @ts-ignore async ({ body, error }) => { @@ -25,7 +28,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // Get current rate limit entry const [rows] = await pool.execute( "SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?", - [hwid] + [hwid], ); const entries = rows as { window_start: Date; count: number }[]; @@ -34,7 +37,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // First request from this HWID await pool.execute( "INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)", - [hwid, now] + [hwid, now], ); return {}; } @@ -47,7 +50,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // Window expired, reset await pool.execute( "UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?", - [now, hwid] + [now, hwid], ); return {}; } @@ -65,8 +68,8 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan // Increment count await pool.execute( "UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?", - [hwid] + [hwid], ); return {}; - } + }, ); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 4b6461c..9ab472b 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -18,20 +18,34 @@ import { getUserById, } from "../services/userService"; import { Log } from "../logger"; -import type { BugReportStatus } from "../types"; +import type { BugReportStatus, DbEnv } from "../types"; export const adminRoutes = new Elysia({ prefix: "/api/admin" }) .onRequest(adminKeyGuard) .get( "/bug-reports", - async ({ query }) => { + async ({ query, headers }) => { const page = parseInt(query.page || "1"); const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); const status = query.status as BugReportStatus | undefined; const search = query.search || undefined; + const useTestDb: boolean = headers["x-db-env"] !== "prod" ? true : false; - Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`); - return await listBugReports({ page, pageSize, status, search }); + if (useTestDb) Log("ADMIN", `Fetching bug reports from test database`); + + Log( + "ADMIN", + `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`, + ); + return await listBugReports( + { + page, + pageSize, + status, + search, + }, + useTestDb, + ); }, { query: t.Object({ @@ -43,41 +57,48 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) t.Literal("in_review"), t.Literal("resolved"), t.Literal("closed"), - ]) + ]), ), search: t.Optional(t.String()), }), detail: { summary: "List bug reports (paginated, filterable)" }, - } + }, ) .get( "/bug-reports/count", - async () => { - const count = await countNewReports(); + async ({ headers }) => { + const count = await countNewReports( + headers["x-db-env"] !== "prod" ? true : false, + ); return { count }; }, - { detail: { summary: "Count new bug reports" } } + { detail: { summary: "Count new bug reports" } }, ) .get( "/bug-reports/:id", - async ({ params, status }) => { + async ({ params, status, headers }) => { Log("ADMIN", `Get bug report id=${params.id}`); - const result = await getBugReport(parseInt(params.id)); - if (!result) return status(404, { success: false, message: "Report not found" }); + const result = await getBugReport( + parseInt(params.id), + headers["x-db-env"] !== "prod" ? true : false, + ); + if (!result) + return status(404, { success: false, message: "Report not found" }); return result; }, { params: t.Object({ id: t.String() }), detail: { summary: "Get bug report with file metadata" }, - } + }, ) .patch( "/bug-reports/:id/status", - async ({ params, body, status }) => { + async ({ params, body, status, headers }) => { Log("ADMIN", `Update status id=${params.id} status=${body.status}`); const updated = await updateBugReportStatus( parseInt(params.id), - body.status + body.status, + headers["x-db-env"] !== "prod" ? true : false, ); if (!updated) return status(404, { success: false, message: "Report not found" }); @@ -94,12 +115,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ]), }), detail: { summary: "Update bug report status" }, - } + }, ) .get( "/bug-reports/:id/files/:fileId", - async ({ params, status, set }) => { - const file = await getFile(parseInt(params.id), parseInt(params.fileId)); + async ({ params, status, set, headers }) => { + const file = await getFile( + parseInt(params.id), + parseInt(params.fileId), + headers["x-db-env"] !== "prod" ? true : false, + ); if (!file) return status(404, { success: false, message: "File not found" }); @@ -111,13 +136,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String(), fileId: t.String() }), detail: { summary: "Download a bug report file" }, - } + }, ) .get( "/bug-reports/:id/download", - async ({ params, status, set }) => { + async ({ params, status, set, headers }) => { Log("ADMIN", `Download zip for report id=${params.id}`); - const zipBuffer = await generateReportZip(parseInt(params.id)); + const zipBuffer = await generateReportZip( + parseInt(params.id), + headers["x-db-env"] !== "prod" ? true : false, + ); if (!zipBuffer) return status(404, { success: false, message: "Report not found" }); @@ -130,13 +158,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String() }), detail: { summary: "Download all files for a bug report as ZIP" }, - } + }, ) .delete( "/bug-reports/:id", - async ({ params, status }) => { + async ({ params, status, headers }) => { Log("ADMIN", `Delete bug report id=${params.id}`); - const deleted = await deleteBugReport(parseInt(params.id)); + const deleted = await deleteBugReport( + parseInt(params.id), + headers["x-db-env"] !== "prod" ? true : false, + ); if (!deleted) return status(404, { success: false, message: "Report not found" }); return { success: true, message: "Report deleted" }; @@ -144,16 +175,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String() }), detail: { summary: "Delete a bug report and its files" }, - } + }, ) // User management .get( "/users", - async () => { + async ({ headers }) => { Log("ADMIN", "List users"); return await listUsers(); }, - { detail: { summary: "List all users" } } + { detail: { summary: "List all users" } }, ) .post( "/users", @@ -177,7 +208,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) role: t.Union([t.Literal("admin"), t.Literal("user")]), }), detail: { summary: "Create a new user" }, - } + }, ) .patch( "/users/:id", @@ -195,7 +226,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) enabled: t.Optional(t.Boolean()), }), detail: { summary: "Update user displayname or enabled status" }, - } + }, ) .post( "/users/:id/reset-password", @@ -210,7 +241,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) params: t.Object({ id: t.String() }), body: t.Object({ password: t.String({ minLength: 1 }) }), detail: { summary: "Reset a user's password" }, - } + }, ) .delete( "/users/:id", @@ -221,7 +252,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) if (!user) throw status(404, { success: false, message: "User not found" }); if (user.role === "admin") - return status(400, { success: false, message: "Cannot delete an admin user" }); + return status(400, { + success: false, + message: "Cannot delete an admin user", + }); const deleted = await deleteUser(params.id); if (!deleted) @@ -231,5 +265,5 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) { params: t.Object({ id: t.String() }), detail: { summary: "Delete a user (non-admin only)" }, - } + }, ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 4e85aea..2e037b3 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,19 +1,30 @@ import { Elysia, t } from "elysia"; import { adminKeyGuard } from "../middleware/auth"; -import { loginUser, validateSession, logoutSession } from "../services/authService"; +import { + loginUser, + validateSession, + logoutSession, +} from "../services/authService"; import { Log } from "../logger"; export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) .onRequest(adminKeyGuard) .post( "/login", - async ({ body, error }) => { + async ({ body, status }) => { const result = await loginUser(body.username, body.password); if (!result) { Log("AUTH", `Login failed for username=${body.username}`); - return error(401, { success: false, message: "Invalid credentials or account disabled" }); + return status(401, { + success: false, + message: "Invalid credentials or account disabled", + }); } - return { success: true, session_id: result.session_id, user: result.user }; + return { + success: true, + session_id: result.session_id, + user: result.user, + }; }, { body: t.Object({ @@ -21,7 +32,7 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) password: t.String({ minLength: 1 }), }), detail: { summary: "Login with username/password" }, - } + }, ) .post( "/logout", @@ -34,22 +45,28 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) }, { detail: { summary: "Logout and invalidate session" }, - } + }, ) .get( "/validate", - async ({ headers, error }) => { + async ({ headers, status }) => { const sessionId = headers["x-session-token"]; if (!sessionId) { - return error(401, { success: false, message: "No session token provided" }); + return status(401, { + success: false, + message: "No session token provided", + }); } const user = await validateSession(sessionId); if (!user) { - return error(401, { success: false, message: "Invalid or expired session" }); + return status(401, { + success: false, + message: "Invalid or expired session", + }); } return { success: true, user }; }, { detail: { summary: "Validate session and return user" }, - } + }, ); diff --git a/src/routes/bugReports.ts b/src/routes/bugReports.ts index 0b05ce3..9092eeb 100644 --- a/src/routes/bugReports.ts +++ b/src/routes/bugReports.ts @@ -14,11 +14,15 @@ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) .onRequest(apiKeyGuard) - .use(hwidRateLimit) + //.use(hwidRateLimit) .post( "/", - async ({ body, request, set }) => { - const { name, email, description, hwid, hostname, os_user, system_info } = body; + async ({ body, request, set, headers }) => { + console.log(headers); + const { name, email, description, hwid, hostname, os_user, system_info } = + body; + const useTestDB = headers["x-db-env"] !== "prod"; + console.log("Creating a bug report in the test DB..."); // Get submitter IP from headers or connection const submitterIp = @@ -26,7 +30,10 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) request.headers.get("x-real-ip") || "unknown"; - Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`); + Log( + "BUGREPORT", + `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`, + ); // Parse system_info - may arrive as a JSON string or already-parsed object let systemInfo: Record | null = null; @@ -43,31 +50,40 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) } // Create the bug report - const reportId = await createBugReport({ - name, - email, - description, - hwid: hwid || "", - hostname: hostname || "", - os_user: os_user || "", - submitter_ip: submitterIp, - system_info: systemInfo, - }); + const reportId = await createBugReport( + { + name, + email, + description, + hwid: hwid || "", + hostname: hostname || "", + os_user: os_user || "", + submitter_ip: submitterIp, + system_info: systemInfo, + }, + useTestDB, + ); // Process file uploads for (const { field, role, mime } of FILE_ROLES) { const file = body[field as keyof typeof body]; if (file && file instanceof File) { const buffer = Buffer.from(await file.arrayBuffer()); - Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`); - await addFile({ - report_id: reportId, - file_role: role, - filename: file.name || `${field}.bin`, - mime_type: file.type || mime, - file_size: buffer.length, - data: buffer, - }); + Log( + "BUGREPORT", + `File uploaded: role=${role} size=${buffer.length} bytes`, + ); + await addFile( + { + report_id: reportId, + file_role: role, + filename: file.name || `${field}.bin`, + mime_type: file.type || mime, + file_size: buffer.length, + data: buffer, + }, + useTestDB, + ); } } @@ -103,5 +119,5 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) }), }, detail: { summary: "Submit a bug report" }, - } + }, ); diff --git a/src/services/bugReportService.ts b/src/services/bugReportService.ts index 13b0fbc..a985a38 100644 --- a/src/services/bugReportService.ts +++ b/src/services/bugReportService.ts @@ -10,17 +10,20 @@ import type { PaginatedResponse, } from "../types"; -export async function createBugReport(data: { - name: string; - email: string; - description: string; - hwid: string; - hostname: string; - os_user: string; - submitter_ip: string; - system_info: Record | null; -}): Promise { - const pool = getPool(); +export async function createBugReport( + data: { + name: string; + email: string; + description: string; + hwid: string; + hostname: string; + os_user: string; + submitter_ip: string; + system_info: Record | null; + }, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( `INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, @@ -33,20 +36,23 @@ export async function createBugReport(data: { data.os_user, data.submitter_ip, data.system_info ? JSON.stringify(data.system_info) : null, - ] + ], ); return result.insertId; } -export async function addFile(data: { - report_id: number; - file_role: FileRole; - filename: string; - mime_type: string; - file_size: number; - data: Buffer; -}): Promise { - const pool = getPool(); +export async function addFile( + data: { + report_id: number; + file_role: FileRole; + filename: string; + mime_type: string; + file_size: number; + data: Buffer; + }, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( `INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data) VALUES (?, ?, ?, ?, ?, ?)`, @@ -57,18 +63,21 @@ export async function addFile(data: { data.mime_type, data.file_size, data.data, - ] + ], ); return result.insertId; } -export async function listBugReports(opts: { - page: number; - pageSize: number; - status?: BugReportStatus; - search?: string; -}): Promise> { - const pool = getPool(); +export async function listBugReports( + opts: { + page: number; + pageSize: number; + status?: BugReportStatus; + search?: string; + }, + useTestDb?: boolean, +): Promise> { + const pool = getPool(useTestDb ? true : false); const { page, pageSize, status, search } = opts; const offset = (page - 1) * pageSize; @@ -83,7 +92,7 @@ export async function listBugReports(opts: { if (search) { const like = `%${search}%`; conditions.push( - "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)" + "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)", ); params.push(like, like, like, like); } @@ -93,7 +102,6 @@ export async function listBugReports(opts: { const [countRows] = await pool.execute( `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`, - params ); const total = (countRows[0] as { total: number }).total; @@ -105,7 +113,6 @@ export async function listBugReports(opts: { GROUP BY br.id ORDER BY br.created_at DESC LIMIT ${pageSize} OFFSET ${offset}`, - params ); return { @@ -117,20 +124,23 @@ export async function listBugReports(opts: { }; } -export async function countNewReports(): Promise { - const pool = getPool(); +export async function countNewReports(useTestDb?: boolean): Promise { + const pool = getPool(useTestDb ? true : false); const [rows] = await pool.execute( - "SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'" + "SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'", ); return (rows[0] as { count: number }).count; } -export async function generateReportZip(reportId: number): Promise { - const pool = getPool(); +export async function generateReportZip( + reportId: number, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [reportRows] = await pool.execute( "SELECT * FROM bug_reports WHERE id = ?", - [reportId] + [reportId], ); if ((reportRows as unknown[]).length === 0) return null; @@ -138,7 +148,7 @@ export async function generateReportZip(reportId: number): Promise( "SELECT * FROM bug_report_files WHERE report_id = ?", - [reportId] + [reportId], ); const files = fileRows as BugReportFile[]; @@ -163,7 +173,11 @@ export async function generateReportZip(reportId: number): Promise[] } | null> { - const pool = getPool(); + const pool = getPool(useTestDb ? true : false); const [reportRows] = await pool.execute( "SELECT * FROM bug_reports WHERE id = ?", - [id] + [id], ); if ((reportRows as unknown[]).length === 0) return null; const [fileRows] = await pool.execute( "SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?", - [id] + [id], ); return { @@ -201,35 +216,40 @@ export async function getBugReport( export async function getFile( reportId: number, - fileId: number + fileId: number, + useTestDb?: boolean, ): Promise { - const pool = getPool(); + const pool = getPool(useTestDb ? true : false); const [rows] = await pool.execute( "SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?", - [fileId, reportId] + [fileId, reportId], ); if ((rows as unknown[]).length === 0) return null; return rows[0] as BugReportFile; } -export async function deleteBugReport(id: number): Promise { - const pool = getPool(); +export async function deleteBugReport( + id: number, + useTestDb?: boolean, +): Promise { + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( "DELETE FROM bug_reports WHERE id = ?", - [id] + [id], ); return result.affectedRows > 0; } export async function updateBugReportStatus( id: number, - status: BugReportStatus + status: BugReportStatus, + useTestDb?: boolean, ): Promise { - const pool = getPool(); + const pool = getPool(useTestDb ? true : false); const [result] = await pool.execute( "UPDATE bug_reports SET status = ? WHERE id = ?", - [status, id] + [status, id], ); return result.affectedRows > 0; } diff --git a/src/types/index.ts b/src/types/index.ts index 6b933a4..f2f58f9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,3 +55,5 @@ export interface PaginatedResponse { pageSize: number; totalPages: number; } + +export type DbEnv = "prod" | "test"; From 5624019f23aed06cac071267648b0f887d022728 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Tue, 17 Mar 2026 10:30:42 +0100 Subject: [PATCH 4/4] Enhances API infrastructure with Swagger, feature flags, and refactored middleware Implements @elysiajs/swagger for automated API documentation and introduces a feature flag system to expose service capabilities based on environment variables. Refactors authentication guards into native Elysia scoped middleware for improved integration and type safety. Updates error handling to support custom status codes and adds instance-specific headers to responses for better observability. Includes an IP fallback mechanism for bug reports that utilizes internal system info when the direct submitter IP is unavailable. --- .env.example | 2 +- package.json | 1 + src/config.ts | 2 +- src/db/connection.ts | 4 +-- src/features.json | 7 ++++ src/index.ts | 42 +++++++++++++++++++++-- src/middleware/auth.ts | 58 ++++++++++++-------------------- src/routes/admin.ts | 7 ++-- src/routes/auth.ts | 5 +-- src/routes/bugReports.ts | 5 +-- src/routes/features.ts | 40 ++++++++++++++++++++++ src/services/bugReportService.ts | 12 +++++++ src/types/index.ts | 13 +++++++ 13 files changed, 149 insertions(+), 49 deletions(-) create mode 100644 src/features.json create mode 100644 src/routes/features.ts diff --git a/.env.example b/.env.example index f05d2ee..f50d0b2 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ RATE_LIMIT_MAX=5 RATE_LIMIT_WINDOW_HOURS=24 # Test DB flag -ENABLE_TEST_DB = false +FLAG_ENABLE_TEST_DB = false # Cloudflare Tunnel CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token diff --git a/package.json b/package.json index ebcafd3..2a47309 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start:wait": "bun run src/wait-for-mysql.ts" }, "dependencies": { + "@elysiajs/swagger": "^1.3.1", "@node-rs/argon2": "^2.0.2", "elysia": "^1.4.27", "jszip": "^3.10.1", diff --git a/src/config.ts b/src/config.ts index badb743..f14b134 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,7 +20,7 @@ export const config = { max: parseInt(process.env.RATE_LIMIT_MAX || "5"), windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"), }, - enableTestDB: process.env.ENABLE_TEST_DB === "true", + enableTestDB: process.env.FLAG_ENABLE_TEST_DB === "true", } as const; // Validate required config on startup diff --git a/src/db/connection.ts b/src/db/connection.ts index 2c24447..1468275 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -7,7 +7,7 @@ let pool: mysql.Pool | null = null; export function getPool(useTestDb?: boolean): mysql.Pool { if (!pool) { if (useTestDb && config.enableTestDB) { - Log("db", "using test db"); + Log("DB", "Using Test DB Pool Connection"); return mysql.createPool({ host: config.testing_mysql.host, port: config.testing_mysql.port, @@ -33,7 +33,7 @@ export function getPool(useTestDb?: boolean): mysql.Pool { }); } if (useTestDb && config.enableTestDB) { - Log("db", "using test db"); + Log("DB", "Using Test DB Pool Connection"); return mysql.createPool({ host: config.testing_mysql.host, port: config.testing_mysql.port, diff --git a/src/features.json b/src/features.json new file mode 100644 index 0000000..ed2eb52 --- /dev/null +++ b/src/features.json @@ -0,0 +1,7 @@ +{ + "testDb": { + "label": "Testing Database", + "description": "Accepts bug reports routed to a separate testing database via the x-db-env header", + "key": "FLAG_ENABLE_TEST_DB" + } +} diff --git a/src/index.ts b/src/index.ts index 3c75bed..678b352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,14 @@ import { Elysia } from "elysia"; +import { swagger } from "@elysiajs/swagger"; import { config, validateConfig } from "./config"; import { runMigrations } from "./db/migrate"; import { closePool } from "./db/connection"; import { bugReportRoutes } from "./routes/bugReports"; import { adminRoutes } from "./routes/admin"; import { authRoutes } from "./routes/auth"; +import { featuresRoutes } from "./routes/features"; import { initLogger, Log } from "./logger"; +import { adminKeyGuard2 } from "./middleware/auth"; const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); @@ -14,7 +17,12 @@ const INSTANCE_ID = initLogger(); // Validate environment -validateConfig(); +try { + validateConfig(); +} catch (error) { + Log("ERROR", "Failed to validate config:", error); + process.exit(1); +} // Run database migrations try { @@ -25,8 +33,9 @@ try { } const app = new Elysia() - .onRequest(({ request }) => { + .onRequest(({ request, set }) => { const url = new URL(request.url); + const ua = request.headers.get("user-agent") ?? "unknown"; const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || @@ -36,6 +45,8 @@ const app = new Elysia() "HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`, ); + set.headers["x-instance-id"] = INSTANCE_ID; + set.headers["x-server"] = "EMLy-API"; }) .onAfterResponse(({ request, set }) => { const url = new URL(request.url); @@ -53,6 +64,10 @@ const app = new Elysia() set.status = 422; return { success: false, message: "Validation error" }; } + if (typeof code === "number") { + set.status = code; + return (error as any).response; + } Log("ERROR", "Unhandled error:", error); set.status = 500; return { success: false, message: "Internal server error" }; @@ -63,6 +78,29 @@ const app = new Elysia() timestamp: new Date().toISOString(), })) .get("/", () => ({ status: "ok", message: "API is running" })) + .use( + new Elysia().use(adminKeyGuard2).use( + swagger({ + path: "/swagger", + documentation: { + info: { title: "EMLy Bug Report API", version: "1.0.0" }, + tags: [ + { name: "Bug Reports", description: "Submit bug reports" }, + { name: "Auth", description: "Admin authentication" }, + { name: "Admin", description: "Admin bug report management" }, + { name: "Features", description: "Feature flags" }, + ], + components: { + securitySchemes: { + apiKey: { type: "apiKey", in: "header", name: "x-api-key" }, + adminKey: { type: "apiKey", in: "header", name: "x-admin-key" }, + }, + }, + }, + }), + ), + ) + .use(featuresRoutes) .use(bugReportRoutes) .use(authRoutes) .use(adminRoutes) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index aa8b80a..54b9c68 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,40 +1,26 @@ import { config } from "../config"; import { Log } from "../logger"; +import Elysia from "elysia"; +import type { UnauthorizedResponse } from "../types"; -// simple middleware functions that enforce API or admin keys -export function apiKeyGuard(ctx: { request?: Request; set: any }) { - const request = ctx.request; - if (!request) return; // nothing to validate at setup time +export const apiKeyGuard2 = new Elysia({ name: "api-key-guard" }).derive( + { as: "scoped" }, + ({ headers, status }): UnauthorizedResponse | {} => { + const apiKey = headers["x-api-key"]; + if (!apiKey || apiKey !== config.apiKey) { + throw status(401, { success: false as const, message: "Unauthorized API Key" }); + } + return {}; + }, +); - if (request.url.includes("/health")) return; - - const key = request.headers.get("x-api-key"); - if (!key || key !== config.apiKey) { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown"; - Log("AUTH-API-KEYGUARD", `Invalid API key from ip=${ip}`); - ctx.set.status = 401; - return { success: false, message: "Invalid or missing API key" }; - } -} - -export function adminKeyGuard(ctx: { request?: Request; set: any }) { - const request = ctx.request; - if (!request) return; - - if (request.url.includes("/health")) return; - if (request.url.includes("/bug-reports")) return; - - const key = request.headers.get("x-admin-key"); - if (!key || key !== config.adminKey) { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown"; - Log("AUTH-ADMIN-KEYGUARD", `Invalid admin key from ip=${ip}`); - ctx.set.status = 401; - return { success: false, message: "Invalid or missing admin key" }; - } -} +export const adminKeyGuard2 = new Elysia({ name: "admin-key-guard" }).derive( + { as: "scoped" }, + ({ headers, status }): UnauthorizedResponse | {} => { + const apiKey = headers["x-admin-key"]; + if (!apiKey || apiKey !== config.adminKey) { + throw status(401, { success: false as const, message: "Unauthorized Admin Key" }); + } + return {}; + }, +); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 9ab472b..3fdcae8 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,5 +1,5 @@ import { Elysia, t } from "elysia"; -import { adminKeyGuard } from "../middleware/auth"; +import { adminKeyGuard2 } from "../middleware/auth"; import { listBugReports, getBugReport, @@ -21,7 +21,7 @@ import { Log } from "../logger"; import type { BugReportStatus, DbEnv } from "../types"; export const adminRoutes = new Elysia({ prefix: "/api/admin" }) - .onRequest(adminKeyGuard) + .use(adminKeyGuard2) .get( "/bug-reports", async ({ query, headers }) => { @@ -37,7 +37,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) "ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`, ); - return await listBugReports( + const res = await listBugReports( { page, pageSize, @@ -46,6 +46,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) }, useTestDb, ); + return res; }, { query: t.Object({ diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 2e037b3..a593e6f 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,5 +1,5 @@ import { Elysia, t } from "elysia"; -import { adminKeyGuard } from "../middleware/auth"; +import { adminKeyGuard2 } from "../middleware/auth"; import { loginUser, validateSession, @@ -8,7 +8,8 @@ import { import { Log } from "../logger"; export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) - .onRequest(adminKeyGuard) + //.onRequest(adminKeyGuard) + .use(adminKeyGuard2) .post( "/login", async ({ body, status }) => { diff --git a/src/routes/bugReports.ts b/src/routes/bugReports.ts index 9092eeb..2202d7d 100644 --- a/src/routes/bugReports.ts +++ b/src/routes/bugReports.ts @@ -1,5 +1,5 @@ import { Elysia, t } from "elysia"; -import { apiKeyGuard } from "../middleware/auth"; +import { apiKeyGuard2, adminKeyGuard2 } from "../middleware/auth"; import { hwidRateLimit } from "../middleware/rateLimit"; import { createBugReport, addFile } from "../services/bugReportService"; import { Log } from "../logger"; @@ -13,7 +13,8 @@ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ ]; export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) - .onRequest(apiKeyGuard) + //.onRequest(apiKeyGuard) + .use(apiKeyGuard2) //.use(hwidRateLimit) .post( "/", diff --git a/src/routes/features.ts b/src/routes/features.ts new file mode 100644 index 0000000..84f689d --- /dev/null +++ b/src/routes/features.ts @@ -0,0 +1,40 @@ +import { Elysia, t } from "elysia"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { FeaturesJson, FeaturesRawJson } from "../types"; +import { apiKeyGuard2 } from "../middleware/auth"; + +const featuresPath = join(import.meta.dir, "../features.json"); + +const FeatureSchema = t.Object({ + label: t.String(), + description: t.String(), + enabled: t.Boolean(), +}); + +export const featuresRoutes = new Elysia({ prefix: "/api/features" }) + .use(apiKeyGuard2) + .get( + "/", + () => { + const raw = readFileSync(featuresPath, "utf-8"); + const jsonData: FeaturesRawJson = JSON.parse(raw); + const returnData: FeaturesJson = {}; + for (const key in jsonData) { + // Try to log the feature flag value from the .env + const envKey = jsonData[key].key; + const envValue = process.env[envKey]; + if (envValue !== undefined) { + returnData[key] = { ...jsonData[key], enabled: envValue === "true" }; + } + } + return returnData as Record< + string, + { label: string; description: string; enabled: boolean } + >; + }, + { + response: t.Record(t.String(), FeatureSchema), + detail: { summary: "Get available features and their enabled state" }, + }, + ); diff --git a/src/services/bugReportService.ts b/src/services/bugReportService.ts index a985a38..682d128 100644 --- a/src/services/bugReportService.ts +++ b/src/services/bugReportService.ts @@ -207,6 +207,18 @@ export async function getBugReport( "SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?", [id], ); + // If the report's submitter_ip is "unknown", use the report system_info's InternalIP if available + const report = reportRows[0] as BugReport; + if ( + report.submitter_ip === "unknown" && + report.system_info !== null && + typeof report.system_info === "object" && + "InternalIP" in report.system_info + ) { + report.submitter_ip = report.system_info.InternalIP as string; + } + + console.log("Fetched report:", reportRows[0]); return { report: reportRows[0] as BugReport, diff --git a/src/types/index.ts b/src/types/index.ts index f2f58f9..74ec9d7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,3 +57,16 @@ export interface PaginatedResponse { } export type DbEnv = "prod" | "test"; + +export type UnauthorizedResponse = import("elysia").ElysiaCustomStatusResponse< + 401, + { success: false; message: string } +>; + +export interface FeaturesRawJson { + [key: string]: { label: string; description: string; key: string }; +} + +export interface FeaturesJson { + [key: string]: { label: string; description: string; enabled: boolean }; +}