mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Merge remote-tracking branch 'origin/main' into feat/issue-119-saved-playlists
# Conflicts: # src/data/config.ts
This commit is contained in:
commit
c8dacebc45
21 files changed
+1163
-86
No files matched your search
@@ -679,4 +679,54 @@ describe("bot router /settings jellyfin block + enabledProviders", () => {
|
||||
// No jellyfin block in the request → no reconfigure call.
|
||||
expect(configureCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// --- #126: operator-chosen default source ---
|
||||
|
||||
it("GET /settings exposes defaultPlatform (null by default)", async () => {
|
||||
const res = await request(mountBot()).get("/api/bot/settings");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.defaultPlatform).toBeNull();
|
||||
});
|
||||
|
||||
it("POST /settings sets an enabled defaultPlatform and persists it", async () => {
|
||||
const res = await request(mountBot()).post("/api/bot/settings").send({
|
||||
defaultPlatform: "bilibili",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.defaultPlatform).toBe("bilibili");
|
||||
expect(config.defaultPlatform).toBe("bilibili");
|
||||
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
expect(onDisk.defaultPlatform).toBe("bilibili");
|
||||
});
|
||||
|
||||
it("POST /settings ignores an unknown or disabled defaultPlatform", async () => {
|
||||
const app = mountBot();
|
||||
// jellyfin is opt-in and not enabled in the default config → rejected.
|
||||
await request(app).post("/api/bot/settings").send({ defaultPlatform: "jellyfin" });
|
||||
expect(config.defaultPlatform).toBeNull();
|
||||
// Unknown value → rejected.
|
||||
await request(app).post("/api/bot/settings").send({ defaultPlatform: "bogus" });
|
||||
expect(config.defaultPlatform).toBeNull();
|
||||
});
|
||||
|
||||
it("POST /settings clears defaultPlatform with null", async () => {
|
||||
const app = mountBot();
|
||||
await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" });
|
||||
expect(config.defaultPlatform).toBe("qq");
|
||||
const res = await request(app).post("/api/bot/settings").send({ defaultPlatform: null });
|
||||
expect(res.body.defaultPlatform).toBeNull();
|
||||
expect(config.defaultPlatform).toBeNull();
|
||||
});
|
||||
|
||||
it("POST /settings drops a default whose source gets disabled in the same request", async () => {
|
||||
const app = mountBot();
|
||||
await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" });
|
||||
expect(config.defaultPlatform).toBe("qq");
|
||||
// Disabling qq via enabledProviders clears the now-invalid default.
|
||||
const res = await request(app).post("/api/bot/settings").send({
|
||||
enabledProviders: ["netease", "bilibili"],
|
||||
});
|
||||
expect(res.body.defaultPlatform).toBeNull();
|
||||
expect(config.defaultPlatform).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ export function createBotRouter(
|
||||
spotify: maskedSpotify(),
|
||||
jellyfin: maskedJellyfin(),
|
||||
enabledProviders: config.enabledProviders,
|
||||
defaultPlatform: config.defaultPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,6 +188,29 @@ export function createBotRouter(
|
||||
);
|
||||
}
|
||||
|
||||
// defaultPlatform (issue #126): the operator-chosen default source for
|
||||
// platform-less commands/REST/WebUI calls. Reconciled AFTER enabledProviders
|
||||
// so both are validated against the same (possibly updated) enabled list:
|
||||
// 1) Drop a stored default that the new enabledProviders no longer allows,
|
||||
// keeping the persisted config consistent with loadConfig's invariant.
|
||||
// 2) Apply an explicit change — `null`/`""` clears it (back to priority
|
||||
// order); a known+enabled provider sets it; anything else is ignored.
|
||||
if (config.defaultPlatform && !config.enabledProviders.includes(config.defaultPlatform)) {
|
||||
config.defaultPlatform = null;
|
||||
}
|
||||
if ("defaultPlatform" in req.body) {
|
||||
const dp = req.body.defaultPlatform;
|
||||
if (dp === null || dp === "") {
|
||||
config.defaultPlatform = null;
|
||||
} else if (
|
||||
typeof dp === "string" &&
|
||||
(GATEABLE_PROVIDERS as readonly string[]).includes(dp) &&
|
||||
config.enabledProviders.includes(dp as GateableProvider)
|
||||
) {
|
||||
config.defaultPlatform = dp as GateableProvider;
|
||||
}
|
||||
}
|
||||
|
||||
saveConfig(configPath, config);
|
||||
|
||||
// Hot-apply the (possibly re-pointed) Jellyfin connection to the live
|
||||
@@ -233,6 +257,7 @@ export function createBotRouter(
|
||||
spotify: maskedSpotify(),
|
||||
jellyfin: maskedJellyfin(),
|
||||
enabledProviders: config.enabledProviders,
|
||||
defaultPlatform: config.defaultPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+150
-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: [] };
|
||||
@@ -118,6 +128,25 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
|
||||
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
|
||||
});
|
||||
|
||||
it("GET /providers reports a configured defaultPlatform override (#126)", async () => {
|
||||
const config = getDefaultConfig();
|
||||
config.defaultPlatform = "qq"; // operator prefers QQ over the priority order
|
||||
const { app } = mount(config);
|
||||
const res = await request(app).get("/api/music/providers");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.default).toBe("qq");
|
||||
});
|
||||
|
||||
it("routes a platform-less /search to the configured defaultPlatform (#126)", async () => {
|
||||
const config = getDefaultConfig();
|
||||
config.defaultPlatform = "bilibili";
|
||||
const { app, netease } = mount(config);
|
||||
const res = await request(app).get("/api/music/search?q=hello");
|
||||
expect(res.status).toBe(200);
|
||||
// Default is now bilibili, so the netease provider must NOT be hit.
|
||||
expect(netease.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** Default config plus the opt-in jellyfin source enabled. */
|
||||
function configWithJellyfin() {
|
||||
const config = getDefaultConfig();
|
||||
@@ -147,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
@@ -78,12 +78,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();
|
||||
});
|
||||
|
||||
@@ -96,6 +103,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" });
|
||||
});
|
||||
@@ -152,7 +166,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