From 5624019f23aed06cac071267648b0f887d022728 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Tue, 17 Mar 2026 10:30:42 +0100 Subject: [PATCH] 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 }; +}