mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
docs: guest-mode (login-less WebUI access) design spec (#83)
Brainstorm-approved design for an optional, default-off guest mode: - guest = anonymous, config-driven principal (no account) - per-ability admin toggles (add-to-end default on; play-next/play-now/ skip/transport/remove-clear/play-mode opt-in) + per-bot guest scope - unified authorize() gate; settings always locked for guests; non-destructive guest "play now" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
06aaec4ba5
commit
694ff77712
1 file changed
+317
@@ -0,0 +1,317 @@
|
||||
# Guest mode (login-less WebUI access) — design
|
||||
|
||||
**Issue:** [#83](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/83) — "请求增加 WebUI 鉴权 guest 登录功能"
|
||||
**Date:** 2026-06-24
|
||||
**Status:** Approved (brainstorm), pending implementation plan
|
||||
|
||||
## Scope
|
||||
|
||||
Add an optional, **default-OFF** guest mode. When an admin enables it, anyone who can
|
||||
reach the WebUI can enter **without logging in** ("以游客身份进入 / Continue as guest")
|
||||
and use a restricted subset of playback/queue features. The admin chooses, per
|
||||
deployment, exactly what guests may do (a set of toggles) and which bot(s) guests may
|
||||
control. Guests can **never** view or change settings, manage bots, set platform
|
||||
credentials, change audio quality, or see the user/audit admin panels.
|
||||
|
||||
This builds directly on the existing `admin | member` role + capability system
|
||||
(`src/data/permissions.ts`, `requirePermission`, `useSession().can()`) and the existing
|
||||
append-vs-play-next queue split (`PlayQueue.add` vs `addNext`). It does **not** rebuild
|
||||
auth.
|
||||
|
||||
The original issue asked specifically that guest song requests go to "下一首" only.
|
||||
That exact behavior is reproducible in this design by the admin turning the
|
||||
"add to end" toggle **off** and the "play next" toggle **on** — it is one configuration
|
||||
of a more general per-ability toggle model (chosen in brainstorm).
|
||||
|
||||
## Problem
|
||||
|
||||
Today every `/api/*` route past the session router requires a real account
|
||||
(`requireAuth`). There is no anonymous/guest path: to let a friend queue a song, an
|
||||
admin must create them a `member` account. The maintainer wants a low-friction,
|
||||
admin-gated way to let untrusted visitors request music without an account, while
|
||||
keeping all administration locked down.
|
||||
|
||||
## Decisions (from brainstorm)
|
||||
|
||||
1. **Guest = no-login.** A guest is an **anonymous, config-driven synthetic principal**
|
||||
(`role: "guest"`), not a database user with a password. No favorites, no
|
||||
change-password, short-lived session.
|
||||
2. **Default OFF**, enforced **server-side** (the guest-session endpoint rejects when
|
||||
the flag is off — never rely on hiding the button).
|
||||
3. **Per-ability toggles**, not a single "mode". Every song action and control action is
|
||||
its own admin switch. Default state when guest mode is first enabled: only
|
||||
"add to end of queue" is ON; everything else OFF.
|
||||
4. **Per-bot guest scope** (`"all"` or an explicit bot list), mirroring the existing
|
||||
member bot allow-list. Guests cannot see or control out-of-scope bots — including
|
||||
over WebSocket.
|
||||
5. **Settings are always hidden AND server-blocked for guests** (view + change), closing
|
||||
the two currently-ungated reads (`GET /api/bot/settings`, `GET /api/music/quality`).
|
||||
6. **"Play now" for guests is non-destructive**: insert-next + skip to it, **never** the
|
||||
existing clear-the-whole-queue `/play-song` behavior.
|
||||
7. **One unified authorization gate** encapsulates admin/member/guest logic so the
|
||||
existing member/admin capability system is left behavior-unchanged.
|
||||
|
||||
## Guest ability model
|
||||
|
||||
### Always allowed (baseline read-only — the point of the feature)
|
||||
- Browse/search library, playlists, history, song detail, lyrics, cover art.
|
||||
- See now-playing and the live queue (REST + WebSocket), **scoped to allowed bots**.
|
||||
|
||||
### Always denied (hard locks — not toggles)
|
||||
- View **or** change any settings (idle timeout, auto-pause, theme persistence server-side, command prefix, etc.).
|
||||
- Bot management (create/edit/delete/start/stop, bot config, avatar, profile).
|
||||
- Music-platform login (`/api/auth/*`), audio quality (`/api/music/quality`).
|
||||
- User management (`/api/users`), audit log (`/api/audit`), change-password.
|
||||
|
||||
### Admin-configurable toggles (`guestMode.permissions.*`, all default `false` except `addToQueue`)
|
||||
|
||||
| Flag | 中文 | Default | Backend route(s) gated |
|
||||
|---|---|---|---|
|
||||
| `addToQueue` | 添加到队列末尾 | **true** | `POST /:botId/add`, `/add-song`, `/add-by-id` |
|
||||
| `playNext` | 添加到下一首 | false | `POST /:botId/play-next-song` |
|
||||
| `playNow` | 立即播放(不清空队列) | false | new guest-safe play-now (insert-next + skip) |
|
||||
| `skip` | 跳过当前歌曲 | false | `POST /:botId/next` |
|
||||
| `transport` | 暂停/继续/进度/音量 | false | `POST /:botId/pause`, `/resume`, `/seek`, `/volume` |
|
||||
| `removeClear` | 移除/清空队列 | false | `DELETE /:botId/queue/:index`, `POST /:botId/clear` |
|
||||
| `playMode` | 切换播放模式 / FM | false | `POST /:botId/mode`, `/fm` |
|
||||
|
||||
Notes:
|
||||
- Routes with **no** guest flag (e.g. `/prev`, `/stop`, `/play-song`, `/play-playlist`,
|
||||
`/play-album`, `/play-at`, all of `/api/bot/*`, `/api/auth/*`, settings, users, audit)
|
||||
are **never** reachable by guests — the gate denies any guest without an explicit flag.
|
||||
This is the safe default: new routes are guest-denied unless deliberately opted in.
|
||||
- `/play-song`, `/play-playlist`, `/play-album` call `queue.clear()` and must stay
|
||||
guest-denied regardless of toggles (they would wipe everyone's queue).
|
||||
|
||||
## Config schema (`src/data/config.ts`)
|
||||
|
||||
```ts
|
||||
export interface GuestPermissions {
|
||||
addToQueue: boolean; // append to end
|
||||
playNext: boolean; // 下一首 (insert after current)
|
||||
playNow: boolean; // 立即播放: insert-next + skip-to-it (non-destructive)
|
||||
skip: boolean; // skip current track
|
||||
transport: boolean; // pause/resume/seek/volume
|
||||
removeClear: boolean; // remove a queue item / clear the queue
|
||||
playMode: boolean; // play mode (shuffle/repeat) + FM
|
||||
}
|
||||
|
||||
export interface GuestModeConfig {
|
||||
enabled: boolean; // master switch, default false
|
||||
bots: "all" | string[]; // per-bot scope (botIds); default "all"
|
||||
permissions: GuestPermissions;
|
||||
}
|
||||
|
||||
// added to BotConfig:
|
||||
guestMode: GuestModeConfig;
|
||||
```
|
||||
|
||||
`getDefaultConfig()` returns:
|
||||
```ts
|
||||
guestMode: {
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Merge hardening:** `loadConfig` currently does a shallow `{...defaults, ...partial}`,
|
||||
which would drop `guestMode` sub-keys if a saved config only contains a partial
|
||||
`guestMode`. `loadConfig` must **deep-merge `guestMode`** (and its `permissions`) over
|
||||
the defaults so missing sub-keys are back-filled. Covered by a `config.test.ts` case.
|
||||
|
||||
**Bot deletion:** when a bot is removed, prune its id from `guestMode.bots` (if it's an
|
||||
array) and persist — mirrors `PermissionStore.pruneBot(botId)` for members. Done in the
|
||||
same `BotManager.removeBot` path that already prunes member access.
|
||||
|
||||
## Backend design
|
||||
|
||||
### Synthetic guest principal & session entry
|
||||
- **Role union widened** to `"admin" | "member" | "guest"` (`UserRole` in
|
||||
`src/data/users.ts`, the `req.user` augmentation in `requireAuth.ts`, the frontend
|
||||
`User` type, and the role badge in Navbar).
|
||||
- **Reserved guest user row.** A single fixed row (e.g. id `"__guest__"`, role `"guest"`,
|
||||
an unusable password hash, username e.g. `"guest"`) is created idempotently by
|
||||
migration. It exists only to satisfy the `sessions.userId` FK and the
|
||||
`validateAndTouch` JOIN; it is excluded from user-management listings and the
|
||||
last-admin guards (those count `role = 'admin'` only, so guests don't interfere).
|
||||
- **Guest login endpoint:** `POST /api/session/guest`, mounted in the **public** block
|
||||
(before `csrfOriginCheck`/`requireAuth`, like `/login` and `/setup`), rate-limited.
|
||||
- If `config.guestMode.enabled` is false → `403 guest mode disabled`.
|
||||
- Else `sessions.createSession("__guest__")` and set the same `tsmb_session` httpOnly
|
||||
cookie. **Guest sessions use a short TTL** (e.g. `GUEST_SESSION_TTL_MS`, ~24h) and
|
||||
**bypass `MAX_SESSIONS_PER_USER`** for the guest principal (otherwise guest #11
|
||||
would evict guest #1). Expired guest sessions are already deleted on validation; an
|
||||
optional periodic sweep can prune stale ones.
|
||||
- **Disable = logout.** In `createRequireAuth`/`validateSession`, if a validated session
|
||||
has `role === "guest"` but `config.guestMode.enabled` is now false, treat it as
|
||||
unauthenticated (401). So flipping guest mode off immediately ends guest access.
|
||||
- **Expose availability:** extend `GET /api/session/needs-setup` (or add a sibling
|
||||
`GET /api/session/guest-config`) to return `guestAllowed: boolean` so the **public**
|
||||
Login page can decide whether to show the guest button. This must not leak any other
|
||||
config.
|
||||
|
||||
### Permission resolution
|
||||
`resolvePermissionContext` gains a `guest` branch. Signature extended to receive the
|
||||
live guest config:
|
||||
```ts
|
||||
resolvePermissionContext(role, userId, store, guestConfig?) => {
|
||||
admin → { capabilities: all CAPABILITIES, bots: "all" }
|
||||
member → stored caps + stored bots // unchanged
|
||||
guest → {
|
||||
capabilities: new Set(), // holds NO member capabilities
|
||||
bots: guestConfig.bots === "all" ? "all" : new Set(guestConfig.bots),
|
||||
guest: guestConfig.permissions, // resolved per-request from live config
|
||||
}
|
||||
}
|
||||
```
|
||||
`PermissionContext` and `req.user` gain an optional `guest?: GuestPermissions`. Because
|
||||
`req.user` is rebuilt per request, toggling a permission or the bot scope takes effect on
|
||||
the guest's next request (no re-login).
|
||||
|
||||
### Unified authorization gate (`src/web/middleware/authorize.ts`, new)
|
||||
Replaces `requirePermission('x')` on **guest-reachable** routes:
|
||||
```ts
|
||||
authorize({ capability?: Capability, guestFlag?: keyof GuestPermissions })
|
||||
// 401 if no req.user
|
||||
// admin → next()
|
||||
// guest → (req.user.guest?.[guestFlag] === true) ? next() : 403 // also 403 if no guestFlag
|
||||
// member → (capability && req.user.capabilities.has(capability)) ? next() : 403
|
||||
```
|
||||
- Member/admin semantics are **identical** to today's `requirePermission`.
|
||||
- A route with no `guestFlag` is automatically guest-denied (safe default).
|
||||
- `requireBotAccess` is unchanged and already enforces the guest `bots` scope (guests
|
||||
flow through `req.user.bots`).
|
||||
- `requireAdmin` is unchanged (guests are non-admin → 403), so `/api/users` and
|
||||
`/api/audit` stay locked.
|
||||
|
||||
### Route changes (`src/web/api/player.ts`, `bot.ts`, `music.ts`)
|
||||
- Re-express guest-reachable player routes via `authorize({ capability, guestFlag })`:
|
||||
- `/add`, `/add-song`, `/add-by-id` → `{ capability: "player.queue", guestFlag: "addToQueue" }`
|
||||
- `/play-next-song` → `{ capability: "player.control", guestFlag: "playNext" }`
|
||||
(members keep `player.control`; guests pass only via `playNext`)
|
||||
- new guest-safe **play-now** → `{ capability: "player.control", guestFlag: "playNow" }`
|
||||
- `/next` → `{ capability: "player.control", guestFlag: "skip" }`
|
||||
- `/pause`,`/resume`,`/seek`,`/volume` → `{ capability: "player.control", guestFlag: "transport" }`
|
||||
- `DELETE /queue/:index`, `/clear` → `{ capability: "player.queue", guestFlag: "removeClear" }`
|
||||
- `/mode`, `/fm` → `{ capability: "player.control", guestFlag: "playMode" }`
|
||||
- everything else stays `authorize({ capability })` (no guest flag) → guest-denied.
|
||||
- **Guest-safe play-now**: a new behavior (own route, e.g. `POST /:botId/play-now`, or a
|
||||
`mode:"now"` branch) that does `queue.addNext(song)` then advances to it (skip into the
|
||||
inserted track) — **no `queue.clear()`**. Members/admins may also use it; the existing
|
||||
destructive `/play-song` stays for the normal ▶ in non-guest UI. Exact wiring decided
|
||||
in the plan.
|
||||
- **Close ungated reads against guests:** `GET /api/bot/settings` and
|
||||
`GET /api/music/quality` currently have no guard, so a guest could read config. Add a
|
||||
small `requireNotGuest` guard (allow `admin` + `member`, deny `guest` → 403). This
|
||||
**does not change member/admin behavior** — members keep their current read access; only
|
||||
guests are newly denied. (Deliberately not a new member capability, to avoid touching
|
||||
member semantics.)
|
||||
|
||||
### WebSocket (`src/web/websocket.ts`, `src/web/server.ts`)
|
||||
- Guests authenticate over the WS upgrade unchanged (session cookie).
|
||||
- **Add per-client bot-scope filtering** for guests: the upgrade handler already stamps
|
||||
`ws.userId`; also resolve and stamp the client's bot scope (`"all"` or a Set). In
|
||||
`setupWebSocket`, when sending `init` and broadcasting `stateChange` /
|
||||
`botConnected/Disconnected/Removed`, **filter to bots the client may see**. For guests
|
||||
with a scoped `bots` list, out-of-scope bots are omitted. Admin/member payloads are
|
||||
unchanged (they resolve to `"all"` or their existing member scope — to avoid changing
|
||||
member behavior, filtering may be applied **only when the client is a guest**; decided
|
||||
in the plan).
|
||||
|
||||
### Settings write (`POST /api/bot/settings`)
|
||||
Extend the existing settings writer (today only idle-timeout + auto-pause) to also accept
|
||||
and persist the `guestMode` block (admin-only via `bot.manage`/`requireAdmin`), calling
|
||||
`saveConfig`. Live effect: subsequent guest requests read the updated in-memory config.
|
||||
|
||||
## Frontend design
|
||||
|
||||
- **`useSession.ts`**: extend `User` with `role:'guest'` and a `guest?: GuestPermissions`
|
||||
field (from `/api/session/me`). Add `isGuest` computed and `guestCan(flag)`; make `can`
|
||||
guest-aware where it maps cleanly, but UI gating for guest-specific actions uses
|
||||
`guestCan('addToQueue' | 'playNext' | ...)`. `canControlBot` already enforces the bot
|
||||
scope and works for guests via the `bots` field.
|
||||
- **Login page (`Login.vue`)**: when `guestAllowed`, show a prominent
|
||||
"以游客身份进入 / Continue as guest" button calling a new `session.continueAsGuest()`
|
||||
→ `POST /api/session/guest` → refresh → redirect to `?next` or home.
|
||||
- **Router (`web/src/router/index.ts`)**: in the global `beforeEach`, block guests from
|
||||
`/settings` and `/setup` (redirect to home). Default-off ⇒ when not a guest, behavior
|
||||
is unchanged.
|
||||
- **Navbar (`Navbar.vue`)**: hide the settings cog for guests; add a `游客` role badge
|
||||
branch; the bot selector already filters via `canControlBot`, so scoped guests only see
|
||||
allowed bots.
|
||||
- **App shell (`App.vue`)**: hide the mobile `/settings` tab for guests; the mini-player
|
||||
transport reduces to the guest's allowed actions.
|
||||
- **SongCard / Queue / Player**: gate each action button by the matching `guestCan(flag)`
|
||||
(e.g. show ▶/下一首/添加 per `playNow`/`playNext`/`addToQueue`; show skip/transport/
|
||||
remove/clear/mode per their flags). Buttons a guest lacks are hidden, mirroring how
|
||||
`Queue.vue` already gates on `can('player.queue')` / `can('player.control')`.
|
||||
- **Settings → Guest mode admin section (`Settings.vue`)**: new admin-only panel: a
|
||||
master enable switch, the 7 permission checkboxes (with 中文 labels), and a bot scope
|
||||
control (an "全部机器人 / all bots" toggle + per-bot checkboxes) reusing the existing
|
||||
member permission-editor bot allow-list UI. Saving calls `POST /api/bot/settings` with
|
||||
the `guestMode` block.
|
||||
|
||||
## Defaults, migration & backward-compat
|
||||
|
||||
- `getDefaultConfig().guestMode.enabled = false` ⇒ **no behavior change** on upgrade;
|
||||
existing installs see nothing until an admin opts in.
|
||||
- Migration adds the reserved `__guest__` user row idempotently (guarded like the
|
||||
existing `backfillMemberPermissions` `schema_meta` marker) and does **not** grant it
|
||||
any `user_permissions` (guest authorization is config-driven, not row-driven).
|
||||
- `loadConfig` deep-merges `guestMode` so older config files gain the new block with
|
||||
defaults.
|
||||
- Member/admin flows, capabilities, and the backfill are untouched.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
- **Config**: `getDefaultConfig` includes `guestMode` default-off; `loadConfig`
|
||||
deep-merges a partial `guestMode` (missing sub-keys back-filled); round-trips through
|
||||
`saveConfig`.
|
||||
- **`resolvePermissionContext` guest branch**: empty member capabilities; `bots` `"all"`
|
||||
vs scoped Set; `guest` permissions object passthrough.
|
||||
- **`authorize` gate**: admin bypass; member has/lacks capability → 200/403 (regression
|
||||
parity with `requirePermission`); guest allowed only when the specific flag is true;
|
||||
guest with no flag on a route → 403; guest on settings reads → 403.
|
||||
- **Enforcement (mirror `permissions-enforcement.test.ts`)**: each toggle independently
|
||||
opens exactly its route(s) for a guest and nothing else; `/play-song`/`/play-playlist`/
|
||||
`/play-album` always 403 for guests; per-bot scope: guest 403 on out-of-scope `:botId`.
|
||||
- **Session entry**: `POST /api/session/guest` → 403 when disabled, mints guest session
|
||||
when enabled; guest session bypasses `MAX_SESSIONS_PER_USER`; disabling guest mode
|
||||
invalidates existing guest sessions (401); guest TTL shorter than member TTL.
|
||||
- **WS scope**: guest receives only in-scope bots' `init`/`stateChange`; reject upgrade
|
||||
unchanged for no cookie.
|
||||
- **Frontend** (where covered): `guestCan` gating; router blocks `/settings` for guests.
|
||||
|
||||
## Non-goals (YAGNI)
|
||||
|
||||
- No guest accounts/usernames, passwords, favorites, or persistence per guest.
|
||||
- No per-guest individual identity or rate-limiting beyond the existing IP rate limits
|
||||
(a basic abuse guard on `/api/session/guest` is in; richer abuse controls are future).
|
||||
- No change to the `admin | member` capability semantics; guest is an additive,
|
||||
config-driven third principal.
|
||||
- No chat-command (TeamSpeak `!add`/`!playnext`) changes — guest mode is **WebUI-only**
|
||||
(the issue is explicitly about WebUI 鉴权).
|
||||
- Per-guest bot scoping beyond a single shared guest scope is out of scope (one guest
|
||||
scope applies to all guests).
|
||||
|
||||
## Key files touched
|
||||
|
||||
Backend: `src/data/config.ts` (+test), `src/data/permissions.ts` (+test),
|
||||
`src/data/users.ts` (role union, reserved guest row), `src/data/database.ts` (migration),
|
||||
`src/data/sessions.ts` (guest TTL + cap bypass), `src/web/middleware/authorize.ts` (new,
|
||||
+test), `src/web/middleware/requireNotGuest.ts` (new, small — for the config reads),
|
||||
`src/web/api/session.ts` (guest endpoint, `/me`, `needs-setup`),
|
||||
`src/web/api/player.ts` (re-gate + guest play-now), `src/web/api/bot.ts` (settings
|
||||
read-lock + guestMode write), `src/web/api/music.ts` (quality read-lock),
|
||||
`src/web/server.ts` + `src/web/websocket.ts` (WS scope), `src/web/auth/validateSession.ts`
|
||||
(guest disable→401), enforcement tests.
|
||||
|
||||
Frontend: `web/src/composables/useSession.ts`, `web/src/views/Login.vue`,
|
||||
`web/src/router/index.ts`, `web/src/components/Navbar.vue`, `web/src/App.vue`,
|
||||
`web/src/components/SongCard.vue`, `web/src/components/Queue.vue`,
|
||||
`web/src/components/Player.vue`, `web/src/views/Settings.vue`,
|
||||
`web/src/stores/player.ts`.
|
||||
Reference in new issue
Block a user