diff --git a/README.md b/README.md index df6f9f7..e56dcea 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## 功能特性 - **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节 -- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(7 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 +- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 - **专属链接(单机器人锁定)** — 通过 `/bot/` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) @@ -188,7 +188,7 @@ sudo ./scripts/install.sh 让访客**无需账号密码**即可进入 WebUI 点歌,同时严格限制其可用能力。该功能**默认关闭**,只有管理员能开启。 - **开启方式**:管理员在 **设置 → 游客模式** 打开「允许游客访问」(仅管理员可见此区块)。开启后登录页会出现 **「以游客身份进入」** 按钮,访客点击即可创建游客会话,无需任何凭据。游客共享同一匿名身份、会话有效期较短(约 1 天)。关闭游客模式(或缩小机器人作用域)后立即生效,所有在线游客会话——包括正在连接的实时 WebSocket——会被立刻断开 / 重新限制。 -- **逐项权限(7 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。 +- **逐项权限(8 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。 | 开关 | 字段 | 默认 | |------|------|------| @@ -199,9 +199,10 @@ sudo ./scripts/install.sh | 暂停/继续/进度/音量 | `transport` | 关 | | 移除/清空队列 | `removeClear` | 关 | | 切换播放模式 / FM | `playMode` | 关 | + | 播放整个歌单/专辑 | `playCollection` | 关 | - **按机器人授权(游客作用域)**:可选择「全部机器人」或指定一份机器人白名单。作用域之外的机器人对游客**不可见、不可控**。 -- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质、收藏歌单、修改密码、访问用户管理与操作审计,以及读取机器人主人的私人歌单 / 私人 FM / 每日推荐等平台账号数据。这些限制不受上面 7 个开关影响,**永远锁死**。 +- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质、收藏歌单、修改密码、访问用户管理与操作审计,以及读取机器人主人的私人歌单 / 私人 FM / 每日推荐等平台账号数据。这些限制不受上面 8 个开关影响,**永远锁死**。 - **复现 issue #83 的「下一首 only」需求**:在 **设置 → 游客模式** 中关闭「添加到队列末尾」并打开「添加到下一首」,游客便只能把歌曲加到下一首播放。 **如何重置忘记的管理员密码**: diff --git a/src/data/config.test.ts b/src/data/config.test.ts index 2f014e4..dee0dcc 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -115,6 +115,7 @@ describe("guestMode config", () => { expect(c.guestMode.permissions).toEqual({ addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, + playCollection: false, }); }); @@ -167,10 +168,11 @@ describe("guestMode config", () => { }); it("a string permissions value yields defaults with no numeric index keys", () => { const gm = loadGuestMode({ guestMode: { permissions: "hacked" } }); - // 7 known flags present at their defaults + // all known flags present at their defaults expect(gm.permissions).toEqual({ addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, + playCollection: false, }); // no garbage index keys leaked from spreading a string expect((gm.permissions as unknown as Record)["0"]).toBeUndefined(); diff --git a/src/data/config.ts b/src/data/config.ts index bd1b788..bc0b77d 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -63,6 +63,7 @@ export function getDefaultConfig(): BotConfig { transport: false, removeClear: false, playMode: false, + playCollection: false, }, }, }; diff --git a/src/data/permissions.test.ts b/src/data/permissions.test.ts index 8b2fd0e..1287e99 100644 --- a/src/data/permissions.test.ts +++ b/src/data/permissions.test.ts @@ -105,6 +105,7 @@ describe("resolvePermissionContext guest branch", () => { permissions: { addToQueue: true, playNext: false, playNow: false, skip: true, transport: false, removeClear: false, playMode: false, + playCollection: false, }, }); expect([...ctx.capabilities]).toEqual([]); @@ -120,14 +121,15 @@ describe("resolvePermissionContext guest branch", () => { permissions: { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, + playCollection: false, }, }); expect(ctx.bots).toBe("all"); }); - it("exposes the 7 canonical flags", () => { + it("exposes the 8 canonical flags", () => { expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual( - ["addToQueue", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort() + ["addToQueue", "playCollection", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort() ); }); }); diff --git a/src/data/permissions.ts b/src/data/permissions.ts index 8bece14..e4a9bcc 100644 --- a/src/data/permissions.ts +++ b/src/data/permissions.ts @@ -29,6 +29,8 @@ export interface GuestPermissions { transport: boolean; removeClear: boolean; playMode: boolean; + /** Load + play an entire playlist/album (clears the queue). Issue #103. */ + playCollection: boolean; } export const GUEST_PERMISSION_FLAGS = [ @@ -39,6 +41,7 @@ export const GUEST_PERMISSION_FLAGS = [ "transport", "removeClear", "playMode", + "playCollection", ] as const; export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number]; diff --git a/src/web/api/permissions-enforcement.test.ts b/src/web/api/permissions-enforcement.test.ts index f74a7bf..9b90aa4 100644 --- a/src/web/api/permissions-enforcement.test.ts +++ b/src/web/api/permissions-enforcement.test.ts @@ -301,6 +301,7 @@ const guest = (perms: Partial> = {}) => ({ transport: false, removeClear: false, playMode: false, + playCollection: false, ...perms, }, }); @@ -373,7 +374,19 @@ describe("guest enforcement on player routes", () => { expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403); }); - it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /play-playlist, /play-album, /playlist, /profile even with ALL flags on", async () => { + it("playCollection flag gates /play-playlist, /play-album (issue #103)", async () => { + const allow = mountGuest({ playCollection: true }); + const deny = mountGuest({ playCollection: false }); + expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).not.toBe(403); + expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).not.toBe(403); + expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403); + expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403); + // playCollection does NOT leak into the destructive single-song / queue ops. + expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403); + expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403); + }); + + it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /playlist, /profile even with ALL flags on", async () => { const all = mountGuest({ addToQueue: true, playNext: true, @@ -382,14 +395,13 @@ describe("guest enforcement on player routes", () => { transport: true, removeClear: true, playMode: true, + playCollection: true, }); expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403); expect((await request(all).post(`/api/player/${ALLOWED_BOT}/prev`)).status).toBe(403); expect((await request(all).post(`/api/player/${ALLOWED_BOT}/stop`)).status).toBe(403); expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403); expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-at`).send({ index: 0 })).status).toBe(403); - expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403); - expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403); expect((await request(all).post(`/api/player/${ALLOWED_BOT}/playlist`).send({ playlistId: "1" })).status).toBe(403); expect((await request(all).put(`/api/player/${ALLOWED_BOT}/profile`).send({})).status).toBe(403); }); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 23fd5fe..61831a7 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -265,7 +265,7 @@ export function createPlayerRouter( // Play a playlist by ID — stores metadata only, resolves URL for first song // Respects current play mode (random = pick random first song) - router.post("/:botId/play-playlist", authorize({ capability: "player.control" }), async (req, res) => { + router.post("/:botId/play-playlist", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => { try { const bot = (req as any).bot; const { playlistId, platform } = req.body; @@ -352,7 +352,7 @@ export function createPlayerRouter( }); // Play an album by ID — mirrors play-playlist but calls getAlbumSongs - router.post("/:botId/play-album", authorize({ capability: "player.control" }), async (req, res) => { + router.post("/:botId/play-album", authorize({ capability: "player.control", guestFlag: "playCollection" }), async (req, res) => { try { const bot = (req as any).bot; const { albumId, platform } = req.body; diff --git a/src/web/api/session.test.ts b/src/web/api/session.test.ts index e26b42d..d980053 100644 --- a/src/web/api/session.test.ts +++ b/src/web/api/session.test.ts @@ -217,6 +217,7 @@ describe("session router — guest mode", () => { transport: false, removeClear: false, playMode: false, + playCollection: false, }, guestBots: "all", }); diff --git a/src/web/middleware/requireAuth.test.ts b/src/web/middleware/requireAuth.test.ts index 12d9519..3dac61f 100644 --- a/src/web/middleware/requireAuth.test.ts +++ b/src/web/middleware/requireAuth.test.ts @@ -36,6 +36,7 @@ describe("requireAuth middleware", () => { transport: true, removeClear: true, playMode: true, + playCollection: true, }, })) ); @@ -100,7 +101,7 @@ describe("requireAuth middleware", () => { it("attaches guest permissions when guest mode is enabled", () => { const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) }; const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] }; - const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false }; + const perms = { addToQueue: true, playNext: false, playNow: false, skip: false, transport: false, removeClear: false, playMode: false, playCollection: false }; const getGuestConfig = () => ({ enabled: true, bots: ["bot1"], permissions: perms }); const mw = createRequireAuth(sessions, permissions, getGuestConfig); const req: any = { headers: { cookie: "tsmb_session=x" }, secure: false }; diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index 2c3244e..2a7004a 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -373,22 +373,32 @@ export const usePlayerStore = defineStore('player', { async playPlaylist(playlistId: string, platform = 'netease') { if (!this.activeBotId) return; - const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform }); - if (res.data?.message) { - this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + try { + const res = await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform }); + if (res.data?.message) { + this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + } + this._setTiming(this.activeBotId, { serverElapsed: 0 }); + this._syncAfterAction(); + } catch (e: any) { + // A 403 here means a guest lacks the "play entire collection" permission + // (issue #103) — surface it instead of failing silently. + this.notify(e?.response?.status === 403 ? '没有权限播放整个歌单' : '播放歌单失败', 'error'); } - this._setTiming(this.activeBotId, { serverElapsed: 0 }); - this._syncAfterAction(); }, async playAlbum(albumId: string, platform = 'netease') { if (!this.activeBotId) return; - const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform }); - if (res.data?.message) { - this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + try { + const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform }); + if (res.data?.message) { + this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); + } + this._setTiming(this.activeBotId, { serverElapsed: 0 }); + this._syncAfterAction(); + } catch (e: any) { + this.notify(e?.response?.status === 403 ? '没有权限播放整个专辑' : '播放专辑失败', 'error'); } - this._setTiming(this.activeBotId, { serverElapsed: 0 }); - this._syncAfterAction(); }, async pause() { diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue index da232d9..e304e18 100644 --- a/web/src/views/Playlist.vue +++ b/web/src/views/Playlist.vue @@ -17,7 +17,7 @@ {{ songs.length }} 首歌曲
- @@ -54,16 +54,22 @@