Merge PR #80: feat(perm) fine-grained account permissions

Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
  requirePermission('player.control') so the new control endpoint honors #80's
  permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
  /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
  POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
  displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
  user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
  two-arg signature.

#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
  identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
This commit is contained in:
saopig1 committed 2026-06-16 15:05:57 +08:00
commit bea2f92508
32 files changed
+2225 -137

No files matched your search

+15 -5
View File
@@ -22,8 +22,9 @@
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
</div>
<!-- Normal: full selector with switching (shown when at least one bot exists) -->
<div v-else-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
<!-- Normal: full selector with switching (shown when at least one
controllable bot exists — scope ∩ permission via displayedBots) -->
<div v-else-if="displayedBots.length > 0" class="bot-selector" ref="selectorRef">
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
@@ -143,17 +144,26 @@ import { useSession } from '../composables/useSession.js';
const store = usePlayerStore();
const session = useSession();
const { canControlBot } = session;
const navRouter = useRouter();
async function onLogout() {
await session.logout();
navRouter.replace({ name: 'login' });
}
// Belt-and-suspenders: the backend already scopes store.bots to the allowed
// set for members, but filtering here keeps the UI correct if an admin (who
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
const activeBot = computed(() => store.activeBot);
// While scoped (dedicated link), the selector is locked to the single scoped
// bot; otherwise the full list is shown and switching is allowed.
// The bots shown in the selector are the INTERSECTION of the permission
// allow-list (controllableBots) and the dedicated-link scope: while scoped the
// selector is locked to the single scoped bot, otherwise the full controllable
// list is shown and switching is allowed.
const displayedBots = computed(() =>
store.isScoped ? store.bots.filter((b) => b.id === store.scopedBotId) : store.bots,
store.isScoped
? controllableBots.value.filter((b) => b.id === store.scopedBotId)
: controllableBots.value,
);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
+43 -23
View File
@@ -3,9 +3,10 @@
<Queue :open="showQueue" @close="showQueue = false" />
<div class="player-bar frosted-glass">
<!-- Progress bar -->
<!-- Progress bar (read-only display; seek interaction gated on player.control) -->
<div
class="progress-bar-container"
:class="{ 'no-seek': !canControl }"
ref="progressBarRef"
@click="onProgressClick"
@mousemove="onProgressHover"
@@ -37,32 +38,38 @@
<div class="player-center">
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
<button class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="play-btn" @click="togglePlay">
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="control-btn" @click="store.next()">
<Icon icon="mdi:skip-next" />
</button>
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
<Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span>
</button>
<!-- Transport controls require player.control -->
<template v-if="canControl">
<button class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="play-btn" @click="togglePlay">
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="control-btn" @click="store.next()">
<Icon icon="mdi:skip-next" />
</button>
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
<Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span>
</button>
</template>
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
</div>
<div class="player-right">
<Icon icon="mdi:volume-high" class="volume-icon" />
<input
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
@change="onVolumeChange"
class="volume-slider"
/>
<!-- Volume requires player.control -->
<template v-if="canControl">
<Icon icon="mdi:volume-high" class="volume-icon" />
<input
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
@change="onVolumeChange"
class="volume-slider"
/>
</template>
<button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue">
<Icon icon="mdi:playlist-music" />
</button>
@@ -79,6 +86,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue';
@@ -86,6 +94,9 @@ const route = useRoute();
const router = useRouter();
const showQueue = ref(false);
const { can } = useSession();
const canControl = computed(() => can('player.control'));
const store = usePlayerStore();
const activeBot = computed(() => store.activeBot);
const currentSong = computed(() => store.currentSong);
@@ -133,6 +144,7 @@ function updateProgress() {
}
async function onProgressClick(e: MouseEvent) {
if (!canControl.value) return; // seek requires player.control
const bar = progressBarRef.value;
if (!bar) return;
const rect = bar.getBoundingClientRect();
@@ -237,6 +249,14 @@ function cycleMode() {
.progress-bar-bg { height: 4px; }
.progress-bar-thumb { opacity: 1; transform: scale(1); }
}
&.no-seek {
cursor: default;
&:hover {
.progress-bar-bg { height: 2px; }
.progress-bar-thumb { opacity: 0; transform: scale(0); }
}
}
}
.progress-bar-bg {
+8 -5
View File
@@ -3,10 +3,10 @@
<div class="queue-header">
<h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span>
<button
v-if="botQueue.length > 0"
class="clear-btn"
@click="clearAndStop"
<button
v-if="botQueue.length > 0 && can('player.control')"
class="clear-btn"
@click="clearAndStop"
title="清空队列并停止播放"
>
<Icon icon="mdi:stop-circle-outline" />
@@ -33,7 +33,7 @@
<div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</div>
</div>
<button class="remove-btn" @click="removeSong(i)" title="移除">
<button v-if="can('player.queue')" class="remove-btn" @click="removeSong(i)" title="移除">
<Icon icon="mdi:close" />
</button>
</div>
@@ -46,6 +46,7 @@ import { watch, computed } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue';
const props = defineProps<{
@@ -57,6 +58,7 @@ defineEmits<{
}>();
const store = usePlayerStore();
const { can } = useSession();
const botQueue = computed(() => store.queue);
// Fetch queue when panel opens
@@ -65,6 +67,7 @@ watch(() => props.open, (isOpen) => {
});
async function playAtIndex(index: number) {
if (!can('player.control')) return;
await store.playAtIndex(index);
await store.fetchQueue();
}