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_screenshot.go # Screenshot capture functionality
├── app_bugreport.go # Bug report creation and submission
├── app_heartbeat.go # Bug report API heartbeat check
├── app_settings.go # Settings import/export
├── app_system.go # Windows system utilities (registry, encoding)
├── 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_screenshot.go` | Screenshots: `TakeScreenshot`, `SaveScreenshot`, `SaveScreenshotAs` |
| `app_bugreport.go` | Bug reports: `CreateBugReportFolder`, `SubmitBugReport`, `zipFolder` |
| `app_heartbeat.go` | API heartbeat: `CheckBugReportAPI` |
| `app_settings.go` | Settings I/O: `ExportSettings`, `ImportSettings` |
| `app_system.go` | System utilities: `CheckIsDefaultEMLHandler`, `OpenDefaultAppsSettings`, `ConvertToUTF8`, `OpenFolderInExplorer` |
| `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 |
| `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 |
| `CheckBugReportAPI()` | Checks if the bug report API is reachable via /health endpoint (3s timeout) |
**Settings (`app_settings.go`)**
@@ -673,9 +676,14 @@ Complete bug reporting system:
3. Includes current mail file if loaded
4. Gathers system information
5. Creates ZIP archive in temp folder
6. Attempts to upload to the bug report API server (if configured)
7. Falls back to local ZIP if server is unreachable
8. Shows server confirmation with report ID, or local path with upload warning
6. Checks if the bug report API is online via heartbeat (`CheckBugReportAPI`)
7. If online, attempts to upload to the bug report API server
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
@@ -684,6 +692,7 @@ A separate API server (`server/` directory) receives bug reports:
- **Deployment**: Docker Compose (`docker compose up -d` from `server/`)
- **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)
- **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)
#### Bug Report Dashboard

View File

@@ -249,14 +249,19 @@ External IP: %s
FolderPath: bugReportFolder,
}
// Attempt to upload to the bug report API server
reportID, uploadErr := a.UploadBugReport(bugReportFolder, input)
if uploadErr != nil {
Log("Bug report upload failed (falling back to local zip):", uploadErr)
result.UploadError = uploadErr.Error()
// 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 {
result.Uploaded = true
result.ReportID = reportID
reportID, uploadErr := a.UploadBugReport(bugReportFolder, input)
if uploadErr != nil {
Log("Bug report upload failed (falling back to local zip):", uploadErr)
result.UploadError = uploadErr.Error()
} else {
result.Uploaded = true
result.ReportID = reportID
}
}
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_PATH =
UPDATE_AUTO_CHECK = false
BUGREPORT_API_URL = "https://api.whiskr.it"
BUGREPORT_API_URL = "https://emly-api.lyzcoote.cloud"
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
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,