Compare commits
6 Commits
stable
...
b7f42ce3ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f42ce3ae | ||
|
|
5624019f23 | ||
|
|
9458d1e8ad | ||
|
|
3f15edae75 | ||
|
|
5761cbaa55 | ||
|
|
19e199a578 |
18
.env.example
18
.env.example
@@ -1,4 +1,4 @@
|
|||||||
# MySQL
|
# MySQL Production DB
|
||||||
MYSQL_HOST=mysql
|
MYSQL_HOST=mysql
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
MYSQL_USER=emly
|
MYSQL_USER=emly
|
||||||
@@ -6,10 +6,21 @@ MYSQL_PASSWORD=change_me_in_production
|
|||||||
MYSQL_DATABASE=emly_bugreports
|
MYSQL_DATABASE=emly_bugreports
|
||||||
MYSQL_ROOT_PASSWORD=change_root_password
|
MYSQL_ROOT_PASSWORD=change_root_password
|
||||||
|
|
||||||
|
# MySQL Testing DB (if ENABLE_TEST_DB is true)
|
||||||
|
TESTING_MYSQL_HOST=mysql
|
||||||
|
TESTING_MYSQL_PORT=3306
|
||||||
|
TESTING_MYSQL_USER=emly
|
||||||
|
TESTING_MYSQL_PASSWORD=change_me_in_production
|
||||||
|
TESTING_MYSQL_DATABASE=emly_bugreports
|
||||||
|
TESTING_MYSQL_ROOT_PASSWORD=change_root_password
|
||||||
|
|
||||||
# API Keys
|
# API Keys
|
||||||
API_KEY=change_me_client_key
|
API_KEY=change_me_client_key
|
||||||
ADMIN_KEY=change_me_admin_key
|
ADMIN_KEY=change_me_admin_key
|
||||||
|
|
||||||
|
# Hostname
|
||||||
|
HOSTNAME=amazing-kobold
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DASHBOARD_PORT=3001
|
DASHBOARD_PORT=3001
|
||||||
@@ -18,6 +29,9 @@ DASHBOARD_PORT=3001
|
|||||||
RATE_LIMIT_MAX=5
|
RATE_LIMIT_MAX=5
|
||||||
RATE_LIMIT_WINDOW_HOURS=24
|
RATE_LIMIT_WINDOW_HOURS=24
|
||||||
|
|
||||||
|
# Test DB flag
|
||||||
|
FLAG_ENABLE_TEST_DB = false
|
||||||
|
|
||||||
# Cloudflare Tunnel
|
# Cloudflare Tunnel
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token
|
CLOUDFLARE_TUNNEL_TOKEN=change_me_cloudflare_tunnel_token
|
||||||
CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token
|
CLOUDFLARE_TUNNEL_TOKEN_DEV=change_me_cloudflare_tunnel_token
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ COPY src/ ./src/
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["bun", "run", "src/index.ts"]
|
# Use a small startup script that waits for MySQL to be ready
|
||||||
|
CMD ["bun", "run", "src/wait-for-mysql.ts"]
|
||||||
|
|||||||
61
compose.yml
61
compose.yml
@@ -1,7 +1,42 @@
|
|||||||
services:
|
services:
|
||||||
api:
|
reverse-proxy:
|
||||||
|
image: traefik:latest
|
||||||
|
command:
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
|
||||||
|
emly-mysql-db:
|
||||||
|
image: mysql:lts
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
- ./init:/docker-entrypoint-initdb.d
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
emly-api:
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
|
INSTANCE_ID: ${HOSTNAME}
|
||||||
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
MYSQL_HOST: ${MYSQL_HOST:-mysql}
|
||||||
MYSQL_PORT: ${MYSQL_PORT:-3306}
|
MYSQL_PORT: ${MYSQL_PORT:-3306}
|
||||||
MYSQL_USER: ${MYSQL_USER:-emly}
|
MYSQL_USER: ${MYSQL_USER:-emly}
|
||||||
@@ -13,5 +48,25 @@ services:
|
|||||||
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
|
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
|
||||||
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
|
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs/api:/app/logs
|
- emly-api-logs:/app/logs
|
||||||
restart: on-failure
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
mode: replicated
|
||||||
|
replicas: 3
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1024M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.emly-api.entrypoints=web"
|
||||||
|
- "traefik.http.routers.emly-api.rule=PathPrefix(`/`)"
|
||||||
|
- "traefik.http.services.emly-api.loadbalancer.server.port=3000"
|
||||||
|
depends_on:
|
||||||
|
- emly-mysql-db
|
||||||
|
volumes:
|
||||||
|
mysql-data:
|
||||||
|
emly-api-logs:
|
||||||
56
init/init.sql
Normal file
56
init/init.sql
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
12
package.json
12
package.json
@@ -4,15 +4,19 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/index.ts",
|
||||||
"start": "bun run src/index.ts"
|
"dev:wait": "bun run --watch src/wait-for-mysql.ts",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"start:wait": "bun run src/wait-for-mysql.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@elysiajs/swagger": "^1.3.1",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"elysia": "^1.2.0",
|
"elysia": "^1.4.27",
|
||||||
"mysql2": "^3.11.0"
|
"jszip": "^3.10.1",
|
||||||
|
"mysql2": "^3.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export const config = {
|
|||||||
password: process.env.MYSQL_PASSWORD || "",
|
password: process.env.MYSQL_PASSWORD || "",
|
||||||
database: process.env.MYSQL_DATABASE || "emly_bugreports",
|
database: process.env.MYSQL_DATABASE || "emly_bugreports",
|
||||||
},
|
},
|
||||||
|
testing_mysql: {
|
||||||
|
host: process.env.TESTING_MYSQL_HOST || "localhost",
|
||||||
|
port: parseInt(process.env.TESTING_MYSQL_PORT || "3306"),
|
||||||
|
user: process.env.TESTING_MYSQL_USER || "emly",
|
||||||
|
password: process.env.TESTING_MYSQL_PASSWORD || "",
|
||||||
|
database: process.env.TESTING_MYSQL_DATABASE || "emly_bugreports",
|
||||||
|
},
|
||||||
apiKey: process.env.API_KEY || "",
|
apiKey: process.env.API_KEY || "",
|
||||||
adminKey: process.env.ADMIN_KEY || "",
|
adminKey: process.env.ADMIN_KEY || "",
|
||||||
port: parseInt(process.env.PORT || "3000"),
|
port: parseInt(process.env.PORT || "3000"),
|
||||||
@@ -13,12 +20,14 @@ export const config = {
|
|||||||
max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
|
max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
|
||||||
windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"),
|
windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"),
|
||||||
},
|
},
|
||||||
|
enableTestDB: process.env.FLAG_ENABLE_TEST_DB === "true",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Validate required config on startup
|
// Validate required config on startup
|
||||||
export function validateConfig(): void {
|
export function validateConfig(): void {
|
||||||
if (!config.apiKey) throw new Error("API_KEY is required");
|
if (!config.apiKey) throw new Error("API_KEY is required");
|
||||||
if (!config.adminKey) throw new Error("ADMIN_KEY is required");
|
if (!config.adminKey) throw new Error("ADMIN_KEY is required");
|
||||||
if (!config.mysql.password)
|
if (!config.mysql.password) throw new Error("MYSQL_PASSWORD is required");
|
||||||
throw new Error("MYSQL_PASSWORD is required");
|
if (!config.testing_mysql.password && config.enableTestDB)
|
||||||
|
throw new Error("TESTING_MYSQL_PASSWORD is required");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
|
||||||
let pool: mysql.Pool | null = null;
|
let pool: mysql.Pool | null = null;
|
||||||
|
|
||||||
export function getPool(): mysql.Pool {
|
export function getPool(useTestDb?: boolean): mysql.Pool {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
|
if (useTestDb && config.enableTestDB) {
|
||||||
|
Log("DB", "Using Test DB Pool Connection");
|
||||||
|
return mysql.createPool({
|
||||||
|
host: config.testing_mysql.host,
|
||||||
|
port: config.testing_mysql.port,
|
||||||
|
user: config.testing_mysql.user,
|
||||||
|
password: config.testing_mysql.password,
|
||||||
|
database: config.testing_mysql.database,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
maxIdle: 5,
|
||||||
|
idleTimeout: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
pool = mysql.createPool({
|
pool = mysql.createPool({
|
||||||
host: config.mysql.host,
|
host: config.mysql.host,
|
||||||
port: config.mysql.port,
|
port: config.mysql.port,
|
||||||
@@ -17,6 +32,20 @@ export function getPool(): mysql.Pool {
|
|||||||
idleTimeout: 60000,
|
idleTimeout: 60000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (useTestDb && config.enableTestDB) {
|
||||||
|
Log("DB", "Using Test DB Pool Connection");
|
||||||
|
return mysql.createPool({
|
||||||
|
host: config.testing_mysql.host,
|
||||||
|
port: config.testing_mysql.port,
|
||||||
|
user: config.testing_mysql.user,
|
||||||
|
password: config.testing_mysql.password,
|
||||||
|
database: config.testing_mysql.database,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
maxIdle: 5,
|
||||||
|
idleTimeout: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function runMigrations(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await pool.execute(migration);
|
await pool.execute(migration);
|
||||||
} catch {
|
} catch {
|
||||||
// Column/index already exists — safe to ignore
|
// Column/index already exists - safe to ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
src/features.json
Normal file
7
src/features.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"testDb": {
|
||||||
|
"label": "Testing Database",
|
||||||
|
"description": "Accepts bug reports routed to a separate testing database via the x-db-env header",
|
||||||
|
"key": "FLAG_ENABLE_TEST_DB"
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/index.ts
85
src/index.ts
@@ -1,49 +1,118 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
|
import { swagger } from "@elysiajs/swagger";
|
||||||
import { config, validateConfig } from "./config";
|
import { config, validateConfig } from "./config";
|
||||||
import { runMigrations } from "./db/migrate";
|
import { runMigrations } from "./db/migrate";
|
||||||
import { closePool } from "./db/connection";
|
import { closePool } from "./db/connection";
|
||||||
import { bugReportRoutes } from "./routes/bugReports";
|
import { bugReportRoutes } from "./routes/bugReports";
|
||||||
import { adminRoutes } from "./routes/admin";
|
import { adminRoutes } from "./routes/admin";
|
||||||
|
import { authRoutes } from "./routes/auth";
|
||||||
|
import { featuresRoutes } from "./routes/features";
|
||||||
import { initLogger, Log } from "./logger";
|
import { initLogger, Log } from "./logger";
|
||||||
|
import { adminKeyGuard2 } from "./middleware/auth";
|
||||||
|
|
||||||
|
const INSTANCE_ID =
|
||||||
|
process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
|
||||||
|
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
initLogger();
|
initLogger();
|
||||||
|
|
||||||
// Validate environment
|
// Validate environment
|
||||||
validateConfig();
|
try {
|
||||||
|
validateConfig();
|
||||||
|
} catch (error) {
|
||||||
|
Log("ERROR", "Failed to validate config:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Run database migrations
|
// Run database migrations
|
||||||
await runMigrations();
|
try {
|
||||||
|
await runMigrations();
|
||||||
|
} catch (error) {
|
||||||
|
Log("ERROR", "Failed to run migrations:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.onRequest(({ request }) => {
|
.onRequest(({ request, set }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
const ua = request.headers.get("user-agent") ?? "unknown";
|
||||||
const ip =
|
const ip =
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
request.headers.get("x-real-ip") ||
|
request.headers.get("x-real-ip") ||
|
||||||
"unknown";
|
"unknown";
|
||||||
Log("HTTP", `${request.method} ${url.pathname} from ${ip}`);
|
if (url.pathname !== "/api/admin/auth/validate")
|
||||||
|
Log(
|
||||||
|
"HTTP",
|
||||||
|
`[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`,
|
||||||
|
);
|
||||||
|
set.headers["x-instance-id"] = INSTANCE_ID;
|
||||||
|
set.headers["x-server"] = "EMLy-API";
|
||||||
})
|
})
|
||||||
.onAfterResponse(({ request, set }) => {
|
.onAfterResponse(({ request, set }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`);
|
if (url.pathname !== "/api/admin/auth/validate")
|
||||||
|
Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`);
|
||||||
})
|
})
|
||||||
.onError(({ error, set }) => {
|
.onError(({ error, set, code }) => {
|
||||||
|
console.error("Error processing request:", error);
|
||||||
|
console.log(code);
|
||||||
|
if (code === "NOT_FOUND") {
|
||||||
|
set.status = 404;
|
||||||
|
return { success: false, message: "Not found" };
|
||||||
|
}
|
||||||
|
if (code === "VALIDATION") {
|
||||||
|
set.status = 422;
|
||||||
|
return { success: false, message: "Validation error" };
|
||||||
|
}
|
||||||
|
if (typeof code === "number") {
|
||||||
|
set.status = code;
|
||||||
|
return (error as any).response;
|
||||||
|
}
|
||||||
Log("ERROR", "Unhandled error:", error);
|
Log("ERROR", "Unhandled error:", error);
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { success: false, message: "Internal server error" };
|
return { success: false, message: "Internal server error" };
|
||||||
})
|
})
|
||||||
.get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() }))
|
.get("/health", () => ({
|
||||||
|
status: "ok",
|
||||||
|
instance: INSTANCE_ID,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
.get("/", () => ({ status: "ok", message: "API is running" }))
|
||||||
|
.use(
|
||||||
|
new Elysia().use(adminKeyGuard2).use(
|
||||||
|
swagger({
|
||||||
|
path: "/swagger",
|
||||||
|
documentation: {
|
||||||
|
info: { title: "EMLy Bug Report API", version: "1.0.0" },
|
||||||
|
tags: [
|
||||||
|
{ name: "Bug Reports", description: "Submit bug reports" },
|
||||||
|
{ name: "Auth", description: "Admin authentication" },
|
||||||
|
{ name: "Admin", description: "Admin bug report management" },
|
||||||
|
{ name: "Features", description: "Feature flags" },
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
apiKey: { type: "apiKey", in: "header", name: "x-api-key" },
|
||||||
|
adminKey: { type: "apiKey", in: "header", name: "x-admin-key" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.use(featuresRoutes)
|
||||||
.use(bugReportRoutes)
|
.use(bugReportRoutes)
|
||||||
|
.use(authRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
.listen({
|
.listen({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
//@ts-ignore
|
||||||
maxBody: 50 * 1024 * 1024, // 50MB
|
maxBody: 50 * 1024 * 1024, // 50MB
|
||||||
});
|
});
|
||||||
|
|
||||||
Log(
|
Log(
|
||||||
"SERVER",
|
"SERVER",
|
||||||
`EMLy Bug Report API running on http://localhost:${app.server?.port}`
|
`EMLy Bug Report API running on http://localhost:${app.server?.port}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
@@ -25,7 +25,19 @@ export function Log(source: string, ...args: unknown[]): void {
|
|||||||
const date = now.toISOString().slice(0, 10);
|
const date = now.toISOString().slice(0, 10);
|
||||||
const time = now.toTimeString().slice(0, 8);
|
const time = now.toTimeString().slice(0, 8);
|
||||||
const msg = args
|
const msg = args
|
||||||
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
.map((a) => {
|
||||||
|
if (a instanceof Error) {
|
||||||
|
return `${a.message}${a.stack ? "\n" + a.stack : ""}`;
|
||||||
|
}
|
||||||
|
if (typeof a === "object") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a);
|
||||||
|
} catch {
|
||||||
|
return String(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(a);
|
||||||
|
})
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const line = `[${date}] - [${time}] - [${source}] - ${msg}`;
|
const line = `[${date}] - [${time}] - [${source}] - ${msg}`;
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
import { Elysia } from "elysia";
|
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { Log } from "../logger";
|
import { Log } from "../logger";
|
||||||
|
import Elysia from "elysia";
|
||||||
|
import type { UnauthorizedResponse } from "../types";
|
||||||
|
|
||||||
export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
|
export const apiKeyGuard2 = new Elysia({ name: "api-key-guard" }).derive(
|
||||||
{ as: "scoped" },
|
{ as: "scoped" },
|
||||||
({ headers, error, request }) => {
|
({ headers, status }): UnauthorizedResponse | {} => {
|
||||||
const key = headers["x-api-key"];
|
const apiKey = headers["x-api-key"];
|
||||||
if (!key || key !== config.apiKey) {
|
if (!apiKey || apiKey !== config.apiKey) {
|
||||||
const ip =
|
throw status(401, { success: false as const, message: "Unauthorized API Key" });
|
||||||
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 {};
|
return {};
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive(
|
export const adminKeyGuard2 = new Elysia({ name: "admin-key-guard" }).derive(
|
||||||
{ as: "scoped" },
|
{ as: "scoped" },
|
||||||
({ headers, error, request }) => {
|
({ headers, status }): UnauthorizedResponse | {} => {
|
||||||
const key = headers["x-admin-key"];
|
const apiKey = headers["x-admin-key"];
|
||||||
if (!key || key !== config.adminKey) {
|
if (!apiKey || apiKey !== config.adminKey) {
|
||||||
const ip =
|
throw status(401, { success: false as const, message: "Unauthorized Admin Key" });
|
||||||
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 {};
|
return {};
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { Log } from "../logger";
|
|||||||
const excludedHwids = new Set<string>([
|
const excludedHwids = new Set<string>([
|
||||||
// Add HWIDs here for development testing
|
// Add HWIDs here for development testing
|
||||||
"95e025d1-7567-462e-9354-ac88b965cd22",
|
"95e025d1-7567-462e-9354-ac88b965cd22",
|
||||||
|
"50973d98-7dce-4496-9f9a-fee21655d38a",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
|
export const hwidRateLimit = new Elysia({
|
||||||
|
name: "hwid-rate-limit",
|
||||||
|
}).onBeforeHandle(
|
||||||
{ as: "scoped" },
|
{ as: "scoped" },
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
async ({ body, error }) => {
|
async ({ body, error }) => {
|
||||||
@@ -25,7 +28,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
|
|||||||
// Get current rate limit entry
|
// Get current rate limit entry
|
||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
"SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?",
|
"SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?",
|
||||||
[hwid]
|
[hwid],
|
||||||
);
|
);
|
||||||
|
|
||||||
const entries = rows as { window_start: Date; count: number }[];
|
const entries = rows as { window_start: Date; count: number }[];
|
||||||
@@ -34,7 +37,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
|
|||||||
// First request from this HWID
|
// First request from this HWID
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)",
|
"INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)",
|
||||||
[hwid, now]
|
[hwid, now],
|
||||||
);
|
);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -47,7 +50,7 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
|
|||||||
// Window expired, reset
|
// Window expired, reset
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?",
|
"UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?",
|
||||||
[now, hwid]
|
[now, hwid],
|
||||||
);
|
);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -65,8 +68,8 @@ export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
|
|||||||
// Increment count
|
// Increment count
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?",
|
"UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?",
|
||||||
[hwid]
|
[hwid],
|
||||||
);
|
);
|
||||||
return {};
|
return {};
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,52 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { adminKeyGuard } from "../middleware/auth";
|
import { adminKeyGuard2 } from "../middleware/auth";
|
||||||
import {
|
import {
|
||||||
listBugReports,
|
listBugReports,
|
||||||
getBugReport,
|
getBugReport,
|
||||||
getFile,
|
getFile,
|
||||||
deleteBugReport,
|
deleteBugReport,
|
||||||
updateBugReportStatus,
|
updateBugReportStatus,
|
||||||
|
countNewReports,
|
||||||
|
generateReportZip,
|
||||||
} from "../services/bugReportService";
|
} from "../services/bugReportService";
|
||||||
|
import {
|
||||||
|
listUsers,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
resetPassword,
|
||||||
|
deleteUser,
|
||||||
|
getUserById,
|
||||||
|
} from "../services/userService";
|
||||||
import { Log } from "../logger";
|
import { Log } from "../logger";
|
||||||
import type { BugReportStatus } from "../types";
|
import type { BugReportStatus, DbEnv } from "../types";
|
||||||
|
|
||||||
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||||
.use(adminKeyGuard)
|
.use(adminKeyGuard2)
|
||||||
.get(
|
.get(
|
||||||
"/bug-reports",
|
"/bug-reports",
|
||||||
async ({ query }) => {
|
async ({ query, headers }) => {
|
||||||
const page = parseInt(query.page || "1");
|
const page = parseInt(query.page || "1");
|
||||||
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
|
const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
|
||||||
const status = query.status as BugReportStatus | undefined;
|
const status = query.status as BugReportStatus | undefined;
|
||||||
|
const search = query.search || undefined;
|
||||||
|
const useTestDb: boolean = headers["x-db-env"] !== "prod" ? true : false;
|
||||||
|
|
||||||
Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`);
|
if (useTestDb) Log("ADMIN", `Fetching bug reports from test database`);
|
||||||
return await listBugReports({ page, pageSize, status });
|
|
||||||
|
Log(
|
||||||
|
"ADMIN",
|
||||||
|
`List bug reports page=${page} pageSize=${pageSize} status=${status || "all"} search=${search || ""}`,
|
||||||
|
);
|
||||||
|
const res = await listBugReports(
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
status,
|
||||||
|
search,
|
||||||
|
},
|
||||||
|
useTestDb,
|
||||||
|
);
|
||||||
|
return res;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
@@ -32,35 +58,51 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
t.Literal("in_review"),
|
t.Literal("in_review"),
|
||||||
t.Literal("resolved"),
|
t.Literal("resolved"),
|
||||||
t.Literal("closed"),
|
t.Literal("closed"),
|
||||||
])
|
]),
|
||||||
),
|
),
|
||||||
|
search: t.Optional(t.String()),
|
||||||
}),
|
}),
|
||||||
detail: { summary: "List bug reports (paginated)" },
|
detail: { summary: "List bug reports (paginated, filterable)" },
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/bug-reports/count",
|
||||||
|
async ({ headers }) => {
|
||||||
|
const count = await countNewReports(
|
||||||
|
headers["x-db-env"] !== "prod" ? true : false,
|
||||||
|
);
|
||||||
|
return { count };
|
||||||
|
},
|
||||||
|
{ detail: { summary: "Count new bug reports" } },
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"/bug-reports/:id",
|
"/bug-reports/:id",
|
||||||
async ({ params, error }) => {
|
async ({ params, status, headers }) => {
|
||||||
Log("ADMIN", `Get bug report id=${params.id}`);
|
Log("ADMIN", `Get bug report id=${params.id}`);
|
||||||
const result = await getBugReport(parseInt(params.id));
|
const result = await getBugReport(
|
||||||
if (!result) return error(404, { success: false, message: "Report not found" });
|
parseInt(params.id),
|
||||||
|
headers["x-db-env"] !== "prod" ? true : false,
|
||||||
|
);
|
||||||
|
if (!result)
|
||||||
|
return status(404, { success: false, message: "Report not found" });
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
detail: { summary: "Get bug report with file metadata" },
|
detail: { summary: "Get bug report with file metadata" },
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.patch(
|
.patch(
|
||||||
"/bug-reports/:id/status",
|
"/bug-reports/:id/status",
|
||||||
async ({ params, body, error }) => {
|
async ({ params, body, status, headers }) => {
|
||||||
Log("ADMIN", `Update status id=${params.id} status=${body.status}`);
|
Log("ADMIN", `Update status id=${params.id} status=${body.status}`);
|
||||||
const updated = await updateBugReportStatus(
|
const updated = await updateBugReportStatus(
|
||||||
parseInt(params.id),
|
parseInt(params.id),
|
||||||
body.status
|
body.status,
|
||||||
|
headers["x-db-env"] !== "prod" ? true : false,
|
||||||
);
|
);
|
||||||
if (!updated)
|
if (!updated)
|
||||||
return error(404, { success: false, message: "Report not found" });
|
return status(404, { success: false, message: "Report not found" });
|
||||||
return { success: true, message: "Status updated" };
|
return { success: true, message: "Status updated" };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,14 +116,18 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
detail: { summary: "Update bug report status" },
|
detail: { summary: "Update bug report status" },
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"/bug-reports/:id/files/:fileId",
|
"/bug-reports/:id/files/:fileId",
|
||||||
async ({ params, error, set }) => {
|
async ({ params, status, set, headers }) => {
|
||||||
const file = await getFile(parseInt(params.id), parseInt(params.fileId));
|
const file = await getFile(
|
||||||
|
parseInt(params.id),
|
||||||
|
parseInt(params.fileId),
|
||||||
|
headers["x-db-env"] !== "prod" ? true : false,
|
||||||
|
);
|
||||||
if (!file)
|
if (!file)
|
||||||
return error(404, { success: false, message: "File not found" });
|
return status(404, { success: false, message: "File not found" });
|
||||||
|
|
||||||
set.headers["content-type"] = file.mime_type;
|
set.headers["content-type"] = file.mime_type;
|
||||||
set.headers["content-disposition"] =
|
set.headers["content-disposition"] =
|
||||||
@@ -91,19 +137,134 @@ export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
|||||||
{
|
{
|
||||||
params: t.Object({ id: t.String(), fileId: t.String() }),
|
params: t.Object({ id: t.String(), fileId: t.String() }),
|
||||||
detail: { summary: "Download a bug report file" },
|
detail: { summary: "Download a bug report file" },
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/bug-reports/:id/download",
|
||||||
|
async ({ params, status, set, headers }) => {
|
||||||
|
Log("ADMIN", `Download zip for report id=${params.id}`);
|
||||||
|
const zipBuffer = await generateReportZip(
|
||||||
|
parseInt(params.id),
|
||||||
|
headers["x-db-env"] !== "prod" ? true : false,
|
||||||
|
);
|
||||||
|
if (!zipBuffer)
|
||||||
|
return status(404, { success: false, message: "Report not found" });
|
||||||
|
|
||||||
|
set.headers["content-type"] = "application/zip";
|
||||||
|
set.headers["content-disposition"] =
|
||||||
|
`attachment; filename="report-${params.id}.zip"`;
|
||||||
|
set.headers["content-length"] = String(zipBuffer.length);
|
||||||
|
return new Response(new Uint8Array(zipBuffer));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
detail: { summary: "Download all files for a bug report as ZIP" },
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.delete(
|
.delete(
|
||||||
"/bug-reports/:id",
|
"/bug-reports/:id",
|
||||||
async ({ params, error }) => {
|
async ({ params, status, headers }) => {
|
||||||
Log("ADMIN", `Delete bug report id=${params.id}`);
|
Log("ADMIN", `Delete bug report id=${params.id}`);
|
||||||
const deleted = await deleteBugReport(parseInt(params.id));
|
const deleted = await deleteBugReport(
|
||||||
|
parseInt(params.id),
|
||||||
|
headers["x-db-env"] !== "prod" ? true : false,
|
||||||
|
);
|
||||||
if (!deleted)
|
if (!deleted)
|
||||||
return error(404, { success: false, message: "Report not found" });
|
return status(404, { success: false, message: "Report not found" });
|
||||||
return { success: true, message: "Report deleted" };
|
return { success: true, message: "Report deleted" };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
detail: { summary: "Delete a bug report and its files" },
|
detail: { summary: "Delete a bug report and its files" },
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
// User management
|
||||||
|
.get(
|
||||||
|
"/users",
|
||||||
|
async ({ headers }) => {
|
||||||
|
Log("ADMIN", "List users");
|
||||||
|
return await listUsers();
|
||||||
|
},
|
||||||
|
{ detail: { summary: "List all users" } },
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/users",
|
||||||
|
async ({ body, status }) => {
|
||||||
|
Log("ADMIN", `Create user username=${body.username}`);
|
||||||
|
try {
|
||||||
|
const user = await createUser(body);
|
||||||
|
return { success: true, user };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === "Username already exists") {
|
||||||
|
return status(409, { success: false, message: err.message });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
username: t.String({ minLength: 3, maxLength: 255 }),
|
||||||
|
displayname: t.String({ default: "" }),
|
||||||
|
password: t.String({ minLength: 1 }),
|
||||||
|
role: t.Union([t.Literal("admin"), t.Literal("user")]),
|
||||||
|
}),
|
||||||
|
detail: { summary: "Create a new user" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.patch(
|
||||||
|
"/users/:id",
|
||||||
|
async ({ params, body, status }) => {
|
||||||
|
Log("ADMIN", `Update user id=${params.id}`);
|
||||||
|
const updated = await updateUser(params.id, body);
|
||||||
|
if (!updated)
|
||||||
|
return status(404, { success: false, message: "User not found" });
|
||||||
|
return { success: true, message: "User updated" };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
displayname: t.Optional(t.String()),
|
||||||
|
enabled: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
|
detail: { summary: "Update user displayname or enabled status" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/users/:id/reset-password",
|
||||||
|
async ({ params, body, status }) => {
|
||||||
|
Log("ADMIN", `Reset password for user id=${params.id}`);
|
||||||
|
const updated = await resetPassword(params.id, body.password);
|
||||||
|
if (!updated)
|
||||||
|
return status(404, { success: false, message: "User not found" });
|
||||||
|
return { success: true, message: "Password reset" };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
body: t.Object({ password: t.String({ minLength: 1 }) }),
|
||||||
|
detail: { summary: "Reset a user's password" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/users/:id",
|
||||||
|
async ({ params, status }) => {
|
||||||
|
Log("ADMIN", `Delete user id=${params.id}`);
|
||||||
|
|
||||||
|
const user = await getUserById(params.id);
|
||||||
|
if (!user)
|
||||||
|
throw status(404, { success: false, message: "User not found" });
|
||||||
|
if (user.role === "admin")
|
||||||
|
return status(400, {
|
||||||
|
success: false,
|
||||||
|
message: "Cannot delete an admin user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteUser(params.id);
|
||||||
|
if (!deleted)
|
||||||
|
return status(404, { success: false, message: "User not found" });
|
||||||
|
return { success: true, message: "User deleted" };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
detail: { summary: "Delete a user (non-admin only)" },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
73
src/routes/auth.ts
Normal file
73
src/routes/auth.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { adminKeyGuard2 } from "../middleware/auth";
|
||||||
|
import {
|
||||||
|
loginUser,
|
||||||
|
validateSession,
|
||||||
|
logoutSession,
|
||||||
|
} from "../services/authService";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
|
||||||
|
export const authRoutes = new Elysia({ prefix: "/api/admin/auth" })
|
||||||
|
//.onRequest(adminKeyGuard)
|
||||||
|
.use(adminKeyGuard2)
|
||||||
|
.post(
|
||||||
|
"/login",
|
||||||
|
async ({ body, status }) => {
|
||||||
|
const result = await loginUser(body.username, body.password);
|
||||||
|
if (!result) {
|
||||||
|
Log("AUTH", `Login failed for username=${body.username}`);
|
||||||
|
return status(401, {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid credentials or account disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
session_id: result.session_id,
|
||||||
|
user: result.user,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
username: t.String({ minLength: 1 }),
|
||||||
|
password: t.String({ minLength: 1 }),
|
||||||
|
}),
|
||||||
|
detail: { summary: "Login with username/password" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/logout",
|
||||||
|
async ({ headers }) => {
|
||||||
|
const sessionId = headers["x-session-token"];
|
||||||
|
if (sessionId) {
|
||||||
|
await logoutSession(sessionId);
|
||||||
|
}
|
||||||
|
return { success: true, message: "Logged out" };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: { summary: "Logout and invalidate session" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/validate",
|
||||||
|
async ({ headers, status }) => {
|
||||||
|
const sessionId = headers["x-session-token"];
|
||||||
|
if (!sessionId) {
|
||||||
|
return status(401, {
|
||||||
|
success: false,
|
||||||
|
message: "No session token provided",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const user = await validateSession(sessionId);
|
||||||
|
if (!user) {
|
||||||
|
return status(401, {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid or expired session",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { success: true, user };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: { summary: "Validate session and return user" },
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { apiKeyGuard } from "../middleware/auth";
|
import { apiKeyGuard2, adminKeyGuard2 } from "../middleware/auth";
|
||||||
import { hwidRateLimit } from "../middleware/rateLimit";
|
import { hwidRateLimit } from "../middleware/rateLimit";
|
||||||
import { createBugReport, addFile } from "../services/bugReportService";
|
import { createBugReport, addFile } from "../services/bugReportService";
|
||||||
import { Log } from "../logger";
|
import { Log } from "../logger";
|
||||||
@@ -13,12 +13,17 @@ const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
||||||
.use(apiKeyGuard)
|
//.onRequest(apiKeyGuard)
|
||||||
.use(hwidRateLimit)
|
.use(apiKeyGuard2)
|
||||||
|
//.use(hwidRateLimit)
|
||||||
.post(
|
.post(
|
||||||
"/",
|
"/",
|
||||||
async ({ body, request, set }) => {
|
async ({ body, request, set, headers }) => {
|
||||||
const { name, email, description, hwid, hostname, os_user, system_info } = body;
|
console.log(headers);
|
||||||
|
const { name, email, description, hwid, hostname, os_user, system_info } =
|
||||||
|
body;
|
||||||
|
const useTestDB = headers["x-db-env"] !== "prod";
|
||||||
|
console.log("Creating a bug report in the test DB...");
|
||||||
|
|
||||||
// Get submitter IP from headers or connection
|
// Get submitter IP from headers or connection
|
||||||
const submitterIp =
|
const submitterIp =
|
||||||
@@ -26,9 +31,12 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
|||||||
request.headers.get("x-real-ip") ||
|
request.headers.get("x-real-ip") ||
|
||||||
"unknown";
|
"unknown";
|
||||||
|
|
||||||
Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`);
|
Log(
|
||||||
|
"BUGREPORT",
|
||||||
|
`Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Parse system_info — may arrive as a JSON string or already-parsed object
|
// Parse system_info - may arrive as a JSON string or already-parsed object
|
||||||
let systemInfo: Record<string, unknown> | null = null;
|
let systemInfo: Record<string, unknown> | null = null;
|
||||||
if (system_info) {
|
if (system_info) {
|
||||||
if (typeof system_info === "string") {
|
if (typeof system_info === "string") {
|
||||||
@@ -43,31 +51,40 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the bug report
|
// Create the bug report
|
||||||
const reportId = await createBugReport({
|
const reportId = await createBugReport(
|
||||||
name,
|
{
|
||||||
email,
|
name,
|
||||||
description,
|
email,
|
||||||
hwid: hwid || "",
|
description,
|
||||||
hostname: hostname || "",
|
hwid: hwid || "",
|
||||||
os_user: os_user || "",
|
hostname: hostname || "",
|
||||||
submitter_ip: submitterIp,
|
os_user: os_user || "",
|
||||||
system_info: systemInfo,
|
submitter_ip: submitterIp,
|
||||||
});
|
system_info: systemInfo,
|
||||||
|
},
|
||||||
|
useTestDB,
|
||||||
|
);
|
||||||
|
|
||||||
// Process file uploads
|
// Process file uploads
|
||||||
for (const { field, role, mime } of FILE_ROLES) {
|
for (const { field, role, mime } of FILE_ROLES) {
|
||||||
const file = body[field as keyof typeof body];
|
const file = body[field as keyof typeof body];
|
||||||
if (file && file instanceof File) {
|
if (file && file instanceof File) {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`);
|
Log(
|
||||||
await addFile({
|
"BUGREPORT",
|
||||||
report_id: reportId,
|
`File uploaded: role=${role} size=${buffer.length} bytes`,
|
||||||
file_role: role,
|
);
|
||||||
filename: file.name || `${field}.bin`,
|
await addFile(
|
||||||
mime_type: file.type || mime,
|
{
|
||||||
file_size: buffer.length,
|
report_id: reportId,
|
||||||
data: buffer,
|
file_role: role,
|
||||||
});
|
filename: file.name || `${field}.bin`,
|
||||||
|
mime_type: file.type || mime,
|
||||||
|
file_size: buffer.length,
|
||||||
|
data: buffer,
|
||||||
|
},
|
||||||
|
useTestDB,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,5 +120,5 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
detail: { summary: "Submit a bug report" },
|
detail: { summary: "Submit a bug report" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
40
src/routes/features.ts
Normal file
40
src/routes/features.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { FeaturesJson, FeaturesRawJson } from "../types";
|
||||||
|
import { apiKeyGuard2 } from "../middleware/auth";
|
||||||
|
|
||||||
|
const featuresPath = join(import.meta.dir, "../features.json");
|
||||||
|
|
||||||
|
const FeatureSchema = t.Object({
|
||||||
|
label: t.String(),
|
||||||
|
description: t.String(),
|
||||||
|
enabled: t.Boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const featuresRoutes = new Elysia({ prefix: "/api/features" })
|
||||||
|
.use(apiKeyGuard2)
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
() => {
|
||||||
|
const raw = readFileSync(featuresPath, "utf-8");
|
||||||
|
const jsonData: FeaturesRawJson = JSON.parse(raw);
|
||||||
|
const returnData: FeaturesJson = {};
|
||||||
|
for (const key in jsonData) {
|
||||||
|
// Try to log the feature flag value from the .env
|
||||||
|
const envKey = jsonData[key].key;
|
||||||
|
const envValue = process.env[envKey];
|
||||||
|
if (envValue !== undefined) {
|
||||||
|
returnData[key] = { ...jsonData[key], enabled: envValue === "true" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return returnData as Record<
|
||||||
|
string,
|
||||||
|
{ label: string; description: string; enabled: boolean }
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
response: t.Record(t.String(), FeatureSchema),
|
||||||
|
detail: { summary: "Get available features and their enabled state" },
|
||||||
|
},
|
||||||
|
);
|
||||||
124
src/services/authService.ts
Normal file
124
src/services/authService.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
|
import type { ResultSetHeader, RowDataPacket } from "mysql2";
|
||||||
|
import { getPool } from "../db/connection";
|
||||||
|
import { Log } from "../logger";
|
||||||
|
|
||||||
|
const ARGON2_OPTIONS = {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
outputLen: 32,
|
||||||
|
parallelism: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SESSION_EXPIRY_DAYS = 30;
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayname: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSessionId(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUser(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ session_id: string; user: AuthUser } | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT id, username, displayname, password_hash, role, enabled FROM `user` WHERE username = ? LIMIT 1",
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((rows as unknown[]).length === 0) return null;
|
||||||
|
|
||||||
|
const row = rows[0] as {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayname: string;
|
||||||
|
password_hash: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const valid = await verify(row.password_hash, password, ARGON2_OPTIONS);
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
if (!row.enabled) return null;
|
||||||
|
|
||||||
|
const sessionId = generateSessionId();
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await pool.execute<ResultSetHeader>(
|
||||||
|
"INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)",
|
||||||
|
[sessionId, row.id, expiresAt]
|
||||||
|
);
|
||||||
|
|
||||||
|
Log("AUTH", `User logged in: username=${username} session=${sessionId.slice(0, 8)}...`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
session_id: sessionId,
|
||||||
|
user: {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayname: row.displayname,
|
||||||
|
role: row.role,
|
||||||
|
enabled: row.enabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSession(
|
||||||
|
sessionId: string
|
||||||
|
): Promise<AuthUser | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT u.id, u.username, u.displayname, u.role, u.enabled, s.expires_at
|
||||||
|
FROM session s
|
||||||
|
JOIN \`user\` u ON u.id = s.user_id
|
||||||
|
WHERE s.id = ? LIMIT 1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((rows as unknown[]).length === 0) return null;
|
||||||
|
|
||||||
|
const row = rows[0] as {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayname: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
enabled: boolean;
|
||||||
|
expires_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (new Date() > new Date(row.expires_at)) {
|
||||||
|
await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.enabled) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayname: row.displayname,
|
||||||
|
role: row.role,
|
||||||
|
enabled: row.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutSession(sessionId: string): Promise<void> {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.execute("DELETE FROM session WHERE id = ?", [sessionId]);
|
||||||
|
Log("AUTH", `Session logged out: ${sessionId.slice(0, 8)}...`);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ResultSetHeader, RowDataPacket } from "mysql2";
|
import type { ResultSetHeader, RowDataPacket } from "mysql2";
|
||||||
|
import JSZip from "jszip";
|
||||||
import { getPool } from "../db/connection";
|
import { getPool } from "../db/connection";
|
||||||
import type {
|
import type {
|
||||||
BugReport,
|
BugReport,
|
||||||
@@ -9,17 +10,20 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export async function createBugReport(data: {
|
export async function createBugReport(
|
||||||
name: string;
|
data: {
|
||||||
email: string;
|
name: string;
|
||||||
description: string;
|
email: string;
|
||||||
hwid: string;
|
description: string;
|
||||||
hostname: string;
|
hwid: string;
|
||||||
os_user: string;
|
hostname: string;
|
||||||
submitter_ip: string;
|
os_user: string;
|
||||||
system_info: Record<string, unknown> | null;
|
submitter_ip: string;
|
||||||
}): Promise<number> {
|
system_info: Record<string, unknown> | null;
|
||||||
const pool = getPool();
|
},
|
||||||
|
useTestDb?: boolean,
|
||||||
|
): Promise<number> {
|
||||||
|
const pool = getPool(useTestDb ? true : false);
|
||||||
const [result] = await pool.execute<ResultSetHeader>(
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
`INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info)
|
`INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -32,20 +36,23 @@ export async function createBugReport(data: {
|
|||||||
data.os_user,
|
data.os_user,
|
||||||
data.submitter_ip,
|
data.submitter_ip,
|
||||||
data.system_info ? JSON.stringify(data.system_info) : null,
|
data.system_info ? JSON.stringify(data.system_info) : null,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
return result.insertId;
|
return result.insertId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFile(data: {
|
export async function addFile(
|
||||||
report_id: number;
|
data: {
|
||||||
file_role: FileRole;
|
report_id: number;
|
||||||
filename: string;
|
file_role: FileRole;
|
||||||
mime_type: string;
|
filename: string;
|
||||||
file_size: number;
|
mime_type: string;
|
||||||
data: Buffer;
|
file_size: number;
|
||||||
}): Promise<number> {
|
data: Buffer;
|
||||||
const pool = getPool();
|
},
|
||||||
|
useTestDb?: boolean,
|
||||||
|
): Promise<number> {
|
||||||
|
const pool = getPool(useTestDb ? true : false);
|
||||||
const [result] = await pool.execute<ResultSetHeader>(
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
`INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data)
|
`INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -56,31 +63,45 @@ export async function addFile(data: {
|
|||||||
data.mime_type,
|
data.mime_type,
|
||||||
data.file_size,
|
data.file_size,
|
||||||
data.data,
|
data.data,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
return result.insertId;
|
return result.insertId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listBugReports(opts: {
|
export async function listBugReports(
|
||||||
page: number;
|
opts: {
|
||||||
pageSize: number;
|
page: number;
|
||||||
status?: BugReportStatus;
|
pageSize: number;
|
||||||
}): Promise<PaginatedResponse<BugReportListItem>> {
|
status?: BugReportStatus;
|
||||||
const pool = getPool();
|
search?: string;
|
||||||
const { page, pageSize, status } = opts;
|
},
|
||||||
|
useTestDb?: boolean,
|
||||||
|
): Promise<PaginatedResponse<BugReportListItem>> {
|
||||||
|
const pool = getPool(useTestDb ? true : false);
|
||||||
|
const { page, pageSize, status, search } = opts;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
let whereClause = "";
|
const conditions: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
whereClause = "WHERE br.status = ?";
|
conditions.push("br.status = ?");
|
||||||
params.push(status);
|
params.push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const like = `%${search}%`;
|
||||||
|
conditions.push(
|
||||||
|
"(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)",
|
||||||
|
);
|
||||||
|
params.push(like, like, like, like);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
const [countRows] = await pool.execute<RowDataPacket[]>(
|
const [countRows] = await pool.execute<RowDataPacket[]>(
|
||||||
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
`SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
|
||||||
params
|
|
||||||
);
|
);
|
||||||
const total = (countRows[0] as { total: number }).total;
|
const total = (countRows[0] as { total: number }).total;
|
||||||
|
|
||||||
@@ -91,8 +112,7 @@ export async function listBugReports(opts: {
|
|||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY br.id
|
GROUP BY br.id
|
||||||
ORDER BY br.created_at DESC
|
ORDER BY br.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||||
[...params, pageSize, offset]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,22 +124,101 @@ export async function listBugReports(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBugReport(
|
export async function countNewReports(useTestDb?: boolean): Promise<number> {
|
||||||
id: number
|
const pool = getPool(useTestDb ? true : false);
|
||||||
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
const pool = getPool();
|
"SELECT COUNT(*) as count FROM bug_reports WHERE status = 'new'",
|
||||||
|
);
|
||||||
|
return (rows[0] as { count: number }).count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateReportZip(
|
||||||
|
reportId: number,
|
||||||
|
useTestDb?: boolean,
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
const pool = getPool(useTestDb ? true : false);
|
||||||
|
|
||||||
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||||
"SELECT * FROM bug_reports WHERE id = ?",
|
"SELECT * FROM bug_reports WHERE id = ?",
|
||||||
[id]
|
[reportId],
|
||||||
|
);
|
||||||
|
if ((reportRows as unknown[]).length === 0) return null;
|
||||||
|
|
||||||
|
const report = reportRows[0] as BugReport;
|
||||||
|
|
||||||
|
const [fileRows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT * FROM bug_report_files WHERE report_id = ?",
|
||||||
|
[reportId],
|
||||||
|
);
|
||||||
|
const files = fileRows as BugReportFile[];
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
zip.file(`${file.file_role}/${file.filename}`, file.data as Buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zip.generateAsync({ type: "nodebuffer" }) as Promise<Buffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBugReport(
|
||||||
|
id: number,
|
||||||
|
useTestDb?: boolean,
|
||||||
|
): Promise<{ report: BugReport; files: Omit<BugReportFile, "data">[] } | null> {
|
||||||
|
const pool = getPool(useTestDb ? true : false);
|
||||||
|
|
||||||
|
const [reportRows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT * FROM bug_reports WHERE id = ?",
|
||||||
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((reportRows as unknown[]).length === 0) return null;
|
if ((reportRows as unknown[]).length === 0) return null;
|
||||||
|
|
||||||
const [fileRows] = await pool.execute<RowDataPacket[]>(
|
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 = ?",
|
"SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?",
|
||||||
[id]
|
[id],
|
||||||
);
|
);
|
||||||
|
// If the report's submitter_ip is "unknown", use the report system_info's InternalIP if available
|
||||||
|
const report = reportRows[0] as BugReport;
|
||||||
|
if (
|
||||||
|
report.submitter_ip === "unknown" &&
|
||||||
|
report.system_info !== null &&
|
||||||
|
typeof report.system_info === "object" &&
|
||||||
|
"InternalIP" in report.system_info
|
||||||
|
) {
|
||||||
|
report.submitter_ip = report.system_info.InternalIP as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Fetched report:", reportRows[0]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
report: reportRows[0] as BugReport,
|
report: reportRows[0] as BugReport,
|
||||||
@@ -129,35 +228,40 @@ export async function getBugReport(
|
|||||||
|
|
||||||
export async function getFile(
|
export async function getFile(
|
||||||
reportId: number,
|
reportId: number,
|
||||||
fileId: number
|
fileId: number,
|
||||||
|
useTestDb?: boolean,
|
||||||
): Promise<BugReportFile | null> {
|
): Promise<BugReportFile | null> {
|
||||||
const pool = getPool();
|
const pool = getPool(useTestDb ? true : false);
|
||||||
const [rows] = await pool.execute<RowDataPacket[]>(
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
"SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?",
|
"SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?",
|
||||||
[fileId, reportId]
|
[fileId, reportId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((rows as unknown[]).length === 0) return null;
|
if ((rows as unknown[]).length === 0) return null;
|
||||||
return rows[0] as BugReportFile;
|
return rows[0] as BugReportFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBugReport(id: number): Promise<boolean> {
|
export async function deleteBugReport(
|
||||||
const pool = getPool();
|
id: number,
|
||||||
|
useTestDb?: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pool = getPool(useTestDb ? true : false);
|
||||||
const [result] = await pool.execute<ResultSetHeader>(
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
"DELETE FROM bug_reports WHERE id = ?",
|
"DELETE FROM bug_reports WHERE id = ?",
|
||||||
[id]
|
[id],
|
||||||
);
|
);
|
||||||
return result.affectedRows > 0;
|
return result.affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBugReportStatus(
|
export async function updateBugReportStatus(
|
||||||
id: number,
|
id: number,
|
||||||
status: BugReportStatus
|
status: BugReportStatus,
|
||||||
|
useTestDb?: boolean,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const pool = getPool();
|
const pool = getPool(useTestDb ? true : false);
|
||||||
const [result] = await pool.execute<ResultSetHeader>(
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
"UPDATE bug_reports SET status = ? WHERE id = ?",
|
"UPDATE bug_reports SET status = ? WHERE id = ?",
|
||||||
[status, id]
|
[status, id],
|
||||||
);
|
);
|
||||||
return result.affectedRows > 0;
|
return result.affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
120
src/services/userService.ts
Normal file
120
src/services/userService.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { hash } from "@node-rs/argon2";
|
||||||
|
import type { ResultSetHeader, RowDataPacket } from "mysql2";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { getPool } from "../db/connection";
|
||||||
|
|
||||||
|
const ARGON2_OPTIONS = {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
outputLen: 32,
|
||||||
|
parallelism: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayname: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUsers(): Promise<User[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT id, username, displayname, role, enabled, created_at FROM `user` ORDER BY created_at ASC"
|
||||||
|
);
|
||||||
|
return rows as User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: {
|
||||||
|
username: string;
|
||||||
|
displayname: string;
|
||||||
|
password: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
}): Promise<User> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// Check for duplicate username
|
||||||
|
const [existing] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT id FROM `user` WHERE username = ? LIMIT 1",
|
||||||
|
[data.username]
|
||||||
|
);
|
||||||
|
if ((existing as unknown[]).length > 0) {
|
||||||
|
throw new Error("Username already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(data.password, ARGON2_OPTIONS);
|
||||||
|
const id = randomUUID();
|
||||||
|
|
||||||
|
await pool.execute<ResultSetHeader>(
|
||||||
|
"INSERT INTO `user` (id, username, displayname, password_hash, role) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[id, data.username, data.displayname, passwordHash, data.role]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return (rows as User[])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
id: string,
|
||||||
|
data: { displayname?: string; enabled?: boolean }
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (data.displayname !== undefined) {
|
||||||
|
fields.push("displayname = ?");
|
||||||
|
params.push(data.displayname);
|
||||||
|
}
|
||||||
|
if (data.enabled !== undefined) {
|
||||||
|
fields.push("enabled = ?");
|
||||||
|
params.push(data.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return false;
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`UPDATE \`user\` SET ${fields.join(", ")} WHERE id = ?`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(
|
||||||
|
id: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const passwordHash = await hash(newPassword, ARGON2_OPTIONS);
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"UPDATE `user` SET password_hash = ? WHERE id = ?",
|
||||||
|
[passwordHash, id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string): Promise<boolean> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
"DELETE FROM `user` WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string): Promise<User | null> {
|
||||||
|
const pool = getPool();
|
||||||
|
const [rows] = await pool.execute<RowDataPacket[]>(
|
||||||
|
"SELECT id, username, displayname, role, enabled, created_at FROM `user` WHERE id = ? LIMIT 1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if ((rows as unknown[]).length === 0) return null;
|
||||||
|
return (rows as User[])[0];
|
||||||
|
}
|
||||||
@@ -55,3 +55,18 @@ export interface PaginatedResponse<T> {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DbEnv = "prod" | "test";
|
||||||
|
|
||||||
|
export type UnauthorizedResponse = import("elysia").ElysiaCustomStatusResponse<
|
||||||
|
401,
|
||||||
|
{ success: false; message: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface FeaturesRawJson {
|
||||||
|
[key: string]: { label: string; description: string; key: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeaturesJson {
|
||||||
|
[key: string]: { label: string; description: string; enabled: boolean };
|
||||||
|
}
|
||||||
|
|||||||
39
src/wait-for-mysql.ts
Normal file
39
src/wait-for-mysql.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import net from 'net'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
|
||||||
|
const host = process.env.MYSQL_HOST || 'mysql'
|
||||||
|
const port = parseInt(process.env.MYSQL_PORT || '3306', 10)
|
||||||
|
const retryDelay = 2000
|
||||||
|
|
||||||
|
function waitForPort(host: string, port: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const tryConnect = () => {
|
||||||
|
const socket = new net.Socket()
|
||||||
|
socket.setTimeout(2000)
|
||||||
|
socket.once('connect', () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
socket.once('error', () => {
|
||||||
|
socket.destroy()
|
||||||
|
setTimeout(tryConnect, retryDelay)
|
||||||
|
})
|
||||||
|
socket.once('timeout', () => {
|
||||||
|
socket.destroy()
|
||||||
|
setTimeout(tryConnect, retryDelay)
|
||||||
|
})
|
||||||
|
socket.connect(port, host)
|
||||||
|
}
|
||||||
|
tryConnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
console.log(`Waiting for MySQL at ${host}:${port}...`)
|
||||||
|
await waitForPort(host, port)
|
||||||
|
console.log('MySQL is available - starting API.')
|
||||||
|
const child = spawn('bun', ['run', 'src/index.ts'], { stdio: 'inherit' })
|
||||||
|
;['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((sig) =>
|
||||||
|
process.on(sig as NodeJS.Signals, () => child.kill(sig as NodeJS.Signals))
|
||||||
|
)
|
||||||
|
})()
|
||||||
Reference in New Issue
Block a user