feat: add heartbeat check for bug report API and enhance logging throughout the application

This commit is contained in:
Flavio Fois
2026-02-16 08:54:29 +01:00
parent 894e8d9e51
commit 828adcfcc2
15 changed files with 312 additions and 26 deletions

View File

@@ -81,6 +81,7 @@ EMLy/
├── app_viewer.go # Viewer window management (image, PDF, EML) ├── app_viewer.go # Viewer window management (image, PDF, EML)
├── app_screenshot.go # Screenshot capture functionality ├── app_screenshot.go # Screenshot capture functionality
├── app_bugreport.go # Bug report creation and submission ├── app_bugreport.go # Bug report creation and submission
├── app_heartbeat.go # Bug report API heartbeat check
├── app_settings.go # Settings import/export ├── app_settings.go # Settings import/export
├── app_system.go # Windows system utilities (registry, encoding) ├── app_system.go # Windows system utilities (registry, encoding)
├── main.go # Application entry point ├── main.go # Application entry point
@@ -199,6 +200,7 @@ The Go backend is split into logical files:
| `app_viewer.go` | Viewer windows: `OpenImageWindow`, `OpenPDFWindow`, `OpenEMLWindow`, `OpenPDF`, `OpenImage`, `GetViewerData` | | `app_viewer.go` | Viewer windows: `OpenImageWindow`, `OpenPDFWindow`, `OpenEMLWindow`, `OpenPDF`, `OpenImage`, `GetViewerData` |
| `app_screenshot.go` | Screenshots: `TakeScreenshot`, `SaveScreenshot`, `SaveScreenshotAs` | | `app_screenshot.go` | Screenshots: `TakeScreenshot`, `SaveScreenshot`, `SaveScreenshotAs` |
| `app_bugreport.go` | Bug reports: `CreateBugReportFolder`, `SubmitBugReport`, `zipFolder` | | `app_bugreport.go` | Bug reports: `CreateBugReportFolder`, `SubmitBugReport`, `zipFolder` |
| `app_heartbeat.go` | API heartbeat: `CheckBugReportAPI` |
| `app_settings.go` | Settings I/O: `ExportSettings`, `ImportSettings` | | `app_settings.go` | Settings I/O: `ExportSettings`, `ImportSettings` |
| `app_system.go` | System utilities: `CheckIsDefaultEMLHandler`, `OpenDefaultAppsSettings`, `ConvertToUTF8`, `OpenFolderInExplorer` | | `app_system.go` | System utilities: `CheckIsDefaultEMLHandler`, `OpenDefaultAppsSettings`, `ConvertToUTF8`, `OpenFolderInExplorer` |
| `app_update.go` | Update system: `CheckForUpdates`, `DownloadUpdate`, `InstallUpdate`, `GetUpdateStatus` | | `app_update.go` | Update system: `CheckForUpdates`, `DownloadUpdate`, `InstallUpdate`, `GetUpdateStatus` |
@@ -254,6 +256,7 @@ The Go backend is split into logical files:
| `CreateBugReportFolder()` | Creates folder with screenshot and mail file | | `CreateBugReportFolder()` | Creates folder with screenshot and mail file |
| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive, attempts server upload | | `SubmitBugReport(input)` | Creates complete bug report with ZIP archive, attempts server upload |
| `UploadBugReport(folderPath, input)` | Uploads bug report files to configured API server via multipart POST | | `UploadBugReport(folderPath, input)` | Uploads bug report files to configured API server via multipart POST |
| `CheckBugReportAPI()` | Checks if the bug report API is reachable via /health endpoint (3s timeout) |
**Settings (`app_settings.go`)** **Settings (`app_settings.go`)**
@@ -673,9 +676,14 @@ Complete bug reporting system:
3. Includes current mail file if loaded 3. Includes current mail file if loaded
4. Gathers system information 4. Gathers system information
5. Creates ZIP archive in temp folder 5. Creates ZIP archive in temp folder
6. Attempts to upload to the bug report API server (if configured) 6. Checks if the bug report API is online via heartbeat (`CheckBugReportAPI`)
7. Falls back to local ZIP if server is unreachable 7. If online, attempts to upload to the bug report API server
8. Shows server confirmation with report ID, or local path with upload warning 8. Falls back to local ZIP if server is offline or upload fails
9. Shows server confirmation with report ID, or local path with upload warning
#### Heartbeat Check (`app_heartbeat.go`)
Before uploading a bug report, the app sends a GET request to `{BUGREPORT_API_URL}/health` with a 3-second timeout. If the API doesn't respond with status 200, the upload is skipped entirely and only the local ZIP is created. The `CheckBugReportAPI()` method is also exposed to the frontend for UI status checks.
#### Bug Report API Server #### Bug Report API Server
@@ -684,6 +692,7 @@ A separate API server (`server/` directory) receives bug reports:
- **Deployment**: Docker Compose (`docker compose up -d` from `server/`) - **Deployment**: Docker Compose (`docker compose up -d` from `server/`)
- **Auth**: Static API key for clients (`X-API-Key`), static admin key (`X-Admin-Key`) - **Auth**: Static API key for clients (`X-API-Key`), static admin key (`X-Admin-Key`)
- **Rate limiting**: HWID-based, configurable (default 5 reports per 24h) - **Rate limiting**: HWID-based, configurable (default 5 reports per 24h)
- **Logging**: Structured file logging to `logs/api.log` with format `[date] - [time] - [source] - message`
- **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin) - **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin)
#### Bug Report Dashboard #### Bug Report Dashboard

View File

@@ -249,7 +249,11 @@ External IP: %s
FolderPath: bugReportFolder, FolderPath: bugReportFolder,
} }
// Attempt to upload to the bug report API server // Attempt to upload to the bug report API server (only if reachable)
if !a.CheckBugReportAPI() {
Log("Bug report API is offline, skipping upload")
result.UploadError = "Bug report API is offline"
} else {
reportID, uploadErr := a.UploadBugReport(bugReportFolder, input) reportID, uploadErr := a.UploadBugReport(bugReportFolder, input)
if uploadErr != nil { if uploadErr != nil {
Log("Bug report upload failed (falling back to local zip):", uploadErr) Log("Bug report upload failed (falling back to local zip):", uploadErr)
@@ -258,6 +262,7 @@ External IP: %s
result.Uploaded = true result.Uploaded = true
result.ReportID = reportID result.ReportID = reportID
} }
}
return result, nil return result, nil
} }

45
app_heartbeat.go Normal file
View File

@@ -0,0 +1,45 @@
// Package main provides heartbeat checking for the bug report API.
package main
import (
"fmt"
"net/http"
"time"
"emly/backend/utils"
)
// CheckBugReportAPI sends a GET request to the bug report API's /health
// endpoint with a short timeout. Returns true if the API responds with
// status 200, false otherwise. This is exposed to the frontend.
func (a *App) CheckBugReportAPI() bool {
cfgPath := utils.DefaultConfigPath()
cfg, err := utils.LoadConfig(cfgPath)
if err != nil {
Log("Heartbeat: failed to load config:", err)
return false
}
apiURL := cfg.EMLy.BugReportAPIURL
if apiURL == "" {
Log("Heartbeat: bug report API URL not configured")
return false
}
endpoint := apiURL + "/health"
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(endpoint)
if err != nil {
Log("Heartbeat: API unreachable:", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
Log(fmt.Sprintf("Heartbeat: API returned status %d", resp.StatusCode))
return false
}
return true
}

View File

@@ -7,5 +7,5 @@ LANGUAGE = it
UPDATE_CHECK_ENABLED = false UPDATE_CHECK_ENABLED = false
UPDATE_PATH = UPDATE_PATH =
UPDATE_AUTO_CHECK = false UPDATE_AUTO_CHECK = false
BUGREPORT_API_URL = "https://api.whiskr.it" BUGREPORT_API_URL = "https://emly-api.lyzcoote.cloud"
BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63" BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"

85
server/compose-dev.yml Normal file
View File

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

View File

@@ -33,6 +33,8 @@ services:
PORT: 3000 PORT: 3000
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5} RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24} RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
volumes:
- ./logs/api:/app/logs
restart: on-failure restart: on-failure
depends_on: depends_on:
mysql: mysql:
@@ -49,10 +51,11 @@ services:
MYSQL_USER: ${MYSQL_USER:-emly} MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports} MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
volumes:
- ./logs/dashboard:/app/logs
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
networks: networks:
emly: emly:
ipv4_address: 172.16.32.4 ipv4_address: 172.16.32.4

View File

@@ -1,7 +1,18 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { lucia } from '$lib/server/auth'; import { lucia } from '$lib/server/auth';
import { initLogger, Log } from '$lib/server/logger';
// Initialize dashboard logger
initLogger();
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const ip =
event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
event.request.headers.get('x-real-ip') ||
event.getClientAddress?.() ||
'unknown';
Log('HTTP', `${event.request.method} ${event.url.pathname} from ${ip}`);
const sessionId = event.cookies.get(lucia.sessionCookieName); const sessionId = event.cookies.get(lucia.sessionCookieName);
if (!sessionId) { if (!sessionId) {
@@ -21,6 +32,7 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
if (!session) { if (!session) {
Log('AUTH', `Invalid session from ip=${ip}`);
const sessionCookie = lucia.createBlankSessionCookie(); const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, { event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.', path: '.',
@@ -30,6 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => {
// If user is disabled, invalidate their session and clear cookie // If user is disabled, invalidate their session and clear cookie
if (session && user && !user.enabled) { if (session && user && !user.enabled) {
Log('AUTH', `Disabled user rejected: username=${user.username} ip=${ip}`);
await lucia.invalidateSession(session.id); await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie(); const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, { event.cookies.set(sessionCookie.name, sessionCookie.value, {

View File

@@ -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 = "dashboard.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
}
}
}

View File

@@ -3,6 +3,7 @@ import { join } from "path";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { hash } from "@node-rs/argon2"; import { hash } from "@node-rs/argon2";
import { getPool } from "./connection"; import { getPool } from "./connection";
import { Log } from "../logger";
export async function runMigrations(): Promise<void> { export async function runMigrations(): Promise<void> {
const pool = getPool(); const pool = getPool();
@@ -51,8 +52,8 @@ export async function runMigrations(): Promise<void> {
"INSERT INTO `user` (`id`, `username`, `password_hash`, `role`) VALUES (?, ?, ?, ?)", "INSERT INTO `user` (`id`, `username`, `password_hash`, `role`) VALUES (?, ?, ?, ?)",
[id, "admin", passwordHash, "admin"] [id, "admin", passwordHash, "admin"]
); );
console.log("Default admin user created (username: admin, password: admin)"); Log("MIGRATE", "Default admin user created (username: admin, password: admin)");
} }
console.log("Database migrations completed"); Log("MIGRATE", "Database migrations completed");
} }

View File

@@ -4,6 +4,10 @@ import { runMigrations } from "./db/migrate";
import { closePool } from "./db/connection"; import { closePool } from "./db/connection";
import { bugReportRoutes } from "./routes/bugReports"; import { bugReportRoutes } from "./routes/bugReports";
import { adminRoutes } from "./routes/admin"; import { adminRoutes } from "./routes/admin";
import { initLogger, Log } from "./logger";
// Initialize logger
initLogger();
// Validate environment // Validate environment
validateConfig(); validateConfig();
@@ -12,8 +16,20 @@ validateConfig();
await runMigrations(); await runMigrations();
const app = new Elysia() 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 }) => { .onError(({ error, set }) => {
console.error("Unhandled error:", error); Log("ERROR", "Unhandled error:", error);
set.status = 500; set.status = 500;
return { success: false, message: "Internal server error" }; return { success: false, message: "Internal server error" };
}) })
@@ -25,19 +41,20 @@ const app = new Elysia()
maxBody: 50 * 1024 * 1024, // 50MB maxBody: 50 * 1024 * 1024, // 50MB
}); });
console.log( 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 // Graceful shutdown
process.on("SIGINT", async () => { process.on("SIGINT", async () => {
console.log("Shutting down..."); Log("SERVER", "Shutting down (SIGINT)...");
await closePool(); await closePool();
process.exit(0); process.exit(0);
}); });
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
console.log("Shutting down..."); Log("SERVER", "Shutting down (SIGTERM)...");
await closePool(); await closePool();
process.exit(0); process.exit(0);
}); });

42
server/src/logger.ts Normal file
View File

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

View File

@@ -1,11 +1,17 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { config } from "../config"; import { config } from "../config";
import { Log } from "../logger";
export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive( export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
{ as: "scoped" }, { as: "scoped" },
({ headers, error }) => { ({ headers, error, request }) => {
const key = headers["x-api-key"]; const key = headers["x-api-key"];
if (!key || key !== config.apiKey) { 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 error(401, { success: false, message: "Invalid or missing API key" });
} }
return {}; return {};
@@ -14,9 +20,14 @@ export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive( export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive(
{ as: "scoped" }, { as: "scoped" },
({ headers, error }) => { ({ headers, error, request }) => {
const key = headers["x-admin-key"]; const key = headers["x-admin-key"];
if (!key || key !== config.adminKey) { 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 error(401, { success: false, message: "Invalid or missing admin key" });
} }
return {}; return {};

View File

@@ -1,6 +1,7 @@
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { getPool } from "../db/connection"; import { getPool } from "../db/connection";
import { config } from "../config"; import { config } from "../config";
import { Log } from "../logger";
const excludedHwids = new Set<string>([ const excludedHwids = new Set<string>([
// Add HWIDs here for development testing // Add HWIDs here for development testing
@@ -54,6 +55,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
if (entry.count >= config.rateLimit.max) { if (entry.count >= config.rateLimit.max) {
const retryAfterMs = windowMs - elapsed; const retryAfterMs = windowMs - elapsed;
const retryAfterMin = Math.ceil(retryAfterMs / 60000); const retryAfterMin = Math.ceil(retryAfterMs / 60000);
Log("RATELIMIT", `Rate limit hit hwid=${hwid} count=${entry.count}`);
return error(429, { return error(429, {
success: false, success: false,
message: `Rate limit exceeded. Try again in ${retryAfterMin} minutes.`, message: `Rate limit exceeded. Try again in ${retryAfterMin} minutes.`,

View File

@@ -7,6 +7,7 @@ import {
deleteBugReport, deleteBugReport,
updateBugReportStatus, updateBugReportStatus,
} from "../services/bugReportService"; } from "../services/bugReportService";
import { Log } from "../logger";
import type { BugReportStatus } from "../types"; import type { BugReportStatus } from "../types";
export const adminRoutes = new Elysia({ prefix: "/api/admin" }) export const adminRoutes = new Elysia({ prefix: "/api/admin" })
@@ -18,6 +19,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100); const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
const status = query.status as BugReportStatus | undefined; const status = query.status as BugReportStatus | undefined;
Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`);
return await listBugReports({ page, pageSize, status }); return await listBugReports({ page, pageSize, status });
}, },
{ {
@@ -39,6 +41,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.get( .get(
"/bug-reports/:id", "/bug-reports/:id",
async ({ params, error }) => { async ({ params, error }) => {
Log("ADMIN", `Get bug report id=${params.id}`);
const result = await getBugReport(parseInt(params.id)); const result = await getBugReport(parseInt(params.id));
if (!result) return error(404, { success: false, message: "Report not found" }); if (!result) return error(404, { success: false, message: "Report not found" });
return result; return result;
@@ -51,6 +54,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.patch( .patch(
"/bug-reports/:id/status", "/bug-reports/:id/status",
async ({ params, body, error }) => { async ({ params, body, error }) => {
Log("ADMIN", `Update status id=${params.id} status=${body.status}`);
const updated = await updateBugReportStatus( const updated = await updateBugReportStatus(
parseInt(params.id), parseInt(params.id),
body.status body.status
@@ -92,6 +96,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.delete( .delete(
"/bug-reports/:id", "/bug-reports/:id",
async ({ params, error }) => { async ({ params, error }) => {
Log("ADMIN", `Delete bug report id=${params.id}`);
const deleted = await deleteBugReport(parseInt(params.id)); const deleted = await deleteBugReport(parseInt(params.id));
if (!deleted) if (!deleted)
return error(404, { success: false, message: "Report not found" }); return error(404, { success: false, message: "Report not found" });

View File

@@ -2,6 +2,7 @@ import { Elysia, t } from "elysia";
import { apiKeyGuard } from "../middleware/auth"; import { apiKeyGuard } from "../middleware/auth";
import { hwidRateLimit } from "../middleware/rateLimit"; import { hwidRateLimit } from "../middleware/rateLimit";
import { createBugReport, addFile } from "../services/bugReportService"; import { createBugReport, addFile } from "../services/bugReportService";
import { Log } from "../logger";
import type { FileRole } from "../types"; import type { FileRole } from "../types";
const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [
@@ -19,6 +20,14 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
async ({ body, request, set }) => { async ({ body, request, set }) => {
const { name, email, description, hwid, hostname, os_user, system_info } = body; 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 // Parse system_info — may arrive as a JSON string or already-parsed object
let systemInfo: Record<string, unknown> | null = null; let systemInfo: Record<string, unknown> | null = null;
if (system_info) { if (system_info) {
@@ -33,12 +42,6 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
} }
} }
// 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";
// Create the bug report // Create the bug report
const reportId = await createBugReport({ const reportId = await createBugReport({
name, name,
@@ -56,6 +59,7 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
const file = body[field as keyof typeof body]; const file = body[field as keyof typeof body];
if (file && file instanceof File) { if (file && file instanceof File) {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`);
await addFile({ await addFile({
report_id: reportId, report_id: reportId,
file_role: role, file_role: role,
@@ -67,6 +71,8 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
} }
} }
Log("BUGREPORT", `Created successfully with id=${reportId}`);
set.status = 201; set.status = 201;
return { return {
success: true, success: true,