Compare commits

...

5 Commits

Author SHA1 Message Date
Flavio Fois
b7f42ce3ae Merge branch 'dev' 2026-03-17 10:33:58 +01:00
Flavio Fois
5624019f23 Enhances API infrastructure with Swagger, feature flags, and refactored middleware
Implements @elysiajs/swagger for automated API documentation and introduces a feature flag system to expose service capabilities based on environment variables.

Refactors authentication guards into native Elysia scoped middleware for improved integration and type safety. Updates error handling to support custom status codes and adds instance-specific headers to responses for better observability.

Includes an IP fallback mechanism for bug reports that utilizes internal system info when the direct submitter IP is unavailable.
2026-03-17 10:30:42 +01:00
Flavio Fois
9458d1e8ad 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.
2026-03-02 23:15:15 +01:00
Flavio Fois
3f15edae75 Refactor middleware and routes to use onRequest for API key and admin key guards; update dependencies and improve logging for error handling 2026-03-02 19:30:59 +01:00
Flavio Fois
5761cbaa55 Added routes for login and bug reporting 2026-02-26 08:53:50 +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 };
}