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 }; +}