diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md
index 6ec0e1e..edc3e56 100644
--- a/DOCUMENTATION.md
+++ b/DOCUMENTATION.md
@@ -252,7 +252,8 @@ The Go backend is split into logical files:
| Method | Description |
|--------|-------------|
| `CreateBugReportFolder()` | Creates folder with screenshot and mail file |
-| `SubmitBugReport(input)` | Creates complete bug report with ZIP archive |
+| `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 |
**Settings (`app_settings.go`)**
@@ -672,7 +673,26 @@ Complete bug reporting system:
3. Includes current mail file if loaded
4. Gathers system information
5. Creates ZIP archive in temp folder
-6. Shows path and allows opening 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
+
+#### Bug Report API Server
+
+A separate API server (`server/` directory) receives bug reports:
+- **Stack**: Bun.js + ElysiaJS + MySQL 8
+- **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)
+- **Endpoints**: `POST /api/bug-reports` (client), `GET/DELETE /api/admin/bug-reports` (admin)
+
+#### Configuration (config.ini)
+
+```ini
+[EMLy]
+BUGREPORT_API_URL="https://your-server.example.com"
+BUGREPORT_API_KEY="your-api-key"
+```
### 5. Settings Management
diff --git a/app_bugreport.go b/app_bugreport.go
index 4b2fc0f..204187e 100644
--- a/app_bugreport.go
+++ b/app_bugreport.go
@@ -5,8 +5,13 @@ package main
import (
"archive/zip"
+ "bytes"
"encoding/base64"
+ "encoding/json"
"fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
"os"
"path/filepath"
"time"
@@ -50,6 +55,12 @@ type SubmitBugReportResult struct {
ZipPath string `json:"zipPath"`
// FolderPath is the path to the bug report folder
FolderPath string `json:"folderPath"`
+ // Uploaded indicates whether the report was successfully uploaded to the server
+ Uploaded bool `json:"uploaded"`
+ // ReportID is the server-assigned report ID (0 if not uploaded)
+ ReportID int64 `json:"reportId"`
+ // UploadError contains the error message if upload failed (empty on success)
+ UploadError string `json:"uploadError"`
}
// =============================================================================
@@ -233,10 +244,161 @@ External IP: %s
return nil, fmt.Errorf("failed to create zip file: %w", err)
}
- return &SubmitBugReportResult{
+ result := &SubmitBugReportResult{
ZipPath: zipPath,
FolderPath: bugReportFolder,
- }, nil
+ }
+
+ // 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()
+ } else {
+ result.Uploaded = true
+ result.ReportID = reportID
+ }
+
+ return result, nil
+}
+
+// UploadBugReport uploads the bug report files from the temp folder to the
+// configured API server. Returns the server-assigned report ID on success.
+//
+// Parameters:
+// - folderPath: Path to the bug report folder containing the files
+// - input: Original bug report input with user details
+//
+// Returns:
+// - int64: Server-assigned report ID
+// - error: Error if upload fails or API is not configured
+func (a *App) UploadBugReport(folderPath string, input BugReportInput) (int64, error) {
+ // Load config to get API URL and key
+ cfgPath := utils.DefaultConfigPath()
+ cfg, err := utils.LoadConfig(cfgPath)
+ if err != nil {
+ return 0, fmt.Errorf("failed to load config: %w", err)
+ }
+
+ apiURL := cfg.EMLy.BugReportAPIURL
+ apiKey := cfg.EMLy.BugReportAPIKey
+
+ if apiURL == "" {
+ return 0, fmt.Errorf("bug report API URL not configured")
+ }
+ if apiKey == "" {
+ return 0, fmt.Errorf("bug report API key not configured")
+ }
+
+ // Build multipart form
+ var buf bytes.Buffer
+ writer := multipart.NewWriter(&buf)
+
+ // Add text fields
+ writer.WriteField("name", input.Name)
+ writer.WriteField("email", input.Email)
+ writer.WriteField("description", input.Description)
+
+ // Add machine identification fields
+ machineInfo, err := utils.GetMachineInfo()
+ if err == nil && machineInfo != nil {
+ writer.WriteField("hwid", machineInfo.HWID)
+ writer.WriteField("hostname", machineInfo.Hostname)
+
+ // Add system_info as JSON string
+ sysInfoJSON, jsonErr := json.Marshal(machineInfo)
+ if jsonErr == nil {
+ writer.WriteField("system_info", string(sysInfoJSON))
+ }
+ }
+
+ // Add current OS username
+ if currentUser, userErr := os.UserHomeDir(); userErr == nil {
+ writer.WriteField("os_user", filepath.Base(currentUser))
+ }
+
+ // Add files from the folder
+ fileRoles := map[string]string{
+ "screenshot": "screenshot",
+ "mail_file": "mail_file",
+ "localStorage.json": "localstorage",
+ "config.json": "config",
+ }
+
+ entries, _ := os.ReadDir(folderPath)
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ filename := entry.Name()
+
+ // Determine file role
+ var role string
+ for pattern, r := range fileRoles {
+ if filename == pattern {
+ role = r
+ break
+ }
+ }
+ // Match screenshot and mail files by prefix/extension
+ if role == "" {
+ if filepath.Ext(filename) == ".png" {
+ role = "screenshot"
+ } else if filepath.Ext(filename) == ".eml" || filepath.Ext(filename) == ".msg" {
+ role = "mail_file"
+ }
+ }
+ if role == "" {
+ continue // skip report.txt and system_info.txt (sent as fields)
+ }
+
+ filePath := filepath.Join(folderPath, filename)
+ fileData, readErr := os.ReadFile(filePath)
+ if readErr != nil {
+ continue
+ }
+
+ part, partErr := writer.CreateFormFile(role, filename)
+ if partErr != nil {
+ continue
+ }
+ part.Write(fileData)
+ }
+
+ writer.Close()
+
+ // Send HTTP request
+ endpoint := apiURL + "/api/bug-reports"
+ req, err := http.NewRequest("POST", endpoint, &buf)
+ if err != nil {
+ return 0, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ req.Header.Set("X-API-Key", apiKey)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+
+ if resp.StatusCode != http.StatusCreated {
+ return 0, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
+ }
+
+ // Parse response
+ var response struct {
+ Success bool `json:"success"`
+ ReportID int64 `json:"report_id"`
+ }
+ if err := json.Unmarshal(body, &response); err != nil {
+ return 0, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ return response.ReportID, nil
}
// =============================================================================
diff --git a/backend/utils/ini-reader.go b/backend/utils/ini-reader.go
index 44be631..ba0d115 100644
--- a/backend/utils/ini-reader.go
+++ b/backend/utils/ini-reader.go
@@ -22,6 +22,8 @@ type EMLyConfig struct {
UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"`
UpdatePath string `ini:"UPDATE_PATH"`
UpdateAutoCheck string `ini:"UPDATE_AUTO_CHECK"`
+ BugReportAPIURL string `ini:"BUGREPORT_API_URL"`
+ BugReportAPIKey string `ini:"BUGREPORT_API_KEY"`
}
// LoadConfig reads the config.ini file at the given path and returns a Config struct
diff --git a/config.ini b/config.ini
index 601a4f8..904ff7f 100644
--- a/config.ini
+++ b/config.ini
@@ -7,3 +7,5 @@ LANGUAGE = it
UPDATE_CHECK_ENABLED = false
UPDATE_PATH =
UPDATE_AUTO_CHECK = true
+BUGREPORT_API_URL = "http://localhost:3000"
+BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"
\ No newline at end of file
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 969600e..69f5daf 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -218,5 +218,8 @@
"pdf_error_no_data_desc": "No PDF data provided. Please open this window from the main EMLy application.",
"pdf_error_timeout": "Timeout loading PDF. The worker might have failed to initialize.",
"pdf_error_parsing": "Error parsing PDF: ",
- "pdf_error_rendering": "Error rendering page: "
+ "pdf_error_rendering": "Error rendering page: ",
+ "bugreport_uploaded_success": "Your bug report has been uploaded successfully! Report ID: #{reportId}",
+ "bugreport_upload_failed": "Could not upload to server. Your report has been saved locally instead.",
+ "bugreport_uploaded_title": "Bug Report Uploaded"
}
diff --git a/frontend/messages/it.json b/frontend/messages/it.json
index ad7f190..e0708fe 100644
--- a/frontend/messages/it.json
+++ b/frontend/messages/it.json
@@ -218,6 +218,8 @@
"pdf_error_rendering": "Errore nel rendering della pagina: ",
"mail_download_btn_label": "Scarica",
"mail_download_btn_title": "Scarica",
- "mail_download_btn_text": "Scarica"
-
+ "mail_download_btn_text": "Scarica",
+ "bugreport_uploaded_success": "La tua segnalazione è stata caricata con successo! ID segnalazione: #{reportId}",
+ "bugreport_upload_failed": "Impossibile caricare sul server. La segnalazione è stata salvata localmente.",
+ "bugreport_uploaded_title": "Segnalazione Bug Caricata"
}
diff --git a/frontend/src/lib/components/BugReportDialog.svelte b/frontend/src/lib/components/BugReportDialog.svelte
index 84a352d..421550f 100644
--- a/frontend/src/lib/components/BugReportDialog.svelte
+++ b/frontend/src/lib/components/BugReportDialog.svelte
@@ -6,16 +6,24 @@
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
- import { CheckCircle, Copy, FolderOpen, Camera, Loader2 } from "@lucide/svelte";
+ import { CheckCircle, Copy, FolderOpen, Camera, Loader2, CloudUpload, AlertTriangle } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
import { browser } from "$app/environment";
+ import { dev } from "$app/environment";
// Bug report form state
let userName = $state("");
let userEmail = $state("");
let bugDescription = $state("");
-
+ // Auto-fill form in dev mode
+ $effect(() => {
+ if (dev && $bugReportDialogOpen && !userName) {
+ userName = "Test User";
+ userEmail = "test@example.com";
+ bugDescription = "This is a test bug report submitted from development mode.";
+ }
+ });
// Bug report screenshot state
let screenshotData = $state("");
let isCapturing = $state(false);
@@ -28,6 +36,9 @@
let isSubmitting = $state(false);
let isSuccess = $state(false);
let resultZipPath = $state("");
+ let uploadedToServer = $state(false);
+ let serverReportId = $state(0);
+ let uploadError = $state("");
let canSubmit: boolean = $derived(
bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing
);
@@ -100,6 +111,9 @@
isSubmitting = false;
isSuccess = false;
resultZipPath = "";
+ uploadedToServer = false;
+ serverReportId = 0;
+ uploadError = "";
}
async function handleBugReportSubmit(event: Event) {
@@ -123,8 +137,11 @@
});
resultZipPath = result.zipPath;
+ uploadedToServer = result.uploaded;
+ serverReportId = result.reportId;
+ uploadError = result.uploadError;
isSuccess = true;
- console.log("Bug report created:", result.zipPath);
+ console.log("Bug report created:", result.zipPath, "uploaded:", result.uploaded);
} catch (err) {
console.error("Failed to create bug report:", err);
toast.error(m.bugreport_error());
@@ -162,15 +179,31 @@
-
- {m.bugreport_success_title()}
+ {#if uploadedToServer}
+
+ {m.bugreport_uploaded_title()}
+ {:else}
+
+ {m.bugreport_success_title()}
+ {/if}
- {m.bugreport_success_message()}
+ {#if uploadedToServer}
+ {m.bugreport_uploaded_success({ reportId: String(serverReportId) })}
+ {:else}
+ {m.bugreport_success_message()}
+ {/if}
+ {#if uploadError}
+
+
+
{m.bugreport_upload_failed()}
+
+ {/if}
+
{resultZipPath}
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..6b1cd43
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,18 @@
+# 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
+
+# Rate Limiting
+RATE_LIMIT_MAX=5
+RATE_LIMIT_WINDOW_HOURS=24
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000..d21b1cb
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env
+dist/
+*.log
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..1eefc79
--- /dev/null
+++ b/server/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/server/docker-compose.yml b/server/docker-compose.yml
new file mode 100644
index 0000000..ff3f75c
--- /dev/null
+++ b/server/docker-compose.yml
@@ -0,0 +1,40 @@
+services:
+ mysql:
+ image: mysql:8.0
+ 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
+
+ api:
+ build: .
+ ports:
+ - "${PORT:-3000}:3000"
+ 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}
+ depends_on:
+ mysql:
+ condition: service_healthy
+
+volumes:
+ mysql_data:
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..987b510
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,17 @@
+{
+ "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": {
+ "elysia": "^1.2.0",
+ "mysql2": "^3.11.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/server/src/config.ts b/server/src/config.ts
new file mode 100644
index 0000000..ec1932b
--- /dev/null
+++ b/server/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/server/src/db/connection.ts b/server/src/db/connection.ts
new file mode 100644
index 0000000..6966d0a
--- /dev/null
+++ b/server/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/server/src/db/migrate.ts b/server/src/db/migrate.ts
new file mode 100644
index 0000000..ad65da3
--- /dev/null
+++ b/server/src/db/migrate.ts
@@ -0,0 +1,37 @@
+import { readFileSync } from "fs";
+import { join } from "path";
+import { getPool } from "./connection";
+
+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)`,
+ ];
+
+ for (const migration of alterMigrations) {
+ try {
+ await pool.execute(migration);
+ } catch {
+ // Column/index already exists — safe to ignore
+ }
+ }
+
+ console.log("Database migrations completed");
+}
diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql
new file mode 100644
index 0000000..034406c
--- /dev/null
+++ b/server/src/db/schema.sql
@@ -0,0 +1,38 @@
+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;
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 0000000..d6e9887
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,43 @@
+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";
+
+// Validate environment
+validateConfig();
+
+// Run database migrations
+await runMigrations();
+
+const app = new Elysia()
+ .onError(({ error, set }) => {
+ console.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
+ });
+
+console.log(
+ `EMLy Bug Report API running on http://localhost:${app.server?.port}`
+);
+
+// Graceful shutdown
+process.on("SIGINT", async () => {
+ console.log("Shutting down...");
+ await closePool();
+ process.exit(0);
+});
+
+process.on("SIGTERM", async () => {
+ console.log("Shutting down...");
+ await closePool();
+ process.exit(0);
+});
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
new file mode 100644
index 0000000..6c084b7
--- /dev/null
+++ b/server/src/middleware/auth.ts
@@ -0,0 +1,24 @@
+import { Elysia } from "elysia";
+import { config } from "../config";
+
+export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
+ { as: "scoped" },
+ ({ headers, error }) => {
+ const key = headers["x-api-key"];
+ if (!key || key !== config.apiKey) {
+ 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 }) => {
+ const key = headers["x-admin-key"];
+ if (!key || key !== config.adminKey) {
+ return error(401, { success: false, message: "Invalid or missing admin key" });
+ }
+ return {};
+ }
+);
diff --git a/server/src/middleware/rateLimit.ts b/server/src/middleware/rateLimit.ts
new file mode 100644
index 0000000..dfd3ded
--- /dev/null
+++ b/server/src/middleware/rateLimit.ts
@@ -0,0 +1,70 @@
+import { Elysia } from "elysia";
+import { getPool } from "../db/connection";
+import { config } from "../config";
+
+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);
+ 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/server/src/routes/admin.ts b/server/src/routes/admin.ts
new file mode 100644
index 0000000..de42a98
--- /dev/null
+++ b/server/src/routes/admin.ts
@@ -0,0 +1,104 @@
+import { Elysia, t } from "elysia";
+import { adminKeyGuard } from "../middleware/auth";
+import {
+ listBugReports,
+ getBugReport,
+ getFile,
+ deleteBugReport,
+ updateBugReportStatus,
+} from "../services/bugReportService";
+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;
+
+ 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 }) => {
+ 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 }) => {
+ 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 }) => {
+ 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/server/src/routes/bugReports.ts b/server/src/routes/bugReports.ts
new file mode 100644
index 0000000..8541705
--- /dev/null
+++ b/server/src/routes/bugReports.ts
@@ -0,0 +1,101 @@
+import { Elysia, t } from "elysia";
+import { apiKeyGuard } from "../middleware/auth";
+import { hwidRateLimit } from "../middleware/rateLimit";
+import { createBugReport, addFile } from "../services/bugReportService";
+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;
+
+ // 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;
+ }
+ }
+
+ // 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,
+ 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());
+ await addFile({
+ report_id: reportId,
+ file_role: role,
+ filename: file.name || `${field}.bin`,
+ mime_type: file.type || mime,
+ file_size: buffer.length,
+ data: buffer,
+ });
+ }
+ }
+
+ 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/server/src/services/bugReportService.ts b/server/src/services/bugReportService.ts
new file mode 100644
index 0000000..6ab6056
--- /dev/null
+++ b/server/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/server/src/types/index.ts b/server/src/types/index.ts
new file mode 100644
index 0000000..6b933a4
--- /dev/null
+++ b/server/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;
+}
diff --git a/server/tsconfig.json b/server/tsconfig.json
new file mode 100644
index 0000000..3575ba1
--- /dev/null
+++ b/server/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "outDir": "dist",
+ "declaration": true,
+ "types": ["bun"]
+ },
+ "include": ["src/**/*.ts"]
+}