Files
teamspeak-music-bot/src/web/server.ts
T

190 lines
6.7 KiB
TypeScript
Executable File

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<void>;
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<typeof setInterval> | null = null;
return {
async start(): Promise<void> {
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();
},
};
}