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"] +}