diff --git a/Dockerfile b/Dockerfile index 5cdf0bb..d9c968f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,4 +9,5 @@ COPY src/ ./src/ 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"] diff --git a/compose.yml b/compose.yml index da79503..4f96000 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,42 @@ 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: . environment: + INSTANCE_ID: ${HOSTNAME} MYSQL_HOST: ${MYSQL_HOST:-mysql} MYSQL_PORT: ${MYSQL_PORT:-3306} MYSQL_USER: ${MYSQL_USER:-emly} @@ -13,5 +48,26 @@ services: RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5} RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24} volumes: - - ./logs/api:/app/logs - restart: on-failure \ No newline at end of file + - emly-api-logs:/app/logs + 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: \ No newline at end of file diff --git a/init/init.sql b/init/init.sql new file mode 100644 index 0000000..6c6e218 --- /dev/null +++ b/init/init.sql @@ -0,0 +1,62 @@ +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; + +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; + diff --git a/package.json b/package.json index 85edc65..ff1aae9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "dev": "bun run --watch src/index.ts", - "start": "bun run src/index.ts" + "start": "bun run src/index.ts", + "start:wait": "bun run src/wait-for-mysql.ts" }, "dependencies": { "@node-rs/argon2": "^2.0.2", diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 28ce54d..347ec03 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -33,7 +33,7 @@ export async function runMigrations(): Promise { try { await pool.execute(migration); } catch { - // Column/index already exists — safe to ignore + // Column/index already exists - safe to ignore } } diff --git a/src/index.ts b/src/index.ts index cddf314..5587c11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import { bugReportRoutes } from "./routes/bugReports"; import { adminRoutes } from "./routes/admin"; import { initLogger, Log } from "./logger"; +const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6); + // Initialize logger initLogger(); @@ -13,7 +15,12 @@ initLogger(); validateConfig(); // Run database migrations -await runMigrations(); +try { + await runMigrations(); +} catch (error) { + Log("ERROR", "Failed to run migrations:", error); + process.exit(1); +} const app = new Elysia() .onRequest(({ request }) => { @@ -22,7 +29,7 @@ const app = new Elysia() request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown"; - Log("HTTP", `${request.method} ${url.pathname} from ${ip}`); + Log("HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`); }) .onAfterResponse(({ request, set }) => { const url = new URL(request.url); @@ -33,11 +40,13 @@ const app = new Elysia() set.status = 500; 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(bugReportRoutes) .use(adminRoutes) .listen({ port: config.port, + //@ts-ignore maxBody: 50 * 1024 * 1024, // 50MB }); diff --git a/src/routes/bugReports.ts b/src/routes/bugReports.ts index 21bc61f..23fb604 100644 --- a/src/routes/bugReports.ts +++ b/src/routes/bugReports.ts @@ -28,7 +28,7 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" }) 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 | null = null; if (system_info) { if (typeof system_info === "string") { diff --git a/src/wait-for-mysql.ts b/src/wait-for-mysql.ts new file mode 100644 index 0000000..79bbd34 --- /dev/null +++ b/src/wait-for-mysql.ts @@ -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((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)) + ) +})()