Compare commits
3 Commits
54a3dff1c2
...
c2052595cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2052595cb | ||
|
|
c6c27f2f30 | ||
|
|
d510c24b69 |
@@ -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,42 @@ 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)
|
||||
|
||||
#### Bug Report Dashboard
|
||||
|
||||
A web dashboard (`dashboard/` directory) for browsing, triaging, and downloading bug reports:
|
||||
- **Stack**: SvelteKit (Svelte 5) + TailwindCSS v4 + Drizzle ORM + Bun.js
|
||||
- **Deployment**: Docker service in `server/docker-compose.yml`, port 3001
|
||||
- **Database**: Connects directly to the same MySQL database via Drizzle ORM (read/write)
|
||||
- **Features**:
|
||||
- Paginated reports list with status filter and search (hostname, user, name, email)
|
||||
- Report detail view with metadata, description, system info (collapsible JSON), and file list
|
||||
- Status management (new → in_review → resolved → closed)
|
||||
- Inline screenshot preview for attached screenshots
|
||||
- Individual file download and bulk ZIP download (all files + report metadata)
|
||||
- Report deletion with confirmation dialog
|
||||
- Dark mode UI matching EMLy's aesthetic
|
||||
- **Development**: `cd dashboard && bun install && bun dev` (localhost:3001)
|
||||
|
||||
#### Configuration (config.ini)
|
||||
|
||||
```ini
|
||||
[EMLy]
|
||||
BUGREPORT_API_URL="https://your-server.example.com"
|
||||
BUGREPORT_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
### 5. Settings Management
|
||||
|
||||
|
||||
166
app_bugreport.go
166
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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
6
dashboard/.env.example
Normal file
6
dashboard/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# MySQL Connection
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=emly
|
||||
MYSQL_PASSWORD=change_me_in_production
|
||||
MYSQL_DATABASE=emly_bugreports
|
||||
5
dashboard/.gitignore
vendored
Normal file
5
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
bun.lock
|
||||
9
dashboard/Dockerfile
Normal file
9
dashboard/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM oven/bun:alpine
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile || bun install
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "build/index.js"]
|
||||
13
dashboard/drizzle.config.ts
Normal file
13
dashboard/drizzle.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/schema.ts',
|
||||
dialect: 'mysql',
|
||||
dbCredentials: {
|
||||
host: process.env.MYSQL_HOST || 'localhost',
|
||||
port: Number(process.env.MYSQL_PORT) || 3306,
|
||||
user: process.env.MYSQL_USER || 'emly',
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
database: process.env.MYSQL_DATABASE || 'emly_bugreports'
|
||||
}
|
||||
});
|
||||
34
dashboard/package.json
Normal file
34
dashboard/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "emly-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3001",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"bits-ui": "^1.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^3.0.0",
|
||||
"tailwind-variants": "^0.3.0",
|
||||
"jszip": "^3.10.0",
|
||||
"lucide-svelte": "^0.469.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
42
dashboard/src/app.css
Normal file
42
dashboard/src/app.css
Normal file
@@ -0,0 +1,42 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: hsl(222.2 84% 4.9%);
|
||||
--color-foreground: hsl(210 40% 98%);
|
||||
--color-card: hsl(222.2 84% 4.9%);
|
||||
--color-card-foreground: hsl(210 40% 98%);
|
||||
--color-popover: hsl(222.2 84% 4.9%);
|
||||
--color-popover-foreground: hsl(210 40% 98%);
|
||||
--color-primary: hsl(217.2 91.2% 59.8%);
|
||||
--color-primary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--color-secondary: hsl(217.2 32.6% 17.5%);
|
||||
--color-secondary-foreground: hsl(210 40% 98%);
|
||||
--color-muted: hsl(217.2 32.6% 17.5%);
|
||||
--color-muted-foreground: hsl(215 20.2% 65.1%);
|
||||
--color-accent: hsl(217.2 32.6% 17.5%);
|
||||
--color-accent-foreground: hsl(210 40% 98%);
|
||||
--color-destructive: hsl(0 62.8% 30.6%);
|
||||
--color-destructive-foreground: hsl(210 40% 98%);
|
||||
--color-border: hsl(217.2 32.6% 17.5%);
|
||||
--color-input: hsl(217.2 32.6% 17.5%);
|
||||
--color-ring: hsl(224.3 76.3% 48%);
|
||||
--radius: 0.5rem;
|
||||
|
||||
--color-sidebar: hsl(222.2 84% 3.5%);
|
||||
--color-sidebar-foreground: hsl(210 40% 98%);
|
||||
--color-sidebar-border: hsl(217.2 32.6% 12%);
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
12
dashboard/src/app.html
Normal file
12
dashboard/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
54
dashboard/src/lib/schema.ts
Normal file
54
dashboard/src/lib/schema.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
int,
|
||||
varchar,
|
||||
text,
|
||||
json,
|
||||
mysqlEnum,
|
||||
timestamp,
|
||||
customType
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
const longblob = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
return 'longblob';
|
||||
}
|
||||
});
|
||||
|
||||
export const bugReports = mysqlTable('bug_reports', {
|
||||
id: int('id').autoincrement().primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull(),
|
||||
description: text('description').notNull(),
|
||||
hwid: varchar('hwid', { length: 255 }).notNull().default(''),
|
||||
hostname: varchar('hostname', { length: 255 }).notNull().default(''),
|
||||
os_user: varchar('os_user', { length: 255 }).notNull().default(''),
|
||||
submitter_ip: varchar('submitter_ip', { length: 45 }).notNull().default(''),
|
||||
system_info: json('system_info'),
|
||||
status: mysqlEnum('status', ['new', 'in_review', 'resolved', 'closed']).notNull().default('new'),
|
||||
created_at: timestamp('created_at').notNull().defaultNow(),
|
||||
updated_at: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
|
||||
});
|
||||
|
||||
export const bugReportFiles = mysqlTable('bug_report_files', {
|
||||
id: int('id').autoincrement().primaryKey(),
|
||||
report_id: int('report_id')
|
||||
.notNull()
|
||||
.references(() => bugReports.id, { onDelete: 'cascade' }),
|
||||
file_role: mysqlEnum('file_role', [
|
||||
'screenshot',
|
||||
'mail_file',
|
||||
'localstorage',
|
||||
'config',
|
||||
'system_info'
|
||||
]).notNull(),
|
||||
filename: varchar('filename', { length: 255 }).notNull(),
|
||||
mime_type: varchar('mime_type', { length: 127 }).notNull().default('application/octet-stream'),
|
||||
file_size: int('file_size').notNull().default(0),
|
||||
data: longblob('data').notNull(),
|
||||
created_at: timestamp('created_at').notNull().defaultNow()
|
||||
});
|
||||
|
||||
export type BugReport = typeof bugReports.$inferSelect;
|
||||
export type BugReportFile = typeof bugReportFiles.$inferSelect;
|
||||
export type BugReportStatus = BugReport['status'];
|
||||
16
dashboard/src/lib/server/db.ts
Normal file
16
dashboard/src/lib/server/db.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import * as schema from '$lib/schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: env.MYSQL_HOST || 'localhost',
|
||||
port: Number(env.MYSQL_PORT) || 3306,
|
||||
user: env.MYSQL_USER || 'emly',
|
||||
password: env.MYSQL_PASSWORD,
|
||||
database: env.MYSQL_DATABASE || 'emly_bugreports',
|
||||
connectionLimit: 10,
|
||||
idleTimeout: 60000
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema, mode: 'default' });
|
||||
39
dashboard/src/lib/utils.ts
Normal file
39
dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export const statusColors: Record<string, string> = {
|
||||
new: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
in_review: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
resolved: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
closed: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30'
|
||||
};
|
||||
|
||||
export const statusLabels: Record<string, string> = {
|
||||
new: 'New',
|
||||
in_review: 'In Review',
|
||||
resolved: 'Resolved',
|
||||
closed: 'Closed'
|
||||
};
|
||||
14
dashboard/src/routes/+error.svelte
Normal file
14
dashboard/src/routes/+error.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
<h1 class="text-6xl font-bold text-muted-foreground">{$page.status}</h1>
|
||||
<p class="mt-4 text-lg text-muted-foreground">{$page.error?.message || 'Something went wrong'}</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-6 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Back to Reports
|
||||
</a>
|
||||
</div>
|
||||
15
dashboard/src/routes/+layout.server.ts
Normal file
15
dashboard/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { bugReports } from '$lib/schema';
|
||||
import { eq, count } from 'drizzle-orm';
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
const [result] = await db
|
||||
.select({ count: count() })
|
||||
.from(bugReports)
|
||||
.where(eq(bugReports.status, 'new'));
|
||||
|
||||
return {
|
||||
newCount: result.count
|
||||
};
|
||||
};
|
||||
61
dashboard/src/routes/+layout.svelte
Normal file
61
dashboard/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { page } from '$app/stores';
|
||||
import { Bug, LayoutDashboard } from 'lucide-svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex w-56 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
|
||||
<div class="flex items-center gap-2 border-b border-sidebar-border px-4 py-4">
|
||||
<Bug class="h-6 w-6 text-primary" />
|
||||
<span class="text-lg font-semibold">EMLy Dashboard</span>
|
||||
</div>
|
||||
<nav class="flex-1 px-2 py-3">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors {$page.url
|
||||
.pathname === '/'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'}"
|
||||
>
|
||||
<LayoutDashboard class="h-4 w-4" />
|
||||
Reports
|
||||
</a>
|
||||
</nav>
|
||||
<div class="border-t border-sidebar-border px-4 py-3 text-xs text-muted-foreground">
|
||||
EMLy Bug Reports
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<!-- Top bar -->
|
||||
<header
|
||||
class="flex h-14 items-center justify-between border-b border-border bg-card px-6"
|
||||
>
|
||||
<h1 class="text-lg font-semibold">
|
||||
{#if $page.url.pathname === '/'}
|
||||
Bug Reports
|
||||
{:else if $page.url.pathname.startsWith('/reports/')}
|
||||
Report Detail
|
||||
{:else}
|
||||
Dashboard
|
||||
{/if}
|
||||
</h1>
|
||||
{#if data.newCount > 0}
|
||||
<div class="flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-1.5 text-sm text-blue-400">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-blue-400"></span>
|
||||
{data.newCount} new {data.newCount === 1 ? 'report' : 'reports'}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 overflow-auto p-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
70
dashboard/src/routes/+page.server.ts
Normal file
70
dashboard/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { bugReports, bugReportFiles } from '$lib/schema';
|
||||
import { eq, like, or, count, sql, desc, and } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||
const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20));
|
||||
const status = url.searchParams.get('status') || '';
|
||||
const search = url.searchParams.get('search') || '';
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) {
|
||||
conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed'));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(bugReports.hostname, `%${search}%`),
|
||||
like(bugReports.os_user, `%${search}%`),
|
||||
like(bugReports.name, `%${search}%`),
|
||||
like(bugReports.email, `%${search}%`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// Get total count
|
||||
const [{ total }] = await db
|
||||
.select({ total: count() })
|
||||
.from(bugReports)
|
||||
.where(where);
|
||||
|
||||
// Get paginated reports with file count
|
||||
const reports = await db
|
||||
.select({
|
||||
id: bugReports.id,
|
||||
name: bugReports.name,
|
||||
email: bugReports.email,
|
||||
hostname: bugReports.hostname,
|
||||
os_user: bugReports.os_user,
|
||||
status: bugReports.status,
|
||||
created_at: bugReports.created_at,
|
||||
file_count: count(bugReportFiles.id)
|
||||
})
|
||||
.from(bugReports)
|
||||
.leftJoin(bugReportFiles, eq(bugReports.id, bugReportFiles.report_id))
|
||||
.where(where)
|
||||
.groupBy(bugReports.id)
|
||||
.orderBy(desc(bugReports.created_at))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize);
|
||||
|
||||
return {
|
||||
reports,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
},
|
||||
filters: {
|
||||
status,
|
||||
search
|
||||
}
|
||||
};
|
||||
};
|
||||
193
dashboard/src/routes/+page.svelte
Normal file
193
dashboard/src/routes/+page.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { statusColors, statusLabels, formatDate } from '$lib/utils';
|
||||
import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw } from 'lucide-svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let searchInput = $state('');
|
||||
let statusFilter = $state('');
|
||||
|
||||
$effect(() => {
|
||||
searchInput = data.filters.search;
|
||||
statusFilter = data.filters.status;
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
const params = new URLSearchParams();
|
||||
if (searchInput) params.set('search', searchInput);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
params.set('page', '1');
|
||||
goto(`/?${params.toString()}`);
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.set('page', String(p));
|
||||
goto(`/?${params.toString()}`);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchInput = '';
|
||||
statusFilter = '';
|
||||
goto('/');
|
||||
}
|
||||
|
||||
async function refreshReports() {
|
||||
try {
|
||||
await invalidateAll();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh reports:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search hostname, user, name, email..."
|
||||
bind:value={searchInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={applyFilters}
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="new">New</option>
|
||||
<option value="in_review">In Review</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={applyFilters}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Filter class="h-4 w-4" />
|
||||
Filter
|
||||
</button>
|
||||
<button
|
||||
onclick={refreshReports}
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<RefreshCcw class="h-4 w-4" />
|
||||
Filter
|
||||
</button>
|
||||
{#if data.filters.search || data.filters.status}
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="rounded-md border border-input px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-muted/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">ID</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Hostname</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Reporter</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Files</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.reports as report (report.id)}
|
||||
<tr
|
||||
class="border-b border-border transition-colors hover:bg-muted/30 cursor-pointer"
|
||||
onclick={() => goto(`/reports/${report.id}`)}
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-muted-foreground">#{report.id}</td>
|
||||
<td class="px-4 py-3">{report.hostname || '—'}</td>
|
||||
<td class="px-4 py-3">{report.os_user || '—'}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div>{report.name}</div>
|
||||
<div class="text-xs text-muted-foreground">{report.email}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded-full border px-2 py-0.5 text-xs font-medium {statusColors[report.status]}"
|
||||
>
|
||||
{statusLabels[report.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if report.file_count > 0}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Paperclip class="h-3.5 w-3.5" />
|
||||
{report.file_count}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{formatDate(report.created_at)}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-12 text-center text-muted-foreground">
|
||||
No reports found.
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.pagination.totalPages > 1}
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to {Math.min(
|
||||
data.pagination.page * data.pagination.pageSize,
|
||||
data.pagination.total
|
||||
)} of {data.pagination.total} reports
|
||||
</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => goToPage(data.pagination.page - 1)}
|
||||
disabled={data.pagination.page <= 1}
|
||||
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
{#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p}
|
||||
{#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)}
|
||||
<button
|
||||
onclick={() => goToPage(p)}
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm {p ===
|
||||
data.pagination.page
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-input hover:bg-accent'}"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
|
||||
<span class="px-1 text-muted-foreground">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => goToPage(data.pagination.page + 1)}
|
||||
disabled={data.pagination.page >= data.pagination.totalPages}
|
||||
class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
68
dashboard/src/routes/api/reports/[id]/download/+server.ts
Normal file
68
dashboard/src/routes/api/reports/[id]/download/+server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { bugReports, bugReportFiles } from '$lib/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) throw error(400, 'Invalid report ID');
|
||||
|
||||
const [report] = await db
|
||||
.select()
|
||||
.from(bugReports)
|
||||
.where(eq(bugReports.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!report) throw error(404, 'Report not found');
|
||||
|
||||
const files = await db
|
||||
.select()
|
||||
.from(bugReportFiles)
|
||||
.where(eq(bugReportFiles.report_id, id));
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Add report metadata as text file
|
||||
const reportText = [
|
||||
`Bug Report #${report.id}`,
|
||||
`========================`,
|
||||
``,
|
||||
`Name: ${report.name}`,
|
||||
`Email: ${report.email}`,
|
||||
`Hostname: ${report.hostname}`,
|
||||
`OS User: ${report.os_user}`,
|
||||
`HWID: ${report.hwid}`,
|
||||
`IP: ${report.submitter_ip}`,
|
||||
`Status: ${report.status}`,
|
||||
`Created: ${report.created_at.toISOString()}`,
|
||||
`Updated: ${report.updated_at.toISOString()}`,
|
||||
``,
|
||||
`Description:`,
|
||||
`------------`,
|
||||
report.description,
|
||||
``,
|
||||
...(report.system_info
|
||||
? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)]
|
||||
: [])
|
||||
].join('\n');
|
||||
|
||||
zip.file('report.txt', reportText);
|
||||
|
||||
// Add all files
|
||||
for (const file of files) {
|
||||
const folder = file.file_role;
|
||||
zip.file(`${folder}/${file.filename}`, file.data);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
|
||||
return new Response(new Uint8Array(zipBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="report-${id}.zip"`,
|
||||
'Content-Length': String(zipBuffer.length)
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { bugReportFiles } from '$lib/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const reportId = Number(params.id);
|
||||
const fileId = Number(params.fileId);
|
||||
|
||||
if (isNaN(reportId) || isNaN(fileId)) throw error(400, 'Invalid ID');
|
||||
|
||||
const [file] = await db
|
||||
.select()
|
||||
.from(bugReportFiles)
|
||||
.where(and(eq(bugReportFiles.id, fileId), eq(bugReportFiles.report_id, reportId)))
|
||||
.limit(1);
|
||||
|
||||
if (!file) throw error(404, 'File not found');
|
||||
|
||||
return new Response(new Uint8Array(file.data), {
|
||||
headers: {
|
||||
'Content-Type': file.mime_type,
|
||||
'Content-Disposition': `inline; filename="${file.filename}"`,
|
||||
'Content-Length': String(file.file_size)
|
||||
}
|
||||
});
|
||||
};
|
||||
13
dashboard/src/routes/api/reports/refresh/+server.ts
Normal file
13
dashboard/src/routes/api/reports/refresh/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { bugReports } from '$lib/schema';
|
||||
import { count } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const [{ total }] = await db
|
||||
.select({ total: count() })
|
||||
.from(bugReports);
|
||||
|
||||
return json({ total });
|
||||
};
|
||||
44
dashboard/src/routes/reports/[id]/+page.server.ts
Normal file
44
dashboard/src/routes/reports/[id]/+page.server.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { bugReports, bugReportFiles } from '$lib/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) throw error(400, 'Invalid report ID');
|
||||
|
||||
const [report] = await db
|
||||
.select()
|
||||
.from(bugReports)
|
||||
.where(eq(bugReports.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!report) throw error(404, 'Report not found');
|
||||
|
||||
const files = await db
|
||||
.select({
|
||||
id: bugReportFiles.id,
|
||||
report_id: bugReportFiles.report_id,
|
||||
file_role: bugReportFiles.file_role,
|
||||
filename: bugReportFiles.filename,
|
||||
mime_type: bugReportFiles.mime_type,
|
||||
file_size: bugReportFiles.file_size,
|
||||
created_at: bugReportFiles.created_at
|
||||
})
|
||||
.from(bugReportFiles)
|
||||
.where(eq(bugReportFiles.report_id, id));
|
||||
|
||||
return {
|
||||
report: {
|
||||
...report,
|
||||
system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null,
|
||||
created_at: report.created_at.toISOString(),
|
||||
updated_at: report.updated_at.toISOString()
|
||||
},
|
||||
files: files.map((f) => ({
|
||||
...f,
|
||||
created_at: f.created_at.toISOString()
|
||||
}))
|
||||
};
|
||||
};
|
||||
279
dashboard/src/routes/reports/[id]/+page.svelte
Normal file
279
dashboard/src/routes/reports/[id]/+page.svelte
Normal file
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { statusColors, statusLabels, formatDate, formatBytes } from '$lib/utils';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Trash2,
|
||||
FileText,
|
||||
Image,
|
||||
Monitor,
|
||||
Settings,
|
||||
Database,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Mail
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let showDeleteDialog = $state(false);
|
||||
let showSystemInfo = $state(false);
|
||||
let statusUpdating = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
const roleIcons: Record<string, typeof FileText> = {
|
||||
screenshot: Image,
|
||||
mail_file: Mail,
|
||||
localstorage: Database,
|
||||
config: Settings,
|
||||
system_info: Monitor
|
||||
};
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
screenshot: 'Screenshot',
|
||||
mail_file: 'Mail File',
|
||||
localstorage: 'Local Storage',
|
||||
config: 'Config',
|
||||
system_info: 'System Info'
|
||||
};
|
||||
|
||||
async function updateStatus(newStatus: string) {
|
||||
statusUpdating = true;
|
||||
try {
|
||||
const res = await fetch(`/reports/${data.report.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
});
|
||||
if (res.ok) await invalidateAll();
|
||||
} finally {
|
||||
statusUpdating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReport() {
|
||||
deleting = true;
|
||||
try {
|
||||
const res = await fetch(`/reports/${data.report.id}`, { method: 'DELETE' });
|
||||
if (res.ok) goto('/');
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Back button -->
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to Reports
|
||||
</a>
|
||||
|
||||
<!-- Header card -->
|
||||
<div class="rounded-lg border border-border bg-card p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-semibold">Report #{data.report.id}</h2>
|
||||
<span
|
||||
class="inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium {statusColors[data.report.status]}"
|
||||
>
|
||||
{statusLabels[data.report.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Submitted by {data.report.name} ({data.report.email})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status selector -->
|
||||
<select
|
||||
value={data.report.status}
|
||||
onchange={(e) => updateStatus(e.currentTarget.value)}
|
||||
disabled={statusUpdating}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
>
|
||||
<option value="new">New</option>
|
||||
<option value="in_review">In Review</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
|
||||
<!-- Download ZIP -->
|
||||
<a
|
||||
href="/api/reports/{data.report.id}/download"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
ZIP
|
||||
</a>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
onclick={() => (showDeleteDialog = true)}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground bg-destructive hover:bg-destructive/80"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata grid -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Hostname</p>
|
||||
<p class="mt-1 text-sm">{data.report.hostname || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">OS User</p>
|
||||
<p class="mt-1 text-sm">{data.report.os_user || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">HWID</p>
|
||||
<p class="mt-1 font-mono text-xs break-all">{data.report.hwid || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">IP Address</p>
|
||||
<p class="mt-1 text-sm">{data.report.submitter_ip || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Created</p>
|
||||
<p class="mt-1 text-sm">{formatDate(data.report.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Updated</p>
|
||||
<p class="mt-1 text-sm">{formatDate(data.report.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="rounded-lg border border-border bg-card p-6">
|
||||
<h3 class="text-sm font-medium text-muted-foreground">Description</h3>
|
||||
<p class="mt-2 whitespace-pre-wrap text-sm">{data.report.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
{#if data.report.system_info}
|
||||
<div class="rounded-lg border border-border bg-card">
|
||||
<button
|
||||
onclick={() => (showSystemInfo = !showSystemInfo)}
|
||||
class="flex w-full items-center gap-2 px-6 py-4 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if showSystemInfo}
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
{:else}
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
{/if}
|
||||
System Information
|
||||
</button>
|
||||
{#if showSystemInfo}
|
||||
<div class="border-t border-border px-6 py-4">
|
||||
<pre class="overflow-auto rounded-md bg-muted/50 p-4 text-xs">{data.report.system_info}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Files -->
|
||||
<div class="rounded-lg border border-border bg-card p-6">
|
||||
<h3 class="mb-4 text-sm font-medium text-muted-foreground">
|
||||
Attached Files ({data.files.length})
|
||||
</h3>
|
||||
{#if data.files.length > 0}
|
||||
<!-- Screenshot previews -->
|
||||
{@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
|
||||
{#if screenshots.length > 0}
|
||||
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each screenshots as file}
|
||||
<div class="overflow-hidden rounded-md border border-border">
|
||||
<img
|
||||
src="/api/reports/{data.report.id}/files/{file.id}"
|
||||
alt={file.filename}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="px-3 py-2 text-xs text-muted-foreground">{file.filename}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-hidden rounded-md border border-border">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-muted/50">
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Role</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Filename</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-muted-foreground">Size</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-muted-foreground">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.files as file}
|
||||
{@const Icon = roleIcons[file.file_role] || FileText}
|
||||
<tr class="border-b border-border last:border-0">
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon class="h-3.5 w-3.5" />
|
||||
{roleLabels[file.file_role] || file.file_role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 font-mono text-xs">{file.filename}</td>
|
||||
<td class="px-4 py-2 text-muted-foreground">{formatBytes(file.file_size)}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<a
|
||||
href="/api/reports/{data.report.id}/files/{file.id}"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-input px-2 py-1 text-xs hover:bg-accent"
|
||||
>
|
||||
<Download class="h-3 w-3" />
|
||||
Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No files attached.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
{#if showDeleteDialog}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
onkeydown={(e) => e.key === 'Escape' && (showDeleteDialog = false)}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="absolute inset-0" onclick={() => (showDeleteDialog = false)}></div>
|
||||
<div class="relative rounded-lg border border-border bg-card p-6 shadow-xl max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold">Delete Report</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Are you sure you want to delete report #{data.report.id}? This will permanently remove the
|
||||
report and all attached files. This action cannot be undone.
|
||||
</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => (showDeleteDialog = false)}
|
||||
class="rounded-md border border-input px-4 py-2 text-sm hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={deleteReport}
|
||||
disabled={deleting}
|
||||
class="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/80 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
39
dashboard/src/routes/reports/[id]/+server.ts
Normal file
39
dashboard/src/routes/reports/[id]/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { bugReports } from '$lib/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) throw error(400, 'Invalid report ID');
|
||||
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
|
||||
if (!['new', 'in_review', 'resolved', 'closed'].includes(status)) {
|
||||
throw error(400, 'Invalid status');
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
.update(bugReports)
|
||||
.set({ status })
|
||||
.where(eq(bugReports.id, id));
|
||||
|
||||
if (result.affectedRows === 0) throw error(404, 'Report not found');
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) throw error(400, 'Invalid report ID');
|
||||
|
||||
const [result] = await db
|
||||
.delete(bugReports)
|
||||
.where(eq(bugReports.id, id));
|
||||
|
||||
if (result.affectedRows === 0) throw error(404, 'Report not found');
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
12
dashboard/svelte.config.js
Normal file
12
dashboard/svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
dashboard/tsconfig.json
Normal file
14
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
7
dashboard/vite.config.ts
Normal file
7
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<!-- Success State -->
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<CheckCircle class="h-5 w-5 text-green-500" />
|
||||
{m.bugreport_success_title()}
|
||||
{#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" />
|
||||
{m.bugreport_success_title()}
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{m.bugreport_success_message()}
|
||||
{#if uploadedToServer}
|
||||
{m.bugreport_uploaded_success({ reportId: String(serverReportId) })}
|
||||
{:else}
|
||||
{m.bugreport_success_message()}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<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">
|
||||
<code class="text-xs break-all select-all">{resultZipPath}</code>
|
||||
</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"]
|
||||
54
server/docker-compose.yml
Normal file
54
server/docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
|
||||
dashboard:
|
||||
build: ../dashboard
|
||||
ports:
|
||||
- "${DASHBOARD_PORT:-3001}:3000"
|
||||
environment:
|
||||
MYSQL_HOST: mysql
|
||||
MYSQL_PORT: 3306
|
||||
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
|
||||
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