Compare commits

..

3 Commits

Author SHA1 Message Date
Flavio Fois
c2052595cb feat: add refresh functionality for bug reports and improve pagination logic 2026-02-14 22:58:23 +01:00
Flavio Fois
c6c27f2f30 feat: initialize dashboard with bug reporting system
- Add Tailwind CSS for styling and custom theme variables.
- Create HTML structure for the dashboard with dark mode support.
- Implement database schema for bug reports and associated files using Drizzle ORM.
- Set up database connection with MySQL and environment variables.
- Create utility functions for class names, byte formatting, and date formatting.
- Develop error handling page for the dashboard.
- Implement layout and routing for the dashboard, including pagination and filtering for bug reports.
- Create API endpoints for downloading reports and files.
- Add functionality for viewing, updating, and deleting bug reports.
- Set up Docker configuration for the dashboard service.
- Include TypeScript configuration and Vite setup for the project.
2026-02-14 21:35:27 +01:00
Flavio Fois
d510c24b69 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.
2026-02-14 21:07:53 +01:00
48 changed files with 2150 additions and 13 deletions

View File

@@ -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

View File

@@ -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
}
// =============================================================================

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
node_modules
.svelte-kit
build
.env
bun.lock

9
dashboard/Dockerfile Normal file
View 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"]

View 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
View 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
View 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
View 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>

View 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'];

View 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' });

View 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'
};

View 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>

View 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
};
};

View 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>

View 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
}
};
};

View 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>

View 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)
}
});
};

View File

@@ -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)
}
});
};

View 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 });
};

View 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()
}))
};
};

View 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}

View 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 });
};

View 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
View 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
View 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()]
});

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,4 @@
node_modules/
.env
dist/
*.log

13
server/Dockerfile Normal file
View 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
View 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
View 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
View 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");
}

View 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
View 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
View 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
View 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);
});

View 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 {};
}
);

View 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
View 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" },
}
);

View 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" },
}
);

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