Compare commits

..
Author SHA1 Message Date
saopig1 75497cf0f7 Merge remote-tracking branch 'origin/main' into feat/issue-126-default-source
# Conflicts:
#	src/data/config.ts
2026-07-17 11:05:05 +08:00
TIANYAO ZHANG 72682f4308 Merge pull request #130 from ZHANGTIANYAO1/feat/issue-125-persist-settings
feat: persist volume, play mode and audio quality across restarts
2026-07-17 11:03:22 +08:00
TIANYAO ZHANG f1585b010d Merge pull request #134 from ZHANGTIANYAO1/fix/issue-128-noindex-webui
feat(web): keep deployed WebUI out of search-engine indexes
2026-07-17 11:02:49 +08:00
TIANYAO ZHANG 1f88220d01 Merge pull request #129 from ZHANGTIANYAO1/fix/issue-122-qq-api-port
fix(qq): pin QQ Music API sidecar to configured qqMusicApiPort
2026-07-17 11:02:46 +08:00
TIANYAO ZHANG 75e3b1c722 Merge pull request #132 from ZHANGTIANYAO1/chore/issue-127-gitignore-claude
chore: add .claude/ to gitignore and untrack committed settings
2026-07-17 11:02:43 +08:00
saopig1andClaude Fable 5 846ee30bce fix(qq): pin the QQ Music API sidecar to qqMusicApiPort
The embedded QQ Music API sidecar could bind a different port than the one
the client base URL (getQQMusicBaseUrl) targets. The upstream
@sansenjian/qq-music-api package derives its default port from
process.env.PORT (falling back to 3200) and, in some historical versions,
auto-started that server as an import side effect. When an old build listened
on 3300 while the client requested 3200 (issue #122), fetching the QQ login QR
failed with ECONNREFUSED on 127.0.0.1:3200, so the QR never showed and login /
cookie persistence silently broke.

Align process.env.PORT with the configured qqMusicApiPort for the duration of
the import (restoring the previous value afterwards so nothing else in the
process is affected), reuse an already-listening instance instead of racing a
second listen, and log the port actually bound (read from the socket) so any
mismatch is visible in the logs.

- src/music/api-server.ts: PORT alignment + reuse-on-auto-start + bound-port log
- src/music/api-server.test.ts: regression coverage that the sidecar follows
  qqMusicPort (not an injected PORT) and restores PORT afterwards
- README.md: QQ login FAQ clarifies the sidecar and client share qqMusicApiPort
  and points stale-latest-image users (who saw 3300) at re-pulling the image

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:24:37 +08:00
saopig1andClaude Fable 5 987513a5f6 feat: add configurable default music source (#126)
Make the default playback source a user-configurable setting so servers
that mostly play e.g. Bilibili no longer need to type `-b` on every
`!play`. Previously defaultPlatform() always picked the first enabled
provider by a fixed priority order, with no way to override it.

- config: add optional `defaultPlatform: GateableProvider | null`.
  loadConfig sanitizes it — kept only when it names a known provider that
  is also currently enabled, else null. defaultPlatform() returns the
  preference when enabled, otherwise falls back to the fixed priority order.
- POST /api/bot/settings accepts `defaultPlatform` (validated against the
  possibly-updated enabledProviders; null/"" clears it), and reconciles a
  stored default that a new enabledProviders list no longer allows. GET and
  POST responses expose the field.
- WebUI: new "默认音源" section with a source picker; saving refreshes the
  store's default source so it takes effect immediately without a restart.
- Tests: extend config defaultPlatform priority tests and add coverage for
  the settings endpoint and /providers routing.
- README: document `defaultPlatform` in the enabledProviders section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:43:39 +08:00
saopig1andClaude Fable 5 ea0f7c17b5 feat(web): keep deployed WebUI out of search-engine indexes
Searching "TsmusicBot" surfaced many deployed instances' WebUI URLs,
letting strangers walk into other people's control pages (issue #128).
Add defence-in-depth so crawlers stop indexing public deployments:

- send `X-Robots-Tag: noindex, nofollow` on every Express response
- serve `/robots.txt` with `User-agent: * / Disallow: /`
- add `<meta name="robots" content="noindex, nofollow">` to index.html,
  which also covers the /bot/<id> dedicated-link pages (same SPA shell)

These layers only prevent indexing; real protection stays with WebUI
auth and the reverse proxy. Document this in the README security section
and warn users not to post their WebUI link on public pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:31:31 +08:00
saopig1andClaude Fable 5 4493269479 chore: add .claude/ to gitignore and untrack it
The .claude/ directory holds local Claude Code settings that should
not be version-controlled. Add it to .gitignore and remove the
already-committed settings from the index (files kept on disk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:27:01 +08:00
15 changed files with 546 additions and 74 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*"
],
"deny": [
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*",
"Bash(npx vitest:*)",
"Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)",
"Bash(gunzip -f /tmp/design.gz)",
"Read(//tmp/**)"
],
"deny": [
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
+1
View File
@@ -7,6 +7,7 @@ config.json
cookies/ cookies/
.superpowers/ .superpowers/
.worktrees/ .worktrees/
.claude/
setup.log setup.log
/bin/ /bin/
scripts/navbar_bigger.png scripts/navbar_bigger.png
+9 -4
View File
@@ -565,9 +565,10 @@ teamspeak-music-bot/
- 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制) - 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制)
- 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏 - 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏
- 不带平台标志的命令走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云) - 不带平台标志的命令默认走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云)
- **自定义默认音源(`defaultPlatform`)** — 想让不带标志的 `!play 歌名` 直接用某个音源(例如常听哔哩哔哩,免去每次加 `-b`),可在 设置 → 默认音源 里选择,或在 `config.json` 中设置 `"defaultPlatform": "bilibili"`。取值须是 `enabledProviders` 里已启用的音源,否则被忽略(回退到上面的固定优先级);留空 / `null` / 删除该字段即恢复固定优先级。WebUI 保存后即时生效,无需重启
- 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动** - 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动**
- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(此时默认音源仍为网易云,点歌用 `-j` 或停用网易云);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]` - 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(默认音源仍为网易云,点歌用 `-j`、停用网易云,或直接把 `defaultPlatform` 设为 `"jellyfin"`);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]`
- 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表) - 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表)
## 可选:YouTube 音源 ## 可选:YouTube 音源
@@ -802,10 +803,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指
A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。 A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。
**Q:端口 3200 被占用?** **Q:端口 3200 被占用?**
A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。
**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)**
A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。
**Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?** **Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?**
A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错:
- 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。 - 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。
- 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。 - 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。
修好版本后重新 `npm install && npm run build` 并重启即可。 修好版本后重新 `npm install && npm run build` 并重启即可。
@@ -905,6 +909,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。 - **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。 - **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。 - **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
- **搜索引擎隐身(防止实例被收录,issue #128)**:为避免部署实例的 WebUI 被搜索引擎收录、被陌生人搜到控制页,采用纵深防御——所有响应携带 `X-Robots-Tag: noindex, nofollow`,`/robots.txt` 返回 `User-agent: * / Disallow: /`,`index.html` 内置 `<meta name="robots" content="noindex, nofollow">`(专属链接 `/bot/<id>` 等所有页面同样覆盖)。这些只阻止「被索引」,不是访问控制——**请不要把自己的 WebUI 链接发到公开网页 / 论坛 / 聊天群**,真正的防护来自登录鉴权与反向代理。
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。 - **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。
### v0.x — Bot Profile 自动更新与协议层升级 ### v0.x — Bot Profile 自动更新与协议层升级
+55
View File
@@ -82,6 +82,61 @@ describe("config", () => {
expect(defaultPlatform(config)).toBe("netease"); expect(defaultPlatform(config)).toBe("netease");
}); });
// --- #126: an explicit operator default source ---
it("defaultPlatform is null by default (follow the priority order)", () => {
expect(getDefaultConfig().defaultPlatform).toBeNull();
});
it("defaultPlatform() honors an explicit, enabled preference over the priority order", () => {
const config = getDefaultConfig();
// Priority would pick netease; a Bilibili-loving server sets B站 instead (#126).
config.defaultPlatform = "bilibili";
expect(defaultPlatform(config)).toBe("bilibili");
});
it("defaultPlatform() ignores a preference whose source is not enabled", () => {
const config = getDefaultConfig();
config.defaultPlatform = "jellyfin"; // opt-in, not enabled in the default config
// Falls back to the fixed priority order (netease)…
expect(defaultPlatform(config)).toBe("netease");
// …until the preferred source is actually enabled.
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
expect(defaultPlatform(config)).toBe("jellyfin");
});
it("loadConfig keeps a valid, enabled defaultPlatform", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ defaultPlatform: "bilibili" }));
const config = loadConfig(path);
expect(config.defaultPlatform).toBe("bilibili");
expect(defaultPlatform(config)).toBe("bilibili");
});
it("loadConfig nulls a defaultPlatform that is unknown, disabled, or the wrong type", () => {
const dir = makeTmpDir();
// Unknown provider name.
const p1 = join(dir, "c1.json");
writeFileSync(p1, JSON.stringify({ defaultPlatform: "bogus" }));
expect(loadConfig(p1).defaultPlatform).toBeNull();
// Known provider, but not in enabledProviders.
const p2 = join(dir, "c2.json");
writeFileSync(p2, JSON.stringify({ enabledProviders: ["netease"], defaultPlatform: "bilibili" }));
expect(loadConfig(p2).defaultPlatform).toBeNull();
// Wrong type.
const p3 = join(dir, "c3.json");
writeFileSync(p3, JSON.stringify({ defaultPlatform: 42 }));
expect(loadConfig(p3).defaultPlatform).toBeNull();
});
it("round-trips defaultPlatform through save/load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
saveConfig(path, { ...getDefaultConfig(), defaultPlatform: "qq" });
expect(loadConfig(path).defaultPlatform).toBe("qq");
});
it("respects an explicit jellyfin-only enabledProviders from disk", () => { it("respects an explicit jellyfin-only enabledProviders from disk", () => {
const dir = makeTmpDir(); const dir = makeTmpDir();
const path = join(dir, "config.json"); const path = join(dir, "config.json");
+38 -7
View File
@@ -77,15 +77,24 @@ export function isProviderEnabled(config: BotConfig, platform: string): boolean
} }
/** /**
* The default platform for !play/!add/!playlist/!album and all REST/WebUI calls: * The default platform for !play/!add/!playlist/!album and all REST/WebUI calls.
* the first enabled provider in a fixed priority order (netease with the default *
* config; jellyfin ranks after the online music platforms because it is an * An explicit user preference (`config.defaultPlatform`) wins whenever it points
* opt-in source, but ahead of the video sites for users who run it as their * at a source that is currently enabled — this lets e.g. a Bilibili-loving server
* only music library). Falls back to "netease" when nothing is enabled so * set B站 as the default so `!play <歌名>` needs no `-b` flag (issue #126). The
* callers always get a provider — the enabled-gate then produces the friendly * enabled-guard here matters at runtime too: if the operator later disables the
* error. * preferred source, we must fall through instead of returning a dead default.
*
* With no (usable) preference we fall back to the first enabled provider in a
* fixed priority order (netease with the default config; jellyfin ranks after
* the online music platforms because it is an opt-in source, but ahead of the
* video sites for users who run it as their only music library). Falls back to
* "netease" when nothing is enabled so callers always get a provider — the
* enabled-gate then produces the friendly error.
*/ */
export function defaultPlatform(config: BotConfig): GateableProvider { export function defaultPlatform(config: BotConfig): GateableProvider {
const pref = config.defaultPlatform;
if (pref && config.enabledProviders.includes(pref)) return pref;
for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) { for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) {
if (config.enabledProviders.includes(p)) return p; if (config.enabledProviders.includes(p)) return p;
} }
@@ -128,6 +137,14 @@ export interface BotConfig {
* API servers must not start (or bind ports 3001/3200) unless enabled. * API servers must not start (or bind ports 3001/3200) unless enabled.
*/ */
enabledProviders: GateableProvider[]; enabledProviders: GateableProvider[];
/**
* Optional operator-chosen default source for commands/REST/WebUI calls that
* omit a platform (issue #126). When set to an enabled gateable provider it
* overrides the fixed priority order in defaultPlatform(); `null` (the default)
* keeps that priority order. loadConfig cleans stale/unknown/disabled values
* back to null.
*/
defaultPlatform: GateableProvider | null;
} }
export function getDefaultConfig(): BotConfig { export function getDefaultConfig(): BotConfig {
@@ -190,6 +207,7 @@ export function getDefaultConfig(): BotConfig {
jellyfin: "direct", jellyfin: "direct",
}, },
enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"], enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"],
defaultPlatform: null,
}; };
} }
@@ -344,6 +362,18 @@ export function loadConfig(path: string): BotConfig {
) )
: defaults.enabledProviders; : defaults.enabledProviders;
// defaultPlatform → an explicit operator default (issue #126). Keep it only
// when it names a KNOWN gateable provider that is ALSO currently enabled;
// anything else (unknown value, disabled source, wrong type, missing) becomes
// null so defaultPlatform() falls back to the fixed priority order.
const rawDefault = partial.defaultPlatform;
const defaultPlatformPref: GateableProvider | null =
typeof rawDefault === "string" &&
(GATEABLE_PROVIDERS as readonly string[]).includes(rawDefault) &&
enabledProviders.includes(rawDefault as GateableProvider)
? (rawDefault as GateableProvider)
: null;
// audioQuality → per-provider strings; each field falls back to its default // audioQuality → per-provider strings; each field falls back to its default
// when missing/blank/non-string (a hand-edited/legacy config must never smuggle // when missing/blank/non-string (a hand-edited/legacy config must never smuggle
// a non-string past the gate — the value is fed straight to provider.setQuality). // a non-string past the gate — the value is fed straight to provider.setQuality).
@@ -367,6 +397,7 @@ export function loadConfig(path: string): BotConfig {
jellyfin, jellyfin,
audioQuality, audioQuality,
enabledProviders, enabledProviders,
defaultPlatform: defaultPlatformPref,
}; };
} }
} }
+105 -2
View File
@@ -1,5 +1,34 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { describeQqApiStartupError } from "./api-server.js"; import { createApiServerManager, describeQqApiStartupError } from "./api-server.js";
import type { Logger } from "../logger.js";
// Record every listen() the QQ sidecar makes so we can assert it is always
// pinned to the configured port (regression coverage for issue #122).
const mockState = vi.hoisted(() => ({
listenCalls: [] as Array<{ port: number; host: string }>,
}));
vi.mock("@sansenjian/qq-music-api", () => {
const app = {
listen(port: number, host: string, cb?: () => void) {
mockState.listenCalls.push({ port, host });
const server = {
address: () => ({ port, address: host, family: "IPv4" as const }),
on() {
return server;
},
close(done?: () => void) {
done?.();
},
};
// Real net/Koa fire the listening callback on a later tick, after the
// caller has captured the returned server handle.
if (cb) setImmediate(cb);
return server;
},
};
return { default: app };
});
describe("describeQqApiStartupError", () => { describe("describeQqApiStartupError", () => {
it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => { it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => {
@@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => {
expect(describeQqApiStartupError(null)).toBeNull(); expect(describeQqApiStartupError(null)).toBeNull();
}); });
}); });
// Regression coverage for issue #122: the QQ Music API sidecar must listen on
// the same port the client base URL targets (config.qqMusicApiPort). A stale
// build once bound 3300 while the client requested 3200, silently breaking the
// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200.
describe("createApiServerManager — QQ sidecar port binding", () => {
const noopLogger = {
info() {},
warn() {},
error() {},
debug() {},
trace() {},
fatal() {},
} as unknown as Logger;
beforeEach(() => {
mockState.listenCalls = [];
});
it("listens on the configured qqMusicPort and exposes a matching base URL", async () => {
const port = 39217; // uncommon port to avoid clashing with a real instance
const manager = createApiServerManager(
{ neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
await manager.start();
manager.stop();
expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`);
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
});
it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => {
const port = 39219;
const previous = process.env.PORT;
// Simulate a hosting platform / compose file injecting a stray PORT that
// must NOT leak into the QQ sidecar's chosen port.
process.env.PORT = "39999";
const manager = createApiServerManager(
{ neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// The sidecar follows qqMusicPort, never the injected PORT.
expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]);
// The injected PORT is restored so nothing else in the process is affected.
expect(process.env.PORT).toBe("39999");
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
it("leaves an absent PORT env unset after importing the sidecar", async () => {
const port = 39221;
const previous = process.env.PORT;
delete process.env.PORT;
const manager = createApiServerManager(
{ neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true },
noopLogger
);
try {
await manager.start();
// Was unset before importing — must be unset again, no leaked override.
expect(process.env.PORT).toBeUndefined();
} finally {
manager.stop();
if (previous === undefined) delete process.env.PORT;
else process.env.PORT = previous;
}
});
});
+46 -10
View File
@@ -114,7 +114,25 @@ export function createApiServerManager(
"QQ Music API port already in use — reusing existing instance" "QQ Music API port already in use — reusing existing instance"
); );
} else { } else {
const qqModule = (await import("@sansenjian/qq-music-api")) as any; // Pin the upstream server to the configured port before importing.
// The package derives its default port from process.env.PORT (falling
// back to 3200) and, in some historical versions, auto-started that
// server as an import side effect. Aligning PORT with qqMusicApiPort
// guarantees the sidecar can never bind a different port than the one
// the client base URL (getQQMusicBaseUrl) targets — the root cause of
// issue #122, where an old build listened on 3300 while the client
// requested 3200. Restore the previous value right after import so we
// never leak the override into the rest of the process (e.g. the web
// server or the NetEase sidecar, which also read PORT as a fallback).
const prevPortEnv = process.env.PORT;
process.env.PORT = String(options.qqMusicPort);
let qqModule: any;
try {
qqModule = (await import("@sansenjian/qq-music-api")) as any;
} finally {
if (prevPortEnv === undefined) delete process.env.PORT;
else process.env.PORT = prevPortEnv;
}
// The module's export structure varies between versions: // The module's export structure varies between versions:
// 2.2.11+: default → Koa app (has .listen) // 2.2.11+: default → Koa app (has .listen)
// 2.2.10: default → wrapper object whose .default is the Koa app // 2.2.10: default → wrapper object whose .default is the Koa app
@@ -124,16 +142,34 @@ export function createApiServerManager(
? candidate ? candidate
: candidate.default ?? null; : candidate.default ?? null;
if (koaApp && typeof koaApp.listen === "function") { if (koaApp && typeof koaApp.listen === "function") {
qqMusicServer = await new Promise<Server>((resolve, reject) => { // A version that auto-started on import has already bound the
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => // configured port (thanks to the PORT alignment above); reuse it
resolve(srv) // rather than racing a second listen that would fail EADDRINUSE.
const stillFree = await isPortFree(options.qqMusicPort);
if (!stillFree) {
logger.info(
{ port: options.qqMusicPort },
"QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance"
); );
srv.on("error", reject); } else {
}); qqMusicServer = await new Promise<Server>((resolve, reject) => {
logger.info( const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
{ port: options.qqMusicPort }, resolve(srv)
"QQ Music API started" );
); srv.on("error", reject);
});
// Log the port actually bound (read from the socket) rather than
// the requested one, so operators can spot a mismatch in the logs.
const addr = qqMusicServer.address();
const boundPort =
addr && typeof addr === "object" && addr !== null
? addr.port
: options.qqMusicPort;
logger.info(
{ port: boundPort },
"QQ Music API started"
);
}
} else { } else {
logger.warn("QQ Music API module does not expose a Koa app"); logger.warn("QQ Music API module does not expose a Koa app");
} }
+50
View File
@@ -647,4 +647,54 @@ describe("bot router /settings jellyfin block + enabledProviders", () => {
// No jellyfin block in the request → no reconfigure call. // No jellyfin block in the request → no reconfigure call.
expect(configureCalls).toHaveLength(0); 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();
});
}); });
+25
View File
@@ -77,6 +77,7 @@ export function createBotRouter(
spotify: maskedSpotify(), spotify: maskedSpotify(),
jellyfin: maskedJellyfin(), jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders, enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
}); });
}); });
@@ -175,6 +176,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); saveConfig(configPath, config);
// Hot-apply the (possibly re-pointed) Jellyfin connection to the live // Hot-apply the (possibly re-pointed) Jellyfin connection to the live
@@ -219,6 +243,7 @@ export function createBotRouter(
spotify: maskedSpotify(), spotify: maskedSpotify(),
jellyfin: maskedJellyfin(), jellyfin: maskedJellyfin(),
enabledProviders: config.enabledProviders, enabledProviders: config.enabledProviders,
defaultPlatform: config.defaultPlatform,
}); });
}); });
+19
View File
@@ -128,6 +128,25 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints",
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off 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. */ /** Default config plus the opt-in jellyfin source enabled. */
function configWithJellyfin() { function configWithJellyfin() {
const config = getDefaultConfig(); const config = getDefaultConfig();
+86
View File
@@ -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");
});
});
+17 -3
View File
@@ -77,12 +77,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.set("trust proxy", true); app.set("trust proxy", true);
} }
// Security headers: prevent the WebUI from being embedded in a third-party // Security headers:
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent // • X-Frame-Options / CSP frame-ancestors — prevent the WebUI from being
// of X-Frame-Options; both are set for compatibility across browsers. // 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) => { app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY"); res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
next(); next();
}); });
@@ -95,6 +102,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
const permissions = createPermissionStore(options.database.db); const permissions = createPermissionStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ─────────────────────────────────── // ─── 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) => { app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" }); res.json({ status: "ok", version: "0.1.0" });
}); });
+6
View File
@@ -3,6 +3,12 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Keep deployed instances out of search-engine indexes (issue #128:
searching "TsmusicBot" surfaced strangers' WebUI URLs). Defence in depth
alongside the server's X-Robots-Tag header and /robots.txt. Applies to
the SPA shell and every in-app route (incl. /bot/<id> dedicated links),
since they all share this single index.html. -->
<meta name="robots" content="noindex, nofollow">
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on <!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
their whitelist, so we must not leak a Referer cross-origin. "same-origin" their whitelist, so we must not leak a Referer cross-origin. "same-origin"
does exactly that: full Referer for our own requests, none for cross-origin does exactly that: full Referer for our own requests, none for cross-origin
+89
View File
@@ -555,6 +555,36 @@
</div> </div>
</section> </section>
<!-- Default music source (issue #126): the source used by chat commands
(!play/!add …) and the WebUI when no platform flag is given. Saving is a
global bot setting, so gate on bot.manage like the other behavior rows. -->
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">默认音源</h2>
<p class="profile-section-hint">
设置不带音源参数时(如聊天里的 <code>!play 歌名</code> 或网页搜索)默认使用的音源。
例如把默认音源设为「哔哩哔哩」后,点播 B 站音乐就不用每次都加 <code>-b</code>。
选择「自动」则按内置优先级挑选第一个已启用的音源(网易云 → QQ → 酷狗 → Jellyfin → 哔哩哔哩 → YouTube)。
</p>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:music-box-multiple-outline" class="setting-icon" />
默认音源
</div>
<div class="prefix-input-wrap">
<select v-model="defaultPlatformForm" class="input input-sm default-source-select">
<option value="">自动(按优先级)</option>
<option v-for="opt in defaultSourceOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<button class="btn-primary" :disabled="defaultSourceSaving" @click="saveDefaultSource">
{{ defaultSourceSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
<p v-if="defaultSourceMessage" class="spotify-message" :class="`tone-${defaultSourceMessageTone}`">{{ defaultSourceMessage }}</p>
</section>
<!-- Spotify (Connect) playback via librespot — requires platform.auth --> <!-- Spotify (Connect) playback via librespot — requires platform.auth -->
<section v-if="can('platform.auth')" class="settings-section"> <section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">Spotify 播放(实验性)</h2> <h2 class="section-title">Spotify 播放(实验性)</h2>
@@ -1161,6 +1191,51 @@ function providerOn(p: string): boolean {
return enabledProviders.value.length === 0 || enabledProviders.value.includes(p); return enabledProviders.value.length === 0 || enabledProviders.value.includes(p);
} }
// --- Default music source (issue #126) ---
// Chinese labels for the gateable providers, shown in the default-source select.
const PROVIDER_LABELS: Record<string, string> = {
netease: '网易云音乐',
qq: 'QQ音乐',
kugou: '酷狗音乐',
bilibili: '哔哩哔哩',
youtube: 'YouTube',
jellyfin: 'Jellyfin',
};
// Empty string = "auto" (follow the fixed priority order); persisted as null.
const defaultPlatformForm = ref('');
const defaultSourceSaving = ref(false);
const defaultSourceMessage = ref('');
const defaultSourceMessageTone = ref<'ok' | 'warn'>('ok');
// Only currently-enabled sources can be picked as the default.
const defaultSourceOptions = computed(() =>
enabledProviders.value
.filter((p) => p in PROVIDER_LABELS)
.map((p) => ({ value: p, label: PROVIDER_LABELS[p] })),
);
async function saveDefaultSource() {
defaultSourceSaving.value = true;
defaultSourceMessage.value = '';
try {
const res = await axios.post('/api/bot/settings', {
defaultPlatform: defaultPlatformForm.value || null,
});
defaultPlatformForm.value = res.data?.defaultPlatform ?? '';
// Push the new default across the app immediately (search bar / play calls
// read store.defaultSource, refreshed via GET /api/music/providers).
await store.fetchProviders();
defaultSourceMessageTone.value = 'ok';
defaultSourceMessage.value = '已保存';
} catch (err: any) {
defaultSourceMessageTone.value = 'warn';
defaultSourceMessage.value = err?.response?.status === 403
? '没有权限修改设置(需要 bot.manage)'
: '保存失败,请稍后重试';
} finally {
defaultSourceSaving.value = false;
}
}
// --- Jellyfin connection (admin-configured; password/apiKey are write-only) --- // --- Jellyfin connection (admin-configured; password/apiKey are write-only) ---
const jellyfinForm = reactive({ const jellyfinForm = reactive({
serverUrl: '', serverUrl: '',
@@ -1217,6 +1292,11 @@ async function saveJellyfin() {
// Search bar / home sections / FM cards react without a reload. // Search bar / home sections / FM cards react without a reload.
store.fetchProviders(); store.fetchProviders();
} }
// Disabling a source can clear a default that pointed at it (backend
// reconciles enabledProviders → defaultPlatform); keep the select in sync.
if (res.data && 'defaultPlatform' in res.data) {
defaultPlatformForm.value = res.data.defaultPlatform ?? '';
}
jellyfinMessageTone.value = 'ok'; jellyfinMessageTone.value = 'ok';
jellyfinMessage.value = '已保存'; jellyfinMessage.value = '已保存';
await checkAuthStatus(); await checkAuthStatus();
@@ -1506,6 +1586,8 @@ async function loadIdleTimeout() {
jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin'); jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin');
enabledProvidersLoaded.value = true; enabledProvidersLoaded.value = true;
} }
// null (unset) → "" so the select shows "自动(按优先级)".
defaultPlatformForm.value = res.data.defaultPlatform ?? '';
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -2448,6 +2530,13 @@ onUnmounted(() => {
.input-sm { max-width: 80px; } .input-sm { max-width: 80px; }
// The default-source picker holds full source names ("网易云音乐"), so it needs
// more room than the 80px .input-sm cap.
.default-source-select {
max-width: none;
flex: 0 0 160px;
}
.textarea { .textarea {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;