Merge branch 'dev'

This commit is contained in:
Flavio Fois
2026-03-17 10:33:58 +01:00
19 changed files with 928 additions and 153 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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");
}

View File

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

7
src/features.json Normal file
View 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"
}
}

View File

@@ -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
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);
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

View File

@@ -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}`;

View File

@@ -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 {};
}
},
);

View File

@@ -6,9 +6,12 @@ import { Log } from "../logger";
const excludedHwids = new Set<string>([
// 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 {};
}
},
);

View File

@@ -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)" },
},
);

73
src/routes/auth.ts Normal file
View File

@@ -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" },
},
);

View File

@@ -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<string, unknown> | null = null;
@@ -43,7 +51,8 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
}
// Create the bug report
const reportId = await createBugReport({
const reportId = await createBugReport(
{
name,
email,
description,
@@ -52,22 +61,30 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
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({
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" },
}
},
);

40
src/routes/features.ts Normal file
View 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" },
},
);

124
src/services/authService.ts Normal file
View File

@@ -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<RowDataPacket[]>(
"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<ResultSetHeader>(
"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<AuthUser | null> {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
`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<void> {
const pool = getPool();
await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]);
Log("AUTH", `Session logged out: ${sessionId.slice(0, 8)}...`);
}

View File

@@ -1,4 +1,5 @@
import type { ResultSetHeader, RowDataPacket } from "mysql2";
import JSZip from "jszip";
import { getPool } from "../db/connection";
import type {
BugReport,
@@ -9,7 +10,8 @@ import type {
PaginatedResponse,
} from "../types";
export async function createBugReport(data: {
export async function createBugReport(
data: {
name: string;
email: string;
description: string;
@@ -18,8 +20,10 @@ export async function createBugReport(data: {
os_user: string;
submitter_ip: string;
system_info: Record<string, unknown> | null;
}): Promise<number> {
const pool = getPool();
},
useTestDb?: boolean,
): Promise<number> {
const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>(
`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: {
export async function addFile(
data: {
report_id: number;
file_role: FileRole;
filename: string;
mime_type: string;
file_size: number;
data: Buffer;
}): Promise<number> {
const pool = getPool();
},
useTestDb?: boolean,
): Promise<number> {
const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>(
`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: {
export async function listBugReports(
opts: {
page: number;
pageSize: number;
status?: BugReportStatus;
}): Promise<PaginatedResponse<BugReportListItem>> {
const pool = getPool();
const { page, pageSize, status } = opts;
search?: string;
},
useTestDb?: boolean,
): Promise<PaginatedResponse<BugReportListItem>> {
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<RowDataPacket[]>(
`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<BugReportFile, "data">[] } | null> {
const pool = getPool();
export async function countNewReports(useTestDb?: boolean): Promise<number> {
const pool = getPool(useTestDb ? true : false);
const [rows] = await pool.execute<RowDataPacket[]>(
"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<Buffer | null> {
const pool = getPool(useTestDb ? true : false);
const [reportRows] = await pool.execute<RowDataPacket[]>(
"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<RowDataPacket[]>(
"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<Buffer>;
}
export async function getBugReport(
id: number,
useTestDb?: boolean,
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
const pool = getPool(useTestDb ? true : false);
const [reportRows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM bug_reports WHERE id = ?",
[id],
);
if ((reportRows as unknown[]).length === 0) return null;
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 = ?",
[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<BugReportFile | null> {
const pool = getPool();
const pool = getPool(useTestDb ? true : false);
const [rows] = await pool.execute<RowDataPacket[]>(
"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<boolean> {
const pool = getPool();
export async function deleteBugReport(
id: number,
useTestDb?: boolean,
): Promise<boolean> {
const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>(
"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<boolean> {
const pool = getPool();
const pool = getPool(useTestDb ? true : false);
const [result] = await pool.execute<ResultSetHeader>(
"UPDATE bug_reports SET status = ? WHERE id = ?",
[status, id]
[status, id],
);
return result.affectedRows > 0;
}

120
src/services/userService.ts Normal file
View File

@@ -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<User[]> {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
"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<User> {
const pool = getPool();
// Check for duplicate username
const [existing] = await pool.execute<RowDataPacket[]>(
"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<ResultSetHeader>(
"INSERT INTO `user` (id, username, displayname, password_hash, role) VALUES (?, ?, ?, ?, ?)",
[id, data.username, data.displayname, passwordHash, data.role]
);
const [rows] = await pool.execute<RowDataPacket[]>(
"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<boolean> {
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<ResultSetHeader>(
`UPDATE \`user\` SET ${fields.join(", ")} WHERE id = ?`,
params
);
return result.affectedRows > 0;
}
export async function resetPassword(
id: string,
newPassword: string
): Promise<boolean> {
const pool = getPool();
const passwordHash = await hash(newPassword, ARGON2_OPTIONS);
const [result] = await pool.execute<ResultSetHeader>(
"UPDATE `user` SET password_hash = ? WHERE id = ?",
[passwordHash, id]
);
return result.affectedRows > 0;
}
export async function deleteUser(id: string): Promise<boolean> {
const pool = getPool();
const [result] = await pool.execute<ResultSetHeader>(
"DELETE FROM `user` WHERE id = ?",
[id]
);
return result.affectedRows > 0;
}
export async function getUserById(id: string): Promise<User | null> {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
"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];
}

View File

@@ -55,3 +55,18 @@ export interface PaginatedResponse<T> {
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 };
}