feat: add heartbeat check for bug report API and enhance logging throughout the application
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -249,14 +249,19 @@ 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)
|
||||||
reportID, uploadErr := a.UploadBugReport(bugReportFolder, input)
|
if !a.CheckBugReportAPI() {
|
||||||
if uploadErr != nil {
|
Log("Bug report API is offline, skipping upload")
|
||||||
Log("Bug report upload failed (falling back to local zip):", uploadErr)
|
result.UploadError = "Bug report API is offline"
|
||||||
result.UploadError = uploadErr.Error()
|
|
||||||
} else {
|
} else {
|
||||||
result.Uploaded = true
|
reportID, uploadErr := a.UploadBugReport(bugReportFolder, input)
|
||||||
result.ReportID = reportID
|
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
|
return result, nil
|
||||||
|
|||||||
45
app_heartbeat.go
Normal file
45
app_heartbeat.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
85
server/compose-dev.yml
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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, {
|
||||||
|
|||||||
42
server/dashboard/src/lib/server/logger.ts
Normal file
42
server/dashboard/src/lib/server/logger.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
42
server/src/logger.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user