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.
This commit is contained in:
@@ -30,7 +30,7 @@ RATE_LIMIT_MAX=5
|
|||||||
RATE_LIMIT_WINDOW_HOURS=24
|
RATE_LIMIT_WINDOW_HOURS=24
|
||||||
|
|
||||||
# Test DB flag
|
# Test DB flag
|
||||||
ENABLE_TEST_DB = false
|
FLAG_ENABLE_TEST_DB = false
|
||||||
|
|
||||||
# Cloudflare Tunnel
|
# Cloudflare Tunnel
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token
|
CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"start:wait": "bun run src/wait-for-mysql.ts"
|
"start:wait": "bun run src/wait-for-mysql.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@elysiajs/swagger": "^1.3.1",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"elysia": "^1.4.27",
|
"elysia": "^1.4.27",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const config = {
|
|||||||
max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
|
max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
|
||||||
windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"),
|
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;
|
} as const;
|
||||||
|
|
||||||
// Validate required config on startup
|
// Validate required config on startup
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ let pool: mysql.Pool | null = null;
|
|||||||
export function getPool(useTestDb?: boolean): mysql.Pool {
|
export function getPool(useTestDb?: boolean): mysql.Pool {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
if (useTestDb && config.enableTestDB) {
|
if (useTestDb && config.enableTestDB) {
|
||||||
Log("db", "using test db");
|
Log("DB", "Using Test DB Pool Connection");
|
||||||
return mysql.createPool({
|
return mysql.createPool({
|
||||||
host: config.testing_mysql.host,
|
host: config.testing_mysql.host,
|
||||||
port: config.testing_mysql.port,
|
port: config.testing_mysql.port,
|
||||||
@@ -33,7 +33,7 @@ export function getPool(useTestDb?: boolean): mysql.Pool {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (useTestDb && config.enableTestDB) {
|
if (useTestDb && config.enableTestDB) {
|
||||||
Log("db", "using test db");
|
Log("DB", "Using Test DB Pool Connection");
|
||||||
return mysql.createPool({
|
return mysql.createPool({
|
||||||
host: config.testing_mysql.host,
|
host: config.testing_mysql.host,
|
||||||
port: config.testing_mysql.port,
|
port: config.testing_mysql.port,
|
||||||
|
|||||||
7
src/features.json
Normal file
7
src/features.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/index.ts
40
src/index.ts
@@ -1,11 +1,14 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
|
import { swagger } from "@elysiajs/swagger";
|
||||||
import { config, validateConfig } from "./config";
|
import { config, validateConfig } from "./config";
|
||||||
import { runMigrations } from "./db/migrate";
|
import { runMigrations } from "./db/migrate";
|
||||||
import { closePool } from "./db/connection";
|
import { closePool } from "./db/connection";
|
||||||
import { bugReportRoutes } from "./routes/bugReports";
|
import { bugReportRoutes } from "./routes/bugReports";
|
||||||
import { adminRoutes } from "./routes/admin";
|
import { adminRoutes } from "./routes/admin";
|
||||||
import { authRoutes } from "./routes/auth";
|
import { authRoutes } from "./routes/auth";
|
||||||
|
import { featuresRoutes } from "./routes/features";
|
||||||
import { initLogger, Log } from "./logger";
|
import { initLogger, Log } from "./logger";
|
||||||
|
import { adminKeyGuard2 } from "./middleware/auth";
|
||||||
|
|
||||||
const INSTANCE_ID =
|
const INSTANCE_ID =
|
||||||
process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
|
process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
|
||||||
@@ -14,7 +17,12 @@ const INSTANCE_ID =
|
|||||||
initLogger();
|
initLogger();
|
||||||
|
|
||||||
// Validate environment
|
// Validate environment
|
||||||
|
try {
|
||||||
validateConfig();
|
validateConfig();
|
||||||
|
} catch (error) {
|
||||||
|
Log("ERROR", "Failed to validate config:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Run database migrations
|
// Run database migrations
|
||||||
try {
|
try {
|
||||||
@@ -25,8 +33,9 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.onRequest(({ request }) => {
|
.onRequest(({ request, set }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
const ua = request.headers.get("user-agent") ?? "unknown";
|
||||||
const ip =
|
const ip =
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
request.headers.get("x-real-ip") ||
|
request.headers.get("x-real-ip") ||
|
||||||
@@ -36,6 +45,8 @@ const app = new Elysia()
|
|||||||
"HTTP",
|
"HTTP",
|
||||||
`[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`,
|
`[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`,
|
||||||
);
|
);
|
||||||
|
set.headers["x-instance-id"] = INSTANCE_ID;
|
||||||
|
set.headers["x-server"] = "EMLy-API";
|
||||||
})
|
})
|
||||||
.onAfterResponse(({ request, set }) => {
|
.onAfterResponse(({ request, set }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -53,6 +64,10 @@ const app = new Elysia()
|
|||||||
set.status = 422;
|
set.status = 422;
|
||||||
return { success: false, message: "Validation error" };
|
return { success: false, message: "Validation error" };
|
||||||
}
|
}
|
||||||
|
if (typeof code === "number") {
|
||||||
|
set.status = code;
|
||||||
|
return (error as any).response;
|
||||||
|
}
|
||||||
Log("ERROR", "Unhandled error:", error);
|
Log("ERROR", "Unhandled error:", error);
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { success: false, message: "Internal server error" };
|
return { success: false, message: "Internal server error" };
|
||||||
@@ -63,6 +78,29 @@ const app = new Elysia()
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}))
|
}))
|
||||||
.get("/", () => ({ status: "ok", message: "API is running" }))
|
.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(bugReportRoutes)
|
||||||
.use(authRoutes)
|
.use(authRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
|
|||||||
@@ -1,40 +1,26 @@
|
|||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { Log } from "../logger";
|
import { Log } from "../logger";
|
||||||
|
import Elysia from "elysia";
|
||||||
|
import type { UnauthorizedResponse } from "../types";
|
||||||
|
|
||||||
// simple middleware functions that enforce API or admin keys
|
export const apiKeyGuard2 = new Elysia({ name: "api-key-guard" }).derive(
|
||||||
export function apiKeyGuard(ctx: { request?: Request; set: any }) {
|
{ as: "scoped" },
|
||||||
const request = ctx.request;
|
({ headers, status }): UnauthorizedResponse | {} => {
|
||||||
if (!request) return; // nothing to validate at setup time
|
const apiKey = headers["x-api-key"];
|
||||||
|
if (!apiKey || apiKey !== config.apiKey) {
|
||||||
if (request.url.includes("/health")) return;
|
throw status(401, { success: false as const, message: "Unauthorized API Key" });
|
||||||
|
|
||||||
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" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export function adminKeyGuard(ctx: { request?: Request; set: any }) {
|
export const adminKeyGuard2 = new Elysia({ name: "admin-key-guard" }).derive(
|
||||||
const request = ctx.request;
|
{ as: "scoped" },
|
||||||
if (!request) return;
|
({ headers, status }): UnauthorizedResponse | {} => {
|
||||||
|
const apiKey = headers["x-admin-key"];
|
||||||
if (request.url.includes("/health")) return;
|
if (!apiKey || apiKey !== config.adminKey) {
|
||||||
if (request.url.includes("/bug-reports")) return;
|
throw status(401, { success: false as const, message: "Unauthorized Admin Key" });
|
||||||
|
|
||||||
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" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { adminKeyGuard } from "../middleware/auth";
|
import { adminKeyGuard2 } from "../middleware/auth";
|
||||||
import {
|
import {
|
||||||
listBugReports,
|
listBugReports,
|
||||||
getBugReport,
|
getBugReport,
|
||||||
@@ -21,7 +21,7 @@ import { Log } from "../logger";
|
|||||||
import type { BugReportStatus, DbEnv } from "../types";
|
import type { BugReportStatus, DbEnv } from "../types";
|
||||||
|
|
||||||
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||||
.onRequest(adminKeyGuard)
|
.use(adminKeyGuard2)
|
||||||
.get(
|
.get(
|
||||||
"/bug-reports",
|
"/bug-reports",
|
||||||
async ({ query, headers }) => {
|
async ({ query, headers }) => {
|
||||||
@@ -37,7 +37,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
"ADMIN",
|
"ADMIN",
|
||||||
`List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`,
|
`List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`,
|
||||||
);
|
);
|
||||||
return await listBugReports(
|
const res = await listBugReports(
|
||||||
{
|
{
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -46,6 +46,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
},
|
},
|
||||||
useTestDb,
|
useTestDb,
|
||||||
);
|
);
|
||||||
|
return res;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { adminKeyGuard } from "../middleware/auth";
|
import { adminKeyGuard2 } from "../middleware/auth";
|
||||||
import {
|
import {
|
||||||
loginUser,
|
loginUser,
|
||||||
validateSession,
|
validateSession,
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
import { Log } from "../logger";
|
import { Log } from "../logger";
|
||||||
|
|
||||||
export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
|
export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
|
||||||
.onRequest(adminKeyGuard)
|
//.onRequest(adminKeyGuard)
|
||||||
|
.use(adminKeyGuard2)
|
||||||
.post(
|
.post(
|
||||||
"/login",
|
"/login",
|
||||||
async ({ body, status }) => {
|
async ({ body, status }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { apiKeyGuard } from "../middleware/auth";
|
import { apiKeyGuard2, adminKeyGuard2 } from "../middleware/auth";
|
||||||
import { hwidRateLimit } from "../middleware/rateLimit";
|
import { hwidRateLimit } from "../middleware/rateLimit";
|
||||||
import { createBugReport, addFile } from "../services/bugReportService";
|
import { createBugReport, addFile } from "../services/bugReportService";
|
||||||
import { Log } from "../logger";
|
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" })
|
export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||||
.onRequest(apiKeyGuard)
|
//.onRequest(apiKeyGuard)
|
||||||
|
.use(apiKeyGuard2)
|
||||||
//.use(hwidRateLimit)
|
//.use(hwidRateLimit)
|
||||||
.post(
|
.post(
|
||||||
"/",
|
"/",
|
||||||
|
|||||||
40
src/routes/features.ts
Normal file
40
src/routes/features.ts
Normal file
@@ -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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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 = ?",
|
"SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?",
|
||||||
[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 {
|
return {
|
||||||
report: reportRows[0] as BugReport,
|
report: reportRows[0] as BugReport,
|
||||||
|
|||||||
@@ -57,3 +57,16 @@ export interface PaginatedResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DbEnv = "prod" | "test";
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user