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

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
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:
@@ -49,10 +51,11 @@ services:
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

View File

@@ -1,7 +1,18 @@
import type { Handle } from '@sveltejs/kit';
import { lucia } from '$lib/server/auth';
import { initLogger, Log } from '$lib/server/logger';
// Initialize dashboard logger
initLogger();
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);
if (!sessionId) {
@@ -21,6 +32,7 @@ export const handle: Handle = async ({ event, resolve }) => {
}
if (!session) {
Log('AUTH', `Invalid session from ip=${ip}`);
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
@@ -30,6 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => {
// If user is disabled, invalidate their session and clear cookie
if (session && user && !user.enabled) {
Log('AUTH', `Disabled user rejected: username=${user.username} ip=${ip}`);
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
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 { hash } from "@node-rs/argon2";
import { getPool } from "./connection";
import { Log } from "../logger";
export async function runMigrations(): Promise<void> {
const pool = getPool();
@@ -51,8 +52,8 @@ export async function runMigrations(): Promise<void> {
"INSERT INTO `user` (`id`, `username`, `password_hash`, `role`) VALUES (?, ?, ?, ?)",
[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 { bugReportRoutes } from "./routes/bugReports";
import { adminRoutes } from "./routes/admin";
import { initLogger, Log } from "./logger";
// Initialize logger
initLogger();
// Validate environment
validateConfig();
@@ -12,8 +16,20 @@ validateConfig();
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 }) => {
console.error("Unhandled error:", error);
Log("ERROR", "Unhandled error:", error);
set.status = 500;
return { success: false, message: "Internal server error" };
})
@@ -25,19 +41,20 @@ const app = new Elysia()
maxBody: 50 * 1024 * 1024, // 50MB
});
console.log(
Log(
"SERVER",
`EMLy Bug Report API running on http://localhost:${app.server?.port}`
);
// Graceful shutdown
process.on("SIGINT", async () => {
console.log("Shutting down...");
Log("SERVER", "Shutting down (SIGINT)...");
await closePool();
process.exit(0);
});
process.on("SIGTERM", async () => {
console.log("Shutting down...");
Log("SERVER", "Shutting down (SIGTERM)...");
await closePool();
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 { config } from "../config";
import { Log } from "../logger";
export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
{ as: "scoped" },
({ headers, error }) => {
({ 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 {};
@@ -14,9 +20,14 @@ export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive(
{ as: "scoped" },
({ headers, error }) => {
({ 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 {};

View File

@@ -1,6 +1,7 @@
import { Elysia } from "elysia";
import { getPool } from "../db/connection";
import { config } from "../config";
import { Log } from "../logger";
const excludedHwids = new Set<string>([
// 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) {
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.`,

View File

@@ -7,6 +7,7 @@ import {
deleteBugReport,
updateBugReportStatus,
} from "../services/bugReportService";
import { Log } from "../logger";
import type { BugReportStatus } from "../types";
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 status = query.status as BugReportStatus | undefined;
Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`);
return await listBugReports({ page, pageSize, status });
},
{
@@ -39,6 +41,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.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;
@@ -51,6 +54,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.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
@@ -92,6 +96,7 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.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" });

View File

@@ -2,6 +2,7 @@ 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 }[] = [
@@ -19,6 +20,14 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
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<string, unknown> | null = null;
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
const reportId = await createBugReport({
name,
@@ -56,6 +59,7 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
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,
@@ -67,6 +71,8 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
}
}
Log("BUGREPORT", `Created successfully with id=${reportId}`);
set.status = 201;
return {
success: true,