From 70c0273ae7a5bf29934a730ff2249771ab36f310 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Mon, 29 Jun 2026 12:12:47 +0800 Subject: [PATCH] fix(guest): add playCollection permission so guests can Play All playlist/album (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New guest flag playCollection (default OFF), gates play-playlist/play-album - Keeps playNow's non-destructive semantics intact (Play All clears the queue) - Admin-toggleable in Settings → 游客模式; default-off, backward-compatible - Frontend: gate the 播放全部 button on the flag + surface 403 as a toast instead of failing silently (the silent-failure half of the issue) --- src/data/config.test.ts | 4 ++- src/data/config.ts | 1 + src/data/permissions.test.ts | 6 +++-- src/data/permissions.ts | 3 +++ src/web/api/permissions-enforcement.test.ts | 18 ++++++++++--- src/web/api/player.ts | 4 +-- src/web/api/session.test.ts | 1 + src/web/middleware/requireAuth.test.ts | 3 ++- web/src/stores/player.ts | 30 ++++++++++++++------- web/src/views/Playlist.vue | 10 +++++-- web/src/views/Settings.vue | 3 ++- 11 files changed, 61 insertions(+), 22 deletions(-) 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 @@