Files
teamspeak-music-bot/src/web/server.ts
T
Claude fecda7cce3 fix(web): make bot-link usable on public IP with HTTP
The "复制专属链接" button silently failed on public-IP HTTP deployments
because navigator.clipboard requires a secure context. Now the link is
always revealed in a modal with a read-only input (select-all on focus),
so users can copy manually even when clipboard APIs and execCommand both
fail. The dialog still tries to auto-copy when possible.

Also adds a publicUrl config option that overrides window.location.origin
for link generation (useful behind reverse proxies / with custom domains),
exposed via GET /api/config/public-url, and a trustProxy flag so Express
honors X-Forwarded-* when fronted by nginx/Caddy/Cloudflare.

https://claude.ai/code/session_019FSX3S3UUcKEYWanYmoqUv
2026-04-17 16:08:00 +00:00

107 lines
3.1 KiB
TypeScript
Executable File

import express from "express";
import http from "node:http";
import path from "node:path";
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 { createBotRouter } from "./api/bot.js";
import { createMusicRouter } from "./api/music.js";
import { createPlayerRouter } from "./api/player.js";
import { createAuthRouter } from "./api/auth.js";
import { setupWebSocket } from "./websocket.js";
export interface WebServerOptions {
port: number;
botManager: BotManager;
neteaseProvider: MusicProvider;
qqProvider: MusicProvider;
bilibiliProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
logger: Logger;
cookieStore?: CookieStore;
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) {
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
app.set("trust proxy", true);
}
app.use(express.json());
app.get("/api/config/public-url", (_req, res) => {
const raw = (options.config.publicUrl ?? "").trim();
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
});
app.use(
"/api/bot",
createBotRouter(options.botManager, options.config, options.configPath, logger)
);
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.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
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");
});
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("error", (err) => {
logger.error({ err }, "WebSocket server error");
});
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
return {
async start(): Promise<void> {
return new Promise((resolve) => {
server.listen(options.port, () => {
logger.info({ port: options.port }, "Web server started");
resolve();
});
});
},
stop(): void {
cleanupWs();
wss.close();
server.close();
},
};
}