diff --git a/.env.example b/.env.example index 8f91e16..f50d0b2 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 +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/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..2a47309 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,19 @@ "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": { + "@elysiajs/swagger": "^1.3.1", "@node-rs/argon2": "^2.0.2", - "elysia": "^1.2.0", - "mysql2": "^3.11.0" + "elysia": "^1.4.27", + "jszip": "^3.10.1", + "mysql2": "^3.18.2" }, "devDependencies": { "@types/bun": "latest", - "typescript": "^5.0.0" + "typescript": "^5.9.3" } } diff --git a/src/config.ts b/src/config.ts index ec1932b..f14b134 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.FLAG_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..1468275 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 Pool Connection"); + 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 Pool Connection"); + 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/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 5587c11..678b352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,28 @@ 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); +const INSTANCE_ID = + process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); // Initialize logger initLogger(); // Validate environment -validateConfig(); +try { + validateConfig(); +} catch (error) { + Log("ERROR", "Failed to validate config:", error); + process.exit(1); +} // Run database migrations try { @@ -23,26 +33,76 @@ 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") || "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}`, + ); + set.headers["x-instance-id"] = INSTANCE_ID; + set.headers["x-server"] = "EMLy-API"; }) .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 }) => { + .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" }; + } + if (code === "VALIDATION") { + 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" }; }) - .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( + 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) .listen({ port: config.port, @@ -52,7 +112,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/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..54b9c68 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,35 +1,26 @@ -import { Elysia } from "elysia"; import { config } from "../config"; import { Log } from "../logger"; +import Elysia from "elysia"; +import type { UnauthorizedResponse } from "../types"; -export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive( +export const apiKeyGuard2 = 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" }); + ({ 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 {}; - } + }, ); -export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive( +export const adminKeyGuard2 = 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" }); + ({ 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/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 70921d8..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" }).derive( +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" }).derive( // 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" }).derive( // 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" }).derive( // 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" }).derive( // 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 3bc3e72..3fdcae8 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,26 +1,52 @@ import { Elysia, t } from "elysia"; -import { adminKeyGuard } from "../middleware/auth"; +import { adminKeyGuard2 } from "../middleware/auth"; import { listBugReports, getBugReport, 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"; +import type { BugReportStatus, DbEnv } from "../types"; export const adminRoutes = new Elysia({ prefix: "/api/admin" }) - .use(adminKeyGuard) + .use(adminKeyGuard2) .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"}`); - return await listBugReports({ page, pageSize, status }); + if (useTestDb) Log("ADMIN", `Fetching bug reports from test database`); + + Log( + "ADMIN", + `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`, + ); + const res = await listBugReports( + { + page, + pageSize, + status, + search, + }, + useTestDb, + ); + return res; }, { query: t.Object({ @@ -32,35 +58,51 @@ 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)" }, - } + detail: { summary: "List bug reports (paginated, filterable)" }, + }, + ) + .get( + "/bug-reports/count", + async ({ headers }) => { + const count = await countNewReports( + headers["x-db-env"] !== "prod" ? true : false, + ); + return { count }; + }, + { detail: { summary: "Count new bug reports" } }, ) .get( "/bug-reports/:id", - async ({ params, error }) => { + async ({ params, status, headers }) => { 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" }); + 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, error }) => { + 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 error(404, { success: false, message: "Report not found" }); + return status(404, { success: false, message: "Report not found" }); return { success: true, message: "Status updated" }; }, { @@ -74,14 +116,18 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" }) ]), }), detail: { summary: "Update bug report status" }, - } + }, ) .get( "/bug-reports/:id/files/:fileId", - async ({ params, error, 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 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"] = @@ -91,19 +137,134 @@ 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, headers }) => { + Log("ADMIN", `Download zip for report id=${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" }); + + 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 }) => { + 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 error(404, { success: false, message: "Report not found" }); + return status(404, { success: false, message: "Report not found" }); return { success: true, message: "Report deleted" }; }, { params: t.Object({ id: t.String() }), detail: { summary: "Delete a bug report and its files" }, - } + }, + ) + // User management + .get( + "/users", + async ({ headers }) => { + Log("ADMIN", "List users"); + return await listUsers(); + }, + { detail: { summary: "List all users" } }, + ) + .post( + "/users", + 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 status(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, status }) => { + Log("ADMIN", `Update user id=${params.id}`); + const updated = await updateUser(params.id, body); + if (!updated) + return status(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, status }) => { + Log("ADMIN", `Reset password for user id=${params.id}`); + const updated = await resetPassword(params.id, body.password); + if (!updated) + return status(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, status }) => { + Log("ADMIN", `Delete user id=${params.id}`); + + const user = await getUserById(params.id); + 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", + }); + + const deleted = await deleteUser(params.id); + if (!deleted) + return status(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..a593e6f --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,73 @@ +import { Elysia, t } from "elysia"; +import { adminKeyGuard2 } from "../middleware/auth"; +import { + loginUser, + validateSession, + logoutSession, +} from "../services/authService"; +import { Log } from "../logger"; + +export const authRoutes = new Elysia({ prefix: "/api/admin/auth" }) + //.onRequest(adminKeyGuard) + .use(adminKeyGuard2) + .post( + "/login", + async ({ body, status }) => { + const result = await loginUser(body.username, body.password); + if (!result) { + Log("AUTH", `Login failed for username=${body.username}`); + return status(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, status }) => { + const sessionId = headers["x-session-token"]; + if (!sessionId) { + return status(401, { + success: false, + message: "No session token provided", + }); + } + const user = await validateSession(sessionId); + if (!user) { + 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 23fb604..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,12 +13,17 @@ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ ]; export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) - .use(apiKeyGuard) - .use(hwidRateLimit) + //.onRequest(apiKeyGuard) + .use(apiKeyGuard2) + //.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 +31,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 +51,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 +120,5 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) }), }, detail: { summary: "Submit a bug report" }, - } + }, ); 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/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..682d128 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, @@ -9,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 (?, ?, ?, ?, ?, ?, ?, ?)`, @@ -32,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 (?, ?, ?, ?, ?, ?)`, @@ -56,31 +63,45 @@ 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; -}): Promise> { - const pool = getPool(); - const { page, pageSize, status } = opts; +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; - 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 ); const total = (countRows[0] as { total: number }).total; @@ -91,8 +112,7 @@ 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}`, ); return { @@ -104,22 +124,101 @@ export async function listBugReports(opts: { }; } -export async function getBugReport( - id: number -): Promise<{ report: BugReport; files: Omit[] } | null> { - 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'", + ); + return (rows[0] as { count: number }).count; +} + +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 = ?", - [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, + useTestDb?: boolean, +): Promise<{ report: BugReport; files: Omit[] } | null> { + const pool = getPool(useTestDb ? true : false); + + const [reportRows] = await pool.execute( + "SELECT * FROM bug_reports WHERE 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], ); + // 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, @@ -129,35 +228,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/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]; +} diff --git a/src/types/index.ts b/src/types/index.ts index 6b933a4..74ec9d7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,3 +55,18 @@ export interface PaginatedResponse { pageSize: number; totalPages: number; } + +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 }; +}