mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
Merge pull request #101 from ZHANGTIANYAO1/feat/guest-mode
Add guest mode (login-less WebUI access) (#83)
This commit is contained in:
49 files changed
+4316
-130
No files matched your search
@@ -182,6 +182,27 @@ sudo ./scripts/install.sh
|
||||
- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。
|
||||
- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。
|
||||
|
||||
**游客模式 / Guest mode**:
|
||||
|
||||
让访客**无需账号密码**即可进入 WebUI 点歌,同时严格限制其可用能力。该功能**默认关闭**,只有管理员能开启。
|
||||
|
||||
- **开启方式**:管理员在 **设置 → 游客模式** 打开「允许游客访问」(仅管理员可见此区块)。开启后登录页会出现 **「以游客身份进入」** 按钮,访客点击即可创建游客会话,无需任何凭据。关闭游客模式后,所有游客会话立即失效。
|
||||
- **逐项权限(7 个开关,管理员配置)**:除「添加到队列末尾」外**全部默认关闭**,按需逐项放开。
|
||||
|
||||
| 开关 | 字段 | 默认 |
|
||||
|------|------|------|
|
||||
| 添加到队列末尾 | `addToQueue` | **开** |
|
||||
| 添加到下一首 | `playNext` | 关 |
|
||||
| 立即播放(不清空队列) | `playNow` | 关 |
|
||||
| 跳过当前歌曲 | `skip` | 关 |
|
||||
| 暂停/继续/进度/音量 | `transport` | 关 |
|
||||
| 移除/清空队列 | `removeClear` | 关 |
|
||||
| 切换播放模式 / FM | `playMode` | 关 |
|
||||
|
||||
- **按机器人授权(游客作用域)**:可选择「全部机器人」或指定一份机器人白名单。作用域之外的机器人对游客**不可见、不可控**。
|
||||
- **游客始终被禁止**:查看或修改任何设置、管理机器人、设置音乐平台账号 / 凭据、修改音质,以及访问用户管理与操作审计。这些限制不受上面 7 个开关影响,**永远锁死**。
|
||||
- **复现 issue #83 的「下一首 only」需求**:在 **设置 → 游客模式** 中关闭「添加到队列末尾」并打开「添加到下一首」,游客便只能把歌曲加到下一首播放。
|
||||
|
||||
**如何重置忘记的管理员密码**:
|
||||
|
||||
如果你忘记了管理员密码,可以直接编辑 SQLite 数据库 `data/tsmusicbot.db`:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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`.
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { BotInstance } from "./instance.js";
|
||||
|
||||
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
|
||||
// reads avatars, etc.), and runExclusive only touches a single private field
|
||||
// (`playGate`). So we exercise the ACTUAL shipped method via its prototype,
|
||||
// bound to a minimal object carrying just that field. This proves the real
|
||||
// serializer logic without standing up a full bot.
|
||||
type Gate = { playGate: Promise<unknown> };
|
||||
const runExclusive = BotInstance.prototype.runExclusive as <T>(
|
||||
this: Gate,
|
||||
fn: () => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
function makeGate(): Gate {
|
||||
return { playGate: Promise.resolve() };
|
||||
}
|
||||
|
||||
/** An explicit, timer-free deferred so ordering is deterministic. */
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("BotInstance.runExclusive — serialization", () => {
|
||||
it("does not start fnB until fnA settles", async () => {
|
||||
const gate = makeGate();
|
||||
const order: string[] = [];
|
||||
const gateA = deferred();
|
||||
|
||||
const pA = runExclusive.call(gate, async () => {
|
||||
order.push("A-start");
|
||||
await gateA.promise; // suspend A until we explicitly release it
|
||||
order.push("A-end");
|
||||
});
|
||||
|
||||
const pB = runExclusive.call(gate, async () => {
|
||||
order.push("B-start");
|
||||
order.push("B-end");
|
||||
});
|
||||
|
||||
// Give the microtask queue a chance: B must NOT have started while A is
|
||||
// still suspended on gateA.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["A-start"]);
|
||||
|
||||
gateA.resolve();
|
||||
await pA;
|
||||
await pB;
|
||||
|
||||
expect(order).toEqual(["A-start", "A-end", "B-start", "B-end"]);
|
||||
});
|
||||
|
||||
it("runs fnB even if fnA rejects (chain survives rejection)", async () => {
|
||||
const gate = makeGate();
|
||||
const order: string[] = [];
|
||||
const gateA = deferred();
|
||||
|
||||
const pA = runExclusive.call(gate, async () => {
|
||||
order.push("A-start");
|
||||
await gateA.promise;
|
||||
throw new Error("A blew up");
|
||||
});
|
||||
|
||||
const pB = runExclusive.call(gate, async () => {
|
||||
order.push("B-start");
|
||||
order.push("B-end");
|
||||
return "B-result";
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["A-start"]);
|
||||
|
||||
gateA.reject(new Error("A blew up"));
|
||||
await expect(pA).rejects.toThrow("A blew up");
|
||||
|
||||
// B still runs, only after A has fully settled.
|
||||
await expect(pB).resolves.toBe("B-result");
|
||||
expect(order).toEqual(["A-start", "B-start", "B-end"]);
|
||||
});
|
||||
|
||||
it("preserves call order across three serialized tasks", async () => {
|
||||
const gate = makeGate();
|
||||
const order: string[] = [];
|
||||
const tasks = ["X", "Y", "Z"];
|
||||
const promises = tasks.map((t) =>
|
||||
runExclusive.call(gate, async () => {
|
||||
order.push(`${t}-start`);
|
||||
await Promise.resolve();
|
||||
order.push(`${t}-end`);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(order).toEqual([
|
||||
"X-start",
|
||||
"X-end",
|
||||
"Y-start",
|
||||
"Y-end",
|
||||
"Z-start",
|
||||
"Z-end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ export class BotInstance extends EventEmitter {
|
||||
private fmProvider: MusicProvider | null = null;
|
||||
/** Results of the most recent !search, for "#N" selection (issue #90). */
|
||||
private lastSearchResults: Song[] = [];
|
||||
private playGate: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
@@ -1051,6 +1052,14 @@ export class BotInstance extends EventEmitter {
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Serialize queue-mutation + play sequences so concurrent requests can't
|
||||
* interleave (audible track must match queue.currentIndex). */
|
||||
runExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.playGate.then(fn, fn);
|
||||
this.playGate = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
getStatus(): BotStatus {
|
||||
return {
|
||||
id: this.id,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { BotManager } from "./manager.js";
|
||||
import { createDatabase, type BotDatabase } from "../data/database.js";
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
|
||||
// removeBot only calls logger.info; provide the full shape it could touch.
|
||||
const stubLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
child() {
|
||||
return stubLogger;
|
||||
},
|
||||
} as unknown as Logger;
|
||||
|
||||
describe("BotManager.removeBot — guest scope pruning", () => {
|
||||
const dirs: string[] = [];
|
||||
let db: BotDatabase;
|
||||
|
||||
function makeTmpConfigPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-manager-test-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "config.json");
|
||||
}
|
||||
|
||||
function makeManager(config: BotConfig, configPath: string): BotManager {
|
||||
db = createDatabase(":memory:");
|
||||
const permissions = createPermissionStore(db.db);
|
||||
saveConfig(configPath, config);
|
||||
return new BotManager(
|
||||
{} as unknown as MusicProvider,
|
||||
{} as unknown as MusicProvider,
|
||||
{} as unknown as MusicProvider,
|
||||
db,
|
||||
config,
|
||||
stubLogger,
|
||||
{} as unknown as AvatarStore,
|
||||
permissions,
|
||||
configPath
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it("prunes a deleted bot from guestMode.bots (array) and persists", async () => {
|
||||
const configPath = makeTmpConfigPath();
|
||||
const config = getDefaultConfig();
|
||||
config.guestMode.bots = ["botA", "botB"];
|
||||
const manager = makeManager(config, configPath);
|
||||
|
||||
await manager.removeBot("botA");
|
||||
|
||||
expect(config.guestMode.bots).toEqual(["botB"]);
|
||||
// Persisted file must also reflect the prune.
|
||||
expect(loadConfig(configPath).guestMode.bots).toEqual(["botB"]);
|
||||
});
|
||||
|
||||
it('leaves guestMode.bots === "all" unchanged (no crash, no change)', async () => {
|
||||
const configPath = makeTmpConfigPath();
|
||||
const config = getDefaultConfig();
|
||||
config.guestMode.bots = "all";
|
||||
const manager = makeManager(config, configPath);
|
||||
|
||||
await manager.removeBot("botA");
|
||||
|
||||
expect(config.guestMode.bots).toBe("all");
|
||||
expect(loadConfig(configPath).guestMode.bots).toBe("all");
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -7,7 +7,7 @@ import {
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import { YouTubeProvider } from "../music/youtube.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import { saveConfig, type BotConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
import type { ServerProtocol } from "../ts-protocol/client.js";
|
||||
@@ -79,6 +79,7 @@ export class BotManager extends EventEmitter {
|
||||
private logger: Logger;
|
||||
private avatarStore: AvatarStore;
|
||||
private permissions: PermissionStore;
|
||||
private configPath: string;
|
||||
|
||||
constructor(
|
||||
neteaseProvider: MusicProvider,
|
||||
@@ -88,7 +89,8 @@ export class BotManager extends EventEmitter {
|
||||
config: BotConfig,
|
||||
logger: Logger,
|
||||
avatarStore: AvatarStore,
|
||||
permissions: PermissionStore
|
||||
permissions: PermissionStore,
|
||||
configPath: string
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
@@ -100,6 +102,7 @@ export class BotManager extends EventEmitter {
|
||||
this.logger = logger;
|
||||
this.avatarStore = avatarStore;
|
||||
this.permissions = permissions;
|
||||
this.configPath = configPath;
|
||||
}
|
||||
|
||||
async createBot(params: CreateBotParams): Promise<BotInstance> {
|
||||
@@ -160,6 +163,11 @@ export class BotManager extends EventEmitter {
|
||||
}
|
||||
this.database.deleteBotInstance(id);
|
||||
this.permissions.pruneBot(id);
|
||||
// Prune the deleted bot from the guest scope allow-list (mirrors permissions.pruneBot).
|
||||
if (Array.isArray(this.config.guestMode.bots) && this.config.guestMode.bots.includes(id)) {
|
||||
this.config.guestMode.bots = this.config.guestMode.bots.filter((b) => b !== id);
|
||||
saveConfig(this.configPath, this.config);
|
||||
}
|
||||
this.emit("botInstanceRemoved", id);
|
||||
this.logger.info({ botId: id }, "Bot instance removed");
|
||||
}
|
||||
|
||||
@@ -106,3 +106,74 @@ describe("config", () => {
|
||||
expect(migrated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guestMode config", () => {
|
||||
it("defaults to disabled, all-bots, append-only", () => {
|
||||
const c = getDefaultConfig();
|
||||
expect(c.guestMode.enabled).toBe(false);
|
||||
expect(c.guestMode.bots).toBe("all");
|
||||
expect(c.guestMode.permissions).toEqual({
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("deep-merges a partial guestMode so missing sub-keys are back-filled", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(p, JSON.stringify({ guestMode: { enabled: true, permissions: { playNext: true } } }));
|
||||
const c = loadConfig(p);
|
||||
expect(c.guestMode.enabled).toBe(true);
|
||||
expect(c.guestMode.bots).toBe("all"); // back-filled
|
||||
expect(c.guestMode.permissions.playNext).toBe(true);
|
||||
expect(c.guestMode.permissions.addToQueue).toBe(true); // back-filled default
|
||||
expect(c.guestMode.permissions.skip).toBe(false); // back-filled default
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// --- B1: loadConfig must sanitize a hand-edited/legacy/corrupt guestMode ---
|
||||
|
||||
function loadGuestMode(raw: unknown) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
|
||||
const p = join(dir, "config.json");
|
||||
writeFileSync(p, JSON.stringify(raw));
|
||||
try {
|
||||
return loadConfig(p).guestMode;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("bots normalization", () => {
|
||||
it("a numeric bots value falls back to the default \"all\" (no crash)", () => {
|
||||
const gm = loadGuestMode({ guestMode: { bots: 5 } });
|
||||
expect(gm.bots).toBe("all");
|
||||
});
|
||||
it("an array bots value is filtered to strings only", () => {
|
||||
const gm = loadGuestMode({ guestMode: { bots: ["a", 2, "b"] } });
|
||||
expect(gm.bots).toEqual(["a", "b"]);
|
||||
});
|
||||
it("the literal \"all\" is preserved", () => {
|
||||
const gm = loadGuestMode({ guestMode: { bots: "all" } });
|
||||
expect(gm.bots).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions coercion", () => {
|
||||
it("a non-boolean truthy flag is coerced to false; a real true stays true", () => {
|
||||
const gm = loadGuestMode({ guestMode: { permissions: { skip: 1, playNext: true } } });
|
||||
expect(gm.permissions.skip).toBe(false);
|
||||
expect(gm.permissions.playNext).toBe(true);
|
||||
});
|
||||
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
|
||||
expect(gm.permissions).toEqual({
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
});
|
||||
// no garbage index keys leaked from spreading a string
|
||||
expect((gm.permissions as unknown as Record<string, unknown>)["0"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+58
-1
@@ -1,5 +1,13 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { BotAccess, GuestPermissions } from "./permissions.js";
|
||||
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
|
||||
|
||||
export interface GuestModeConfig {
|
||||
enabled: boolean;
|
||||
bots: BotAccess; // "all" | string[]
|
||||
permissions: GuestPermissions;
|
||||
}
|
||||
|
||||
export interface BotConfig {
|
||||
webPort: number;
|
||||
@@ -22,6 +30,7 @@ export interface BotConfig {
|
||||
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
|
||||
// behind HTTPS-terminating proxies.
|
||||
trustProxy: boolean;
|
||||
guestMode: GuestModeConfig;
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): BotConfig {
|
||||
@@ -43,6 +52,19 @@ export function getDefaultConfig(): BotConfig {
|
||||
idleTimeoutMinutes: 0,
|
||||
publicUrl: "",
|
||||
trustProxy: false,
|
||||
guestMode: {
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {
|
||||
addToQueue: true,
|
||||
playNext: false,
|
||||
playNow: false,
|
||||
skip: false,
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +73,42 @@ export function loadConfig(path: string): BotConfig {
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const partial = JSON.parse(raw) as Partial<BotConfig>;
|
||||
return { ...defaults, ...partial };
|
||||
|
||||
// Normalize/sanitize guestMode on load. The WRITE path (POST /api/bot/settings)
|
||||
// sanitizes too, but a hand-edited/legacy/corrupt config.json reaches the gate
|
||||
// directly — so coerce it here as well, mirroring that write-path logic.
|
||||
const partialGm = (partial.guestMode ?? {}) as Partial<GuestModeConfig>;
|
||||
const gm: GuestModeConfig = {
|
||||
...defaults.guestMode,
|
||||
...partialGm,
|
||||
// bots → "all" | string[]; anything else falls back to the default ("all").
|
||||
bots:
|
||||
partialGm.bots === "all"
|
||||
? "all"
|
||||
: Array.isArray(partialGm.bots)
|
||||
? partialGm.bots.filter((id): id is string => typeof id === "string")
|
||||
: defaults.guestMode.bots,
|
||||
// permissions → defaults, then spread ONLY a plain object, then strict-coerce
|
||||
// each known flag to a boolean (drops index keys + non-boolean values).
|
||||
permissions: { ...defaults.guestMode.permissions },
|
||||
};
|
||||
const partialPerms = partialGm.permissions;
|
||||
if (
|
||||
partialPerms !== null &&
|
||||
typeof partialPerms === "object" &&
|
||||
!Array.isArray(partialPerms)
|
||||
) {
|
||||
Object.assign(gm.permissions, partialPerms);
|
||||
}
|
||||
for (const f of GUEST_PERMISSION_FLAGS) {
|
||||
gm.permissions[f] = gm.permissions[f] === true;
|
||||
}
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...partial,
|
||||
guestMode: gm,
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
|
||||
import { createUserStore, GUEST_USER_ID } from "./users.js";
|
||||
|
||||
describe("database", () => {
|
||||
let botDb: BotDatabase;
|
||||
@@ -148,3 +152,28 @@ describe("database", () => {
|
||||
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest principal migration", () => {
|
||||
it("creates exactly one reserved guest row, idempotently", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-db-"));
|
||||
const p = join(dir, "t.db");
|
||||
const a = createDatabase(p); a.db.close();
|
||||
const b = createDatabase(p); // run again — must not duplicate
|
||||
const row = b.db.prepare("SELECT id, role FROM users WHERE id = ?").get(GUEST_USER_ID) as { id: string; role: string } | undefined;
|
||||
expect(row?.role).toBe("guest");
|
||||
const n = (b.db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='guest'").get() as { n: number }).n;
|
||||
expect(n).toBe(1);
|
||||
b.db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("guest row does not break first-run detection (countUsers excludes it)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmb-db2-"));
|
||||
const p = join(dir, "t.db");
|
||||
const d = createDatabase(p);
|
||||
const users = createUserStore(d.db);
|
||||
expect(users.countUsers()).toBe(0); // guest excluded → still needs setup
|
||||
d.db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
|
||||
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
|
||||
|
||||
export interface PlayHistoryEntry {
|
||||
botId: string;
|
||||
@@ -241,6 +242,19 @@ export function backfillMemberPermissions(db: Database.Database): void {
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the reserved guest principal exists. Idempotent via the PK on
|
||||
* `users.id`. This row only backs login-less guest sessions; it is excluded
|
||||
* from countUsers()/listUsers() so it never interferes with first-run setup
|
||||
* or the user-management UI, and holds an unusable password hash.
|
||||
*/
|
||||
export function ensureGuestUser(db: Database.Database): void {
|
||||
const now = Date.now();
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')"
|
||||
).run(GUEST_USER_ID, GUEST_USERNAME, now, now);
|
||||
}
|
||||
|
||||
export function createDatabase(dbPath: string): BotDatabase {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
@@ -248,6 +262,7 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
initTables(db);
|
||||
migrateSchema(db);
|
||||
backfillMemberPermissions(db);
|
||||
ensureGuestUser(db);
|
||||
|
||||
const insertHistory = db.prepare(`
|
||||
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
||||
|
||||
@@ -88,3 +88,46 @@ describe("PermissionStore", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
|
||||
|
||||
describe("resolvePermissionContext guest branch", () => {
|
||||
const noStore = {
|
||||
getCapabilities: () => [],
|
||||
getBotAccess: () => [] as string[],
|
||||
setPermissions: () => {},
|
||||
pruneBot: () => {},
|
||||
};
|
||||
|
||||
it("guest has no member capabilities and exposes the guest permissions + bots", () => {
|
||||
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
|
||||
bots: ["bot1"],
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: true, transport: false, removeClear: false, playMode: false,
|
||||
},
|
||||
});
|
||||
expect([...ctx.capabilities]).toEqual([]);
|
||||
expect(ctx.bots).toBeInstanceOf(Set);
|
||||
expect((ctx.bots as Set<string>).has("bot1")).toBe(true);
|
||||
expect(ctx.guest?.addToQueue).toBe(true);
|
||||
expect(ctx.guest?.skip).toBe(true);
|
||||
});
|
||||
|
||||
it("guest with bots:'all' resolves to 'all'", () => {
|
||||
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
|
||||
bots: "all",
|
||||
permissions: {
|
||||
addToQueue: true, playNext: false, playNow: false,
|
||||
skip: false, transport: false, removeClear: false, playMode: false,
|
||||
},
|
||||
});
|
||||
expect(ctx.bots).toBe("all");
|
||||
});
|
||||
|
||||
it("exposes the 7 canonical flags", () => {
|
||||
expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual(
|
||||
["addToQueue", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
+33
-2
@@ -21,6 +21,27 @@ export function isCapability(x: string): x is Capability {
|
||||
|
||||
export type BotAccess = "all" | string[];
|
||||
|
||||
export interface GuestPermissions {
|
||||
addToQueue: boolean;
|
||||
playNext: boolean;
|
||||
playNow: boolean;
|
||||
skip: boolean;
|
||||
transport: boolean;
|
||||
removeClear: boolean;
|
||||
playMode: boolean;
|
||||
}
|
||||
|
||||
export const GUEST_PERMISSION_FLAGS = [
|
||||
"addToQueue",
|
||||
"playNext",
|
||||
"playNow",
|
||||
"skip",
|
||||
"transport",
|
||||
"removeClear",
|
||||
"playMode",
|
||||
] as const;
|
||||
export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number];
|
||||
|
||||
export interface PermissionStore {
|
||||
getCapabilities(userId: string): Capability[];
|
||||
getBotAccess(userId: string): BotAccess;
|
||||
@@ -71,16 +92,26 @@ export function createPermissionStore(db: Database.Database): PermissionStore {
|
||||
export interface PermissionContext {
|
||||
capabilities: Set<string>;
|
||||
bots: "all" | Set<string>;
|
||||
guest?: GuestPermissions;
|
||||
}
|
||||
|
||||
export function resolvePermissionContext(
|
||||
role: "admin" | "member",
|
||||
role: "admin" | "member" | "guest",
|
||||
userId: string,
|
||||
store: PermissionStore
|
||||
store: PermissionStore,
|
||||
guest?: { bots: BotAccess; permissions: GuestPermissions }
|
||||
): PermissionContext {
|
||||
if (role === "admin") {
|
||||
return { capabilities: new Set(CAPABILITIES), bots: "all" };
|
||||
}
|
||||
if (role === "guest") {
|
||||
const bots = guest?.bots ?? [];
|
||||
return {
|
||||
capabilities: new Set<string>(),
|
||||
bots: bots === "all" ? "all" : new Set(bots),
|
||||
guest: guest?.permissions,
|
||||
};
|
||||
}
|
||||
const access = store.getBotAccess(userId);
|
||||
return {
|
||||
capabilities: new Set(store.getCapabilities(userId)),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createUserStore, type UserStore } from "./users.js";
|
||||
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js";
|
||||
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER, GUEST_SESSION_TTL_MS } from "./sessions.js";
|
||||
|
||||
function sha256(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -126,3 +126,74 @@ describe("SessionStore", () => {
|
||||
expect(count).toBe(MAX_SESSIONS_PER_USER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest sessions", () => {
|
||||
let botDb: BotDatabase;
|
||||
let sessions: SessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
sessions = createSessionStore(botDb.db);
|
||||
// Create the synthetic guest user row to satisfy the sessions FK.
|
||||
botDb.db
|
||||
.prepare("INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')")
|
||||
.run(Date.now(), Date.now());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) {
|
||||
tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token);
|
||||
}
|
||||
// The first token must STILL validate (not evicted).
|
||||
expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest");
|
||||
const n = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n;
|
||||
expect(n).toBe(MAX_SESSIONS_PER_USER + 3);
|
||||
});
|
||||
|
||||
it("ttlMs sets a shorter expiry than the default", () => {
|
||||
const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50);
|
||||
});
|
||||
|
||||
it("validateAndTouch refreshes a guest session to GUEST_SESSION_TTL_MS (1d), not SESSION_TTL_MS (7d)", () => {
|
||||
const { token } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
|
||||
// Force the touch branch: backdate lastSeenAt past the touch interval.
|
||||
botDb.db
|
||||
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = '__guest__'")
|
||||
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
|
||||
const result = sessions.validateAndTouch(token);
|
||||
expect(result?.role).toBe("guest");
|
||||
const row = botDb.db
|
||||
.prepare("SELECT expiresAt FROM sessions WHERE userId = '__guest__'")
|
||||
.get() as { expiresAt: number };
|
||||
// Should refresh to ~now + 1 day, NOT now + 7 days.
|
||||
expect(row.expiresAt).toBeGreaterThan(Date.now() + GUEST_SESSION_TTL_MS - 5000);
|
||||
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 5000);
|
||||
// Sanity: well below the 7d window.
|
||||
expect(row.expiresAt).toBeLessThan(Date.now() + SESSION_TTL_MS);
|
||||
});
|
||||
|
||||
it("validateAndTouch still refreshes a non-guest (admin) session to SESSION_TTL_MS (7d) on touch", () => {
|
||||
botDb.db
|
||||
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('admin1','adminuser','!',?,?, 'admin')")
|
||||
.run(Date.now(), Date.now());
|
||||
const { token } = sessions.createSession("admin1");
|
||||
botDb.db
|
||||
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = 'admin1'")
|
||||
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
|
||||
const result = sessions.validateAndTouch(token);
|
||||
expect(result?.role).toBe("admin");
|
||||
const row = botDb.db
|
||||
.prepare("SELECT expiresAt FROM sessions WHERE userId = 'admin1'")
|
||||
.get() as { expiresAt: number };
|
||||
// Refreshes to ~now + 7 days, NOT the 1d guest window.
|
||||
expect(row.expiresAt).toBeGreaterThan(Date.now() + SESSION_TTL_MS - 5000);
|
||||
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + SESSION_TTL_MS + 5000);
|
||||
});
|
||||
});
|
||||
+12
-6
@@ -2,17 +2,18 @@ import { createHash, randomBytes } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived
|
||||
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const MAX_SESSIONS_PER_USER = 10;
|
||||
|
||||
export interface SessionValidation {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: "admin" | "member";
|
||||
role: "admin" | "member" | "guest";
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
createSession(userId: string): { token: string; expiresAt: number };
|
||||
createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number };
|
||||
validateAndTouch(rawToken: string): SessionValidation | null;
|
||||
deleteSession(rawToken: string): void;
|
||||
deleteAllForUser(userId: string, exceptToken?: string): void;
|
||||
@@ -47,7 +48,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
|
||||
);
|
||||
|
||||
return {
|
||||
createSession(userId) {
|
||||
createSession(userId, opts) {
|
||||
// Cap concurrent sessions per user — oldest gets evicted on overflow.
|
||||
// Wrap the count → delete → insert in a transaction so concurrent logins
|
||||
// for the same user can't both pass the cap check and both insert,
|
||||
@@ -55,12 +56,14 @@ export function createSessionStore(db: Database.Database): SessionStore {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const id = hashToken(token);
|
||||
const now = Date.now();
|
||||
const expiresAt = now + SESSION_TTL_MS;
|
||||
const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
|
||||
const tx = db.transaction(() => {
|
||||
if (!opts?.skipCap) {
|
||||
const existing = (countForUserStmt.get(userId) as { n: number }).n;
|
||||
if (existing >= MAX_SESSIONS_PER_USER) {
|
||||
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
|
||||
}
|
||||
}
|
||||
insertStmt.run(id, userId, now, expiresAt, now);
|
||||
});
|
||||
tx();
|
||||
@@ -80,9 +83,12 @@ export function createSessionStore(db: Database.Database): SessionStore {
|
||||
return null;
|
||||
}
|
||||
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
|
||||
touchStmt.run(now, now + SESSION_TTL_MS, id);
|
||||
// Refresh against the role's own TTL — guests are short-lived (1d) and
|
||||
// must NOT be bumped to the member/admin 7d window on touch.
|
||||
const ttl = row.role === "guest" ? GUEST_SESSION_TTL_MS : SESSION_TTL_MS;
|
||||
touchStmt.run(now, now + ttl, id);
|
||||
}
|
||||
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" };
|
||||
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" };
|
||||
},
|
||||
|
||||
deleteSession(rawToken) {
|
||||
|
||||
+41
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDatabase, type BotDatabase } from "./database.js";
|
||||
import { createUserStore, UsernameTakenError, type UserStore } from "./users.js";
|
||||
import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js";
|
||||
|
||||
describe("UserStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
@@ -184,3 +184,43 @@ describe("UserStore", () => {
|
||||
expect(users.countAdmins()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("guest row exclusion", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("countUsers and listUsers ignore the reserved guest row", async () => {
|
||||
await users.createUser("alice", "password123", "member");
|
||||
// Insert the reserved guest row directly (mirrors the migration).
|
||||
botDb.db.prepare(
|
||||
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')"
|
||||
).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now());
|
||||
|
||||
expect(users.countUsers()).toBe(1); // alice only
|
||||
expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("setRoleIfNotLastAdmin refuses to re-role the reserved guest principal", () => {
|
||||
// The guest row is seeded by createDatabase via ensureGuestUser.
|
||||
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest"); // sanity
|
||||
expect(users.setRoleIfNotLastAdmin(GUEST_USER_ID, "admin")).toBe("not_found");
|
||||
// The guest row's role is unchanged.
|
||||
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
|
||||
});
|
||||
|
||||
it("deleteUserIfNotLastAdmin refuses to delete the reserved guest principal", () => {
|
||||
expect(users.findById(GUEST_USER_ID)).not.toBeNull(); // sanity
|
||||
expect(users.deleteUserIfNotLastAdmin(GUEST_USER_ID)).toBe("not_found");
|
||||
// The guest row still exists.
|
||||
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+11
-3
@@ -4,7 +4,13 @@ import bcrypt from "bcryptjs";
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
export type UserRole = "admin" | "member";
|
||||
export type UserRole = "admin" | "member" | "guest";
|
||||
|
||||
/** Reserved synthetic principal for login-less guest sessions. The username is
|
||||
* non-ASCII so it can never collide with an API-created account (which is
|
||||
* validated against ^[A-Za-z0-9_\-.]{3,32}$). */
|
||||
export const GUEST_USER_ID = "__guest__";
|
||||
export const GUEST_USERNAME = "游客";
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
@@ -39,7 +45,7 @@ export class UsernameTakenError extends Error {
|
||||
}
|
||||
|
||||
export function createUserStore(db: Database.Database): UserStore {
|
||||
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users");
|
||||
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
|
||||
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
@@ -57,7 +63,7 @@ export function createUserStore(db: Database.Database): UserStore {
|
||||
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
|
||||
);
|
||||
const listUsersStmt = db.prepare(
|
||||
"SELECT id, username, createdAt, role FROM users ORDER BY createdAt ASC"
|
||||
"SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
|
||||
);
|
||||
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
|
||||
|
||||
@@ -131,6 +137,7 @@ export function createUserStore(db: Database.Database): UserStore {
|
||||
const tx = db.transaction(() => {
|
||||
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||
if (!row) return "not_found" as const;
|
||||
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
|
||||
if (row.role === newRole) return "ok" as const; // no-op
|
||||
if (row.role === "admin" && newRole === "member") {
|
||||
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||
@@ -155,6 +162,7 @@ export function createUserStore(db: Database.Database): UserStore {
|
||||
const tx = db.transaction(() => {
|
||||
const row = findByIdStmt.get(id) as UserRow | undefined;
|
||||
if (!row) return "not_found" as const;
|
||||
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
|
||||
if (row.role === "admin") {
|
||||
const adminCount = (countAdminsStmt.get() as { n: number }).n;
|
||||
if (adminCount <= 1) return "would_orphan" as const;
|
||||
|
||||
+2
-1
@@ -74,7 +74,8 @@ async function main() {
|
||||
config,
|
||||
logger,
|
||||
avatarStore,
|
||||
permissions
|
||||
permissions,
|
||||
CONFIG_PATH
|
||||
);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createAuditStore } from "../../data/audit.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { getDefaultConfig } from "../../data/config.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createAuditRouter } from "./audit.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -34,7 +35,7 @@ describe("audit router", () => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions, permissions));
|
||||
app.use("/api", createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
|
||||
app.use("/api/audit", createAuditRouter(audit));
|
||||
});
|
||||
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { CookieStore } from "../../music/auth.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
|
||||
export function createAuthRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
@@ -23,7 +24,7 @@ export function createAuthRouter(
|
||||
return platform === "qq" ? qqProvider : neteaseProvider;
|
||||
}
|
||||
|
||||
router.get("/status", async (req, res) => {
|
||||
router.get("/status", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const platform = req.query.platform as string;
|
||||
const provider = getProvider(platform);
|
||||
@@ -49,7 +50,7 @@ export function createAuthRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/qrcode/status", async (req, res) => {
|
||||
router.get("/qrcode/status", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const { key, platform } = req.query;
|
||||
if (!key) {
|
||||
|
||||
+56
-1
@@ -60,7 +60,7 @@ describe("bot router /settings", () => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
|
||||
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
|
||||
@@ -159,3 +159,58 @@ describe("bot router /settings", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bot router /settings guest-mode gating + persistence", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let config: BotConfig;
|
||||
let botDb: BotDatabase;
|
||||
|
||||
beforeEach(() => {
|
||||
botDb = createDatabase(":memory:");
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-gm-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
config = getDefaultConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Mounts createBotRouter with an injected req.user (no session/cookie). */
|
||||
function mountBot(injectUser: () => unknown): express.Express {
|
||||
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
|
||||
const avatarStore = createAvatarStore(tmpDir);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as { user?: unknown }).user = injectUser(); next(); });
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("GET /settings is 403 for guests and includes guestMode for admins", async () => {
|
||||
const guestApp = mountBot(() => ({ role: "guest", guest: {} }));
|
||||
expect((await request(guestApp).get("/api/bot/settings")).status).toBe(403);
|
||||
const adminApp = mountBot(() => ({ role: "admin" }));
|
||||
const res = await request(adminApp).get("/api/bot/settings");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.guestMode).toBeDefined();
|
||||
expect(res.body.guestMode.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("POST /settings persists a guestMode block", async () => {
|
||||
const adminApp = mountBot(() => ({ role: "admin" }));
|
||||
const res = await request(adminApp).post("/api/bot/settings").send({
|
||||
guestMode: { enabled: true, bots: ["bot1"], permissions: { playNext: true } },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.guestMode.enabled).toBe(true);
|
||||
expect(res.body.guestMode.bots).toEqual(["bot1"]);
|
||||
expect(res.body.guestMode.permissions.playNext).toBe(true);
|
||||
expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default
|
||||
});
|
||||
});
|
||||
+34
-3
@@ -1,11 +1,13 @@
|
||||
import { Router } from "express";
|
||||
import type { BotManager } from "../../bot/manager.js";
|
||||
import type { BotConfig } from "../../data/config.js";
|
||||
import type { BotConfig, GuestModeConfig } from "../../data/config.js";
|
||||
import { saveConfig } from "../../data/config.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { BotDatabase } from "../../data/database.js";
|
||||
import type { AvatarStore } from "../../data/avatars.js";
|
||||
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { GUEST_PERMISSION_FLAGS } from "../../data/permissions.js";
|
||||
|
||||
export function createBotRouter(
|
||||
botManager: BotManager,
|
||||
@@ -14,6 +16,7 @@ export function createBotRouter(
|
||||
logger: Logger,
|
||||
botDb: BotDatabase,
|
||||
avatarStore: AvatarStore,
|
||||
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -29,17 +32,18 @@ export function createBotRouter(
|
||||
|
||||
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route.
|
||||
router.get("/settings", (_req, res) => {
|
||||
router.get("/settings", requireNotGuest, (_req, res) => {
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
|
||||
// behavior is a bot.manage operation, consistent with PR #80's permission model)
|
||||
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
|
||||
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
|
||||
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode } = req.body;
|
||||
|
||||
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
||||
@@ -51,8 +55,34 @@ export function createBotRouter(
|
||||
|
||||
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||
|
||||
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
|
||||
if (hasGuestMode) {
|
||||
const gm = config.guestMode;
|
||||
if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled;
|
||||
if (guestMode.bots === "all") {
|
||||
gm.bots = "all";
|
||||
} else if (Array.isArray(guestMode.bots)) {
|
||||
gm.bots = guestMode.bots.filter((id: unknown): id is string => typeof id === "string");
|
||||
}
|
||||
if (guestMode.permissions && typeof guestMode.permissions === "object") {
|
||||
for (const f of GUEST_PERMISSION_FLAGS) {
|
||||
if (typeof guestMode.permissions[f] === "boolean") {
|
||||
gm.permissions[f] = guestMode.permissions[f];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveConfig(configPath, config);
|
||||
|
||||
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
|
||||
// disabled or narrowed scope takes effect immediately (matches requireAuth's
|
||||
// "disabling immediately invalidates in-flight guest sessions" invariant).
|
||||
if (hasGuestMode) {
|
||||
onGuestPolicyChanged?.(config.guestMode);
|
||||
}
|
||||
|
||||
// 通知所有 bot 实例更新
|
||||
for (const bot of botManager.getAllBots()) {
|
||||
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
|
||||
@@ -62,6 +92,7 @@ export function createBotRouter(
|
||||
res.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||
guestMode: config.guestMode,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
|
||||
import { YouTubeProvider } from "../../music/youtube.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
|
||||
export function createMusicRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
@@ -127,7 +128,7 @@ export function createMusicRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/recommend/songs", async (req, res) => {
|
||||
router.get("/recommend/songs", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
if (!provider.getDailyRecommendSongs) {
|
||||
@@ -142,7 +143,7 @@ export function createMusicRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/personal/fm", async (req, res) => {
|
||||
router.get("/personal/fm", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
if (!provider.getPersonalFm) {
|
||||
@@ -157,7 +158,7 @@ export function createMusicRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/user/playlists", async (req, res) => {
|
||||
router.get("/user/playlists", requireNotGuest, async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(req.query.platform as string);
|
||||
if (!provider.getUserPlaylists) {
|
||||
@@ -209,7 +210,7 @@ export function createMusicRouter(
|
||||
});
|
||||
|
||||
// Get current quality
|
||||
router.get("/quality", (_req, res) => {
|
||||
router.get("/quality", requireNotGuest, (_req, res) => {
|
||||
res.json({
|
||||
netease: neteaseProvider.getQuality(),
|
||||
qq: qqProvider.getQuality(),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createPlayerRouter } from "./player.js";
|
||||
import { createBotRouter } from "./bot.js";
|
||||
import { createAuthRouter } from "./auth.js";
|
||||
import { createMusicRouter } from "./music.js";
|
||||
import { createFavoritesRouter } from "./favorites.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
@@ -188,6 +190,36 @@ describe("permission enforcement on action routes", () => {
|
||||
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("GET /api/music/quality is 403 for guests, allowed for members", async () => {
|
||||
const guestApp = makeApp(guest());
|
||||
expect((await request(guestApp).get("/api/music/quality")).status).toBe(403);
|
||||
const memberApp = makeApp(member([], "all"));
|
||||
expect((await request(memberApp).get("/api/music/quality")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// The operator's personal-account reads (their recommendations, FM, and
|
||||
// playlists) must never leak to login-less guests. These routes are gated
|
||||
// with requireNotGuest; generic search/browse stays open.
|
||||
describe("operator personal-data reads are denied to guests", () => {
|
||||
const personalRoutes = [
|
||||
"/api/music/recommend/songs",
|
||||
"/api/music/personal/fm",
|
||||
"/api/music/user/playlists",
|
||||
];
|
||||
|
||||
for (const route of personalRoutes) {
|
||||
it(`GET ${route} is 403 for a guest`, async () => {
|
||||
const app = makeApp(guest());
|
||||
expect((await request(app).get(route)).status).toBe(403);
|
||||
});
|
||||
|
||||
it(`GET ${route} is NOT 403 for a member`, async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
expect((await request(app).get(route)).status).not.toBe(403);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("read-only routes stay open", () => {
|
||||
@@ -197,7 +229,7 @@ describe("permission enforcement on action routes", () => {
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("GET /api/music/quality not gated", async () => {
|
||||
it("GET /api/music/quality readable by members, denied to guests", async () => {
|
||||
const app = makeApp(member([], "all"));
|
||||
const res = await request(app).get("/api/music/quality");
|
||||
expect(res.status).not.toBe(403);
|
||||
@@ -208,6 +240,12 @@ describe("permission enforcement on action routes", () => {
|
||||
const res = await request(app).get("/api/bot");
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("GET /api/auth/status and /api/auth/qrcode/status are 403 for guests", async () => {
|
||||
const app = makeApp(guest());
|
||||
expect((await request(app).get("/api/auth/status")).status).toBe(403);
|
||||
expect((await request(app).get("/api/auth/qrcode/status?key=k")).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin bypasses every gate", () => {
|
||||
@@ -235,3 +273,174 @@ describe("permission enforcement on action routes", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Guest enforcement on the player routes. Guests carry per-flag permissions
|
||||
// (req.user.guest) instead of capabilities; authorize() opens a route only
|
||||
// when its guestFlag is set AND enabled. Routes with no guestFlag are denied
|
||||
// to guests no matter which flags are on. We reuse makeApp() (it injects
|
||||
// req.user and mounts the real player router over the fake bot manager) and
|
||||
// assert purely on 403-vs-not-403 — a 200/500 from the fake bot both prove
|
||||
// the gate let the request through.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const SONG = { id: "1", platform: "netease", name: "x", artist: "y" };
|
||||
|
||||
// Build a guest user with all flags off, then override the ones passed in.
|
||||
const guest = (perms: Partial<Record<string, boolean>> = {}) => ({
|
||||
id: "__guest__",
|
||||
username: "游客",
|
||||
role: "guest" as const,
|
||||
capabilities: new Set<string>(),
|
||||
bots: "all" as const,
|
||||
guest: {
|
||||
addToQueue: false,
|
||||
playNext: false,
|
||||
playNow: false,
|
||||
skip: false,
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
...perms,
|
||||
},
|
||||
});
|
||||
|
||||
const mountGuest = (perms: Partial<Record<string, boolean>> = {}) => makeApp(guest(perms));
|
||||
|
||||
describe("guest enforcement on player routes", () => {
|
||||
it("addToQueue flag gates POST /add, /add-song, /add-by-id", async () => {
|
||||
const allow = mountGuest({ addToQueue: true });
|
||||
const deny = mountGuest({ addToQueue: false });
|
||||
for (const path of ["add", "add-song", "add-by-id"]) {
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).not.toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("playNext flag gates /play-next-song", async () => {
|
||||
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).not.toBe(403);
|
||||
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("playNow flag gates the new /play-now-song", async () => {
|
||||
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).not.toBe(403);
|
||||
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
|
||||
// playNext does NOT open play-now-song, and playNow does NOT open play-next-song.
|
||||
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
|
||||
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("skip flag gates /next", async () => {
|
||||
expect((await request(mountGuest({ skip: true })).post(`/api/player/${ALLOWED_BOT}/next`)).status).not.toBe(403);
|
||||
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/next`)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("transport flag gates /pause, /resume, /seek, /volume", async () => {
|
||||
const allow = mountGuest({ transport: true });
|
||||
const deny = mountGuest({ transport: false });
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/resume`)).status).not.toBe(403);
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).not.toBe(403);
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).not.toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/resume`)).status).toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).toBe(403);
|
||||
});
|
||||
|
||||
it("playMode flag gates /mode, /fm", async () => {
|
||||
const allow = mountGuest({ playMode: true });
|
||||
const deny = mountGuest({ playMode: false });
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).not.toBe(403);
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).not.toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).toBe(403);
|
||||
});
|
||||
|
||||
it("removeClear flag gates /clear and DELETE /queue/:index", async () => {
|
||||
const allow = mountGuest({ removeClear: true });
|
||||
const deny = mountGuest({ removeClear: false });
|
||||
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
|
||||
expect((await request(allow).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).not.toBe(403);
|
||||
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/clear`)).status).toBe(403);
|
||||
expect((await request(deny).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).toBe(403);
|
||||
});
|
||||
|
||||
it("each guest flag opens exactly its own route(s) — a single flag does not leak", async () => {
|
||||
// With only addToQueue on, a transport route stays denied.
|
||||
expect((await request(mountGuest({ addToQueue: true })).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
|
||||
// With only transport on, an add route stays denied.
|
||||
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 () => {
|
||||
const all = mountGuest({
|
||||
addToQueue: true,
|
||||
playNext: true,
|
||||
playNow: true,
|
||||
skip: true,
|
||||
transport: true,
|
||||
removeClear: true,
|
||||
playMode: 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);
|
||||
});
|
||||
|
||||
it("members are unaffected — player.queue still reaches /add-song", async () => {
|
||||
const m = makeApp(member(["player.queue"], [ALLOWED_BOT]));
|
||||
expect((await request(m).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Favorites are member-only: the router keys everything off req.user.id and
|
||||
// all guests share the __guest__ principal, so a guest must never reach it.
|
||||
// server.ts gates the mount with requireNotGuest; we mirror that mount here
|
||||
// and assert a guest gets 403 (the requireNotGuest guard runs before any
|
||||
// handler, so the fake database is never touched).
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function makeFavoritesApp(user: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||
const fakeDb = {
|
||||
getFavorites: () => [],
|
||||
addFavorite: () => {},
|
||||
removeFavorite: () => {},
|
||||
isFavorited: () => false,
|
||||
} as any;
|
||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(fakeDb, logger));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("favorites are denied to guests", () => {
|
||||
it("403 for a guest on GET /api/favorites", async () => {
|
||||
const app = makeFavoritesApp(guest());
|
||||
expect((await request(app).get("/api/favorites")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("403 for a guest on GET /api/favorites/check", async () => {
|
||||
const app = makeFavoritesApp(guest());
|
||||
expect((await request(app).get("/api/favorites/check?platform=netease&playlistId=x")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("403 for a guest on POST /api/favorites", async () => {
|
||||
const app = makeFavoritesApp(guest());
|
||||
const res = await request(app).post("/api/favorites").send({ platform: "netease", playlistId: "x", name: "n" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("NOT 403 for a member on GET /api/favorites", async () => {
|
||||
const app = makeFavoritesApp(member([], "all"));
|
||||
expect((await request(app).get("/api/favorites")).status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
+84
-37
@@ -4,7 +4,8 @@ import type { BotDatabase } from "../../data/database.js";
|
||||
import type { MusicProvider } from "../../music/provider.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import { parseCommand } from "../../bot/commands.js";
|
||||
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||
import { requireBotAccess } from "../middleware/requirePermission.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
|
||||
export function createPlayerRouter(
|
||||
botManager: BotManager,
|
||||
@@ -41,7 +42,7 @@ export function createPlayerRouter(
|
||||
return "";
|
||||
};
|
||||
|
||||
router.post("/:botId/play", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { query, platform } = req.body;
|
||||
@@ -61,7 +62,7 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/add", requirePermission("player.queue"), async (req, res) => {
|
||||
router.post("/:botId/add", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { query, platform } = req.body;
|
||||
@@ -88,14 +89,14 @@ export function createPlayerRouter(
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause"));
|
||||
router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume"));
|
||||
router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next"));
|
||||
router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev"));
|
||||
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
|
||||
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear"));
|
||||
router.post("/:botId/pause", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!pause"));
|
||||
router.post("/:botId/resume", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!resume"));
|
||||
router.post("/:botId/next", authorize({ capability: "player.control", guestFlag: "skip" }), simpleCommand("!next"));
|
||||
router.post("/:botId/prev", authorize({ capability: "player.control" }), simpleCommand("!prev"));
|
||||
router.post("/:botId/stop", authorize({ capability: "player.control" }), simpleCommand("!stop"));
|
||||
router.post("/:botId/clear", authorize({ capability: "player.queue", guestFlag: "removeClear" }), simpleCommand("!clear"));
|
||||
|
||||
router.post("/:botId/fm", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/fm", authorize({ capability: "player.control", guestFlag: "playMode" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { platform } = req.body;
|
||||
@@ -117,7 +118,7 @@ export function createPlayerRouter(
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/volume", authorize({ capability: "player.control", guestFlag: "transport" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { volume } = req.body;
|
||||
@@ -145,7 +146,7 @@ export function createPlayerRouter(
|
||||
|
||||
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
||||
|
||||
router.post("/:botId/mode", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/mode", authorize({ capability: "player.control", guestFlag: "playMode" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { mode } = req.body;
|
||||
@@ -170,7 +171,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Seek to position
|
||||
router.post("/:botId/seek", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/seek", authorize({ capability: "player.control", guestFlag: "transport" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { position } = req.body; // seconds
|
||||
@@ -194,7 +195,7 @@ export function createPlayerRouter(
|
||||
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
|
||||
});
|
||||
|
||||
router.delete("/:botId/queue/:index", requirePermission("player.queue"), async (req, res) => {
|
||||
router.delete("/:botId/queue/:index", authorize({ capability: "player.queue", guestFlag: "removeClear" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
|
||||
@@ -206,7 +207,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Jump to a specific index in the queue (without clearing it)
|
||||
router.post("/:botId/play-at", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/play-at", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { index } = req.body;
|
||||
@@ -214,33 +215,40 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "index is required" });
|
||||
return;
|
||||
}
|
||||
// Serialize the index-validation + stop/reset/playAt/resolveAndPlay so a
|
||||
// concurrent request can't interleave between mutating the queue and
|
||||
// starting playback (audible track must match queue.currentIndex).
|
||||
const result = await bot.runExclusive(async () => {
|
||||
const queue = bot.getQueueManager();
|
||||
// Validate the index BEFORE stopping current playback — otherwise an
|
||||
// invalid index silently kills the user's current song and leaves the
|
||||
// queue idle.
|
||||
if (index >= queue.size()) {
|
||||
res.status(400).json({ error: "Invalid queue index" });
|
||||
return;
|
||||
return { status: 400 as const, body: { error: "Invalid queue index" } };
|
||||
}
|
||||
bot.getPlayer().stop();
|
||||
bot.getPlayer().resetFailures();
|
||||
const song = queue.playAt(index);
|
||||
if (!song) {
|
||||
res.status(400).json({ error: "Invalid queue index" });
|
||||
return;
|
||||
return { status: 400 as const, body: { error: "Invalid queue index" } };
|
||||
}
|
||||
const ok = await bot.resolveAndPlay(song);
|
||||
if (!ok) {
|
||||
res.json({ message: `Cannot play: ${song.name}` });
|
||||
return { body: { message: `Cannot play: ${song.name}` } };
|
||||
}
|
||||
return { body: { message: `Now playing: ${song.name} - ${song.artist}` } };
|
||||
});
|
||||
if (result.status) {
|
||||
res.status(result.status).json(result.body);
|
||||
return;
|
||||
}
|
||||
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
|
||||
res.json(result.body);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/playlist", requirePermission("player.queue"), async (req, res) => {
|
||||
router.post("/:botId/playlist", authorize({ capability: "player.queue" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
@@ -257,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", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/play-playlist", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { playlistId, platform } = req.body;
|
||||
@@ -344,7 +352,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
||||
router.post("/:botId/play-album", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/play-album", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { albumId, platform } = req.body;
|
||||
@@ -416,7 +424,7 @@ export function createPlayerRouter(
|
||||
});
|
||||
|
||||
// Play a single song by ID — resolves URL on demand
|
||||
router.post("/:botId/play-song", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { song } = req.body;
|
||||
@@ -444,7 +452,7 @@ export function createPlayerRouter(
|
||||
|
||||
// Insert a single song to play right after the current one.
|
||||
// If nothing is playing, behaves like /play-song (start immediately).
|
||||
router.post("/:botId/play-next-song", requirePermission("player.control"), async (req, res) => {
|
||||
router.post("/:botId/play-next-song", authorize({ capability: "player.control", guestFlag: "playNext" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { song } = req.body;
|
||||
@@ -452,6 +460,9 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
// Serialize the queue mutation + playback so concurrent requests can't
|
||||
// interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
const queue = bot.getQueueManager();
|
||||
const wasIdle = bot.getPlayer().getState() === "idle";
|
||||
// Capture the slot addNext WILL insert at, before mutating the queue.
|
||||
@@ -469,20 +480,23 @@ export function createPlayerRouter(
|
||||
bot.getPlayer().resetFailures();
|
||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||
if (!ok) {
|
||||
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
|
||||
return;
|
||||
return { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
|
||||
}
|
||||
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
||||
return;
|
||||
return { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
|
||||
}
|
||||
|
||||
res.json({ ok: true, message: `已加入下一首:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
||||
return { ok: true, message: `已加入下一首:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/add-song", requirePermission("player.queue"), async (req, res) => {
|
||||
// Play a song "now" without clearing the queue: insert after current, then
|
||||
// promote to current and start it. Non-destructive (unlike /play-song which
|
||||
// clears the whole queue) — this is the guest-safe "play now".
|
||||
router.post("/:botId/play-now-song", authorize({ capability: "player.control", guestFlag: "playNow" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { song } = req.body;
|
||||
@@ -490,6 +504,38 @@ export function createPlayerRouter(
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
// Serialize the insert-after-current + promote + playback so concurrent
|
||||
// requests can't interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
const queue = bot.getQueueManager();
|
||||
const insertedAt =
|
||||
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
||||
queue.addNext(song);
|
||||
queue.playAt(insertedAt);
|
||||
bot.getPlayer().resetFailures();
|
||||
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||
if (!ok) {
|
||||
return { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
|
||||
}
|
||||
return { ok: true, message: `正在播放:${song.name || "Unknown"} - ${song.artist || "Unknown"}` };
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/add-song", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { song } = req.body;
|
||||
if (!song || !song.id || !song.platform) {
|
||||
res.status(400).json({ error: "song object with id and platform is required" });
|
||||
return;
|
||||
}
|
||||
// Serialize the queue mutation + (possible) playback so concurrent
|
||||
// requests can't interleave (audible track must match queue.currentIndex).
|
||||
const body = await bot.runExclusive(async () => {
|
||||
const queue = bot.getQueueManager();
|
||||
const wasIdle = bot.getPlayer().getState() === "idle";
|
||||
queue.add(song);
|
||||
@@ -499,18 +545,19 @@ export function createPlayerRouter(
|
||||
queue.playAt(queue.size() - 1);
|
||||
bot.getPlayer().resetFailures();
|
||||
await bot.resolveAndPlay(queue.current()!);
|
||||
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
|
||||
return;
|
||||
return { message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
|
||||
}
|
||||
|
||||
res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` });
|
||||
return { message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` };
|
||||
});
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a song to queue by ID — metadata only
|
||||
router.post("/:botId/add-by-id", requirePermission("player.queue"), async (req, res) => {
|
||||
router.post("/:botId/add-by-id", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const { songId, platform } = req.body;
|
||||
@@ -548,7 +595,7 @@ export function createPlayerRouter(
|
||||
res.json(bot.getProfileManager().getConfig());
|
||||
});
|
||||
|
||||
router.put("/:botId/profile", requirePermission("bot.manage"), (req, res) => {
|
||||
router.put("/:botId/profile", authorize({ capability: "bot.manage" }), (req, res) => {
|
||||
try {
|
||||
const bot = (req as any).bot;
|
||||
const pm = bot.getProfileManager();
|
||||
|
||||
+116
-3
@@ -8,6 +8,8 @@ import { createUserStore, type UserStore } from "../../data/users.js";
|
||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||
import { createAuditStore } from "../../data/audit.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { getDefaultConfig, type GuestModeConfig } from "../../data/config.js";
|
||||
import type { GuestPermissions, BotAccess } from "../../data/permissions.js";
|
||||
import { createSessionRouter } from "./session.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
@@ -17,7 +19,17 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||
app.use(cookieParser());
|
||||
const audit = createAuditStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
|
||||
app.use(
|
||||
"/api/session",
|
||||
createSessionRouter(
|
||||
users,
|
||||
sessions,
|
||||
audit,
|
||||
pino({ level: "silent" }),
|
||||
permissions,
|
||||
() => getDefaultConfig().guestMode
|
||||
)
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -47,7 +59,7 @@ describe("session router", () => {
|
||||
it("GET /needs-setup returns true on an empty db", async () => {
|
||||
const res = await request(app).get("/api/session/needs-setup");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ needsSetup: true });
|
||||
expect(res.body).toEqual({ needsSetup: true, guestAllowed: false });
|
||||
});
|
||||
|
||||
it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => {
|
||||
@@ -59,7 +71,7 @@ describe("session router", () => {
|
||||
extractCookie(setupRes);
|
||||
|
||||
const needs = await request(app).get("/api/session/needs-setup");
|
||||
expect(needs.body).toEqual({ needsSetup: false });
|
||||
expect(needs.body).toEqual({ needsSetup: false, guestAllowed: false });
|
||||
});
|
||||
|
||||
it("POST /setup returns 409 once a user already exists", async () => {
|
||||
@@ -157,3 +169,104 @@ describe("session router", () => {
|
||||
expect(u.id).toBe(meA.body.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session router — guest mode", () => {
|
||||
let botDb: BotDatabase;
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
function makeApp(opts: {
|
||||
guestEnabled: boolean;
|
||||
guestPermissions?: GuestPermissions;
|
||||
guestBots?: BotAccess;
|
||||
}) {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const audit = createAuditStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
const guestCfg: GuestModeConfig = {
|
||||
enabled: opts.guestEnabled,
|
||||
bots: opts.guestBots ?? getDefaultConfig().guestMode.bots,
|
||||
permissions: opts.guestPermissions ?? getDefaultConfig().guestMode.permissions,
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
"/api/session",
|
||||
createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions, () => guestCfg)
|
||||
);
|
||||
return { app, users, sessions };
|
||||
}
|
||||
|
||||
it("POST /guest is 403 when guest mode disabled", async () => {
|
||||
const { app } = makeApp({ guestEnabled: false });
|
||||
const res = await request(app).post("/api/session/guest");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /guest mints a guest session when enabled, and /me reports role guest + flags", async () => {
|
||||
const { app } = makeApp({
|
||||
guestEnabled: true,
|
||||
guestPermissions: {
|
||||
addToQueue: true,
|
||||
playNext: true,
|
||||
playNow: false,
|
||||
skip: false,
|
||||
transport: false,
|
||||
removeClear: false,
|
||||
playMode: false,
|
||||
},
|
||||
guestBots: "all",
|
||||
});
|
||||
const login = await request(app).post("/api/session/guest");
|
||||
expect(login.status).toBe(200);
|
||||
expect(login.body.role).toBe("guest");
|
||||
const cookie = login.headers["set-cookie"];
|
||||
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||
expect(me.body.role).toBe("guest");
|
||||
expect(me.body.guest.addToQueue).toBe(true);
|
||||
expect(me.body.guest.playNext).toBe(true);
|
||||
expect(me.body.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
it("GET /needs-setup exposes guestAllowed", async () => {
|
||||
const { app } = makeApp({ guestEnabled: true });
|
||||
const res = await request(app).get("/api/session/needs-setup");
|
||||
expect(res.body.guestAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("GET /me returns 401 for a guest session once guest mode is disabled", async () => {
|
||||
// Build an app whose guest config can be toggled at runtime, mirroring an
|
||||
// admin flipping the setting mid-session (requireAuthInline must reject).
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const audit = createAuditStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
const guestCfg: GuestModeConfig = {
|
||||
enabled: true,
|
||||
bots: getDefaultConfig().guestMode.bots,
|
||||
permissions: getDefaultConfig().guestMode.permissions,
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
"/api/session",
|
||||
createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions, () => guestCfg)
|
||||
);
|
||||
|
||||
const login = await request(app).post("/api/session/guest");
|
||||
expect(login.status).toBe(200);
|
||||
const cookie = login.headers["set-cookie"];
|
||||
|
||||
// While enabled, /me works for the guest.
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", cookie)).status).toBe(200);
|
||||
|
||||
// Admin disables guest mode → the in-flight guest session is now invalid.
|
||||
guestCfg.enabled = false;
|
||||
expect((await request(app).get("/api/session/me").set("Cookie", cookie)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
+41
-4
@@ -5,7 +5,9 @@ import type { UserStore } from "../../data/users.js";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import type { AuditStore } from "../../data/audit.js";
|
||||
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import { SESSION_TTL_MS, GUEST_SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import { GUEST_USER_ID, GUEST_USERNAME } from "../../data/users.js";
|
||||
import type { GuestModeConfig } from "../../data/config.js";
|
||||
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
|
||||
|
||||
const FAILED_LOGIN_DELAY_MS = 250;
|
||||
@@ -51,7 +53,8 @@ export function createSessionRouter(
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger,
|
||||
permissions: PermissionStore
|
||||
permissions: PermissionStore,
|
||||
getGuestConfig: () => GuestModeConfig
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -62,6 +65,13 @@ export function createSessionRouter(
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
// A guest session is only valid while guest mode is enabled. Disabling it
|
||||
// immediately invalidates any in-flight guest sessions (mirrors createRequireAuth).
|
||||
if (result.role === "guest" && !getGuestConfig().enabled) {
|
||||
clearSessionCookie(res);
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
req.user = { id: result.userId, username: result.username, role: result.role };
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) setSessionCookie(res, token);
|
||||
@@ -69,7 +79,7 @@ export function createSessionRouter(
|
||||
};
|
||||
|
||||
router.get("/needs-setup", (_req, res) => {
|
||||
res.json({ needsSetup: users.countUsers() === 0 });
|
||||
res.json({ needsSetup: users.countUsers() === 0, guestAllowed: getGuestConfig().enabled });
|
||||
});
|
||||
|
||||
router.post("/setup", async (req, res) => {
|
||||
@@ -125,6 +135,26 @@ export function createSessionRouter(
|
||||
res.json({ id: user.id, username: user.username, role: user.role });
|
||||
});
|
||||
|
||||
router.post("/guest", (_req, res) => {
|
||||
const cfg = getGuestConfig();
|
||||
if (!cfg.enabled) {
|
||||
res.status(403).json({ error: "guest mode disabled" });
|
||||
return;
|
||||
}
|
||||
let token: string;
|
||||
try {
|
||||
// If the reserved guest row is somehow missing, the session FK would
|
||||
// throw; surface a clean 503 rather than letting it become a 500.
|
||||
({ token } = sessions.createSession(GUEST_USER_ID, { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }));
|
||||
} catch (err) {
|
||||
logger.error({ err }, "guest session creation failed");
|
||||
res.status(503).json({ error: "guest unavailable" });
|
||||
return;
|
||||
}
|
||||
setSessionCookie(res, token);
|
||||
res.json({ id: GUEST_USER_ID, username: GUEST_USERNAME, role: "guest" });
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
const token = parseTokenFromCookie(req.headers.cookie);
|
||||
if (token) {
|
||||
@@ -136,13 +166,20 @@ export function createSessionRouter(
|
||||
|
||||
router.get("/me", requireAuthInline, (req, res) => {
|
||||
const user = req.user!;
|
||||
const ctx = resolvePermissionContext(user.role, user.id, permissions);
|
||||
const cfg = getGuestConfig();
|
||||
const ctx = resolvePermissionContext(
|
||||
user.role,
|
||||
user.id,
|
||||
permissions,
|
||||
user.role === "guest" ? { bots: cfg.bots, permissions: cfg.permissions } : undefined
|
||||
);
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
capabilities: [...ctx.capabilities],
|
||||
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
|
||||
guest: ctx.guest ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||
import { createUserStore, GUEST_USER_ID, type UserStore } from "../../data/users.js";
|
||||
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||
import { createAuditStore, type AuditStore } from "../../data/audit.js";
|
||||
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
|
||||
import { getDefaultConfig } from "../../data/config.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createUsersRouter } from "./users.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -17,7 +18,7 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
const requireAuth = createRequireAuth(sessions, permissions);
|
||||
const requireAuth = createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode);
|
||||
const audit = createAuditStore(botDb.db);
|
||||
app.use("/api", requireAuth);
|
||||
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
|
||||
@@ -152,7 +153,7 @@ describe("users router", () => {
|
||||
const localApp = express();
|
||||
localApp.use(express.json());
|
||||
localApp.use(cookieParser());
|
||||
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
|
||||
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
localApp.use(
|
||||
"/api/users",
|
||||
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
|
||||
@@ -341,4 +342,49 @@ describe("users router", () => {
|
||||
expect(perms.status).toBe(200);
|
||||
expect(perms.body).toEqual({ capabilities: [], bots: [] });
|
||||
});
|
||||
|
||||
// --- reserved guest principal is never mutable/visible via user mgmt -------
|
||||
// The synthetic __guest__ row is seeded by createDatabase. findById has no
|
||||
// role filter, so without the 404-guard these by-id handlers would operate
|
||||
// on it (privilege-escalation / DoS / cred-login holes).
|
||||
describe("reserved __guest__ principal is 404 on every by-id handler", () => {
|
||||
it("DELETE /:id → 404 and the guest row survives", async () => {
|
||||
const res = await request(app).delete(`/api/users/${GUEST_USER_ID}`).set("Cookie", aliceCookie);
|
||||
expect(res.status).toBe(404);
|
||||
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
|
||||
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
|
||||
});
|
||||
|
||||
it("PATCH /:id/role {role:'admin'} → 404 and the guest role is unchanged", async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/api/users/${GUEST_USER_ID}/role`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ role: "admin" });
|
||||
expect(res.status).toBe(404);
|
||||
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
|
||||
});
|
||||
|
||||
it("POST /:id/reset-password → 404 (cannot give the guest a login)", async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${GUEST_USER_ID}/reset-password`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ newPassword: "guest-new-pw" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /:id/permissions → 404", async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/users/${GUEST_USER_ID}/permissions`)
|
||||
.set("Cookie", aliceCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("PUT /:id/permissions → 404", async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/users/${GUEST_USER_ID}/permissions`)
|
||||
.set("Cookie", aliceCookie)
|
||||
.send({ capabilities: ["player.control"], bots: "all" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import type { Logger } from "../../logger.js";
|
||||
import type { UserStore } from "../../data/users.js";
|
||||
import { UsernameTakenError } from "../../data/users.js";
|
||||
import { UsernameTakenError, GUEST_USER_ID } from "../../data/users.js";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import type { AuditStore } from "../../data/audit.js";
|
||||
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
|
||||
@@ -63,6 +63,7 @@ export function createUsersRouter(
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const targetId = req.params.id;
|
||||
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
|
||||
// Snapshot target's username BEFORE deletion for audit
|
||||
const target = users.findById(targetId);
|
||||
if (!target) {
|
||||
@@ -104,6 +105,7 @@ export function createUsersRouter(
|
||||
return;
|
||||
}
|
||||
const targetId = req.params.id;
|
||||
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
|
||||
const target = users.findById(targetId);
|
||||
if (!target) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
@@ -130,6 +132,7 @@ export function createUsersRouter(
|
||||
|
||||
router.patch("/:id/role", (req, res) => {
|
||||
const targetId = req.params.id;
|
||||
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
|
||||
const { role: newRole } = req.body ?? {};
|
||||
if (newRole !== "admin" && newRole !== "member") {
|
||||
res.status(400).json({ error: "invalid role" });
|
||||
@@ -168,6 +171,7 @@ export function createUsersRouter(
|
||||
});
|
||||
|
||||
router.get("/:id/permissions", (req, res) => {
|
||||
if (req.params.id === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
|
||||
const user = users.findById(req.params.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "not_found" });
|
||||
@@ -180,6 +184,7 @@ export function createUsersRouter(
|
||||
});
|
||||
|
||||
router.put("/:id/permissions", (req, res) => {
|
||||
if (req.params.id === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
|
||||
const user = users.findById(req.params.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "not_found" });
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { authorize } from "./authorize.js";
|
||||
|
||||
function run(user: any, opts: any) {
|
||||
const req: any = { user };
|
||||
const res: any = { statusCode: 0, body: null, status(c: number) { this.statusCode = c; return this; }, json(b: any) { this.body = b; return this; } };
|
||||
const next = vi.fn();
|
||||
authorize(opts)(req, res, next);
|
||||
return { res, next };
|
||||
}
|
||||
|
||||
describe("authorize", () => {
|
||||
it("401 when unauthenticated", () => {
|
||||
const { res, next } = run(undefined, { capability: "player.queue" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
it("admin always passes", () => {
|
||||
const { next } = run({ role: "admin" }, { capability: "bot.manage" });
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
it("member passes only with the capability", () => {
|
||||
expect(run({ role: "member", capabilities: new Set(["player.queue"]) }, { capability: "player.queue" }).next).toHaveBeenCalled();
|
||||
expect(run({ role: "member", capabilities: new Set() }, { capability: "player.queue" }).res.statusCode).toBe(403);
|
||||
});
|
||||
it("guest passes only when its flag is enabled", () => {
|
||||
expect(run({ role: "guest", guest: { playNext: true } }, { capability: "player.control", guestFlag: "playNext" }).next).toHaveBeenCalled();
|
||||
expect(run({ role: "guest", guest: { playNext: false } }, { capability: "player.control", guestFlag: "playNext" }).res.statusCode).toBe(403);
|
||||
});
|
||||
it("guest is denied on routes with no guestFlag (e.g. play-song)", () => {
|
||||
expect(run({ role: "guest", guest: { addToQueue: true } }, { capability: "player.control" }).res.statusCode).toBe(403);
|
||||
});
|
||||
it("guest with a non-boolean truthy flag value (1) is denied (strict-boolean gate)", () => {
|
||||
expect(run({ role: "guest", guest: { playNext: 1 } as any }, { guestFlag: "playNext" }).res.statusCode).toBe(403);
|
||||
expect(run({ role: "guest", guest: { playNext: true } }, { guestFlag: "playNext" }).next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import type { GuestFlag } from "../../data/permissions.js";
|
||||
|
||||
/**
|
||||
* Unified authorization gate.
|
||||
* - admin → always allowed (unchanged from requirePermission)
|
||||
* - member → allowed iff it holds `capability` (unchanged from requirePermission)
|
||||
* - guest → allowed iff `guestFlag` is set AND that flag is enabled in the
|
||||
* guest's resolved permissions; a route with no `guestFlag` is
|
||||
* denied to guests by default.
|
||||
* Generic over the route-param shape `P` for the same reason requirePermission is.
|
||||
*/
|
||||
export function authorize<P = Record<string, string>>(opts: {
|
||||
capability?: string;
|
||||
guestFlag?: GuestFlag;
|
||||
}): RequestHandler<P> {
|
||||
return (req: Request<P>, res: Response, next: NextFunction) => {
|
||||
const user = req.user;
|
||||
if (!user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||
if (user.role === "admin") { next(); return; }
|
||||
if (user.role === "guest") {
|
||||
if (opts.guestFlag && user.guest?.[opts.guestFlag] === true) { next(); return; }
|
||||
res.status(403).json({ error: "forbidden" });
|
||||
return;
|
||||
}
|
||||
if (opts.capability && user.capabilities?.has(opts.capability)) { next(); return; }
|
||||
res.status(403).json({ error: "forbidden" });
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||
import { createUserStore } from "../../data/users.js";
|
||||
import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { getDefaultConfig } from "../../data/config.js";
|
||||
import { createRequireAuth } from "./requireAuth.js";
|
||||
import { requireAdmin } from "./requireAdmin.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -27,7 +28,7 @@ describe("requireAdmin middleware", () => {
|
||||
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(createRequireAuth(sessions, permissions));
|
||||
app.use(createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
|
||||
app.use(requireAdmin);
|
||||
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import request from "supertest";
|
||||
@@ -24,7 +24,21 @@ describe("requireAuth middleware", () => {
|
||||
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(createRequireAuth(sessions, permissions));
|
||||
app.use(
|
||||
createRequireAuth(sessions, permissions, () => ({
|
||||
enabled: true,
|
||||
bots: "all",
|
||||
permissions: {
|
||||
addToQueue: true,
|
||||
playNext: true,
|
||||
playNow: true,
|
||||
skip: true,
|
||||
transport: true,
|
||||
removeClear: true,
|
||||
playMode: true,
|
||||
},
|
||||
}))
|
||||
);
|
||||
app.get("/protected", (req, res) => {
|
||||
res.json({ ok: true, user: (req as any).user });
|
||||
});
|
||||
@@ -68,4 +82,34 @@ describe("requireAuth middleware", () => {
|
||||
expect(refreshed).toBeDefined();
|
||||
expect(refreshed!).toMatch(/Max-Age=\d+/);
|
||||
});
|
||||
|
||||
// A guest session is rejected (401) when guest mode is disabled.
|
||||
it("rejects a guest session when guest mode is disabled", () => {
|
||||
const sessions: any = { validateAndTouch: () => ({ userId: "__guest__", username: "游客", role: "guest" }) };
|
||||
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
|
||||
const getGuestConfig = () => ({ enabled: false, bots: "all" as const, permissions: {} as any });
|
||||
const mw = createRequireAuth(sessions, permissions, getGuestConfig);
|
||||
const req: any = { headers: { cookie: "tsmb_session=x" } };
|
||||
const res: any = { statusCode: 0, cleared: false, clearCookie() { this.cleared = true; }, status(c: number) { this.statusCode = c; return this; }, json() { return this; }, cookie() {} };
|
||||
const next = vi.fn();
|
||||
mw(req, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 getGuestConfig = () => ({ enabled: true, bots: ["bot1"], permissions: perms });
|
||||
const mw = createRequireAuth(sessions, permissions, getGuestConfig);
|
||||
const req: any = { headers: { cookie: "tsmb_session=x" }, secure: false };
|
||||
const res: any = { status() { return this; }, json() { return this; }, cookie() {}, clearCookie() {} };
|
||||
const next = vi.fn();
|
||||
mw(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user.role).toBe("guest");
|
||||
expect(req.user.guest.addToQueue).toBe(true);
|
||||
expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||
import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js";
|
||||
import type { GuestModeConfig } from "../../data/config.js";
|
||||
import {
|
||||
validateSessionFromHeaders,
|
||||
extractSessionToken,
|
||||
@@ -13,14 +14,19 @@ declare module "express-serve-static-core" {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: "admin" | "member";
|
||||
role: "admin" | "member" | "guest";
|
||||
capabilities?: Set<string>;
|
||||
bots?: "all" | Set<string>;
|
||||
guest?: GuestPermissions;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
|
||||
export function createRequireAuth(
|
||||
sessions: SessionStore,
|
||||
permissions: PermissionStore,
|
||||
getGuestConfig: () => GuestModeConfig
|
||||
): RequestHandler {
|
||||
return function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||
if (!result) {
|
||||
@@ -28,13 +34,27 @@ export function createRequireAuth(sessions: SessionStore, permissions: Permissio
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
|
||||
// A guest session is only valid while guest mode is enabled. Disabling it
|
||||
// immediately invalidates any in-flight guest sessions.
|
||||
const guestCfg = getGuestConfig();
|
||||
if (result.role === "guest" && !guestCfg.enabled) {
|
||||
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
res.status(401).json({ error: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
const ctx = resolvePermissionContext(
|
||||
result.role,
|
||||
result.userId,
|
||||
permissions,
|
||||
result.role === "guest" ? { bots: guestCfg.bots, permissions: guestCfg.permissions } : undefined
|
||||
);
|
||||
req.user = {
|
||||
id: result.userId,
|
||||
username: result.username,
|
||||
role: result.role,
|
||||
capabilities: ctx.capabilities,
|
||||
bots: ctx.bots,
|
||||
guest: ctx.guest,
|
||||
};
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { requireNotGuest } from "./requireNotGuest.js";
|
||||
|
||||
function run(user: any) {
|
||||
const req: any = { user };
|
||||
const res: any = { statusCode: 0, status(c: number) { this.statusCode = c; return this; }, json() { return this; } };
|
||||
const next = vi.fn();
|
||||
requireNotGuest(req, res, next);
|
||||
return { res, next };
|
||||
}
|
||||
|
||||
describe("requireNotGuest", () => {
|
||||
it("401 when no user", () => { expect(run(undefined).res.statusCode).toBe(401); });
|
||||
it("403 for guests", () => { expect(run({ role: "guest" }).res.statusCode).toBe(403); });
|
||||
it("passes admins and members", () => {
|
||||
expect(run({ role: "admin" }).next).toHaveBeenCalled();
|
||||
expect(run({ role: "member" }).next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
/** Allow admins and members; deny login-less guests (used for config reads
|
||||
* that must never leak to guests, e.g. GET /api/bot/settings, GET /api/music/quality). */
|
||||
export function requireNotGuest(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||
if (req.user.role === "guest") { res.status(403).json({ error: "forbidden" }); return; }
|
||||
next();
|
||||
}
|
||||
+29
-7
@@ -6,7 +6,7 @@ import { WebSocketServer } from "ws";
|
||||
import type { BotManager } from "../bot/manager.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import type { BotDatabase } from "../data/database.js";
|
||||
import type { BotConfig } from "../data/config.js";
|
||||
import type { BotConfig, GuestModeConfig } from "../data/config.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { CookieStore } from "../music/auth.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
@@ -25,6 +25,7 @@ import { createSessionStore } from "../data/sessions.js";
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
import { createRequireAuth } from "./middleware/requireAuth.js";
|
||||
import { requireAdmin } from "./middleware/requireAdmin.js";
|
||||
import { requireNotGuest } from "./middleware/requireNotGuest.js";
|
||||
import { csrfOriginCheck } from "./middleware/csrf.js";
|
||||
import { createRateLimit } from "./middleware/rateLimit.js";
|
||||
import { validateSessionFromHeaders } from "./auth/validateSession.js";
|
||||
@@ -95,14 +96,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
app.use("/api/session/login", loginLimit);
|
||||
app.use("/api/session/setup", setupLimit);
|
||||
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions));
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
|
||||
|
||||
// ─── Gates for everything else under /api ───────────────────────────────
|
||||
const requireAuth = createRequireAuth(sessions, permissions);
|
||||
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);
|
||||
app.use("/api", csrfOriginCheck);
|
||||
app.use("/api", requireAuth);
|
||||
|
||||
// ─── Protected routes ───────────────────────────────────────────────────
|
||||
// The bot router is mounted BEFORE setupWebSocket runs, but its /settings
|
||||
// handler needs to trigger a guest-policy refresh on the (later-created) WS
|
||||
// controller. Bridge the two with a mutable indirection that starts as a
|
||||
// no-op and is wired to the real refreshGuestPolicy once the WS is set up.
|
||||
let onGuestPolicyChanged: (cfg: GuestModeConfig) => void = () => {};
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(
|
||||
@@ -112,6 +118,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
logger,
|
||||
options.database,
|
||||
options.avatarStore,
|
||||
(cfg) => onGuestPolicyChanged(cfg),
|
||||
)
|
||||
);
|
||||
app.use(
|
||||
@@ -126,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
"/api/auth",
|
||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||
);
|
||||
app.use("/api/favorites", createFavoritesRouter(options.database, logger));
|
||||
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
|
||||
|
||||
// admin-only routes
|
||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
||||
@@ -175,12 +182,27 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
// Guest sessions are only valid while guest mode is enabled.
|
||||
if (result.role === "guest" && !options.config.guestMode.enabled) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const guestBots = options.config.guestMode.bots;
|
||||
const botScope: "all" | Set<string> =
|
||||
result.role === "guest"
|
||||
? guestBots === "all" ? "all" : new Set(guestBots)
|
||||
: "all";
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
(ws as unknown as { userId: string }).userId = result.userId;
|
||||
const w = ws as unknown as { userId: string; isGuest: boolean; botScope: "all" | Set<string> };
|
||||
w.userId = result.userId;
|
||||
w.isGuest = result.role === "guest";
|
||||
w.botScope = botScope;
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
||||
const controller = setupWebSocket(wss, options.botManager, logger);
|
||||
onGuestPolicyChanged = controller.refreshGuestPolicy;
|
||||
|
||||
// ─── Session cleanup interval ──────────────────────────────────────────
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -206,7 +228,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
cleanupWs();
|
||||
controller.cleanup();
|
||||
wss.close();
|
||||
server.close();
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createDatabase, type BotDatabase } from "../data/database.js";
|
||||
import { createUserStore } from "../data/users.js";
|
||||
import { createSessionStore } from "../data/sessions.js";
|
||||
import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js";
|
||||
import { setupWebSocket } from "./websocket.js";
|
||||
|
||||
function buildServer(sessions: ReturnType<typeof createSessionStore>) {
|
||||
const app = express();
|
||||
@@ -72,3 +73,109 @@ describe("WebSocket auth at upgrade", () => {
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket guest bot scope", () => {
|
||||
it("guest init is filtered to the guest bot scope", () => {
|
||||
const sent: any[] = [];
|
||||
const fakeWs: any = {
|
||||
readyState: 1,
|
||||
isGuest: true,
|
||||
botScope: new Set(["bot1"]),
|
||||
send: (m: string) => sent.push(JSON.parse(m)),
|
||||
on: () => {},
|
||||
};
|
||||
const fakeWss: any = {
|
||||
on: (ev: string, cb: any) => {
|
||||
if (ev === "connection") fakeWss._conn = cb;
|
||||
},
|
||||
};
|
||||
const makeBot = (id: string) => ({
|
||||
id,
|
||||
getStatus: () => ({ id }),
|
||||
getQueue: () => [],
|
||||
on: () => {},
|
||||
removeListener: () => {},
|
||||
});
|
||||
const botManager: any = {
|
||||
getAllBots: () => [makeBot("bot1"), makeBot("bot2")],
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
const { cleanup } = setupWebSocket(fakeWss, botManager, {
|
||||
debug() {},
|
||||
error() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
} as any);
|
||||
fakeWss._conn(fakeWs);
|
||||
const init = sent.find((m) => m.type === "init");
|
||||
expect(init.bots.map((b: any) => b.id)).toEqual(["bot1"]);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket refreshGuestPolicy", () => {
|
||||
function makeHarness() {
|
||||
const clients: any[] = [];
|
||||
const fakeWss: any = {
|
||||
on: (ev: string, cb: any) => {
|
||||
if (ev === "connection") fakeWss._conn = cb;
|
||||
},
|
||||
};
|
||||
const botManager: any = {
|
||||
getAllBots: () => [],
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
removeListener: () => {},
|
||||
};
|
||||
const logger = { debug() {}, error() {}, info() {}, warn() {} } as any;
|
||||
const controller = setupWebSocket(fakeWss, botManager, logger);
|
||||
// Connect fake sockets via the connection handler so they land in `clients`.
|
||||
const connect = (ws: any) => {
|
||||
clients.push(ws);
|
||||
fakeWss._conn(ws);
|
||||
};
|
||||
return { controller, connect };
|
||||
}
|
||||
|
||||
function makeFakeWs(opts: { isGuest: boolean; botScope?: "all" | Set<string> }) {
|
||||
const closeCalls: Array<{ code?: number; reason?: string }> = [];
|
||||
const ws: any = {
|
||||
readyState: 1,
|
||||
isGuest: opts.isGuest,
|
||||
botScope: opts.botScope,
|
||||
send: () => {},
|
||||
on: () => {},
|
||||
close: (code?: number, reason?: string) => closeCalls.push({ code, reason }),
|
||||
};
|
||||
return { ws, closeCalls };
|
||||
}
|
||||
|
||||
it("disabling guest mode closes guest sockets but leaves non-guest sockets open", () => {
|
||||
const { controller, connect } = makeHarness();
|
||||
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
|
||||
const member = makeFakeWs({ isGuest: false, botScope: "all" });
|
||||
connect(guest.ws);
|
||||
connect(member.ws);
|
||||
|
||||
controller.refreshGuestPolicy({ enabled: false, bots: "all" });
|
||||
|
||||
expect(guest.closeCalls.length).toBe(1);
|
||||
expect(guest.closeCalls[0].code).toBe(1008);
|
||||
expect(member.closeCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("narrowing the guest scope live re-scopes open guest sockets", () => {
|
||||
const { controller, connect } = makeHarness();
|
||||
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
|
||||
connect(guest.ws);
|
||||
|
||||
controller.refreshGuestPolicy({ enabled: true, bots: ["bot2"] });
|
||||
|
||||
expect(guest.closeCalls.length).toBe(0);
|
||||
expect(guest.ws.botScope instanceof Set).toBe(true);
|
||||
expect(guest.ws.botScope.has("bot2")).toBe(true);
|
||||
expect(guest.ws.botScope.has("bot1")).toBe(false);
|
||||
});
|
||||
});
|
||||
+55
-10
@@ -3,13 +3,34 @@ import type { BotManager } from "../bot/manager.js";
|
||||
import type { BotInstance } from "../bot/instance.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
export interface WebSocketController {
|
||||
cleanup: () => void;
|
||||
/**
|
||||
* Re-apply the current guest-mode policy to every already-open guest socket.
|
||||
* If guest mode is disabled, in-flight guest sockets are force-closed; otherwise
|
||||
* each guest socket is live re-scoped so out-of-scope bots stop streaming.
|
||||
*/
|
||||
refreshGuestPolicy: (cfg: { enabled: boolean; bots: "all" | string[] }) => void;
|
||||
}
|
||||
|
||||
export function setupWebSocket(
|
||||
wss: WebSocketServer,
|
||||
botManager: BotManager,
|
||||
logger: Logger
|
||||
): () => void {
|
||||
): WebSocketController {
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
/**
|
||||
* Whether a given bot is visible to a WebSocket client. Member/admin clients
|
||||
* (non-guest) and guests with full scope see everything; scoped guests only
|
||||
* see bots in their allowed set.
|
||||
*/
|
||||
function visibleToClient(ws: WebSocket, botId: string): boolean {
|
||||
const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set<string> };
|
||||
if (!w.isGuest || w.botScope === "all" || !w.botScope) return true;
|
||||
return w.botScope.has(botId);
|
||||
}
|
||||
|
||||
/** Track which bot instances have listeners attached (keyed by id, storing ref) */
|
||||
const attachedBots = new Map<string, {
|
||||
bot: BotInstance;
|
||||
@@ -22,7 +43,10 @@ export function setupWebSocket(
|
||||
clients.add(ws);
|
||||
logger.debug("WebSocket client connected");
|
||||
|
||||
const bots = botManager.getAllBots().map((b) => b.getStatus());
|
||||
const bots = botManager
|
||||
.getAllBots()
|
||||
.filter((b) => visibleToClient(ws, b.id))
|
||||
.map((b) => b.getStatus());
|
||||
ws.send(JSON.stringify({ type: "init", bots }));
|
||||
|
||||
ws.on("close", () => {
|
||||
@@ -36,17 +60,17 @@ export function setupWebSocket(
|
||||
});
|
||||
});
|
||||
|
||||
const broadcast = (data: object) => {
|
||||
const broadcast = (data: object, botId?: string) => {
|
||||
const message = JSON.stringify(data);
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
if (client.readyState !== WebSocket.OPEN) continue;
|
||||
if (botId !== undefined && !visibleToClient(client, botId)) continue;
|
||||
try {
|
||||
client.send(message);
|
||||
} catch {
|
||||
clients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function detachBotListener(id: string): void {
|
||||
@@ -72,7 +96,7 @@ export function setupWebSocket(
|
||||
botId: bot.id,
|
||||
status: bot.getStatus(),
|
||||
queue: bot.getQueue(),
|
||||
});
|
||||
}, bot.id);
|
||||
};
|
||||
|
||||
const onConnected = () => {
|
||||
@@ -80,7 +104,7 @@ export function setupWebSocket(
|
||||
type: "botConnected",
|
||||
botId: bot.id,
|
||||
status: bot.getStatus(),
|
||||
});
|
||||
}, bot.id);
|
||||
};
|
||||
|
||||
const onDisconnected = () => {
|
||||
@@ -88,7 +112,7 @@ export function setupWebSocket(
|
||||
type: "botDisconnected",
|
||||
botId: bot.id,
|
||||
status: bot.getStatus(),
|
||||
});
|
||||
}, bot.id);
|
||||
};
|
||||
|
||||
bot.on("stateChange", onStateChange);
|
||||
@@ -117,7 +141,7 @@ export function setupWebSocket(
|
||||
// React when a bot is removed: detach its listener and tell clients to drop it
|
||||
const onBotInstanceRemoved = (id: string) => {
|
||||
detachBotListener(id);
|
||||
broadcast({ type: "botRemoved", botId: id });
|
||||
broadcast({ type: "botRemoved", botId: id }, id);
|
||||
};
|
||||
botManager.on("botInstanceRemoved", onBotInstanceRemoved);
|
||||
|
||||
@@ -136,7 +160,7 @@ export function setupWebSocket(
|
||||
}, 5000);
|
||||
ensureAllBotsAttached();
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
clearInterval(intervalId);
|
||||
botManager.removeListener("botInstance", onBotInstance);
|
||||
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
|
||||
@@ -145,4 +169,25 @@ export function setupWebSocket(
|
||||
detachBotListener(id);
|
||||
}
|
||||
};
|
||||
|
||||
// When the admin changes guestMode (disable / narrow scope), already-open guest
|
||||
// sockets must stop streaming immediately — their isGuest/botScope were stamped
|
||||
// once at upgrade and would otherwise keep receiving bot state.
|
||||
const refreshGuestPolicy = (cfg: { enabled: boolean; bots: "all" | string[] }) => {
|
||||
for (const ws of clients) {
|
||||
const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set<string> };
|
||||
if (!w.isGuest) continue;
|
||||
if (!cfg.enabled) {
|
||||
try {
|
||||
ws.close(1008, "guest mode disabled");
|
||||
} catch {
|
||||
// socket may already be closing; ignore
|
||||
}
|
||||
} else {
|
||||
w.botScope = cfg.bots === "all" ? "all" : new Set(cfg.bots);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { cleanup, refreshGuestPolicy };
|
||||
}
|
||||
+13
-6
@@ -19,22 +19,22 @@
|
||||
<div class="m-player-artist">{{ currentSong.artist }}</div>
|
||||
</div>
|
||||
<div class="m-player-controls" @click.stop>
|
||||
<button class="m-player-btn" @click="playerStore.prev()">
|
||||
<button v-if="can('player.control')" class="m-player-btn" @click="playerStore.prev()">
|
||||
<Icon icon="mdi:skip-previous" />
|
||||
</button>
|
||||
<button class="m-player-btn" @click="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
|
||||
<button v-if="canTransport" class="m-player-btn" @click="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
|
||||
<Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
|
||||
</button>
|
||||
<button class="m-player-btn" @click="playerStore.next()">
|
||||
<button v-if="canSkip" class="m-player-btn" @click="playerStore.next()">
|
||||
<Icon icon="mdi:skip-next" />
|
||||
</button>
|
||||
<button class="m-player-btn" @click="cycleMobileMode">
|
||||
<button v-if="canModeCtl" class="m-player-btn" @click="cycleMobileMode">
|
||||
<Icon :icon="mobileModeIcon" />
|
||||
</button>
|
||||
<button class="m-player-btn" @click="toggleMobileQueue">
|
||||
<Icon icon="mdi:playlist-music" />
|
||||
</button>
|
||||
<button class="m-player-btn" @click="toggleMobileVolume">
|
||||
<button v-if="canTransport" class="m-player-btn" @click="toggleMobileVolume">
|
||||
<Icon icon="mdi:volume-high" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@
|
||||
<Icon icon="mdi:music-box-multiple" class="tab-icon" />
|
||||
<span class="tab-label">音乐库</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/settings" class="m-tab" :class="{ active: route.path.startsWith('/settings') }">
|
||||
<RouterLink v-if="!session.isGuest.value" to="/settings" class="m-tab" :class="{ active: route.path.startsWith('/settings') }">
|
||||
<Icon icon="mdi:cog" class="tab-icon" />
|
||||
<span class="tab-label">设置</span>
|
||||
</RouterLink>
|
||||
@@ -80,6 +80,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from './stores/player.js';
|
||||
import { useWebSocket } from './composables/useWebSocket.js';
|
||||
import { useSession } from './composables/useSession.js';
|
||||
import Navbar from './components/Navbar.vue';
|
||||
import Player from './components/Player.vue';
|
||||
import CoverArt from './components/CoverArt.vue';
|
||||
@@ -87,6 +88,12 @@ import Toast from './components/Toast.vue';
|
||||
import Queue from './components/Queue.vue';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const session = useSession();
|
||||
const { can, guestCan } = session;
|
||||
// Mobile mini-player transport gating — mirrors components/Player.vue.
|
||||
const canTransport = computed(() => can('player.control') || guestCan('transport'));
|
||||
const canSkip = computed(() => can('player.control') || guestCan('skip'));
|
||||
const canModeCtl = computed(() => can('player.control') || guestCan('playMode'));
|
||||
const theme = computed(() => playerStore.theme);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -98,14 +98,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink to="/settings" class="settings-btn">
|
||||
<RouterLink v-if="!session.isGuest.value" to="/settings" class="settings-btn">
|
||||
<Icon icon="mdi:cog" />
|
||||
</RouterLink>
|
||||
|
||||
<div v-if="session.currentUser.value" class="nav-user">
|
||||
<span class="nav-user-name">{{ session.currentUser.value.username }}</span>
|
||||
<span class="nav-user-role" :class="`role-${session.currentUser.value.role}`">
|
||||
{{ session.currentUser.value.role === 'admin' ? '管理员' : '成员' }}
|
||||
{{ session.currentUser.value.role === 'admin' ? '管理员' : session.currentUser.value.role === 'guest' ? '游客' : '成员' }}
|
||||
</span>
|
||||
<button class="nav-user-logout" @click="onLogout" title="退出">
|
||||
<Icon icon="mdi:logout" />
|
||||
@@ -769,4 +769,5 @@ onUnmounted(() => {
|
||||
}
|
||||
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
|
||||
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
|
||||
.role-guest { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
|
||||
</style>
|
||||
@@ -3,10 +3,10 @@
|
||||
<Queue :open="showQueue" @close="showQueue = false" />
|
||||
|
||||
<div class="player-bar frosted-glass">
|
||||
<!-- Progress bar (read-only display; seek interaction gated on player.control) -->
|
||||
<!-- Progress bar (read-only display; seek interaction gated on transport / canTransport) -->
|
||||
<div
|
||||
class="progress-bar-container"
|
||||
:class="{ 'no-seek': !canControl }"
|
||||
:class="{ 'no-seek': !canTransport }"
|
||||
ref="progressBarRef"
|
||||
@click="onProgressClick"
|
||||
@mousemove="onProgressHover"
|
||||
@@ -38,18 +38,18 @@
|
||||
|
||||
<div class="player-center">
|
||||
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
|
||||
<!-- Transport controls require player.control -->
|
||||
<template v-if="canControl">
|
||||
<button class="control-btn" @click="store.prev()">
|
||||
<!-- Transport controls: per-button gating honoring guest flags -->
|
||||
<template v-if="canControl || canTransport || canSkip || canModeCtl">
|
||||
<button v-if="canControl" class="control-btn" @click="store.prev()">
|
||||
<Icon icon="mdi:skip-previous" />
|
||||
</button>
|
||||
<button class="play-btn" @click="togglePlay">
|
||||
<button v-if="canTransport" class="play-btn" @click="togglePlay">
|
||||
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
|
||||
</button>
|
||||
<button class="control-btn" @click="store.next()">
|
||||
<button v-if="canSkip" class="control-btn" @click="store.next()">
|
||||
<Icon icon="mdi:skip-next" />
|
||||
</button>
|
||||
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
|
||||
<button v-if="canModeCtl" class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
|
||||
<Icon :icon="modeIcon" />
|
||||
<span class="mode-label">{{ modeLabel }}</span>
|
||||
</button>
|
||||
@@ -58,8 +58,8 @@
|
||||
</div>
|
||||
|
||||
<div class="player-right">
|
||||
<!-- Volume requires player.control -->
|
||||
<template v-if="canControl">
|
||||
<!-- Volume gated on transport -->
|
||||
<template v-if="canTransport">
|
||||
<Icon icon="mdi:volume-high" class="volume-icon" />
|
||||
<input
|
||||
type="range"
|
||||
@@ -94,8 +94,11 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showQueue = ref(false);
|
||||
|
||||
const { can } = useSession();
|
||||
const { can, guestCan } = useSession();
|
||||
const canControl = computed(() => can('player.control'));
|
||||
const canTransport = computed(() => can('player.control') || guestCan('transport'));
|
||||
const canSkip = computed(() => can('player.control') || guestCan('skip'));
|
||||
const canModeCtl = computed(() => can('player.control') || guestCan('playMode'));
|
||||
|
||||
const store = usePlayerStore();
|
||||
const activeBot = computed(() => store.activeBot);
|
||||
@@ -144,7 +147,7 @@ function updateProgress() {
|
||||
}
|
||||
|
||||
async function onProgressClick(e: MouseEvent) {
|
||||
if (!canControl.value) return; // seek requires player.control
|
||||
if (!canTransport.value) return; // seek gated on transport (canTransport)
|
||||
const bar = progressBarRef.value;
|
||||
if (!bar) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h3 class="queue-title">播放队列</h3>
|
||||
<span class="queue-count">{{ botQueue.length }} 首</span>
|
||||
<button
|
||||
v-if="botQueue.length > 0 && can('player.control')"
|
||||
v-if="botQueue.length > 0 && (can('player.control') || guestCan('removeClear'))"
|
||||
class="clear-btn"
|
||||
@click="clearAndStop"
|
||||
title="清空队列并停止播放"
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="queue-song-name">{{ song.name }}</div>
|
||||
<div class="queue-song-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
<button v-if="can('player.queue')" class="remove-btn" @click="removeSong(i)" title="移除">
|
||||
<button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click="removeSong(i)" title="移除">
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@ defineEmits<{
|
||||
}>();
|
||||
|
||||
const store = usePlayerStore();
|
||||
const { can } = useSession();
|
||||
const { can, guestCan } = useSession();
|
||||
const botQueue = computed(() => store.queue);
|
||||
|
||||
// Fetch queue when panel opens
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="song-card" :class="{ active }" @dblclick="$emit('play')">
|
||||
<div class="song-card" :class="{ active }" @dblclick="showPlay && $emit('play')">
|
||||
<div class="song-index">{{ index }}</div>
|
||||
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
|
||||
<div class="song-info">
|
||||
@@ -15,13 +15,13 @@
|
||||
<div class="song-album">{{ song.album }}</div>
|
||||
<div class="song-duration">{{ formatDuration(song.duration) }}</div>
|
||||
<div class="song-actions">
|
||||
<button class="action-btn" @click.stop="$emit('play')" title="播放">
|
||||
<button v-if="showPlay" class="action-btn" @click.stop="$emit('play')" title="播放">
|
||||
<Icon icon="mdi:play" />
|
||||
</button>
|
||||
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
|
||||
<button v-if="showPlayNext" class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
|
||||
<Icon icon="mdi:playlist-play" />
|
||||
</button>
|
||||
<button class="action-btn" @click.stop="$emit('add')" title="添加到队列">
|
||||
<button v-if="showAdd" class="action-btn" @click.stop="$emit('add')" title="添加到队列">
|
||||
<Icon icon="mdi:playlist-plus" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -29,9 +29,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
import { Song } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
|
||||
defineProps<{
|
||||
song: Song;
|
||||
@@ -39,6 +41,11 @@ defineProps<{
|
||||
active?: boolean;
|
||||
}>();
|
||||
|
||||
const { can, guestCan } = useSession();
|
||||
const showPlay = computed(() => can('player.control') || guestCan('playNow'));
|
||||
const showPlayNext = computed(() => can('player.control') || guestCan('playNext'));
|
||||
const showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));
|
||||
|
||||
defineEmits<{
|
||||
play: [];
|
||||
playNext: [];
|
||||
|
||||
@@ -3,13 +3,15 @@ import { ref, computed, readonly } from "vue";
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'member';
|
||||
role: 'admin' | 'member' | 'guest';
|
||||
capabilities?: string[];
|
||||
bots?: "all" | string[];
|
||||
guest?: Record<string, boolean> | null;
|
||||
}
|
||||
|
||||
const currentUser = ref<User | null>(null);
|
||||
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
|
||||
const guestAllowed = ref(false);
|
||||
const ready = ref(false);
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -37,6 +39,7 @@ async function refreshNeedsSetup(): Promise<void> {
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
needsSetup.value = Boolean(body.needsSetup);
|
||||
guestAllowed.value = Boolean(body.guestAllowed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +79,16 @@ async function login(username: string, password: string): Promise<void> {
|
||||
await refreshMe();
|
||||
}
|
||||
|
||||
async function continueAsGuest(): Promise<void> {
|
||||
const res = await fetch("/api/session/guest", { method: "POST", credentials: "same-origin" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `guest entry failed (${res.status})`);
|
||||
}
|
||||
currentUser.value = (await res.json()) as User;
|
||||
await refreshMe(); // authoritative role + guest flags + bots
|
||||
}
|
||||
|
||||
async function setup(username: string, password: string): Promise<void> {
|
||||
const res = await fetch("/api/session/setup", {
|
||||
method: "POST",
|
||||
@@ -104,6 +117,11 @@ function can(cap: string): boolean {
|
||||
return !!u && (u.role === "admin" || (u.capabilities ?? []).includes(cap));
|
||||
}
|
||||
|
||||
function guestCan(flag: string): boolean {
|
||||
const u = currentUser.value;
|
||||
return !!u && u.role === "guest" && !!u.guest && u.guest[flag] === true;
|
||||
}
|
||||
|
||||
function canControlBot(botId: string): boolean {
|
||||
const u = currentUser.value;
|
||||
if (!u) return false;
|
||||
@@ -115,14 +133,18 @@ export function useSession() {
|
||||
return {
|
||||
currentUser: readonly(currentUser),
|
||||
needsSetup: readonly(needsSetup),
|
||||
guestAllowed: readonly(guestAllowed),
|
||||
isAuthenticated: computed(() => currentUser.value !== null),
|
||||
isAdmin: computed(() => currentUser.value?.role === 'admin'),
|
||||
isGuest: computed(() => currentUser.value?.role === 'guest'),
|
||||
ready: readonly(ready),
|
||||
refresh,
|
||||
login,
|
||||
logout,
|
||||
setup,
|
||||
continueAsGuest,
|
||||
can,
|
||||
guestCan,
|
||||
canControlBot,
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,12 @@ router.beforeEach(async (to) => {
|
||||
return { name: 'login', query: { next: to.fullPath } };
|
||||
}
|
||||
|
||||
// Guests may never reach settings/setup, even by typing the URL.
|
||||
const GUEST_BLOCKED = new Set(['settings', 'setup']);
|
||||
if (session.isGuest.value && GUEST_BLOCKED.has(to.name as string)) {
|
||||
return { name: 'home' };
|
||||
}
|
||||
|
||||
// Navigation is allowed to proceed to `to` past here (auth/setup redirects above take precedence).
|
||||
// Sync + preserve the dedicated-link scope carried by ?bot.
|
||||
const store = usePlayerStore();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import { resolveScopedBot } from './scope.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
|
||||
export interface Song {
|
||||
id: string;
|
||||
@@ -334,7 +335,10 @@ export const usePlayerStore = defineStore('player', {
|
||||
|
||||
async playSong(song: Song) {
|
||||
if (!this.activeBotId) return;
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-song`, { song });
|
||||
// Guests use the non-destructive "play now" (insert-next + skip) so they
|
||||
// can't wipe everyone else's queue; members/admins keep the normal behavior.
|
||||
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/${endpoint}`, { song });
|
||||
if (res.data?.ok === false && res.data?.message) {
|
||||
this.notify(res.data.message, 'error');
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
<p v-if="error" class="auth-error">{{ error }}</p>
|
||||
<button type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button>
|
||||
</form>
|
||||
<button
|
||||
v-if="session.guestAllowed.value"
|
||||
type="button"
|
||||
class="guest-btn"
|
||||
:disabled="loading"
|
||||
@click="enterAsGuest"
|
||||
>
|
||||
以游客身份进入
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -43,12 +52,28 @@ async function submit() {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enterAsGuest() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await session.continueAsGuest();
|
||||
const rawNext = typeof route.query.next === 'string' ? route.query.next : '/';
|
||||
const next = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/';
|
||||
router.replace(next);
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
@@ -75,4 +100,11 @@ async function submit() {
|
||||
}
|
||||
.auth-card button:disabled { opacity: 0.6; cursor: progress; }
|
||||
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; }
|
||||
.guest-btn {
|
||||
width: 360px; height: 38px; margin-top: 4px; border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color); cursor: pointer;
|
||||
}
|
||||
.guest-btn:hover { color: var(--text-primary); }
|
||||
.guest-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
</style>
|
||||
@@ -455,6 +455,55 @@
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Guest Mode (admin only) -->
|
||||
<section v-if="session.isAdmin.value" class="settings-section">
|
||||
<h2 class="section-title">游客模式</h2>
|
||||
<p class="profile-section-hint">开启后,访客无需登录即可进入并点歌(默认关闭)。游客永远无法查看或修改设置。下面逐项决定游客可用的能力。</p>
|
||||
|
||||
<label class="profile-toggle behavior-toggle">
|
||||
<div class="profile-toggle-text">
|
||||
<div class="profile-toggle-label">允许游客访问</div>
|
||||
<div class="profile-toggle-hint">登录页会出现「以游客身份进入」。关闭后所有游客会话立即失效。</div>
|
||||
</div>
|
||||
<input v-model="guestMode.enabled" type="checkbox" class="profile-toggle-switch" />
|
||||
</label>
|
||||
|
||||
<div v-if="guestMode.enabled" class="perm-group">
|
||||
<div class="perm-group-title">游客权限</div>
|
||||
<div class="perm-checks">
|
||||
<label v-for="f in GUEST_FLAGS" :key="f.token" class="perm-check">
|
||||
<input type="checkbox" v-model="guestMode.permissions[f.token]" />
|
||||
{{ f.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="guestMode.enabled" class="perm-group">
|
||||
<div class="perm-group-title">可控制的机器人</div>
|
||||
<label class="perm-check">
|
||||
<input type="checkbox" v-model="guestMode.botsAll" />
|
||||
全部机器人
|
||||
</label>
|
||||
<div v-if="!guestMode.botsAll" class="perm-checks perm-bots">
|
||||
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="guestMode.selectedBotIds.includes(bot.id)"
|
||||
@change="toggleGuestBot(bot.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ bot.name }}
|
||||
</label>
|
||||
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" :disabled="guestSaving" @click="saveGuestMode">
|
||||
{{ guestSaving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||
<section v-if="can('bot.manage')" class="settings-section">
|
||||
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
|
||||
@@ -977,6 +1026,7 @@ async function loadIdleTimeout() {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
|
||||
applyGuestModeFromServer(res.data.guestMode);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -992,6 +1042,56 @@ async function saveAutoPause() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Guest mode (admin only) ---
|
||||
const GUEST_FLAGS: { token: string; label: string }[] = [
|
||||
{ token: 'addToQueue', label: '添加到队列末尾' },
|
||||
{ token: 'playNext', label: '添加到下一首' },
|
||||
{ token: 'playNow', label: '立即播放(不清空队列)' },
|
||||
{ token: 'skip', label: '跳过当前歌曲' },
|
||||
{ token: 'transport', label: '暂停/继续/进度/音量' },
|
||||
{ token: 'removeClear', label: '移除/清空队列' },
|
||||
{ token: 'playMode', label: '切换播放模式 / FM' },
|
||||
];
|
||||
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 },
|
||||
});
|
||||
const guestSaving = ref(false);
|
||||
|
||||
function applyGuestModeFromServer(gm: any) {
|
||||
if (!gm) return;
|
||||
guestMode.enabled = Boolean(gm.enabled);
|
||||
guestMode.botsAll = gm.bots === 'all';
|
||||
guestMode.selectedBotIds = Array.isArray(gm.bots) ? [...gm.bots] : [];
|
||||
for (const f of GUEST_FLAGS) {
|
||||
guestMode.permissions[f.token] = Boolean(gm.permissions?.[f.token]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGuestBot(id: string, checked: boolean) {
|
||||
const has = guestMode.selectedBotIds.includes(id);
|
||||
if (checked && !has) guestMode.selectedBotIds.push(id);
|
||||
else if (!checked && has) guestMode.selectedBotIds = guestMode.selectedBotIds.filter((b) => b !== id);
|
||||
}
|
||||
|
||||
async function saveGuestMode() {
|
||||
guestSaving.value = true;
|
||||
try {
|
||||
const res = await axios.post('/api/bot/settings', {
|
||||
guestMode: {
|
||||
enabled: guestMode.enabled,
|
||||
bots: guestMode.botsAll ? 'all' : [...guestMode.selectedBotIds],
|
||||
permissions: { ...guestMode.permissions },
|
||||
},
|
||||
});
|
||||
applyGuestModeFromServer(res.data?.guestMode);
|
||||
} catch { /* ignore */ } finally {
|
||||
guestSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bot Profile config ---
|
||||
interface ProfileConfig {
|
||||
avatarEnabled: boolean;
|
||||
|
||||
Reference in new issue
Block a user