Add initial implementation of EMLy Bug Report API with MySQL integration and Docker support
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 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
|
||||||
|
DASHBOARD_PORT=3001
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_MAX=5
|
||||||
|
RATE_LIMIT_WINDOW_HOURS=24
|
||||||
|
|
||||||
|
# Cloudflare Tunnel
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -136,3 +136,7 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
13
Dockerfile
Normal file
13
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"]
|
||||||
85
compose-dev.yml
Normal file
85
compose-dev.yml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:lts
|
||||||
|
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
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.2
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
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}
|
||||||
|
volumes:
|
||||||
|
- ./logs/api:/app/logs
|
||||||
|
restart: on-failure
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.3
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
build: ./dashboard
|
||||||
|
environment:
|
||||||
|
MYSQL_HOST: mysql
|
||||||
|
MYSQL_PORT: 3306
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
|
||||||
|
volumes:
|
||||||
|
- ./logs/dashboard:/app/logs
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.4
|
||||||
|
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN_DEV}
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
- dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.16.32.0/24
|
||||||
|
gateway: 172.16.32.1
|
||||||
85
compose-prod.yml
Normal file
85
compose-prod.yml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:lts
|
||||||
|
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
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.2
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
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}
|
||||||
|
volumes:
|
||||||
|
- ./logs/api:/app/logs
|
||||||
|
restart: on-failure
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.3
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
build: ./dashboard
|
||||||
|
environment:
|
||||||
|
MYSQL_HOST: mysql
|
||||||
|
MYSQL_PORT: 3306
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
|
||||||
|
volumes:
|
||||||
|
- ./logs/dashboard:/app/logs
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.4
|
||||||
|
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
- dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
ipv4_address: 172.16.32.5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
emly:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.16.32.0/24
|
||||||
|
gateway: 172.16.32.1
|
||||||
16
emly-server.service
Normal file
16
emly-server.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=EMLy Bug Report Server (Docker Compose)
|
||||||
|
Requires=docker.service
|
||||||
|
After=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory=/opt/emly-server
|
||||||
|
ExecStart=/usr/bin/docker compose up -d
|
||||||
|
ExecStop=/usr/bin/docker compose down
|
||||||
|
Restart=on-failure
|
||||||
|
TimeoutStartSec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
"elysia": "^1.2.0",
|
||||||
|
"mysql2": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/config.ts
Normal file
24
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
src/db/connection.ts
Normal file
28
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/db/migrate.ts
Normal file
59
src/db/migrate.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { hash } from "@node-rs/argon2";
|
||||||
|
import { getPool } from "./connection";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
|
||||||
|
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)`,
|
||||||
|
`ALTER TABLE user ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER role`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const migration of alterMigrations) {
|
||||||
|
try {
|
||||||
|
await pool.execute(migration);
|
||||||
|
} catch {
|
||||||
|
// Column/index already exists — safe to ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default admin user if user table is empty
|
||||||
|
const [rows] = await pool.execute("SELECT COUNT(*) as count FROM `user`");
|
||||||
|
const userCount = (rows as Array<{ count: number }>)[0].count;
|
||||||
|
if (userCount === 0) {
|
||||||
|
const passwordHash = await hash("admin", {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
outputLen: 32,
|
||||||
|
parallelism: 1
|
||||||
|
});
|
||||||
|
const id = randomUUID();
|
||||||
|
await pool.execute(
|
||||||
|
"INSERT INTO `user` (`id`, `username`, `password_hash`, `role`) VALUES (?, ?, ?, ?)",
|
||||||
|
[id, "admin", passwordHash, "admin"]
|
||||||
|
);
|
||||||
|
Log("MIGRATE", "Default admin user created (username: admin, password: admin)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("MIGRATE", "Database migrations completed");
|
||||||
|
}
|
||||||
55
src/db/schema.sql
Normal file
55
src/db/schema.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `user` (
|
||||||
|
`id` VARCHAR(255) PRIMARY KEY,
|
||||||
|
`username` VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
`password_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`role` ENUM('admin', 'user') NOT NULL DEFAULT 'user',
|
||||||
|
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`displayname` VARCHAR(255) NOT NULL DEFAULT ''
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `session` (
|
||||||
|
`id` VARCHAR(255) PRIMARY KEY,
|
||||||
|
`user_id` VARCHAR(255) NOT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
60
src/index.ts
Normal file
60
src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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";
|
||||||
|
import { initLogger, Log } from "./logger";
|
||||||
|
|
||||||
|
// Initialize logger
|
||||||
|
initLogger();
|
||||||
|
|
||||||
|
// Validate environment
|
||||||
|
validateConfig();
|
||||||
|
|
||||||
|
// Run database migrations
|
||||||
|
await runMigrations();
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
.onRequest(({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const ip =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"unknown";
|
||||||
|
Log("HTTP", `${request.method} ${url.pathname} from ${ip}`);
|
||||||
|
})
|
||||||
|
.onAfterResponse(({ request, set }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`);
|
||||||
|
})
|
||||||
|
.onError(({ error, set }) => {
|
||||||
|
Log("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
|
||||||
|
});
|
||||||
|
|
||||||
|
Log(
|
||||||
|
"SERVER",
|
||||||
|
`EMLy Bug Report API running on http://localhost:${app.server?.port}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
Log("SERVER", "Shutting down (SIGINT)...");
|
||||||
|
await closePool();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
Log("SERVER", "Shutting down (SIGTERM)...");
|
||||||
|
await closePool();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
42
src/logger.ts
Normal file
42
src/logger.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { mkdirSync, appendFileSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
let logFilePath: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the logger. Creates the logs/ directory if needed
|
||||||
|
* and opens the log file in append mode.
|
||||||
|
*/
|
||||||
|
export function initLogger(filename = "api.log"): void {
|
||||||
|
const logsDir = join(process.cwd(), "logs");
|
||||||
|
if (!existsSync(logsDir)) {
|
||||||
|
mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
logFilePath = join(logsDir, filename);
|
||||||
|
Log("LOGGER", "Logger initialized. Writing to:", logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a timestamped, source-tagged message to stdout and the log file.
|
||||||
|
* Format: [YYYY-MM-DD] - [HH:MM:SS] - [source] - message
|
||||||
|
*/
|
||||||
|
export function Log(source: string, ...args: unknown[]): void {
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toISOString().slice(0, 10);
|
||||||
|
const time = now.toTimeString().slice(0, 8);
|
||||||
|
const msg = args
|
||||||
|
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const line = `[${date}] - [${time}] - [${source}] - ${msg}`;
|
||||||
|
|
||||||
|
console.log(line);
|
||||||
|
|
||||||
|
if (logFilePath) {
|
||||||
|
try {
|
||||||
|
appendFileSync(logFilePath, line + "\n");
|
||||||
|
} catch {
|
||||||
|
// If file write fails, stdout logging still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/middleware/auth.ts
Normal file
35
src/middleware/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
|
||||||
|
export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
|
||||||
|
{ as: "scoped" },
|
||||||
|
({ headers, error, request }) => {
|
||||||
|
const key = headers["x-api-key"];
|
||||||
|
if (!key || key !== config.apiKey) {
|
||||||
|
const ip =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"unknown";
|
||||||
|
Log("AUTH", `Invalid API key from ip=${ip}`);
|
||||||
|
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, request }) => {
|
||||||
|
const key = headers["x-admin-key"];
|
||||||
|
if (!key || key !== config.adminKey) {
|
||||||
|
const ip =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"unknown";
|
||||||
|
Log("AUTH", `Invalid admin key from ip=${ip}`);
|
||||||
|
return error(401, { success: false, message: "Invalid or missing admin key" });
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
);
|
||||||
72
src/middleware/rateLimit.ts
Normal file
72
src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { getPool } from "../db/connection";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
|
||||||
|
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);
|
||||||
|
Log("RATELIMIT", `Rate limit hit hwid=${hwid} count=${entry.count}`);
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
);
|
||||||
109
src/routes/admin.ts
Normal file
109
src/routes/admin.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { adminKeyGuard } from "../middleware/auth";
|
||||||
|
import {
|
||||||
|
listBugReports,
|
||||||
|
getBugReport,
|
||||||
|
getFile,
|
||||||
|
deleteBugReport,
|
||||||
|
updateBugReportStatus,
|
||||||
|
} from "../services/bugReportService";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
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;
|
||||||
|
|
||||||
|
Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`);
|
||||||
|
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 }) => {
|
||||||
|
Log("ADMIN", `Get bug report id=${params.id}`);
|
||||||
|
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 }) => {
|
||||||
|
Log("ADMIN", `Update status id=${params.id} status=${body.status}`);
|
||||||
|
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 }) => {
|
||||||
|
Log("ADMIN", `Delete bug report id=${params.id}`);
|
||||||
|
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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
107
src/routes/bugReports.ts
Normal file
107
src/routes/bugReports.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { apiKeyGuard } from "../middleware/auth";
|
||||||
|
import { hwidRateLimit } from "../middleware/rateLimit";
|
||||||
|
import { createBugReport, addFile } from "../services/bugReportService";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`);
|
||||||
|
|
||||||
|
// 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`);
|
||||||
|
await addFile({
|
||||||
|
report_id: reportId,
|
||||||
|
file_role: role,
|
||||||
|
filename: file.name || `${field}.bin`,
|
||||||
|
mime_type: file.type || mime,
|
||||||
|
file_size: buffer.length,
|
||||||
|
data: buffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("BUGREPORT", `Created successfully with id=${reportId}`);
|
||||||
|
|
||||||
|
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
src/services/bugReportService.ts
Normal file
163
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
src/types/index.ts
Normal file
57
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user