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.
This commit is contained in:
Flavio Fois
2026-03-02 23:15:15 +01:00
parent 3f15edae75
commit 9458d1e8ad
11 changed files with 297 additions and 138 deletions

View File

@@ -1,4 +1,4 @@
# MySQL # MySQL Production DB
MYSQL_HOST=mysql MYSQL_HOST=mysql
MYSQL_PORT=3306 MYSQL_PORT=3306
MYSQL_USER=emly MYSQL_USER=emly
@@ -6,10 +6,21 @@ MYSQL_PASSWORD=change_me_in_production
MYSQL_DATABASE=emly_bugreports MYSQL_DATABASE=emly_bugreports
MYSQL_ROOT_PASSWORD=change_root_password 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 Keys
API_KEY=change_me_client_key API_KEY=change_me_client_key
ADMIN_KEY=change_me_admin_key ADMIN_KEY=change_me_admin_key
# Hostname
HOSTNAME=amazing-kobold
# Server # Server
PORT=3000 PORT=3000
DASHBOARD_PORT=3001 DASHBOARD_PORT=3001
@@ -18,6 +29,9 @@ DASHBOARD_PORT=3001
RATE_LIMIT_MAX=5 RATE_LIMIT_MAX=5
RATE_LIMIT_WINDOW_HOURS=24 RATE_LIMIT_WINDOW_HOURS=24
# Test DB 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
CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token

View File

@@ -6,6 +6,13 @@ export const config = {
password: process.env.MYSQL_PASSWORD || "", password: process.env.MYSQL_PASSWORD || "",
database: process.env.MYSQL_DATABASE || "emly_bugreports", 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 || "", apiKey: process.env.API_KEY || "",
adminKey: process.env.ADMIN_KEY || "", adminKey: process.env.ADMIN_KEY || "",
port: parseInt(process.env.PORT || "3000"), port: parseInt(process.env.PORT || "3000"),
@@ -13,12 +20,14 @@ 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",
} as const; } as const;
// Validate required config on startup // Validate required config on startup
export function validateConfig(): void { export function validateConfig(): void {
if (!config.apiKey) throw new Error("API_KEY is required"); if (!config.apiKey) throw new Error("API_KEY is required");
if (!config.adminKey) throw new Error("ADMIN_KEY is required"); if (!config.adminKey) throw new Error("ADMIN_KEY is required");
if (!config.mysql.password) if (!config.mysql.password) throw new Error("MYSQL_PASSWORD is required");
throw new Error("MYSQL_PASSWORD is required"); if (!config.testing_mysql.password && config.enableTestDB)
throw new Error("TESTING_MYSQL_PASSWORD is required");
} }

View File

@@ -1,10 +1,25 @@
import mysql from "mysql2/promise"; import mysql from "mysql2/promise";
import { config } from "../config"; import { config } from "../config";
import { Log } from "../logger";
let pool: mysql.Pool | null = null; let pool: mysql.Pool | null = null;
export function getPool(): mysql.Pool { export function getPool(useTestDb?: boolean): mysql.Pool {
if (!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({ pool = mysql.createPool({
host: config.mysql.host, host: config.mysql.host,
port: config.mysql.port, port: config.mysql.port,
@@ -17,6 +32,20 @@ export function getPool(): mysql.Pool {
idleTimeout: 60000, 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; return pool;
} }

View File

@@ -7,7 +7,8 @@ import { adminRoutes } from "./routes/admin";
import { authRoutes } from "./routes/auth"; import { authRoutes } from "./routes/auth";
import { initLogger, Log } from "./logger"; 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 // Initialize logger
initLogger(); initLogger();
@@ -30,15 +31,20 @@ const app = new Elysia()
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") ||
"unknown"; "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 }) => { .onAfterResponse(({ request, set }) => {
const url = new URL(request.url); 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 }) => { .onError(({ error, set, code }) => {
console.error("Error processing request:", error); console.error("Error processing request:", error);
console.log(code) console.log(code);
if (code === "NOT_FOUND") { if (code === "NOT_FOUND") {
set.status = 404; set.status = 404;
return { success: false, message: "Not found" }; return { success: false, message: "Not found" };
@@ -51,7 +57,11 @@ const app = new Elysia()
set.status = 500; set.status = 500;
return { success: false, message: "Internal server error" }; 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" })) .get("/", () => ({ status: "ok", message: "API is running" }))
.use(bugReportRoutes) .use(bugReportRoutes)
.use(authRoutes) .use(authRoutes)
@@ -64,7 +74,7 @@ const app = new Elysia()
Log( Log(
"SERVER", "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 // Graceful shutdown

View File

@@ -6,13 +6,15 @@ export function apiKeyGuard(ctx: { request?: Request; set: any }) {
const request = ctx.request; const request = ctx.request;
if (!request) return; // nothing to validate at setup time if (!request) return; // nothing to validate at setup time
if (request.url.includes("/health")) return;
const key = request.headers.get("x-api-key"); const key = request.headers.get("x-api-key");
if (!key || key !== config.apiKey) { if (!key || key !== config.apiKey) {
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") ||
"unknown"; "unknown";
Log("AUTH", `Invalid API key from ip=${ip}`); Log("AUTH-API-KEYGUARD", `Invalid API key from ip=${ip}`);
ctx.set.status = 401; ctx.set.status = 401;
return { success: false, message: "Invalid or missing API key" }; 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; const request = ctx.request;
if (!request) return; if (!request) return;
if (request.url.includes("/health")) return;
if (request.url.includes("/bug-reports")) return;
const key = request.headers.get("x-admin-key"); const key = request.headers.get("x-admin-key");
if (!key || key !== config.adminKey) { if (!key || key !== config.adminKey) {
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") ||
"unknown"; "unknown";
Log("AUTH", `Invalid admin key from ip=${ip}`); Log("AUTH-ADMIN-KEYGUARD", `Invalid admin key from ip=${ip}`);
ctx.set.status = 401; ctx.set.status = 401;
return { success: false, message: "Invalid or missing admin key" }; return { success: false, message: "Invalid or missing admin key" };
} }

View File

@@ -6,9 +6,12 @@ import { Log } from "../logger";
const excludedHwids = new Set<string>([ const excludedHwids = new Set<string>([
// Add HWIDs here for development testing // Add HWIDs here for development testing
"95e025d1-7567-462e-9354-ac88b965cd22", "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" }, { as: "scoped" },
// @ts-ignore // @ts-ignore
async ({ body, error }) => { async ({ body, error }) => {
@@ -25,7 +28,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan
// Get current rate limit entry // Get current rate limit entry
const [rows] = await pool.execute( const [rows] = await pool.execute(
"SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?", "SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?",
[hwid] [hwid],
); );
const entries = rows as { window_start: Date; count: number }[]; 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 // First request from this HWID
await pool.execute( await pool.execute(
"INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)", "INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)",
[hwid, now] [hwid, now],
); );
return {}; return {};
} }
@@ -47,7 +50,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan
// Window expired, reset // Window expired, reset
await pool.execute( await pool.execute(
"UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?", "UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?",
[now, hwid] [now, hwid],
); );
return {}; return {};
} }
@@ -65,8 +68,8 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).onBeforeHan
// Increment count // Increment count
await pool.execute( await pool.execute(
"UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?", "UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?",
[hwid] [hwid],
); );
return {}; return {};
} },
); );

View File

@@ -18,20 +18,34 @@ import {
getUserById, getUserById,
} from "../services/userService"; } from "../services/userService";
import { Log } from "../logger"; import { Log } from "../logger";
import type { BugReportStatus } 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) .onRequest(adminKeyGuard)
.get( .get(
"/bug-reports", "/bug-reports",
async ({ query }) => { async ({ query, headers }) => {
const page = parseInt(query.page || "1"); const page = parseInt(query.page || "1");
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
const status = query.status as BugReportStatus | undefined; const status = query.status as BugReportStatus | undefined;
const search = query.search || 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 || ""}`); if (useTestDb) Log("ADMIN", `Fetching bug reports from test database`);
return await listBugReports({ page, pageSize, status, search });
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({ query: t.Object({
@@ -43,41 +57,48 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
t.Literal("in_review"), t.Literal("in_review"),
t.Literal("resolved"), t.Literal("resolved"),
t.Literal("closed"), t.Literal("closed"),
]) ]),
), ),
search: t.Optional(t.String()), search: t.Optional(t.String()),
}), }),
detail: { summary: "List bug reports (paginated, filterable)" }, detail: { summary: "List bug reports (paginated, filterable)" },
} },
) )
.get( .get(
"/bug-reports/count", "/bug-reports/count",
async () => { async ({ headers }) => {
const count = await countNewReports(); const count = await countNewReports(
headers["x-db-env"] !== "prod" ? true : false,
);
return { count }; return { count };
}, },
{ detail: { summary: "Count new bug reports" } } { detail: { summary: "Count new bug reports" } },
) )
.get( .get(
"/bug-reports/:id", "/bug-reports/:id",
async ({ params, status }) => { async ({ params, status, headers }) => {
Log("ADMIN", `Get bug report id=${params.id}`); Log("ADMIN", `Get bug report id=${params.id}`);
const result = await getBugReport(parseInt(params.id)); const result = await getBugReport(
if (!result) return status(404, { success: false, message: "Report not found" }); parseInt(params.id),
headers["x-db-env"] !== "prod" ? true : false,
);
if (!result)
return status(404, { success: false, message: "Report not found" });
return result; return result;
}, },
{ {
params: t.Object({ id: t.String() }), params: t.Object({ id: t.String() }),
detail: { summary: "Get bug report with file metadata" }, detail: { summary: "Get bug report with file metadata" },
} },
) )
.patch( .patch(
"/bug-reports/:id/status", "/bug-reports/:id/status",
async ({ params, body, status }) => { async ({ params, body, status, headers }) => {
Log("ADMIN", `Update status id=${params.id} status=${body.status}`); Log("ADMIN", `Update status id=${params.id} status=${body.status}`);
const updated = await updateBugReportStatus( const updated = await updateBugReportStatus(
parseInt(params.id), parseInt(params.id),
body.status body.status,
headers["x-db-env"] !== "prod" ? true : false,
); );
if (!updated) if (!updated)
return status(404, { success: false, message: "Report not found" }); 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" }, detail: { summary: "Update bug report status" },
} },
) )
.get( .get(
"/bug-reports/:id/files/:fileId", "/bug-reports/:id/files/:fileId",
async ({ params, status, set }) => { async ({ params, status, set, headers }) => {
const file = await getFile(parseInt(params.id), parseInt(params.fileId)); const file = await getFile(
parseInt(params.id),
parseInt(params.fileId),
headers["x-db-env"] !== "prod" ? true : false,
);
if (!file) if (!file)
return status(404, { success: false, message: "File not found" }); 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() }), params: t.Object({ id: t.String(), fileId: t.String() }),
detail: { summary: "Download a bug report file" }, detail: { summary: "Download a bug report file" },
} },
) )
.get( .get(
"/bug-reports/:id/download", "/bug-reports/:id/download",
async ({ params, status, set }) => { async ({ params, status, set, headers }) => {
Log("ADMIN", `Download zip for report id=${params.id}`); 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) if (!zipBuffer)
return status(404, { success: false, message: "Report not found" }); 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() }), params: t.Object({ id: t.String() }),
detail: { summary: "Download all files for a bug report as ZIP" }, detail: { summary: "Download all files for a bug report as ZIP" },
} },
) )
.delete( .delete(
"/bug-reports/:id", "/bug-reports/:id",
async ({ params, status }) => { async ({ params, status, headers }) => {
Log("ADMIN", `Delete bug report id=${params.id}`); 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) if (!deleted)
return status(404, { success: false, message: "Report not found" }); return status(404, { success: false, message: "Report not found" });
return { success: true, message: "Report deleted" }; return { success: true, message: "Report deleted" };
@@ -144,16 +175,16 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
{ {
params: t.Object({ id: t.String() }), params: t.Object({ id: t.String() }),
detail: { summary: "Delete a bug report and its files" }, detail: { summary: "Delete a bug report and its files" },
} },
) )
// User management // User management
.get( .get(
"/users", "/users",
async () => { async ({ headers }) => {
Log("ADMIN", "List users"); Log("ADMIN", "List users");
return await listUsers(); return await listUsers();
}, },
{ detail: { summary: "List all users" } } { detail: { summary: "List all users" } },
) )
.post( .post(
"/users", "/users",
@@ -177,7 +208,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
role: t.Union([t.Literal("admin"), t.Literal("user")]), role: t.Union([t.Literal("admin"), t.Literal("user")]),
}), }),
detail: { summary: "Create a new user" }, detail: { summary: "Create a new user" },
} },
) )
.patch( .patch(
"/users/:id", "/users/:id",
@@ -195,7 +226,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
enabled: t.Optional(t.Boolean()), enabled: t.Optional(t.Boolean()),
}), }),
detail: { summary: "Update user displayname or enabled status" }, detail: { summary: "Update user displayname or enabled status" },
} },
) )
.post( .post(
"/users/:id/reset-password", "/users/:id/reset-password",
@@ -210,7 +241,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
params: t.Object({ id: t.String() }), params: t.Object({ id: t.String() }),
body: t.Object({ password: t.String({ minLength: 1 }) }), body: t.Object({ password: t.String({ minLength: 1 }) }),
detail: { summary: "Reset a user's password" }, detail: { summary: "Reset a user's password" },
} },
) )
.delete( .delete(
"/users/:id", "/users/:id",
@@ -221,7 +252,10 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
if (!user) if (!user)
throw status(404, { success: false, message: "User not found" }); throw status(404, { success: false, message: "User not found" });
if (user.role === "admin") 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); const deleted = await deleteUser(params.id);
if (!deleted) if (!deleted)
@@ -231,5 +265,5 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
{ {
params: t.Object({ id: t.String() }), params: t.Object({ id: t.String() }),
detail: { summary: "Delete a user (non-admin only)" }, detail: { summary: "Delete a user (non-admin only)" },
} },
); );

View File

@@ -1,19 +1,30 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { adminKeyGuard } from "../middleware/auth"; import { adminKeyGuard } from "../middleware/auth";
import { loginUser, validateSession, logoutSession } from "../services/authService"; import {
loginUser,
validateSession,
logoutSession,
} from "../services/authService";
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)
.post( .post(
"/login", "/login",
async ({ body, error }) => { async ({ body, status }) => {
const result = await loginUser(body.username, body.password); const result = await loginUser(body.username, body.password);
if (!result) { if (!result) {
Log("AUTH", `Login failed for username=${body.username}`); 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({ body: t.Object({
@@ -21,7 +32,7 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
password: t.String({ minLength: 1 }), password: t.String({ minLength: 1 }),
}), }),
detail: { summary: "Login with username/password" }, detail: { summary: "Login with username/password" },
} },
) )
.post( .post(
"/logout", "/logout",
@@ -34,22 +45,28 @@ export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
}, },
{ {
detail: { summary: "Logout and invalidate session" }, detail: { summary: "Logout and invalidate session" },
} },
) )
.get( .get(
"/validate", "/validate",
async ({ headers, error }) => { async ({ headers, status }) => {
const sessionId = headers["x-session-token"]; const sessionId = headers["x-session-token"];
if (!sessionId) { 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); const user = await validateSession(sessionId);
if (!user) { 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 }; return { success: true, user };
}, },
{ {
detail: { summary: "Validate session and return user" }, detail: { summary: "Validate session and return user" },
} },
); );

View File

@@ -14,11 +14,15 @@ 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(hwidRateLimit) //.use(hwidRateLimit)
.post( .post(
"/", "/",
async ({ body, request, set }) => { async ({ body, request, set, headers }) => {
const { name, email, description, hwid, hostname, os_user, system_info } = body; 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 // Get submitter IP from headers or connection
const submitterIp = const submitterIp =
@@ -26,7 +30,10 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
request.headers.get("x-real-ip") || request.headers.get("x-real-ip") ||
"unknown"; "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 // Parse system_info - may arrive as a JSON string or already-parsed object
let systemInfo: Record<string, unknown> | null = null; let systemInfo: Record<string, unknown> | null = null;
@@ -43,31 +50,40 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
} }
// Create the bug report // Create the bug report
const reportId = await createBugReport({ const reportId = await createBugReport(
name, {
email, name,
description, email,
hwid: hwid || "", description,
hostname: hostname || "", hwid: hwid || "",
os_user: os_user || "", hostname: hostname || "",
submitter_ip: submitterIp, os_user: os_user || "",
system_info: systemInfo, submitter_ip: submitterIp,
}); system_info: systemInfo,
},
useTestDB,
);
// Process file uploads // Process file uploads
for (const { field, role, mime } of FILE_ROLES) { for (const { field, role, mime } of FILE_ROLES) {
const file = body[field as keyof typeof body]; const file = body[field as keyof typeof body];
if (file && file instanceof File) { if (file && file instanceof File) {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`); Log(
await addFile({ "BUGREPORT",
report_id: reportId, `File uploaded: role=${role} size=${buffer.length} bytes`,
file_role: role, );
filename: file.name || `${field}.bin`, await addFile(
mime_type: file.type || mime, {
file_size: buffer.length, report_id: reportId,
data: buffer, 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" }, detail: { summary: "Submit a bug report" },
} },
); );

View File

@@ -10,17 +10,20 @@ import type {
PaginatedResponse, PaginatedResponse,
} from "../types"; } from "../types";
export async function createBugReport(data: { export async function createBugReport(
name: string; data: {
email: string; name: string;
description: string; email: string;
hwid: string; description: string;
hostname: string; hwid: string;
os_user: string; hostname: string;
submitter_ip: string; os_user: string;
system_info: Record<string, unknown> | null; submitter_ip: string;
}): Promise<number> { system_info: Record<string, unknown> | null;
const pool = getPool(); },
useTestDb?: boolean,
): Promise<number> {
const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>( const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info) `INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -33,20 +36,23 @@ export async function createBugReport(data: {
data.os_user, data.os_user,
data.submitter_ip, data.submitter_ip,
data.system_info ? JSON.stringify(data.system_info) : null, data.system_info ? JSON.stringify(data.system_info) : null,
] ],
); );
return result.insertId; return result.insertId;
} }
export async function addFile(data: { export async function addFile(
report_id: number; data: {
file_role: FileRole; report_id: number;
filename: string; file_role: FileRole;
mime_type: string; filename: string;
file_size: number; mime_type: string;
data: Buffer; file_size: number;
}): Promise<number> { data: Buffer;
const pool = getPool(); },
useTestDb?: boolean,
): Promise<number> {
const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>( const [result] = await pool.execute<ResultSetHeader>(
`INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data) `INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
@@ -57,18 +63,21 @@ export async function addFile(data: {
data.mime_type, data.mime_type,
data.file_size, data.file_size,
data.data, data.data,
] ],
); );
return result.insertId; return result.insertId;
} }
export async function listBugReports(opts: { export async function listBugReports(
page: number; opts: {
pageSize: number; page: number;
status?: BugReportStatus; pageSize: number;
search?: string; status?: BugReportStatus;
}): Promise<PaginatedResponse<BugReportListItem>> { search?: string;
const pool = getPool(); },
useTestDb?: boolean,
): Promise<PaginatedResponse<BugReportListItem>> {
const pool = getPool(useTestDb ? true : false);
const { page, pageSize, status, search } = opts; const { page, pageSize, status, search } = opts;
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
@@ -83,7 +92,7 @@ export async function listBugReports(opts: {
if (search) { if (search) {
const like = `%${search}%`; const like = `%${search}%`;
conditions.push( 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); params.push(like, like, like, like);
} }
@@ -93,7 +102,6 @@ export async function listBugReports(opts: {
const [countRows] = await pool.execute<RowDataPacket[]>( const [countRows] = await pool.execute<RowDataPacket[]>(
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`, `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
params
); );
const total = (countRows[0] as { total: number }).total; const total = (countRows[0] as { total: number }).total;
@@ -105,7 +113,6 @@ export async function listBugReports(opts: {
GROUP BY br.id GROUP BY br.id
ORDER BY br.created_at DESC ORDER BY br.created_at DESC
LIMIT ${pageSize} OFFSET ${offset}`, LIMIT ${pageSize} OFFSET ${offset}`,
params
); );
return { return {
@@ -117,20 +124,23 @@ export async function listBugReports(opts: {
}; };
} }
export async function countNewReports(): Promise<number> { export async function countNewReports(useTestDb?: boolean): Promise<number> {
const pool = getPool(); const pool = getPool(useTestDb ? true : false);
const [rows] = await pool.execute<RowDataPacket[]>( const [rows] = await pool.execute<RowDataPacket[]>(
"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; return (rows[0] as { count: number }).count;
} }
export async function generateReportZip(reportId: number): Promise<Buffer | null> { export async function generateReportZip(
const pool = getPool(); reportId: number,
useTestDb?: boolean,
): Promise<Buffer | null> {
const pool = getPool(useTestDb ? true : false);
const [reportRows] = await pool.execute<RowDataPacket[]>( const [reportRows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM bug_reports WHERE id = ?", "SELECT * FROM bug_reports WHERE id = ?",
[reportId] [reportId],
); );
if ((reportRows as unknown[]).length === 0) return null; if ((reportRows as unknown[]).length === 0) return null;
@@ -138,7 +148,7 @@ export async function generateReportZip(reportId: number): Promise<Buffer | null
const [fileRows] = await pool.execute<RowDataPacket[]>( const [fileRows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM bug_report_files WHERE report_id = ?", "SELECT * FROM bug_report_files WHERE report_id = ?",
[reportId] [reportId],
); );
const files = fileRows as BugReportFile[]; const files = fileRows as BugReportFile[];
@@ -163,7 +173,11 @@ export async function generateReportZip(reportId: number): Promise<Buffer | null
report.description, report.description,
``, ``,
...(report.system_info ...(report.system_info
? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)] ? [
`System Info:`,
`------------`,
JSON.stringify(report.system_info, null, 2),
]
: []), : []),
].join("\n"); ].join("\n");
@@ -177,20 +191,21 @@ export async function generateReportZip(reportId: number): Promise<Buffer | null
} }
export async function getBugReport( export async function getBugReport(
id: number id: number,
useTestDb?: boolean,
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> { ): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
const pool = getPool(); const pool = getPool(useTestDb ? true : false);
const [reportRows] = await pool.execute<RowDataPacket[]>( const [reportRows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM bug_reports WHERE id = ?", "SELECT * FROM bug_reports WHERE id = ?",
[id] [id],
); );
if ((reportRows as unknown[]).length === 0) return null; if ((reportRows as unknown[]).length === 0) return null;
const [fileRows] = await pool.execute<RowDataPacket[]>( const [fileRows] = await pool.execute<RowDataPacket[]>(
"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],
); );
return { return {
@@ -201,35 +216,40 @@ export async function getBugReport(
export async function getFile( export async function getFile(
reportId: number, reportId: number,
fileId: number fileId: number,
useTestDb?: boolean,
): Promise<BugReportFile | null> { ): Promise<BugReportFile | null> {
const pool = getPool(); const pool = getPool(useTestDb ? true : false);
const [rows] = await pool.execute<RowDataPacket[]>( const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?", "SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?",
[fileId, reportId] [fileId, reportId],
); );
if ((rows as unknown[]).length === 0) return null; if ((rows as unknown[]).length === 0) return null;
return rows[0] as BugReportFile; return rows[0] as BugReportFile;
} }
export async function deleteBugReport(id: number): Promise<boolean> { export async function deleteBugReport(
const pool = getPool(); id: number,
useTestDb?: boolean,
): Promise<boolean> {
const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>( const [result] = await pool.execute<ResultSetHeader>(
"DELETE FROM bug_reports WHERE id = ?", "DELETE FROM bug_reports WHERE id = ?",
[id] [id],
); );
return result.affectedRows > 0; return result.affectedRows > 0;
} }
export async function updateBugReportStatus( export async function updateBugReportStatus(
id: number, id: number,
status: BugReportStatus status: BugReportStatus,
useTestDb?: boolean,
): Promise<boolean> { ): Promise<boolean> {
const pool = getPool(); const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>( const [result] = await pool.execute<ResultSetHeader>(
"UPDATE bug_reports SET status = ? WHERE id = ?", "UPDATE bug_reports SET status = ? WHERE id = ?",
[status, id] [status, id],
); );
return result.affectedRows > 0; return result.affectedRows > 0;
} }

View File

@@ -55,3 +55,5 @@ export interface PaginatedResponse<T> {
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;
} }
export type DbEnv = "prod" | "test";