From 0cad94dadd87bca8215f7a018f4a10eb231b76ea Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Mon, 16 Feb 2026 11:11:50 +0100 Subject: [PATCH] Add initial implementation of EMLy Bug Report API with MySQL integration and Docker support --- .env.example | 23 +++++ .gitignore | 4 + Dockerfile | 13 +++ compose-dev.yml | 85 ++++++++++++++++ compose-prod.yml | 85 ++++++++++++++++ emly-server.service | 16 +++ package.json | 18 ++++ src/config.ts | 24 +++++ src/db/connection.ts | 28 ++++++ src/db/migrate.ts | 59 +++++++++++ src/db/schema.sql | 55 +++++++++++ src/index.ts | 60 ++++++++++++ src/logger.ts | 42 ++++++++ src/middleware/auth.ts | 35 +++++++ src/middleware/rateLimit.ts | 72 ++++++++++++++ src/routes/admin.ts | 109 +++++++++++++++++++++ src/routes/bugReports.ts | 107 ++++++++++++++++++++ src/services/bugReportService.ts | 163 +++++++++++++++++++++++++++++++ src/types/index.ts | 57 +++++++++++ 19 files changed, 1055 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 compose-dev.yml create mode 100644 compose-prod.yml create mode 100644 emly-server.service create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/db/connection.ts create mode 100644 src/db/migrate.ts create mode 100644 src/db/schema.sql create mode 100644 src/index.ts create mode 100644 src/logger.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/middleware/rateLimit.ts create mode 100644 src/routes/admin.ts create mode 100644 src/routes/bugReports.ts create mode 100644 src/services/bugReportService.ts create mode 100644 src/types/index.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f91e16 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# MySQL +MYSQL_HOST=mysql +MYSQL_PORT=3306 +MYSQL_USER=emly +MYSQL_PASSWORD=change_me_in_production +MYSQL_DATABASE=emly_bugreports +MYSQL_ROOT_PASSWORD=change_root_password + +# API Keys +API_KEY=change_me_client_key +ADMIN_KEY=change_me_admin_key + +# Server +PORT=3000 +DASHBOARD_PORT=3001 + +# Rate Limiting +RATE_LIMIT_MAX=5 +RATE_LIMIT_WINDOW_HOURS=24 + +# 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 diff --git a/.gitignore b/.gitignore index 2309cc8..9b92967 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,7 @@ dist .yarn/install-state.gz .pnp.* + +# IDEs +.idea/ +.vscode/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1eefc79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM oven/bun:alpine + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +COPY tsconfig.json ./ +COPY src/ ./src/ + +EXPOSE 3000 + +CMD ["bun", "run", "src/index.ts"] diff --git a/compose-dev.yml b/compose-dev.yml new file mode 100644 index 0000000..f97f940 --- /dev/null +++ b/compose-dev.yml @@ -0,0 +1,85 @@ +services: + mysql: + image: mysql:lts + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + emly: + ipv4_address: 172.16.32.2 + + api: + build: . + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + API_KEY: ${API_KEY} + ADMIN_KEY: ${ADMIN_KEY} + PORT: 3000 + RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5} + RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24} + volumes: + - ./logs/api:/app/logs + restart: on-failure + depends_on: + mysql: + condition: service_healthy + networks: + emly: + ipv4_address: 172.16.32.3 + + dashboard: + build: ./dashboard + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + volumes: + - ./logs/dashboard:/app/logs + depends_on: + mysql: + condition: service_healthy + networks: + emly: + ipv4_address: 172.16.32.4 + + cloudflared: + image: cloudflare/cloudflared:latest + command: tunnel run + environment: + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN_DEV} + depends_on: + - api + - dashboard + restart: unless-stopped + networks: + emly: + ipv4_address: 172.16.32.5 + +volumes: + mysql_data: + +networks: + emly: + driver: bridge + ipam: + config: + - subnet: 172.16.32.0/24 + gateway: 172.16.32.1 diff --git a/compose-prod.yml b/compose-prod.yml new file mode 100644 index 0000000..0bff7c9 --- /dev/null +++ b/compose-prod.yml @@ -0,0 +1,85 @@ +services: + mysql: + image: mysql:lts + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + emly: + ipv4_address: 172.16.32.2 + + api: + build: . + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + API_KEY: ${API_KEY} + ADMIN_KEY: ${ADMIN_KEY} + PORT: 3000 + RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5} + RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24} + volumes: + - ./logs/api:/app/logs + restart: on-failure + depends_on: + mysql: + condition: service_healthy + networks: + emly: + ipv4_address: 172.16.32.3 + + dashboard: + build: ./dashboard + environment: + MYSQL_HOST: mysql + MYSQL_PORT: 3306 + MYSQL_USER: ${MYSQL_USER:-emly} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} + volumes: + - ./logs/dashboard:/app/logs + depends_on: + mysql: + condition: service_healthy + networks: + emly: + ipv4_address: 172.16.32.4 + + cloudflared: + image: cloudflare/cloudflared:latest + command: tunnel run + environment: + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} + depends_on: + - api + - dashboard + restart: unless-stopped + networks: + emly: + ipv4_address: 172.16.32.5 + +volumes: + mysql_data: + +networks: + emly: + driver: bridge + ipam: + config: + - subnet: 172.16.32.0/24 + gateway: 172.16.32.1 diff --git a/emly-server.service b/emly-server.service new file mode 100644 index 0000000..25fd717 --- /dev/null +++ b/emly-server.service @@ -0,0 +1,16 @@ +[Unit] +Description=EMLy Bug Report Server (Docker Compose) +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/emly-server +ExecStart=/usr/bin/docker compose up -d +ExecStop=/usr/bin/docker compose down +Restart=on-failure +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/package.json b/package.json new file mode 100644 index 0000000..85edc65 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "emly-bugreport-server", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "elysia": "^1.2.0", + "mysql2": "^3.11.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ec1932b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,24 @@ +export const config = { + mysql: { + host: process.env.MYSQL_HOST || "localhost", + port: parseInt(process.env.MYSQL_PORT || "3306"), + user: process.env.MYSQL_USER || "emly", + password: process.env.MYSQL_PASSWORD || "", + database: process.env.MYSQL_DATABASE || "emly_bugreports", + }, + apiKey: process.env.API_KEY || "", + adminKey: process.env.ADMIN_KEY || "", + port: parseInt(process.env.PORT || "3000"), + rateLimit: { + max: parseInt(process.env.RATE_LIMIT_MAX || "5"), + windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"), + }, +} 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"); +} diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 0000000..6966d0a --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,28 @@ +import mysql from "mysql2/promise"; +import { config } from "../config"; + +let pool: mysql.Pool | null = null; + +export function getPool(): mysql.Pool { + if (!pool) { + pool = mysql.createPool({ + host: config.mysql.host, + port: config.mysql.port, + user: config.mysql.user, + password: config.mysql.password, + database: config.mysql.database, + waitForConnections: true, + connectionLimit: 10, + maxIdle: 5, + idleTimeout: 60000, + }); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..28ce54d --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { randomUUID } from "crypto"; +import { hash } from "@node-rs/argon2"; +import { getPool } from "./connection"; +import { Log } from "../logger"; + +export async function runMigrations(): Promise { + const pool = getPool(); + const schemaPath = join(import.meta.dir, "schema.sql"); + const schema = readFileSync(schemaPath, "utf-8"); + + // Split on semicolons, filter empty statements + const statements = schema + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + for (const statement of statements) { + await pool.execute(statement); + } + + // Additive migrations for existing databases + const alterMigrations = [ + `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`, + ]; + + for (const migration of alterMigrations) { + try { + await pool.execute(migration); + } catch { + // Column/index already exists — safe to ignore + } + } + + // Seed default admin user if user table is empty + const [rows] = await pool.execute("SELECT COUNT(*) as count FROM `user`"); + const userCount = (rows as Array<{ count: number }>)[0].count; + if (userCount === 0) { + const passwordHash = await hash("admin", { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); + const id = randomUUID(); + await pool.execute( + "INSERT INTO `user` (`id`, `username`, `password_hash`, `role`) VALUES (?, ?, ?, ?)", + [id, "admin", passwordHash, "admin"] + ); + Log("MIGRATE", "Default admin user created (username: admin, password: admin)"); + } + + Log("MIGRATE", "Database migrations completed"); +} diff --git a/src/db/schema.sql b/src/db/schema.sql new file mode 100644 index 0000000..228f52a --- /dev/null +++ b/src/db/schema.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS `bug_reports` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `description` TEXT NOT NULL, + `hwid` VARCHAR(255) NOT NULL DEFAULT '', + `hostname` VARCHAR(255) NOT NULL DEFAULT '', + `os_user` VARCHAR(255) NOT NULL DEFAULT '', + `submitter_ip` VARCHAR(45) NOT NULL DEFAULT '', + `system_info` JSON NULL, + `status` ENUM('new', 'in_review', 'resolved', 'closed') NOT NULL DEFAULT 'new', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_status` (`status`), + INDEX `idx_hwid` (`hwid`), + INDEX `idx_hostname` (`hostname`), + INDEX `idx_os_user` (`os_user`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `bug_report_files` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `report_id` INT UNSIGNED NOT NULL, + `file_role` ENUM('screenshot', 'mail_file', 'localstorage', 'config', 'system_info') NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `mime_type` VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream', + `file_size` INT UNSIGNED NOT NULL DEFAULT 0, + `data` LONGBLOB NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `fk_report` FOREIGN KEY (`report_id`) REFERENCES `bug_reports`(`id`) ON DELETE CASCADE, + INDEX `idx_report_id` (`report_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `rate_limit_hwid` ( + `hwid` VARCHAR(255) PRIMARY KEY, + `window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `count` INT UNSIGNED NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user` ( + `id` VARCHAR(255) PRIMARY KEY, + `username` VARCHAR(255) NOT NULL UNIQUE, + `password_hash` VARCHAR(255) NOT NULL, + `role` ENUM('admin', 'user') NOT NULL DEFAULT 'user', + `enabled` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `displayname` VARCHAR(255) NOT NULL DEFAULT '' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `session` ( + `id` VARCHAR(255) PRIMARY KEY, + `user_id` VARCHAR(255) NOT NULL, + `expires_at` DATETIME NOT NULL, + CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cddf314 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,60 @@ +import { Elysia } from "elysia"; +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 { initLogger, Log } from "./logger"; + +// Initialize logger +initLogger(); + +// Validate environment +validateConfig(); + +// Run database migrations +await runMigrations(); + +const app = new Elysia() + .onRequest(({ request }) => { + const url = new URL(request.url); + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + Log("HTTP", `${request.method} ${url.pathname} from ${ip}`); + }) + .onAfterResponse(({ request, set }) => { + const url = new URL(request.url); + Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`); + }) + .onError(({ error, set }) => { + Log("ERROR", "Unhandled error:", error); + set.status = 500; + return { success: false, message: "Internal server error" }; + }) + .get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() })) + .use(bugReportRoutes) + .use(adminRoutes) + .listen({ + port: config.port, + maxBody: 50 * 1024 * 1024, // 50MB + }); + +Log( + "SERVER", + `EMLy Bug Report API running on http://localhost:${app.server?.port}` +); + +// Graceful shutdown +process.on("SIGINT", async () => { + Log("SERVER", "Shutting down (SIGINT)..."); + await closePool(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + Log("SERVER", "Shutting down (SIGTERM)..."); + await closePool(); + process.exit(0); +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..bc953a4 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,42 @@ +import { mkdirSync, appendFileSync, existsSync } from "fs"; +import { join } from "path"; + +let logFilePath: string | null = null; + +/** + * Initialize the logger. Creates the logs/ directory if needed + * and opens the log file in append mode. + */ +export function initLogger(filename = "api.log"): void { + const logsDir = join(process.cwd(), "logs"); + if (!existsSync(logsDir)) { + mkdirSync(logsDir, { recursive: true }); + } + logFilePath = join(logsDir, filename); + Log("LOGGER", "Logger initialized. Writing to:", logFilePath); +} + +/** + * Log a timestamped, source-tagged message to stdout and the log file. + * Format: [YYYY-MM-DD] - [HH:MM:SS] - [source] - message + */ +export function Log(source: string, ...args: unknown[]): void { + const now = new Date(); + 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))) + .join(" "); + + const line = `[${date}] - [${time}] - [${source}] - ${msg}`; + + console.log(line); + + if (logFilePath) { + try { + appendFileSync(logFilePath, line + "\n"); + } catch { + // If file write fails, stdout logging still works + } + } +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..df1e2b6 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,35 @@ +import { Elysia } from "elysia"; +import { config } from "../config"; +import { Log } from "../logger"; + +export const apiKeyGuard = 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" }); + } + return {}; + } +); + +export const adminKeyGuard = 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" }); + } + return {}; + } +); diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts new file mode 100644 index 0000000..70921d8 --- /dev/null +++ b/src/middleware/rateLimit.ts @@ -0,0 +1,72 @@ +import { Elysia } from "elysia"; +import { getPool } from "../db/connection"; +import { config } from "../config"; +import { Log } from "../logger"; + +const excludedHwids = new Set([ + // Add HWIDs here for development testing + "95e025d1-7567-462e-9354-ac88b965cd22", +]); + +export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive( + { as: "scoped" }, + // @ts-ignore + async ({ body, error }) => { + const hwid = (body as { hwid?: string })?.hwid; + if (!hwid || excludedHwids.has(hwid)) { + // No HWID provided or excluded, skip rate limiting + return {}; + } + + const pool = getPool(); + const windowMs = config.rateLimit.windowHours * 60 * 60 * 1000; + const now = new Date(); + + // Get current rate limit entry + const [rows] = await pool.execute( + "SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?", + [hwid] + ); + + const entries = rows as { window_start: Date; count: number }[]; + + if (entries.length === 0) { + // First request from this HWID + await pool.execute( + "INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)", + [hwid, now] + ); + return {}; + } + + const entry = entries[0]; + const windowStart = new Date(entry.window_start); + const elapsed = now.getTime() - windowStart.getTime(); + + if (elapsed > windowMs) { + // Window expired, reset + await pool.execute( + "UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?", + [now, hwid] + ); + return {}; + } + + if (entry.count >= config.rateLimit.max) { + const retryAfterMs = windowMs - elapsed; + const retryAfterMin = Math.ceil(retryAfterMs / 60000); + Log("RATELIMIT", `Rate limit hit hwid=${hwid} count=${entry.count}`); + return error(429, { + success: false, + message: `Rate limit exceeded. Try again in ${retryAfterMin} minutes.`, + }); + } + + // Increment count + await pool.execute( + "UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?", + [hwid] + ); + return {}; + } +); diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..3bc3e72 --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,109 @@ +import { Elysia, t } from "elysia"; +import { adminKeyGuard } from "../middleware/auth"; +import { + listBugReports, + getBugReport, + getFile, + deleteBugReport, + updateBugReportStatus, +} from "../services/bugReportService"; +import { Log } from "../logger"; +import type { BugReportStatus } from "../types"; + +export const adminRoutes = new Elysia({ prefix: "/api/admin" }) + .use(adminKeyGuard) + .get( + "/bug-reports", + async ({ query }) => { + const page = parseInt(query.page || "1"); + const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); + const status = query.status as BugReportStatus | undefined; + + Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`); + return await listBugReports({ page, pageSize, status }); + }, + { + query: t.Object({ + page: t.Optional(t.String()), + pageSize: t.Optional(t.String()), + status: t.Optional( + t.Union([ + t.Literal("new"), + t.Literal("in_review"), + t.Literal("resolved"), + t.Literal("closed"), + ]) + ), + }), + detail: { summary: "List bug reports (paginated)" }, + } + ) + .get( + "/bug-reports/:id", + async ({ params, error }) => { + 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" }); + 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 }) => { + Log("ADMIN", `Update status id=${params.id} status=${body.status}`); + const updated = await updateBugReportStatus( + parseInt(params.id), + body.status + ); + if (!updated) + return error(404, { success: false, message: "Report not found" }); + return { success: true, message: "Status updated" }; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + status: t.Union([ + t.Literal("new"), + t.Literal("in_review"), + t.Literal("resolved"), + t.Literal("closed"), + ]), + }), + 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)); + if (!file) + return error(404, { success: false, message: "File not found" }); + + set.headers["content-type"] = file.mime_type; + set.headers["content-disposition"] = + `attachment; filename="${file.filename}"`; + return new Response(file.data); + }, + { + params: t.Object({ id: t.String(), fileId: t.String() }), + detail: { summary: "Download a bug report file" }, + } + ) + .delete( + "/bug-reports/:id", + async ({ params, error }) => { + Log("ADMIN", `Delete bug report id=${params.id}`); + const deleted = await deleteBugReport(parseInt(params.id)); + if (!deleted) + return error(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" }, + } + ); diff --git a/src/routes/bugReports.ts b/src/routes/bugReports.ts new file mode 100644 index 0000000..21bc61f --- /dev/null +++ b/src/routes/bugReports.ts @@ -0,0 +1,107 @@ +import { Elysia, t } from "elysia"; +import { apiKeyGuard } from "../middleware/auth"; +import { hwidRateLimit } from "../middleware/rateLimit"; +import { createBugReport, addFile } from "../services/bugReportService"; +import { Log } from "../logger"; +import type { FileRole } from "../types"; + +const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ + { field: "screenshot", role: "screenshot", mime: "image/png" }, + { field: "mail_file", role: "mail_file", mime: "application/octet-stream" }, + { field: "localstorage", role: "localstorage", mime: "application/json" }, + { field: "config", role: "config", mime: "application/json" }, +]; + +export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) + .use(apiKeyGuard) + .use(hwidRateLimit) + .post( + "/", + async ({ body, request, set }) => { + const { name, email, description, hwid, hostname, os_user, system_info } = body; + + // Get submitter IP from headers or connection + const submitterIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + + 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; + if (system_info) { + if (typeof system_info === "string") { + try { + systemInfo = JSON.parse(system_info); + } catch { + systemInfo = null; + } + } else if (typeof system_info === "object") { + systemInfo = system_info as Record; + } + } + + // 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, + }); + + // 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", `Created successfully with id=${reportId}`); + + set.status = 201; + return { + success: true, + report_id: reportId, + message: "Bug report submitted successfully", + }; + }, + { + type: "multipart/form-data", + body: t.Object({ + name: t.String(), + email: t.String(), + description: t.String(), + hwid: t.Optional(t.String()), + hostname: t.Optional(t.String()), + os_user: t.Optional(t.String()), + system_info: t.Optional(t.Any()), + screenshot: t.Optional(t.File()), + mail_file: t.Optional(t.File()), + localstorage: t.Optional(t.File()), + config: t.Optional(t.File()), + }), + response: { + 201: t.Object({ + success: t.Boolean(), + report_id: t.Number(), + message: t.String(), + }), + }, + detail: { summary: "Submit a bug report" }, + } + ); diff --git a/src/services/bugReportService.ts b/src/services/bugReportService.ts new file mode 100644 index 0000000..6ab6056 --- /dev/null +++ b/src/services/bugReportService.ts @@ -0,0 +1,163 @@ +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import { getPool } from "../db/connection"; +import type { + BugReport, + BugReportFile, + BugReportListItem, + BugReportStatus, + FileRole, + 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(); + const [result] = await pool.execute( + `INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.name, + data.email, + data.description, + data.hwid, + data.hostname, + 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(); + const [result] = await pool.execute( + `INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + data.report_id, + data.file_role, + data.filename, + 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; + const offset = (page - 1) * pageSize; + + let whereClause = ""; + const params: unknown[] = []; + + if (status) { + whereClause = "WHERE br.status = ?"; + params.push(status); + } + + const [countRows] = await pool.execute( + `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`, + params + ); + const total = (countRows[0] as { total: number }).total; + + const [rows] = await pool.execute( + `SELECT br.*, COUNT(bf.id) as file_count + FROM bug_reports br + LEFT JOIN bug_report_files bf ON bf.report_id = br.id + ${whereClause} + GROUP BY br.id + ORDER BY br.created_at DESC + LIMIT ? OFFSET ?`, + [...params, pageSize, offset] + ); + + return { + data: rows as BugReportListItem[], + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; +} + +export async function getBugReport( + id: number +): Promise<{ report: BugReport; files: Omit[] } | null> { + const pool = getPool(); + + 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] + ); + + return { + report: reportRows[0] as BugReport, + files: fileRows as Omit[], + }; +} + +export async function getFile( + reportId: number, + fileId: number +): Promise { + const pool = getPool(); + const [rows] = await pool.execute( + "SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?", + [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(); + const [result] = await pool.execute( + "DELETE FROM bug_reports WHERE id = ?", + [id] + ); + return result.affectedRows > 0; +} + +export async function updateBugReportStatus( + id: number, + status: BugReportStatus +): Promise { + const pool = getPool(); + const [result] = await pool.execute( + "UPDATE bug_reports SET status = ? WHERE id = ?", + [status, id] + ); + return result.affectedRows > 0; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6b933a4 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,57 @@ +export type BugReportStatus = "new" | "in_review" | "resolved" | "closed"; + +export type FileRole = + | "screenshot" + | "mail_file" + | "localstorage" + | "config" + | "system_info"; + +export interface BugReport { + id: number; + name: string; + email: string; + description: string; + hwid: string; + hostname: string; + os_user: string; + submitter_ip: string; + system_info: Record | null; + status: BugReportStatus; + created_at: Date; + updated_at: Date; +} + +export interface BugReportFile { + id: number; + report_id: number; + file_role: FileRole; + filename: string; + mime_type: string; + file_size: number; + data?: Buffer; + created_at: Date; +} + +export interface BugReportListItem { + id: number; + name: string; + email: string; + description: string; + hwid: string; + hostname: string; + os_user: string; + submitter_ip: string; + status: BugReportStatus; + created_at: Date; + updated_at: Date; + file_count: number; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +}