Merge pull request #106 from ZHANGTIANYAO1/fix/guest-play-collection

fix(guest): allow Play All for guests via a dedicated playCollection permission (#103)
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-06-29 17:39:50 +08:00
commit 4e148302fc
12 files changed
+55 -15

No files matched your search

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