mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(guest): add playCollection permission so guests can Play All playlist/album (#103)
- 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)
This commit is contained in:
1 parent
e2fa288f48
commit
70c0273ae7
11 files changed
+51
-12
No files matched your search
@@ -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<string, unknown>)["0"]).toBeUndefined();
|
||||
|
||||
@@ -63,6 +63,7 @@ export function getDefaultConfig(): BotConfig {
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@ const guest = (perms: Partial<Record<string, boolean>> = {}) => ({
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -217,6 +217,7 @@ describe("session router — guest mode", () => {
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
playCollection: false,
|
||||
},
|
||||
guestBots: "all",
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -373,22 +373,32 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||
if (!this.activeBotId) return;
|
||||
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');
|
||||
}
|
||||
},
|
||||
|
||||
async playAlbum(albumId: string, platform = 'netease') {
|
||||
if (!this.activeBotId) return;
|
||||
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');
|
||||
}
|
||||
},
|
||||
|
||||
async pause() {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{ songs.length }} 首歌曲
|
||||
</div>
|
||||
<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" />
|
||||
播放全部
|
||||
</button>
|
||||
@@ -54,16 +54,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
import CoverArt from '../components/CoverArt.vue';
|
||||
import SongCard from '../components/SongCard.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
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';
|
||||
|
||||
|
||||
@@ -1069,12 +1069,13 @@ const GUEST_FLAGS: { token: string; label: string }[] = [
|
||||
{ token: 'transport', label: '暂停/继续/进度/音量' },
|
||||
{ token: 'removeClear', label: '移除/清空队列' },
|
||||
{ token: 'playMode', label: '切换播放模式 / FM' },
|
||||
{ token: 'playCollection', label: '播放整个歌单/专辑' },
|
||||
];
|
||||
const guestMode = reactive<{ enabled: boolean; botsAll: boolean; selectedBotIds: string[]; permissions: Record<string, boolean> }>({
|
||||
enabled: false,
|
||||
botsAll: true,
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user