import express from "express"; import http from "node:http"; import path from "node:path"; import cookieParser from "cookie-parser"; import { WebSocketServer } from "ws"; import type { BotManager } from "../bot/manager.js"; import type { MusicProvider } from "../music/provider.js"; import type { BotDatabase } from "../data/database.js"; import type { BotConfig } from "../data/config.js"; import type { Logger } from "../logger.js"; import type { CookieStore } from "../music/auth.js"; import type { AvatarStore } from "../data/avatars.js"; import { createBotRouter } from "./api/bot.js"; import { createMusicRouter } from "./api/music.js"; import { createPlayerRouter } from "./api/player.js"; import { createAuthRouter } from "./api/auth.js"; import { createSessionRouter } from "./api/session.js"; import { createUsersRouter } from "./api/users.js"; import { createAuditStore } from "../data/audit.js"; import { createAuditRouter } from "./api/audit.js"; import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; import { createRequireAuth } from "./middleware/requireAuth.js"; import { csrfOriginCheck } from "./middleware/csrf.js"; import { validateSessionFromHeaders } from "./auth/validateSession.js"; const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour export interface WebServerOptions { port: number; botManager: BotManager; neteaseProvider: MusicProvider; qqProvider: MusicProvider; bilibiliProvider: MusicProvider; database: BotDatabase; config: BotConfig; configPath: string; logger: Logger; cookieStore?: CookieStore; avatarStore: AvatarStore; staticDir?: string; } export interface WebServer { start(): Promise; stop(): void; } export function createWebServer(options: WebServerOptions): WebServer { const app = express(); const server = http.createServer(app); const logger = options.logger.child({ component: "web" }); if (options.config.trustProxy) { app.set("trust proxy", true); } app.use(express.json({ limit: "400kb" })); app.use(cookieParser()); const users = createUserStore(options.database.db); const sessions = createSessionStore(options.database.db); const audit = createAuditStore(options.database.db); // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── app.get("/api/health", (_req, res) => { res.json({ status: "ok", version: "0.1.0" }); }); app.get("/api/config/public-url", (_req, res) => { const raw = (options.config.publicUrl ?? "").trim(); res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null }); }); app.use("/api/session", createSessionRouter(users, sessions, audit, logger)); // ─── Gates for everything else under /api ─────────────────────────────── const requireAuth = createRequireAuth(sessions); app.use("/api", csrfOriginCheck); app.use("/api", requireAuth); // ─── Protected routes ─────────────────────────────────────────────────── app.use( "/api/bot", createBotRouter( options.botManager, options.config, options.configPath, logger, options.database, options.avatarStore, ) ); app.use( "/api/music", createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger) ); app.use("/api/player", createPlayerRouter( options.botManager, logger, options.database, options.neteaseProvider, options.qqProvider, options.bilibiliProvider, )); app.use( "/api/auth", createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) ); app.use("/api/users", createUsersRouter(users, sessions, audit, logger)); app.use("/api/audit", createAuditRouter(audit)); // ─── Static SPA (public) ──────────────────────────────────────────────── if (options.staticDir) { app.use(express.static(options.staticDir)); app.get(/^(?!\/api|\/ws)/, (_req, res) => { res.sendFile(path.join(options.staticDir!, "index.html")); }); } server.on("error", (err) => { logger.error({ err }, "HTTP server error"); }); // ─── WebSocket with manual upgrade auth ──────────────────────────────── const wss = new WebSocketServer({ noServer: true }); wss.on("error", (err) => { logger.error({ err }, "WebSocket server error"); }); server.on("upgrade", (req, socket, head) => { if (req.url !== "/ws") { socket.destroy(); return; } const reqHost = req.headers.host; const originHeader = req.headers.origin; if (originHeader) { let originHost: string | null = null; try { originHost = new URL(originHeader).host; } catch { // fall through; treat as missing/invalid origin } if (!originHost || originHost !== reqHost) { socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); socket.destroy(); return; } } const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions); if (!result) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { (ws as unknown as { userId: string }).userId = result.userId; wss.emit("connection", ws, req); }); }); const cleanupWs = setupWebSocket(wss, options.botManager, logger); // ─── Session cleanup interval ────────────────────────────────────────── let cleanupTimer: ReturnType | null = null; return { async start(): Promise { return new Promise((resolve) => { server.listen(options.port, () => { logger.info({ port: options.port }, "Web server started"); cleanupTimer = setInterval(() => { try { sessions.cleanupExpired(); } catch (err) { logger.error({ err }, "session cleanup failed"); } }, SESSION_CLEANUP_INTERVAL_MS); resolve(); }); }); }, stop(): void { if (cleanupTimer) { clearInterval(cleanupTimer); cleanupTimer = null; } cleanupWs(); wss.close(); server.close(); }, }; }