mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Merge remote-tracking branch 'origin/main' into feat/issue-126-default-source
# Conflicts: # src/data/config.ts
This commit is contained in:
commit
75497cf0f7
18 files changed
+885
-77
No files matched your search
+131
-2
@@ -1,9 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { MusicProvider, SearchResult } from "../../music/provider.js";
|
||||
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||
import { getDefaultConfig, loadConfig, type BotConfig } from "../../data/config.js";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
import { createMusicRouter } from "./music.js";
|
||||
|
||||
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||
@@ -166,3 +176,122 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("music router POST /quality — persistence (#125)", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let config: BotConfig;
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let cookie: string;
|
||||
let providers: Record<string, MusicProvider>;
|
||||
|
||||
/** A provider whose in-memory quality is settable and readable, like the real
|
||||
* ones. */
|
||||
function qualityProvider(platform: MusicProvider["platform"], initial: string): MusicProvider {
|
||||
let q = initial;
|
||||
return {
|
||||
platform,
|
||||
search: vi.fn().mockResolvedValue(empty),
|
||||
getQuality: vi.fn(() => q),
|
||||
setQuality: vi.fn((v: string) => { q = v; }),
|
||||
} as unknown as MusicProvider;
|
||||
}
|
||||
|
||||
/** Jellyfin only accepts its own tiers (mirrors the real provider), so a
|
||||
* broadcast of a foreign value is ignored — proving the snapshot captures each
|
||||
* provider's ACTUAL post-apply state, not just the request value. */
|
||||
function jellyfinQualityProvider(): MusicProvider {
|
||||
let q = "direct";
|
||||
const tiers = new Set(["direct", "320", "192", "128"]);
|
||||
return {
|
||||
platform: "jellyfin",
|
||||
search: vi.fn().mockResolvedValue(empty),
|
||||
getQuality: vi.fn(() => q),
|
||||
setQuality: vi.fn((v: string) => { if (tiers.has(v)) q = v; }),
|
||||
} as unknown as MusicProvider;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const admin = await users.createUser("admin", "pw-admin", "admin");
|
||||
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
|
||||
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "musicquality-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
config = getDefaultConfig();
|
||||
|
||||
providers = {
|
||||
netease: qualityProvider("netease", "exhigh"),
|
||||
qq: qualityProvider("qq", "exhigh"),
|
||||
bilibili: qualityProvider("bilibili", "high"),
|
||||
kugou: qualityProvider("kugou", "128"),
|
||||
jellyfin: jellyfinQualityProvider(),
|
||||
};
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(
|
||||
providers.netease, providers.qq, providers.bilibili, pino({ level: "silent" }),
|
||||
undefined, config, providers.kugou, undefined, providers.jellyfin, configPath,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists a platform-specific quality change to config.json", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/quality")
|
||||
.set("Cookie", cookie)
|
||||
.send({ platform: "netease", quality: "lossless" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(providers.netease.setQuality).toHaveBeenCalledWith("lossless");
|
||||
// in-memory config mutated
|
||||
expect(config.audioQuality.netease).toBe("lossless");
|
||||
// written to disk + reload reflects it (survives a restart)
|
||||
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
expect(onDisk.audioQuality.netease).toBe("lossless");
|
||||
expect(loadConfig(configPath).audioQuality.netease).toBe("lossless");
|
||||
});
|
||||
|
||||
it("snapshots each provider's post-apply quality on a broadcast change", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/quality")
|
||||
.set("Cookie", cookie)
|
||||
.send({ quality: "320" });
|
||||
expect(res.status).toBe(200);
|
||||
// Broadcast reached every provider…
|
||||
expect(providers.netease.setQuality).toHaveBeenCalledWith("320");
|
||||
expect(providers.jellyfin.setQuality).toHaveBeenCalledWith("320");
|
||||
// …and the snapshot reflects what each one actually accepted. Jellyfin's
|
||||
// "320" is a valid tier here, so it takes; a foreign value would be ignored.
|
||||
expect(config.audioQuality).toEqual({
|
||||
netease: "320",
|
||||
qq: "320",
|
||||
bilibili: "320",
|
||||
kugou: "320",
|
||||
jellyfin: "320",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores foreign broadcast values that a provider rejects (jellyfin)", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/quality")
|
||||
.set("Cookie", cookie)
|
||||
.send({ quality: "lossless" });
|
||||
expect(res.status).toBe(200);
|
||||
// jellyfin rejects the NetEase-style value → stays at its default tier.
|
||||
expect(config.audioQuality.jellyfin).toBe("direct");
|
||||
expect(config.audioQuality.netease).toBe("lossless");
|
||||
});
|
||||
});
|
||||
+25
-2
@@ -2,7 +2,7 @@ import express, { Router, type Response } from "express";
|
||||
import type { MusicProvider, Song, Album } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { isProviderEnabled, defaultPlatform, type BotConfig } from "../../data/config.js";
|
||||
import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
@@ -16,7 +16,10 @@ export function createMusicRouter(
|
||||
config?: BotConfig,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider,
|
||||
jellyfinProvider?: MusicProvider
|
||||
jellyfinProvider?: MusicProvider,
|
||||
// When set (alongside config), a quality change is persisted to config.json so
|
||||
// it survives a restart (#125). Omitted by unit-test routers → no persistence.
|
||||
configPath?: string,
|
||||
): Router {
|
||||
const router = Router();
|
||||
const youtubeProvider: MusicProvider = new YouTubeProvider();
|
||||
@@ -480,6 +483,26 @@ export function createMusicRouter(
|
||||
if ((!platform || platform === "jellyfin") && jellyfinProvider) {
|
||||
jellyfinProvider.setQuality(quality);
|
||||
}
|
||||
|
||||
// Persist the (post-apply) per-provider quality so it survives a restart
|
||||
// (#125). Snapshotting each provider's getQuality() AFTER setQuality captures
|
||||
// exactly what each one accepted (jellyfin ignores foreign tiers, kugou maps
|
||||
// aliases), so replaying these on startup reproduces this state faithfully.
|
||||
if (config && configPath) {
|
||||
config.audioQuality = {
|
||||
netease: neteaseProvider.getQuality(),
|
||||
qq: qqProvider.getQuality(),
|
||||
bilibili: bilibiliProvider.getQuality(),
|
||||
kugou: kugouProvider?.getQuality() ?? config.audioQuality.kugou,
|
||||
jellyfin: jellyfinProvider?.getQuality() ?? config.audioQuality.jellyfin,
|
||||
};
|
||||
try {
|
||||
saveConfig(configPath, config);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to persist audio quality");
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ quality, platform }, "Audio quality changed");
|
||||
res.json({ success: true, quality });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Search-engine hardening for issue #128: searching "TsmusicBot" surfaced a
|
||||
* large number of deployed instances' WebUI URLs, letting strangers walk into
|
||||
* other people's control pages. The fix is defence in depth — none of these
|
||||
* layers is authentication (that's handled elsewhere), they just keep the
|
||||
* public URL out of crawler indexes:
|
||||
*
|
||||
* 1. `X-Robots-Tag: noindex, nofollow` on EVERY response;
|
||||
* 2. `GET /robots.txt` → `User-agent: * / Disallow: /`;
|
||||
* 3. `<meta name="robots" content="noindex, nofollow">` in web/index.html.
|
||||
*
|
||||
* The header middleware and the /robots.txt route both live at the top of
|
||||
* `createWebServer` in `server.ts`; this test asserts the exact behaviour we
|
||||
* expect from them in isolation (the wiring inside server.ts is verified by
|
||||
* code review / git diff, matching security-headers.test.ts).
|
||||
*/
|
||||
describe("search-engine hardening (issue #128 noindex)", () => {
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
// Mirrors the security-headers middleware in server.ts.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
|
||||
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
||||
next();
|
||||
});
|
||||
// Mirrors the public /robots.txt route in server.ts.
|
||||
app.get("/robots.txt", (_req, res) => {
|
||||
res.type("text/plain").send("User-agent: *\nDisallow: /\n");
|
||||
});
|
||||
app.get("/", (_req, res) => res.json({ ok: true }));
|
||||
app.post("/api/session/login", (_req, res) => res.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("sets X-Robots-Tag: noindex, nofollow on GET responses", async () => {
|
||||
const res = await request(buildApp()).get("/");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
|
||||
});
|
||||
|
||||
it("sets X-Robots-Tag on POST (API) responses too", async () => {
|
||||
const res = await request(buildApp()).post("/api/session/login");
|
||||
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
|
||||
});
|
||||
|
||||
it("serves /robots.txt disallowing all crawlers", async () => {
|
||||
const res = await request(buildApp()).get("/robots.txt");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toMatch(/text\/plain/);
|
||||
expect(res.text).toContain("User-agent: *");
|
||||
expect(res.text).toContain("Disallow: /");
|
||||
});
|
||||
|
||||
it("still tags the /robots.txt response itself as noindex", async () => {
|
||||
const res = await request(buildApp()).get("/robots.txt");
|
||||
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("frontend robots meta tag (issue #128 noindex)", () => {
|
||||
const indexHtmlPath = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../web/index.html"
|
||||
);
|
||||
const html = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||
|
||||
const robotsMeta = html.match(
|
||||
/<meta\s+name=["']robots["']\s+content=["']([^"']+)["']\s*\/?>/i
|
||||
);
|
||||
|
||||
it("declares a robots meta tag", () => {
|
||||
expect(robotsMeta).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks the SPA shell noindex, nofollow (covers /bot/<id> dedicated links)", () => {
|
||||
expect(robotsMeta?.[1]).toBe("noindex, nofollow");
|
||||
});
|
||||
});
|
||||
+18
-4
@@ -77,12 +77,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
app.set("trust proxy", true);
|
||||
}
|
||||
|
||||
// Security headers: prevent the WebUI from being embedded in a third-party
|
||||
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
|
||||
// of X-Frame-Options; both are set for compatibility across browsers.
|
||||
// Security headers:
|
||||
// • X-Frame-Options / CSP frame-ancestors — prevent the WebUI from being
|
||||
// embedded in a third-party iframe (clickjacking defence). CSP
|
||||
// frame-ancestors is the modern equivalent of X-Frame-Options; both are
|
||||
// set for compatibility across browsers.
|
||||
// • X-Robots-Tag — keep deployed instances out of search-engine indexes
|
||||
// (issue #128: searching "TsmusicBot" surfaced strangers' WebUI URLs).
|
||||
// Set on EVERY response so JSON/API responses and the SPA shell are all
|
||||
// covered; complements /robots.txt and the <meta name="robots"> tag.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
|
||||
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -95,6 +102,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
const permissions = createPermissionStore(options.database.db);
|
||||
|
||||
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
|
||||
// Disallow every crawler (issue #128). Declared before the static SPA
|
||||
// fallback so this wins over index.html for /robots.txt. Belt-and-braces
|
||||
// with the X-Robots-Tag header above and the <meta name="robots"> tag.
|
||||
app.get("/robots.txt", (_req, res) => {
|
||||
res.type("text/plain").send("User-agent: *\nDisallow: /\n");
|
||||
});
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ status: "ok", version: "0.1.0" });
|
||||
});
|
||||
@@ -151,7 +165,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider)
|
||||
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.configPath)
|
||||
);
|
||||
app.use("/api/player", createPlayerRouter(
|
||||
options.botManager, logger, options.database,
|
||||
|
||||
Reference in new issue
Block a user