feat: implement bug report submission with server upload functionality
- Updated documentation to include new API server details and configuration options. - Enhanced `SubmitBugReport` method to attempt server upload and handle errors gracefully. - Added `UploadBugReport` method to handle multipart file uploads to the API server. - Introduced new API server with MySQL backend for managing bug reports. - Implemented rate limiting and authentication for the API. - Created database schema and migration scripts for bug report storage. - Added admin routes for managing bug reports and files. - Updated frontend to reflect changes in bug report submission and success/error messages.
This commit is contained in:
@@ -252,7 +252,8 @@ The Go backend is split into logical files:
|
|||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `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 |
|
| `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`)**
|
**Settings (`app_settings.go`)**
|
||||||
|
|
||||||
@@ -672,7 +673,26 @@ 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. 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
|
### 5. Settings Management
|
||||||
|
|
||||||
|
|||||||
166
app_bugreport.go
166
app_bugreport.go
@@ -5,8 +5,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -50,6 +55,12 @@ type SubmitBugReportResult struct {
|
|||||||
ZipPath string `json:"zipPath"`
|
ZipPath string `json:"zipPath"`
|
||||||
// FolderPath is the path to the bug report folder
|
// FolderPath is the path to the bug report folder
|
||||||
FolderPath string `json:"folderPath"`
|
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 nil, fmt.Errorf("failed to create zip file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SubmitBugReportResult{
|
result := &SubmitBugReportResult{
|
||||||
ZipPath: zipPath,
|
ZipPath: zipPath,
|
||||||
FolderPath: bugReportFolder,
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type EMLyConfig struct {
|
|||||||
UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"`
|
UpdateCheckEnabled string `ini:"UPDATE_CHECK_ENABLED"`
|
||||||
UpdatePath string `ini:"UPDATE_PATH"`
|
UpdatePath string `ini:"UPDATE_PATH"`
|
||||||
UpdateAutoCheck string `ini:"UPDATE_AUTO_CHECK"`
|
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
|
// LoadConfig reads the config.ini file at the given path and returns a Config struct
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ LANGUAGE = it
|
|||||||
UPDATE_CHECK_ENABLED = false
|
UPDATE_CHECK_ENABLED = false
|
||||||
UPDATE_PATH =
|
UPDATE_PATH =
|
||||||
UPDATE_AUTO_CHECK = true
|
UPDATE_AUTO_CHECK = true
|
||||||
|
BUGREPORT_API_URL = "http://localhost:3000"
|
||||||
|
BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"
|
||||||
@@ -218,5 +218,8 @@
|
|||||||
"pdf_error_no_data_desc": "No PDF data provided. Please open this window from the main EMLy application.",
|
"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_timeout": "Timeout loading PDF. The worker might have failed to initialize.",
|
||||||
"pdf_error_parsing": "Error parsing PDF: ",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,8 @@
|
|||||||
"pdf_error_rendering": "Errore nel rendering della pagina: ",
|
"pdf_error_rendering": "Errore nel rendering della pagina: ",
|
||||||
"mail_download_btn_label": "Scarica",
|
"mail_download_btn_label": "Scarica",
|
||||||
"mail_download_btn_title": "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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,24 @@
|
|||||||
import { Input } from "$lib/components/ui/input/index.js";
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
import { Label } from "$lib/components/ui/label/index.js";
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
import { Textarea } from "$lib/components/ui/textarea/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 { toast } from "svelte-sonner";
|
||||||
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
|
import { TakeScreenshot, SubmitBugReport, OpenFolderInExplorer, GetConfig } from "$lib/wailsjs/go/main/App";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { dev } from "$app/environment";
|
||||||
|
|
||||||
// Bug report form state
|
// Bug report form state
|
||||||
let userName = $state("");
|
let userName = $state("");
|
||||||
let userEmail = $state("");
|
let userEmail = $state("");
|
||||||
let bugDescription = $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
|
// Bug report screenshot state
|
||||||
let screenshotData = $state("");
|
let screenshotData = $state("");
|
||||||
let isCapturing = $state(false);
|
let isCapturing = $state(false);
|
||||||
@@ -28,6 +36,9 @@
|
|||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let isSuccess = $state(false);
|
let isSuccess = $state(false);
|
||||||
let resultZipPath = $state("");
|
let resultZipPath = $state("");
|
||||||
|
let uploadedToServer = $state(false);
|
||||||
|
let serverReportId = $state(0);
|
||||||
|
let uploadError = $state("");
|
||||||
let canSubmit: boolean = $derived(
|
let canSubmit: boolean = $derived(
|
||||||
bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing
|
bugDescription.trim().length > 0 && userName.trim().length > 0 && userEmail.trim().length > 0 && !isSubmitting && !isCapturing
|
||||||
);
|
);
|
||||||
@@ -100,6 +111,9 @@
|
|||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
isSuccess = false;
|
isSuccess = false;
|
||||||
resultZipPath = "";
|
resultZipPath = "";
|
||||||
|
uploadedToServer = false;
|
||||||
|
serverReportId = 0;
|
||||||
|
uploadError = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleBugReportSubmit(event: Event) {
|
async function handleBugReportSubmit(event: Event) {
|
||||||
@@ -123,8 +137,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
resultZipPath = result.zipPath;
|
resultZipPath = result.zipPath;
|
||||||
|
uploadedToServer = result.uploaded;
|
||||||
|
serverReportId = result.reportId;
|
||||||
|
uploadError = result.uploadError;
|
||||||
isSuccess = true;
|
isSuccess = true;
|
||||||
console.log("Bug report created:", result.zipPath);
|
console.log("Bug report created:", result.zipPath, "uploaded:", result.uploaded);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create bug report:", err);
|
console.error("Failed to create bug report:", err);
|
||||||
toast.error(m.bugreport_error());
|
toast.error(m.bugreport_error());
|
||||||
@@ -162,15 +179,31 @@
|
|||||||
<!-- Success State -->
|
<!-- Success State -->
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
{#if uploadedToServer}
|
||||||
|
<CloudUpload class="h-5 w-5 text-green-500" />
|
||||||
|
{m.bugreport_uploaded_title()}
|
||||||
|
{:else}
|
||||||
<CheckCircle class="h-5 w-5 text-green-500" />
|
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||||
{m.bugreport_success_title()}
|
{m.bugreport_success_title()}
|
||||||
|
{/if}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
|
{#if uploadedToServer}
|
||||||
|
{m.bugreport_uploaded_success({ reportId: String(serverReportId) })}
|
||||||
|
{:else}
|
||||||
{m.bugreport_success_message()}
|
{m.bugreport_success_message()}
|
||||||
|
{/if}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="grid gap-4 py-4">
|
<div class="grid gap-4 py-4">
|
||||||
|
{#if uploadError}
|
||||||
|
<div class="flex items-start gap-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md p-3">
|
||||||
|
<AlertTriangle class="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
|
||||||
|
<p class="text-sm text-yellow-600 dark:text-yellow-400">{m.bugreport_upload_failed()}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="bg-muted rounded-md p-3">
|
<div class="bg-muted rounded-md p-3">
|
||||||
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
server/.env.example
Normal file
18
server/.env.example
Normal file
@@ -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
|
||||||
4
server/.gitignore
vendored
Normal file
4
server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
13
server/Dockerfile
Normal file
13
server/Dockerfile
Normal file
@@ -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"]
|
||||||
40
server/docker-compose.yml
Normal file
40
server/docker-compose.yml
Normal file
@@ -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:
|
||||||
17
server/package.json
Normal file
17
server/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
server/src/config.ts
Normal file
24
server/src/config.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
28
server/src/db/connection.ts
Normal file
28
server/src/db/connection.ts
Normal file
@@ -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<void> {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/src/db/migrate.ts
Normal file
37
server/src/db/migrate.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { getPool } from "./connection";
|
||||||
|
|
||||||
|
export async function runMigrations(): Promise<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
38
server/src/db/schema.sql
Normal file
38
server/src/db/schema.sql
Normal file
@@ -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;
|
||||||
43
server/src/index.ts
Normal file
43
server/src/index.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
24
server/src/middleware/auth.ts
Normal file
24
server/src/middleware/auth.ts
Normal file
@@ -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 {};
|
||||||
|
}
|
||||||
|
);
|
||||||
70
server/src/middleware/rateLimit.ts
Normal file
70
server/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { getPool } from "../db/connection";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
|
const excludedHwids = new Set<string>([
|
||||||
|
// 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 {};
|
||||||
|
}
|
||||||
|
);
|
||||||
104
server/src/routes/admin.ts
Normal file
104
server/src/routes/admin.ts
Normal file
@@ -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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
101
server/src/routes/bugReports.ts
Normal file
101
server/src/routes/bugReports.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
163
server/src/services/bugReportService.ts
Normal file
163
server/src/services/bugReportService.ts
Normal file
@@ -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<string, unknown> | null;
|
||||||
|
}): Promise<number> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`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<number> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`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<PaginatedResponse<BugReportListItem>> {
|
||||||
|
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<RowDataPacket[]>(
|
||||||
|
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = (countRows[0] as { total: number }).total;
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
`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<BugReportFile, "data">[] } | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT * FROM bug_reports WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((reportRows as unknown[]).length === 0) return null;
|
||||||
|
|
||||||
|
const [fileRows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<BugReportFile, "data">[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFile(
|
||||||
|
reportId: number,
|
||||||
|
fileId: number
|
||||||
|
): Promise<BugReportFile | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"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<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"DELETE FROM bug_reports WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBugReportStatus(
|
||||||
|
id: number,
|
||||||
|
status: BugReportStatus
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"UPDATE bug_reports SET status = ? WHERE id = ?",
|
||||||
|
[status, id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
57
server/src/types/index.ts
Normal file
57
server/src/types/index.ts
Normal file
@@ -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<string, unknown> | 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<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user