Merge pull request #101 from ZHANGTIANYAO1/feat/guest-mode

Add guest mode (login-less WebUI access) (#83)
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-06-25 16:20:55 +08:00
commit 253c0a46a1
49 files changed
+4360 -174

No files matched your search

+21
View File
@@ -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`: 如果你忘记了管理员密码,可以直接编辑 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`.
+112
View File
@@ -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",
]);
});
});
+9
View File
@@ -78,6 +78,7 @@ export class BotInstance extends EventEmitter {
private fmProvider: MusicProvider | null = null; private fmProvider: MusicProvider | null = null;
/** Results of the most recent !search, for "#N" selection (issue #90). */ /** Results of the most recent !search, for "#N" selection (issue #90). */
private lastSearchResults: Song[] = []; private lastSearchResults: Song[] = [];
private playGate: Promise<unknown> = Promise.resolve();
constructor(options: BotInstanceOptions) { constructor(options: BotInstanceOptions) {
super(); super();
@@ -1051,6 +1052,14 @@ export class BotInstance extends EventEmitter {
return input; 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 { getStatus(): BotStatus {
return { return {
id: this.id, id: this.id,
+87
View File
@@ -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
View File
@@ -7,7 +7,7 @@ import {
import type { MusicProvider } from "../music/provider.js"; import type { MusicProvider } from "../music/provider.js";
import { YouTubeProvider } from "../music/youtube.js"; import { YouTubeProvider } from "../music/youtube.js";
import type { BotDatabase } from "../data/database.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 { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js"; import type { ServerProtocol } from "../ts-protocol/client.js";
@@ -79,6 +79,7 @@ export class BotManager extends EventEmitter {
private logger: Logger; private logger: Logger;
private avatarStore: AvatarStore; private avatarStore: AvatarStore;
private permissions: PermissionStore; private permissions: PermissionStore;
private configPath: string;
constructor( constructor(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
@@ -88,7 +89,8 @@ export class BotManager extends EventEmitter {
config: BotConfig, config: BotConfig,
logger: Logger, logger: Logger,
avatarStore: AvatarStore, avatarStore: AvatarStore,
permissions: PermissionStore permissions: PermissionStore,
configPath: string
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -100,6 +102,7 @@ export class BotManager extends EventEmitter {
this.logger = logger; this.logger = logger;
this.avatarStore = avatarStore; this.avatarStore = avatarStore;
this.permissions = permissions; this.permissions = permissions;
this.configPath = configPath;
} }
async createBot(params: CreateBotParams): Promise<BotInstance> { async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -160,6 +163,11 @@ export class BotManager extends EventEmitter {
} }
this.database.deleteBotInstance(id); this.database.deleteBotInstance(id);
this.permissions.pruneBot(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.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed"); this.logger.info({ botId: id }, "Bot instance removed");
} }
+71
View File
@@ -106,3 +106,74 @@ describe("config", () => {
expect(migrated).toBe(false); 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
View File
@@ -1,5 +1,13 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs"; import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
import { dirname } from "node:path"; 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 { export interface BotConfig {
webPort: number; webPort: number;
@@ -22,6 +30,7 @@ export interface BotConfig {
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection // (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
// behind HTTPS-terminating proxies. // behind HTTPS-terminating proxies.
trustProxy: boolean; trustProxy: boolean;
guestMode: GuestModeConfig;
} }
export function getDefaultConfig(): BotConfig { export function getDefaultConfig(): BotConfig {
@@ -43,6 +52,19 @@ export function getDefaultConfig(): BotConfig {
idleTimeoutMinutes: 0, idleTimeoutMinutes: 0,
publicUrl: "", publicUrl: "",
trustProxy: false, 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 { try {
const raw = readFileSync(path, "utf-8"); const raw = readFileSync(path, "utf-8");
const partial = JSON.parse(raw) as Partial<BotConfig>; 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 { } catch {
return defaults; return defaults;
} }
+29
View File
@@ -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 { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js"; import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
import { createUserStore, GUEST_USER_ID } from "./users.js";
describe("database", () => { describe("database", () => {
let botDb: BotDatabase; let botDb: BotDatabase;
@@ -148,3 +152,28 @@ describe("database", () => {
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull(); 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 });
});
});
+15
View File
@@ -1,5 +1,6 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js"; import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
export interface PlayHistoryEntry { export interface PlayHistoryEntry {
botId: string; botId: string;
@@ -241,6 +242,19 @@ export function backfillMemberPermissions(db: Database.Database): void {
tx(); 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 { export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma("journal_mode = WAL"); db.pragma("journal_mode = WAL");
@@ -248,6 +262,7 @@ export function createDatabase(dbPath: string): BotDatabase {
initTables(db); initTables(db);
migrateSchema(db); migrateSchema(db);
backfillMemberPermissions(db); backfillMemberPermissions(db);
ensureGuestUser(db);
const insertHistory = db.prepare(` const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
+43
View File
@@ -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
View File
@@ -21,6 +21,27 @@ export function isCapability(x: string): x is Capability {
export type BotAccess = "all" | string[]; 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 { export interface PermissionStore {
getCapabilities(userId: string): Capability[]; getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess; getBotAccess(userId: string): BotAccess;
@@ -71,16 +92,26 @@ export function createPermissionStore(db: Database.Database): PermissionStore {
export interface PermissionContext { export interface PermissionContext {
capabilities: Set<string>; capabilities: Set<string>;
bots: "all" | Set<string>; bots: "all" | Set<string>;
guest?: GuestPermissions;
} }
export function resolvePermissionContext( export function resolvePermissionContext(
role: "admin" | "member", role: "admin" | "member" | "guest",
userId: string, userId: string,
store: PermissionStore store: PermissionStore,
guest?: { bots: BotAccess; permissions: GuestPermissions }
): PermissionContext { ): PermissionContext {
if (role === "admin") { if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" }; 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); const access = store.getBotAccess(userId);
return { return {
capabilities: new Set(store.getCapabilities(userId)), capabilities: new Set(store.getCapabilities(userId)),
+72 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js"; import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.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) { function sha256(token: string) {
return createHash("sha256").update(token).digest("hex"); return createHash("sha256").update(token).digest("hex");
@@ -126,3 +126,74 @@ describe("SessionStore", () => {
expect(count).toBe(MAX_SESSIONS_PER_USER); 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);
});
});
+15 -9
View File
@@ -2,17 +2,18 @@ import { createHash, randomBytes } from "node:crypto";
import type Database from "better-sqlite3"; import type Database from "better-sqlite3";
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 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 SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const MAX_SESSIONS_PER_USER = 10; export const MAX_SESSIONS_PER_USER = 10;
export interface SessionValidation { export interface SessionValidation {
userId: string; userId: string;
username: string; username: string;
role: "admin" | "member"; role: "admin" | "member" | "guest";
} }
export interface SessionStore { 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; validateAndTouch(rawToken: string): SessionValidation | null;
deleteSession(rawToken: string): void; deleteSession(rawToken: string): void;
deleteAllForUser(userId: string, exceptToken?: string): void; deleteAllForUser(userId: string, exceptToken?: string): void;
@@ -47,7 +48,7 @@ export function createSessionStore(db: Database.Database): SessionStore {
); );
return { return {
createSession(userId) { createSession(userId, opts) {
// Cap concurrent sessions per user — oldest gets evicted on overflow. // Cap concurrent sessions per user — oldest gets evicted on overflow.
// Wrap the count → delete → insert in a transaction so concurrent logins // 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, // for the same user can't both pass the cap check and both insert,
@@ -55,11 +56,13 @@ export function createSessionStore(db: Database.Database): SessionStore {
const token = randomBytes(32).toString("base64url"); const token = randomBytes(32).toString("base64url");
const id = hashToken(token); const id = hashToken(token);
const now = Date.now(); const now = Date.now();
const expiresAt = now + SESSION_TTL_MS; const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
const tx = db.transaction(() => { const tx = db.transaction(() => {
const existing = (countForUserStmt.get(userId) as { n: number }).n; if (!opts?.skipCap) {
if (existing >= MAX_SESSIONS_PER_USER) { const existing = (countForUserStmt.get(userId) as { n: number }).n;
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); if (existing >= MAX_SESSIONS_PER_USER) {
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
}
} }
insertStmt.run(id, userId, now, expiresAt, now); insertStmt.run(id, userId, now, expiresAt, now);
}); });
@@ -80,9 +83,12 @@ export function createSessionStore(db: Database.Database): SessionStore {
return null; return null;
} }
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { 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) { deleteSession(rawToken) {
+41 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js"; 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", () => { describe("UserStore", () => {
let botDb: BotDatabase; let botDb: BotDatabase;
@@ -184,3 +184,43 @@ describe("UserStore", () => {
expect(users.countAdmins()).toBe(1); 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
View File
@@ -4,7 +4,13 @@ import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12; 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 { export interface UserRow {
id: string; id: string;
@@ -39,7 +45,7 @@ export class UsernameTakenError extends Error {
} }
export function createUserStore(db: Database.Database): UserStore { 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 countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
const insertStmt = db.prepare( const insertStmt = db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)" "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 = ?" "UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
); );
const listUsersStmt = db.prepare( 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 = ?"); 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 tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined; const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const; 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 === newRole) return "ok" as const; // no-op
if (row.role === "admin" && newRole === "member") { if (row.role === "admin" && newRole === "member") {
const adminCount = (countAdminsStmt.get() as { n: number }).n; const adminCount = (countAdminsStmt.get() as { n: number }).n;
@@ -155,6 +162,7 @@ export function createUserStore(db: Database.Database): UserStore {
const tx = db.transaction(() => { const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined; const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const; if (!row) return "not_found" as const;
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
if (row.role === "admin") { if (row.role === "admin") {
const adminCount = (countAdminsStmt.get() as { n: number }).n; const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const; if (adminCount <= 1) return "would_orphan" as const;
+2 -1
View File
@@ -74,7 +74,8 @@ async function main() {
config, config,
logger, logger,
avatarStore, avatarStore,
permissions permissions,
CONFIG_PATH
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
+2 -1
View File
@@ -7,6 +7,7 @@ import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js"; import { createSessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js"; import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js"; import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "../middleware/requireAuth.js"; import { createRequireAuth } from "../middleware/requireAuth.js";
import { createAuditRouter } from "./audit.js"; import { createAuditRouter } from "./audit.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -34,7 +35,7 @@ describe("audit router", () => {
app = express(); app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, permissions)); app.use("/api", createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use("/api/audit", createAuditRouter(audit)); app.use("/api/audit", createAuditRouter(audit));
}); });
+3 -2
View File
@@ -4,6 +4,7 @@ import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js"; import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js"; import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
export function createAuthRouter( export function createAuthRouter(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
@@ -23,7 +24,7 @@ export function createAuthRouter(
return platform === "qq" ? qqProvider : neteaseProvider; return platform === "qq" ? qqProvider : neteaseProvider;
} }
router.get("/status", async (req, res) => { router.get("/status", requireNotGuest, async (req, res) => {
try { try {
const platform = req.query.platform as string; const platform = req.query.platform as string;
const provider = getProvider(platform); 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 { try {
const { key, platform } = req.query; const { key, platform } = req.query;
if (!key) { if (!key) {
+56 -1
View File
@@ -60,7 +60,7 @@ describe("bot router /settings", () => {
app = express(); app = express();
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db))); app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use( app.use(
"/api/bot", "/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore), 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
View File
@@ -1,11 +1,13 @@
import { Router } from "express"; import { Router } from "express";
import type { BotManager } from "../../bot/manager.js"; 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 { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js"; import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js"; import type { AvatarStore } from "../../data/avatars.js";
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.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( export function createBotRouter(
botManager: BotManager, botManager: BotManager,
@@ -14,6 +16,7 @@ export function createBotRouter(
logger: Logger, logger: Logger,
botDb: BotDatabase, botDb: BotDatabase,
avatarStore: AvatarStore, avatarStore: AvatarStore,
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
): Router { ): Router {
const router = Router(); const router = Router();
@@ -29,17 +32,18 @@ export function createBotRouter(
// GET /api/bot/settings — 读取全局 bot 行为设置 // GET /api/bot/settings — 读取全局 bot 行为设置
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route. // 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({ res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
guestMode: config.guestMode,
}); });
}); });
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot // POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model) // behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => { router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body; const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode } = req.body;
const hasIdle = idleTimeoutMinutes !== undefined; const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) { if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
@@ -51,8 +55,34 @@ export function createBotRouter(
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes; if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; 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); 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 实例更新 // 通知所有 bot 实例更新
for (const bot of botManager.getAllBots()) { for (const bot of botManager.getAllBots()) {
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes); if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
@@ -62,6 +92,7 @@ export function createBotRouter(
res.json({ res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0, idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty, autoPauseOnEmpty: config.autoPauseOnEmpty,
guestMode: config.guestMode,
}); });
}); });
+5 -4
View File
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js"; import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js"; import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
export function createMusicRouter( export function createMusicRouter(
neteaseProvider: MusicProvider, 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 { try {
const provider = getProvider(req.query.platform as string); const provider = getProvider(req.query.platform as string);
if (!provider.getDailyRecommendSongs) { 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 { try {
const provider = getProvider(req.query.platform as string); const provider = getProvider(req.query.platform as string);
if (!provider.getPersonalFm) { 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 { try {
const provider = getProvider(req.query.platform as string); const provider = getProvider(req.query.platform as string);
if (!provider.getUserPlaylists) { if (!provider.getUserPlaylists) {
@@ -209,7 +210,7 @@ export function createMusicRouter(
}); });
// Get current quality // Get current quality
router.get("/quality", (_req, res) => { router.get("/quality", requireNotGuest, (_req, res) => {
res.json({ res.json({
netease: neteaseProvider.getQuality(), netease: neteaseProvider.getQuality(),
qq: qqProvider.getQuality(), qq: qqProvider.getQuality(),
+210 -1
View File
@@ -6,6 +6,8 @@ import { createPlayerRouter } from "./player.js";
import { createBotRouter } from "./bot.js"; import { createBotRouter } from "./bot.js";
import { createAuthRouter } from "./auth.js"; import { createAuthRouter } from "./auth.js";
import { createMusicRouter } from "./music.js"; import { createMusicRouter } from "./music.js";
import { createFavoritesRouter } from "./favorites.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
const logger = pino({ level: "silent" }); 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" }); const res = await request(app).post("/api/music/quality").send({ quality: "high" });
expect(res.status).not.toBe(403); 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", () => { describe("read-only routes stay open", () => {
@@ -197,7 +229,7 @@ describe("permission enforcement on action routes", () => {
expect(res.status).not.toBe(403); 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 app = makeApp(member([], "all"));
const res = await request(app).get("/api/music/quality"); const res = await request(app).get("/api/music/quality");
expect(res.status).not.toBe(403); expect(res.status).not.toBe(403);
@@ -208,6 +240,12 @@ describe("permission enforcement on action routes", () => {
const res = await request(app).get("/api/bot"); const res = await request(app).get("/api/bot");
expect(res.status).not.toBe(403); 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", () => { 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);
});
});
+121 -74
View File
@@ -4,7 +4,8 @@ import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider } from "../../music/provider.js"; import type { MusicProvider } from "../../music/provider.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.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( export function createPlayerRouter(
botManager: BotManager, botManager: BotManager,
@@ -41,7 +42,7 @@ export function createPlayerRouter(
return ""; return "";
}; };
router.post("/:botId/play", requirePermission("player.control"), async (req, res) => { router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { query, platform } = req.body; 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { query, platform } = req.body; const { query, platform } = req.body;
@@ -88,14 +89,14 @@ export function createPlayerRouter(
} }
}; };
router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause")); router.post("/:botId/pause", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!pause"));
router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume")); router.post("/:botId/resume", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!resume"));
router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next")); router.post("/:botId/next", authorize({ capability: "player.control", guestFlag: "skip" }), simpleCommand("!next"));
router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev")); router.post("/:botId/prev", authorize({ capability: "player.control" }), simpleCommand("!prev"));
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop")); router.post("/:botId/stop", authorize({ capability: "player.control" }), simpleCommand("!stop"));
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear")); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { platform } = req.body; 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { volume } = req.body; const { volume } = req.body;
@@ -145,7 +146,7 @@ export function createPlayerRouter(
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { mode } = req.body; const { mode } = req.body;
@@ -170,7 +171,7 @@ export function createPlayerRouter(
}); });
// Seek to position // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { position } = req.body; // seconds const { position } = req.body; // seconds
@@ -194,7 +195,7 @@ export function createPlayerRouter(
res.json({ queue: bot.getQueue(), status: bot.getStatus() }); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!; 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) // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { index } = req.body; const { index } = req.body;
@@ -214,33 +215,40 @@ export function createPlayerRouter(
res.status(400).json({ error: "index is required" }); res.status(400).json({ error: "index is required" });
return; return;
} }
const queue = bot.getQueueManager(); // Serialize the index-validation + stop/reset/playAt/resolveAndPlay so a
// Validate the index BEFORE stopping current playback — otherwise an // concurrent request can't interleave between mutating the queue and
// invalid index silently kills the user's current song and leaves the // starting playback (audible track must match queue.currentIndex).
// queue idle. const result = await bot.runExclusive(async () => {
if (index >= queue.size()) { const queue = bot.getQueueManager();
res.status(400).json({ error: "Invalid queue index" }); // 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()) {
return { status: 400 as const, body: { error: "Invalid queue index" } };
}
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const song = queue.playAt(index);
if (!song) {
return { status: 400 as const, body: { error: "Invalid queue index" } };
}
const ok = await bot.resolveAndPlay(song);
if (!ok) {
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; return;
} }
bot.getPlayer().stop(); res.json(result.body);
bot.getPlayer().resetFailures();
const song = queue.playAt(index);
if (!song) {
res.status(400).json({ error: "Invalid queue index" });
return;
}
const ok = await bot.resolveAndPlay(song);
if (!ok) {
res.json({ message: `Cannot play: ${song.name}` });
return;
}
res.json({ message: `Now playing: ${song.name} - ${song.artist}` });
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { playlistId, platform } = req.body; 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 // Play a playlist by ID — stores metadata only, resolves URL for first song
// Respects current play mode (random = pick random first song) // Respects current play mode (random = pick random first song)
router.post("/:botId/play-playlist", requirePermission("player.control"), async (req, res) => { router.post("/:botId/play-playlist", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { playlistId, platform } = req.body; const { playlistId, platform } = req.body;
@@ -344,7 +352,7 @@ export function createPlayerRouter(
}); });
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs // Play an album by ID — mirrors play-playlist but calls getAlbumSongs
router.post("/:botId/play-album", requirePermission("player.control"), async (req, res) => { router.post("/:botId/play-album", authorize({ capability: "player.control" }), async (req, res) => {
try { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { albumId, platform } = req.body; const { albumId, platform } = req.body;
@@ -416,7 +424,7 @@ export function createPlayerRouter(
}); });
// Play a single song by ID — resolves URL on demand // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { song } = req.body; const { song } = req.body;
@@ -444,7 +452,7 @@ export function createPlayerRouter(
// Insert a single song to play right after the current one. // Insert a single song to play right after the current one.
// If nothing is playing, behaves like /play-song (start immediately). // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { song } = req.body; const { song } = req.body;
@@ -452,37 +460,43 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" }); res.status(400).json({ error: "song object with id and platform is required" });
return; return;
} }
const queue = bot.getQueueManager(); // Serialize the queue mutation + playback so concurrent requests can't
const wasIdle = bot.getPlayer().getState() === "idle"; // interleave (audible track must match queue.currentIndex).
// Capture the slot addNext WILL insert at, before mutating the queue. const body = await bot.runExclusive(async () => {
// addNext pushes when currentIndex<0 (slot = size); otherwise splices const queue = bot.getQueueManager();
// at currentIndex+1. Using size-1 after addNext was wrong when the const wasIdle = bot.getPlayer().getState() === "idle";
// queue had stale currentIndex>=0 while the player was idle (e.g., // Capture the slot addNext WILL insert at, before mutating the queue.
// after natural track end without queue.clear()). // addNext pushes when currentIndex<0 (slot = size); otherwise splices
const insertedAt = // at currentIndex+1. Using size-1 after addNext was wrong when the
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1; // queue had stale currentIndex>=0 while the player was idle (e.g.,
queue.addNext(song); // after natural track end without queue.clear()).
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
if (wasIdle) { if (wasIdle) {
// Promote the just-added song to current and start it. // Promote the just-added song to current and start it.
queue.playAt(insertedAt); queue.playAt(insertedAt);
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) { if (!ok) {
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` }); return { ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` };
return; }
return { ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
} }
res.json({ ok: true, message: `正在播放:${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` });
return;
}
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) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { song } = req.body; const { song } = req.body;
@@ -490,27 +504,60 @@ export function createPlayerRouter(
res.status(400).json({ error: "song object with id and platform is required" }); res.status(400).json({ error: "song object with id and platform is required" });
return; return;
} }
const queue = bot.getQueueManager(); // Serialize the insert-after-current + promote + playback so concurrent
const wasIdle = bot.getPlayer().getState() === "idle"; // requests can't interleave (audible track must match queue.currentIndex).
queue.add(song); const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
// If nothing was playing, start this newly-added song immediately. const insertedAt =
if (wasIdle) { queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.playAt(queue.size() - 1); queue.addNext(song);
queue.playAt(insertedAt);
bot.getPlayer().resetFailures(); bot.getPlayer().resetFailures();
await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
res.json({ message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` }); 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; 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);
res.json({ message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` }); // If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
queue.playAt(queue.size() - 1);
bot.getPlayer().resetFailures();
await bot.resolveAndPlay(queue.current()!);
return { message: `Now playing: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'}` };
}
return { message: `Added to queue: ${song.name || 'Unknown'} - ${song.artist || 'Unknown'} (position ${queue.size()})` };
});
res.json(body);
} catch (err) { } catch (err) {
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
} }
}); });
// Add a song to queue by ID — metadata only // 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const { songId, platform } = req.body; const { songId, platform } = req.body;
@@ -548,7 +595,7 @@ export function createPlayerRouter(
res.json(bot.getProfileManager().getConfig()); 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 { try {
const bot = (req as any).bot; const bot = (req as any).bot;
const pm = bot.getProfileManager(); const pm = bot.getProfileManager();
+116 -3
View File
@@ -8,6 +8,8 @@ import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js"; import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js"; import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.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 { createSessionRouter } from "./session.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -17,7 +19,17 @@ function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
app.use(cookieParser()); app.use(cookieParser());
const audit = createAuditStore(botDb.db); const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(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; return app;
} }
@@ -47,7 +59,7 @@ describe("session router", () => {
it("GET /needs-setup returns true on an empty db", async () => { it("GET /needs-setup returns true on an empty db", async () => {
const res = await request(app).get("/api/session/needs-setup"); const res = await request(app).get("/api/session/needs-setup");
expect(res.status).toBe(200); 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 () => { 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); extractCookie(setupRes);
const needs = await request(app).get("/api/session/needs-setup"); 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 () => { 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); 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
View File
@@ -5,7 +5,9 @@ import type { UserStore } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js"; import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js"; import type { AuditStore } from "../../data/audit.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.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"; import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
const FAILED_LOGIN_DELAY_MS = 250; const FAILED_LOGIN_DELAY_MS = 250;
@@ -51,7 +53,8 @@ export function createSessionRouter(
sessions: SessionStore, sessions: SessionStore,
audit: AuditStore, audit: AuditStore,
logger: Logger, logger: Logger,
permissions: PermissionStore permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
): Router { ): Router {
const router = Router(); const router = Router();
@@ -62,6 +65,13 @@ export function createSessionRouter(
res.status(401).json({ error: "unauthenticated" }); res.status(401).json({ error: "unauthenticated" });
return; 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 }; req.user = { id: result.userId, username: result.username, role: result.role };
const token = extractSessionToken(req.headers.cookie); const token = extractSessionToken(req.headers.cookie);
if (token) setSessionCookie(res, token); if (token) setSessionCookie(res, token);
@@ -69,7 +79,7 @@ export function createSessionRouter(
}; };
router.get("/needs-setup", (_req, res) => { 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) => { router.post("/setup", async (req, res) => {
@@ -125,6 +135,26 @@ export function createSessionRouter(
res.json({ id: user.id, username: user.username, role: user.role }); 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) => { router.post("/logout", (req, res) => {
const token = parseTokenFromCookie(req.headers.cookie); const token = parseTokenFromCookie(req.headers.cookie);
if (token) { if (token) {
@@ -136,13 +166,20 @@ export function createSessionRouter(
router.get("/me", requireAuthInline, (req, res) => { router.get("/me", requireAuthInline, (req, res) => {
const user = req.user!; 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({ res.json({
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
capabilities: [...ctx.capabilities], capabilities: [...ctx.capabilities],
bots: ctx.bots === "all" ? "all" : [...ctx.bots], bots: ctx.bots === "all" ? "all" : [...ctx.bots],
guest: ctx.guest ?? null,
}); });
}); });
+49 -3
View File
@@ -4,10 +4,11 @@ import cookieParser from "cookie-parser";
import request from "supertest"; import request from "supertest";
import pino from "pino"; import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js"; 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 { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore, type AuditStore } from "../../data/audit.js"; import { createAuditStore, type AuditStore } from "../../data/audit.js";
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js"; import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "../middleware/requireAuth.js"; import { createRequireAuth } from "../middleware/requireAuth.js";
import { createUsersRouter } from "./users.js"; import { createUsersRouter } from "./users.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.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(express.json());
app.use(cookieParser()); app.use(cookieParser());
const permissions = createPermissionStore(botDb.db); const permissions = createPermissionStore(botDb.db);
const requireAuth = createRequireAuth(sessions, permissions); const requireAuth = createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode);
const audit = createAuditStore(botDb.db); const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth); app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions)); app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
@@ -152,7 +153,7 @@ describe("users router", () => {
const localApp = express(); const localApp = express();
localApp.use(express.json()); localApp.use(express.json());
localApp.use(cookieParser()); localApp.use(cookieParser());
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db))); localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
localApp.use( localApp.use(
"/api/users", "/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db)) createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
@@ -341,4 +342,49 @@ describe("users router", () => {
expect(perms.status).toBe(200); expect(perms.status).toBe(200);
expect(perms.body).toEqual({ capabilities: [], bots: [] }); 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);
});
});
}); });
+6 -1
View File
@@ -1,7 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.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 { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js"; import type { AuditStore } from "../../data/audit.js";
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js"; import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
@@ -63,6 +63,7 @@ export function createUsersRouter(
router.delete("/:id", (req, res) => { router.delete("/:id", (req, res) => {
const targetId = req.params.id; 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 // Snapshot target's username BEFORE deletion for audit
const target = users.findById(targetId); const target = users.findById(targetId);
if (!target) { if (!target) {
@@ -104,6 +105,7 @@ export function createUsersRouter(
return; return;
} }
const targetId = req.params.id; const targetId = req.params.id;
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
const target = users.findById(targetId); const target = users.findById(targetId);
if (!target) { if (!target) {
res.status(404).json({ error: "not found" }); res.status(404).json({ error: "not found" });
@@ -130,6 +132,7 @@ export function createUsersRouter(
router.patch("/:id/role", (req, res) => { router.patch("/:id/role", (req, res) => {
const targetId = req.params.id; const targetId = req.params.id;
if (targetId === GUEST_USER_ID) { res.status(404).json({ error: "not found" }); return; }
const { role: newRole } = req.body ?? {}; const { role: newRole } = req.body ?? {};
if (newRole !== "admin" && newRole !== "member") { if (newRole !== "admin" && newRole !== "member") {
res.status(400).json({ error: "invalid role" }); res.status(400).json({ error: "invalid role" });
@@ -168,6 +171,7 @@ export function createUsersRouter(
}); });
router.get("/:id/permissions", (req, res) => { 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); const user = users.findById(req.params.id);
if (!user) { if (!user) {
res.status(404).json({ error: "not_found" }); res.status(404).json({ error: "not_found" });
@@ -180,6 +184,7 @@ export function createUsersRouter(
}); });
router.put("/:id/permissions", (req, res) => { 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); const user = users.findById(req.params.id);
if (!user) { if (!user) {
res.status(404).json({ error: "not_found" }); res.status(404).json({ error: "not_found" });
+37
View File
@@ -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();
});
});
+29
View File
@@ -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" });
};
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js"; import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js"; import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js"; import { createPermissionStore } from "../../data/permissions.js";
import { getDefaultConfig } from "../../data/config.js";
import { createRequireAuth } from "./requireAuth.js"; import { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js"; import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.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}`; memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express(); app = express();
app.use(cookieParser()); app.use(cookieParser());
app.use(createRequireAuth(sessions, permissions)); app.use(createRequireAuth(sessions, permissions, () => getDefaultConfig().guestMode));
app.use(requireAdmin); app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true })); app.get("/admin-only", (_req, res) => res.json({ ok: true }));
}); });
+46 -2
View File
@@ -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 express from "express";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import request from "supertest"; import request from "supertest";
@@ -24,7 +24,21 @@ describe("requireAuth middleware", () => {
app = express(); app = express();
app.use(cookieParser()); 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) => { app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user }); res.json({ ok: true, user: (req as any).user });
}); });
@@ -68,4 +82,34 @@ describe("requireAuth middleware", () => {
expect(refreshed).toBeDefined(); expect(refreshed).toBeDefined();
expect(refreshed!).toMatch(/Max-Age=\d+/); 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);
});
}); });
+24 -4
View File
@@ -1,7 +1,8 @@
import type { Request, Response, NextFunction, RequestHandler } from "express"; import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js"; import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } 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 { import {
validateSessionFromHeaders, validateSessionFromHeaders,
extractSessionToken, extractSessionToken,
@@ -13,14 +14,19 @@ declare module "express-serve-static-core" {
user?: { user?: {
id: string; id: string;
username: string; username: string;
role: "admin" | "member"; role: "admin" | "member" | "guest";
capabilities?: Set<string>; capabilities?: Set<string>;
bots?: "all" | 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) { return function requireAuth(req: Request, res: Response, next: NextFunction) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions); const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) { if (!result) {
@@ -28,13 +34,27 @@ export function createRequireAuth(sessions: SessionStore, permissions: Permissio
res.status(401).json({ error: "unauthenticated" }); res.status(401).json({ error: "unauthenticated" });
return; 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 = { req.user = {
id: result.userId, id: result.userId,
username: result.username, username: result.username,
role: result.role, role: result.role,
capabilities: ctx.capabilities, capabilities: ctx.capabilities,
bots: ctx.bots, bots: ctx.bots,
guest: ctx.guest,
}; };
const token = extractSessionToken(req.headers.cookie); const token = extractSessionToken(req.headers.cookie);
if (token) { 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();
});
});
+9
View File
@@ -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
View File
@@ -6,7 +6,7 @@ import { WebSocketServer } from "ws";
import type { BotManager } from "../bot/manager.js"; import type { BotManager } from "../bot/manager.js";
import type { MusicProvider } from "../music/provider.js"; import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.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 { Logger } from "../logger.js";
import type { CookieStore } from "../music/auth.js"; import type { CookieStore } from "../music/auth.js";
import type { AvatarStore } from "../data/avatars.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 { createPermissionStore } from "../data/permissions.js";
import { createRequireAuth } from "./middleware/requireAuth.js"; import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js"; import { requireAdmin } from "./middleware/requireAdmin.js";
import { requireNotGuest } from "./middleware/requireNotGuest.js";
import { csrfOriginCheck } from "./middleware/csrf.js"; import { csrfOriginCheck } from "./middleware/csrf.js";
import { createRateLimit } from "./middleware/rateLimit.js"; import { createRateLimit } from "./middleware/rateLimit.js";
import { validateSessionFromHeaders } from "./auth/validateSession.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/login", loginLimit);
app.use("/api/session/setup", setupLimit); 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 ─────────────────────────────── // ─── 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", csrfOriginCheck);
app.use("/api", requireAuth); app.use("/api", requireAuth);
// ─── Protected routes ─────────────────────────────────────────────────── // ─── 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( app.use(
"/api/bot", "/api/bot",
createBotRouter( createBotRouter(
@@ -112,6 +118,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
logger, logger,
options.database, options.database,
options.avatarStore, options.avatarStore,
(cfg) => onGuestPolicyChanged(cfg),
) )
); );
app.use( app.use(
@@ -126,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth", "/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) 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 // admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
@@ -175,12 +182,27 @@ export function createWebServer(options: WebServerOptions): WebServer {
socket.destroy(); socket.destroy();
return; 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) => { 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); 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 ────────────────────────────────────────── // ─── Session cleanup interval ──────────────────────────────────────────
let cleanupTimer: ReturnType<typeof setInterval> | null = null; let cleanupTimer: ReturnType<typeof setInterval> | null = null;
@@ -206,7 +228,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
clearInterval(cleanupTimer); clearInterval(cleanupTimer);
cleanupTimer = null; cleanupTimer = null;
} }
cleanupWs(); controller.cleanup();
wss.close(); wss.close();
server.close(); server.close();
}, },
+107
View File
@@ -7,6 +7,7 @@ import { createDatabase, type BotDatabase } from "../data/database.js";
import { createUserStore } from "../data/users.js"; import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js"; import { createSessionStore } from "../data/sessions.js";
import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js"; import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js";
import { setupWebSocket } from "./websocket.js";
function buildServer(sessions: ReturnType<typeof createSessionStore>) { function buildServer(sessions: ReturnType<typeof createSessionStore>) {
const app = express(); const app = express();
@@ -72,3 +73,109 @@ describe("WebSocket auth at upgrade", () => {
ws.close(); 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);
});
});
+59 -14
View File
@@ -3,13 +3,34 @@ import type { BotManager } from "../bot/manager.js";
import type { BotInstance } from "../bot/instance.js"; import type { BotInstance } from "../bot/instance.js";
import type { Logger } from "../logger.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( export function setupWebSocket(
wss: WebSocketServer, wss: WebSocketServer,
botManager: BotManager, botManager: BotManager,
logger: Logger logger: Logger
): () => void { ): WebSocketController {
const clients = new Set<WebSocket>(); 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) */ /** Track which bot instances have listeners attached (keyed by id, storing ref) */
const attachedBots = new Map<string, { const attachedBots = new Map<string, {
bot: BotInstance; bot: BotInstance;
@@ -22,7 +43,10 @@ export function setupWebSocket(
clients.add(ws); clients.add(ws);
logger.debug("WebSocket client connected"); 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.send(JSON.stringify({ type: "init", bots }));
ws.on("close", () => { ws.on("close", () => {
@@ -36,15 +60,15 @@ export function setupWebSocket(
}); });
}); });
const broadcast = (data: object) => { const broadcast = (data: object, botId?: string) => {
const message = JSON.stringify(data); const message = JSON.stringify(data);
for (const client of clients) { for (const client of clients) {
if (client.readyState === WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) continue;
try { if (botId !== undefined && !visibleToClient(client, botId)) continue;
client.send(message); try {
} catch { client.send(message);
clients.delete(client); } catch {
} clients.delete(client);
} }
} }
}; };
@@ -72,7 +96,7 @@ export function setupWebSocket(
botId: bot.id, botId: bot.id,
status: bot.getStatus(), status: bot.getStatus(),
queue: bot.getQueue(), queue: bot.getQueue(),
}); }, bot.id);
}; };
const onConnected = () => { const onConnected = () => {
@@ -80,7 +104,7 @@ export function setupWebSocket(
type: "botConnected", type: "botConnected",
botId: bot.id, botId: bot.id,
status: bot.getStatus(), status: bot.getStatus(),
}); }, bot.id);
}; };
const onDisconnected = () => { const onDisconnected = () => {
@@ -88,7 +112,7 @@ export function setupWebSocket(
type: "botDisconnected", type: "botDisconnected",
botId: bot.id, botId: bot.id,
status: bot.getStatus(), status: bot.getStatus(),
}); }, bot.id);
}; };
bot.on("stateChange", onStateChange); 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 // React when a bot is removed: detach its listener and tell clients to drop it
const onBotInstanceRemoved = (id: string) => { const onBotInstanceRemoved = (id: string) => {
detachBotListener(id); detachBotListener(id);
broadcast({ type: "botRemoved", botId: id }); broadcast({ type: "botRemoved", botId: id }, id);
}; };
botManager.on("botInstanceRemoved", onBotInstanceRemoved); botManager.on("botInstanceRemoved", onBotInstanceRemoved);
@@ -136,7 +160,7 @@ export function setupWebSocket(
}, 5000); }, 5000);
ensureAllBotsAttached(); ensureAllBotsAttached();
return () => { const cleanup = () => {
clearInterval(intervalId); clearInterval(intervalId);
botManager.removeListener("botInstance", onBotInstance); botManager.removeListener("botInstance", onBotInstance);
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved); botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
@@ -145,4 +169,25 @@ export function setupWebSocket(
detachBotListener(id); 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
View File
@@ -19,22 +19,22 @@
<div class="m-player-artist">{{ currentSong.artist }}</div> <div class="m-player-artist">{{ currentSong.artist }}</div>
</div> </div>
<div class="m-player-controls" @click.stop> <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" /> <Icon icon="mdi:skip-previous" />
</button> </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'" /> <Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button> </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" /> <Icon icon="mdi:skip-next" />
</button> </button>
<button class="m-player-btn" @click="cycleMobileMode"> <button v-if="canModeCtl" class="m-player-btn" @click="cycleMobileMode">
<Icon :icon="mobileModeIcon" /> <Icon :icon="mobileModeIcon" />
</button> </button>
<button class="m-player-btn" @click="toggleMobileQueue"> <button class="m-player-btn" @click="toggleMobileQueue">
<Icon icon="mdi:playlist-music" /> <Icon icon="mdi:playlist-music" />
</button> </button>
<button class="m-player-btn" @click="toggleMobileVolume"> <button v-if="canTransport" class="m-player-btn" @click="toggleMobileVolume">
<Icon icon="mdi:volume-high" /> <Icon icon="mdi:volume-high" />
</button> </button>
</div> </div>
@@ -66,7 +66,7 @@
<Icon icon="mdi:music-box-multiple" class="tab-icon" /> <Icon icon="mdi:music-box-multiple" class="tab-icon" />
<span class="tab-label">音乐库</span> <span class="tab-label">音乐库</span>
</RouterLink> </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" /> <Icon icon="mdi:cog" class="tab-icon" />
<span class="tab-label">设置</span> <span class="tab-label">设置</span>
</RouterLink> </RouterLink>
@@ -80,6 +80,7 @@ import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js'; import { usePlayerStore } from './stores/player.js';
import { useWebSocket } from './composables/useWebSocket.js'; import { useWebSocket } from './composables/useWebSocket.js';
import { useSession } from './composables/useSession.js';
import Navbar from './components/Navbar.vue'; import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue'; import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue'; import CoverArt from './components/CoverArt.vue';
@@ -87,6 +88,12 @@ import Toast from './components/Toast.vue';
import Queue from './components/Queue.vue'; import Queue from './components/Queue.vue';
const playerStore = usePlayerStore(); 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 theme = computed(() => playerStore.theme);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
+3 -2
View File
@@ -98,14 +98,14 @@
</div> </div>
</div> </div>
<RouterLink to="/settings" class="settings-btn"> <RouterLink v-if="!session.isGuest.value" to="/settings" class="settings-btn">
<Icon icon="mdi:cog" /> <Icon icon="mdi:cog" />
</RouterLink> </RouterLink>
<div v-if="session.currentUser.value" class="nav-user"> <div v-if="session.currentUser.value" class="nav-user">
<span class="nav-user-name">{{ session.currentUser.value.username }}</span> <span class="nav-user-name">{{ session.currentUser.value.username }}</span>
<span class="nav-user-role" :class="`role-${session.currentUser.value.role}`"> <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> </span>
<button class="nav-user-logout" @click="onLogout" title="退出"> <button class="nav-user-logout" @click="onLogout" title="退出">
<Icon icon="mdi:logout" /> <Icon icon="mdi:logout" />
@@ -769,4 +769,5 @@ onUnmounted(() => {
} }
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; } .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-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> </style>
+15 -12
View File
@@ -3,10 +3,10 @@
<Queue :open="showQueue" @close="showQueue = false" /> <Queue :open="showQueue" @close="showQueue = false" />
<div class="player-bar frosted-glass"> <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 <div
class="progress-bar-container" class="progress-bar-container"
:class="{ 'no-seek': !canControl }" :class="{ 'no-seek': !canTransport }"
ref="progressBarRef" ref="progressBarRef"
@click="onProgressClick" @click="onProgressClick"
@mousemove="onProgressHover" @mousemove="onProgressHover"
@@ -38,18 +38,18 @@
<div class="player-center"> <div class="player-center">
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span> <span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
<!-- Transport controls require player.control --> <!-- Transport controls: per-button gating honoring guest flags -->
<template v-if="canControl"> <template v-if="canControl || canTransport || canSkip || canModeCtl">
<button class="control-btn" @click="store.prev()"> <button v-if="canControl" class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" /> <Icon icon="mdi:skip-previous" />
</button> </button>
<button class="play-btn" @click="togglePlay"> <button v-if="canTransport" class="play-btn" @click="togglePlay">
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" /> <Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button> </button>
<button class="control-btn" @click="store.next()"> <button v-if="canSkip" class="control-btn" @click="store.next()">
<Icon icon="mdi:skip-next" /> <Icon icon="mdi:skip-next" />
</button> </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" /> <Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span> <span class="mode-label">{{ modeLabel }}</span>
</button> </button>
@@ -58,8 +58,8 @@
</div> </div>
<div class="player-right"> <div class="player-right">
<!-- Volume requires player.control --> <!-- Volume gated on transport -->
<template v-if="canControl"> <template v-if="canTransport">
<Icon icon="mdi:volume-high" class="volume-icon" /> <Icon icon="mdi:volume-high" class="volume-icon" />
<input <input
type="range" type="range"
@@ -94,8 +94,11 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const showQueue = ref(false); const showQueue = ref(false);
const { can } = useSession(); const { can, guestCan } = useSession();
const canControl = computed(() => can('player.control')); 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 store = usePlayerStore();
const activeBot = computed(() => store.activeBot); const activeBot = computed(() => store.activeBot);
@@ -144,7 +147,7 @@ function updateProgress() {
} }
async function onProgressClick(e: MouseEvent) { 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; const bar = progressBarRef.value;
if (!bar) return; if (!bar) return;
const rect = bar.getBoundingClientRect(); const rect = bar.getBoundingClientRect();
+3 -3
View File
@@ -4,7 +4,7 @@
<h3 class="queue-title">播放队列</h3> <h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span> <span class="queue-count">{{ botQueue.length }} 首</span>
<button <button
v-if="botQueue.length > 0 && can('player.control')" v-if="botQueue.length > 0 && (can('player.control') || guestCan('removeClear'))"
class="clear-btn" class="clear-btn"
@click="clearAndStop" @click="clearAndStop"
title="清空队列并停止播放" title="清空队列并停止播放"
@@ -33,7 +33,7 @@
<div class="queue-song-name">{{ song.name }}</div> <div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</div> <div class="queue-song-artist">{{ song.artist }}</div>
</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" /> <Icon icon="mdi:close" />
</button> </button>
</div> </div>
@@ -58,7 +58,7 @@ defineEmits<{
}>(); }>();
const store = usePlayerStore(); const store = usePlayerStore();
const { can } = useSession(); const { can, guestCan } = useSession();
const botQueue = computed(() => store.queue); const botQueue = computed(() => store.queue);
// Fetch queue when panel opens // Fetch queue when panel opens
+11 -4
View File
@@ -1,5 +1,5 @@
<template> <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> <div class="song-index">{{ index }}</div>
<CoverArt :url="song.coverUrl" :size="36" :radius="6" /> <CoverArt :url="song.coverUrl" :size="36" :radius="6" />
<div class="song-info"> <div class="song-info">
@@ -15,13 +15,13 @@
<div class="song-album">{{ song.album }}</div> <div class="song-album">{{ song.album }}</div>
<div class="song-duration">{{ formatDuration(song.duration) }}</div> <div class="song-duration">{{ formatDuration(song.duration) }}</div>
<div class="song-actions"> <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" /> <Icon icon="mdi:play" />
</button> </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" /> <Icon icon="mdi:playlist-play" />
</button> </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" /> <Icon icon="mdi:playlist-plus" />
</button> </button>
</div> </div>
@@ -29,9 +29,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import CoverArt from './CoverArt.vue'; import CoverArt from './CoverArt.vue';
import { Song } from '../stores/player.js'; import { Song } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
defineProps<{ defineProps<{
song: Song; song: Song;
@@ -39,6 +41,11 @@ defineProps<{
active?: boolean; 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<{ defineEmits<{
play: []; play: [];
playNext: []; playNext: [];
+23 -1
View File
@@ -3,13 +3,15 @@ import { ref, computed, readonly } from "vue";
interface User { interface User {
id: string; id: string;
username: string; username: string;
role: 'admin' | 'member'; role: 'admin' | 'member' | 'guest';
capabilities?: string[]; capabilities?: string[];
bots?: "all" | string[]; bots?: "all" | string[];
guest?: Record<string, boolean> | null;
} }
const currentUser = ref<User | null>(null); const currentUser = ref<User | null>(null);
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
const guestAllowed = ref(false);
const ready = ref(false); const ready = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -37,6 +39,7 @@ async function refreshNeedsSetup(): Promise<void> {
if (res.ok) { if (res.ok) {
const body = await res.json(); const body = await res.json();
needsSetup.value = Boolean(body.needsSetup); 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(); 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> { async function setup(username: string, password: string): Promise<void> {
const res = await fetch("/api/session/setup", { const res = await fetch("/api/session/setup", {
method: "POST", method: "POST",
@@ -104,6 +117,11 @@ function can(cap: string): boolean {
return !!u && (u.role === "admin" || (u.capabilities ?? []).includes(cap)); 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 { function canControlBot(botId: string): boolean {
const u = currentUser.value; const u = currentUser.value;
if (!u) return false; if (!u) return false;
@@ -115,14 +133,18 @@ export function useSession() {
return { return {
currentUser: readonly(currentUser), currentUser: readonly(currentUser),
needsSetup: readonly(needsSetup), needsSetup: readonly(needsSetup),
guestAllowed: readonly(guestAllowed),
isAuthenticated: computed(() => currentUser.value !== null), isAuthenticated: computed(() => currentUser.value !== null),
isAdmin: computed(() => currentUser.value?.role === 'admin'), isAdmin: computed(() => currentUser.value?.role === 'admin'),
isGuest: computed(() => currentUser.value?.role === 'guest'),
ready: readonly(ready), ready: readonly(ready),
refresh, refresh,
login, login,
logout, logout,
setup, setup,
continueAsGuest,
can, can,
guestCan,
canControlBot, canControlBot,
}; };
} }
+6
View File
@@ -58,6 +58,12 @@ router.beforeEach(async (to) => {
return { name: 'login', query: { next: to.fullPath } }; 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). // Navigation is allowed to proceed to `to` past here (auth/setup redirects above take precedence).
// Sync + preserve the dedicated-link scope carried by ?bot. // Sync + preserve the dedicated-link scope carried by ?bot.
const store = usePlayerStore(); const store = usePlayerStore();
+5 -1
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import axios from 'axios'; import axios from 'axios';
import { resolveScopedBot } from './scope.js'; import { resolveScopedBot } from './scope.js';
import { useSession } from '../composables/useSession.js';
export interface Song { export interface Song {
id: string; id: string;
@@ -334,7 +335,10 @@ export const usePlayerStore = defineStore('player', {
async playSong(song: Song) { async playSong(song: Song) {
if (!this.activeBotId) return; 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) { if (res.data?.ok === false && res.data?.message) {
this.notify(res.data.message, 'error'); this.notify(res.data.message, 'error');
} }
+32
View File
@@ -13,6 +13,15 @@
<p v-if="error" class="auth-error">{{ error }}</p> <p v-if="error" class="auth-error">{{ error }}</p>
<button type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button> <button type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button>
</form> </form>
<button
v-if="session.guestAllowed.value"
type="button"
class="guest-btn"
:disabled="loading"
@click="enterAsGuest"
>
以游客身份进入
</button>
</div> </div>
</template> </template>
@@ -43,12 +52,28 @@ async function submit() {
loading.value = false; 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.auth-page { .auth-page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--bg-primary); background: var(--bg-primary);
@@ -75,4 +100,11 @@ async function submit() {
} }
.auth-card button:disabled { opacity: 0.6; cursor: progress; } .auth-card button:disabled { opacity: 0.6; cursor: progress; }
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; } .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> </style>
+100
View File
@@ -455,6 +455,55 @@
</label> </label>
</section> </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) --> <!-- Bot Profile (TeamSpeak Behavior) -->
<section v-if="can('bot.manage')" class="settings-section"> <section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2> <h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
@@ -977,6 +1026,7 @@ async function loadIdleTimeout() {
const res = await axios.get('/api/bot/settings'); const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0; idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false; autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
applyGuestModeFromServer(res.data.guestMode);
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -992,6 +1042,56 @@ async function saveAutoPause() {
} catch { /* ignore */ } } 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 --- // --- Bot Profile config ---
interface ProfileConfig { interface ProfileConfig {
avatarEnabled: boolean; avatarEnabled: boolean;