Compare commits

...
111 Commits
Author SHA1 Message Date
TIANYAO ZHANG ba11519fdb Merge pull request #97 from ZHANGTIANYAO1/fix/autopause-occupancy-unknown
fix(auto-pause): don't treat failed clientlist as empty channel; default OFF
2026-06-16 23:56:56 +08:00
saopig1andClaude Opus 4.8 d3fd547ea0 fix(auto-pause): never treat a failed clientlist as "channel empty"; default OFF
Auto-pause within the first seconds of playback (and re-pause after a manual
play) whenever a listener is actually in the channel.

Root cause (confirmed live against a TS3 server): the full-client
`clientlist -uid -away -voice -groups` command TIMES OUT when other clients are
present in the bot's channel. `getClientsInChannel()` catches the error and
returns `[]`, so the occupancy callers computed `userCount = [].length - 1 = -1`,
which `decideOccupancyAction` reads as `-1 <= 0` → "channel empty" → pause. With
the bot alone, clientlist succeeds (returns just the bot), so the bug only
surfaced when someone was listening — exactly the report.

Fix: a connected bot is always a member of its own channel, so a valid query
returns >= 1 (itself). A length of 0 therefore means the query FAILED, not that
the channel is empty. New pure helper `occupancyFromClientList()` maps a
0-length result to `null` ("occupancy unknown"); `refreshOccupancy()` and the
30s idle poller skip the auto-pause / idle-disconnect decision when the count is
unknown instead of mis-reading it as empty. This also removes a latent
false-positive idle-disconnect on the same failed query.

Also default `autoPauseOnEmpty` to OFF (occupancy detection is unreliable on
some servers); users can opt in from Settings.

Verified live with two clients in one channel: clientlist returns 0 → helper
returns null → no false pause (control: bot alone returns 1 → 0 others, normal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:55:44 +08:00
TIANYAO ZHANG 75f09694a3 Merge pull request #96 from ZHANGTIANYAO1/fix/song-ref-url-corner-cases
fix(song-ref): NetEase collection URLs + trailing-punct ids (#90 follow-up)
2026-06-16 22:06:41 +08:00
saopig1andClaude Opus 4.8 6d56f1f371 fix(song-ref): don't misparse NetEase collection URLs / trailing-punct ids (#90 follow-up)
Corner-case review of the #90 play-by-id parser found two reachable issues:

- A NetEase playlist/album/artist/toplist/djradio share URL (which reuses ?id=)
  was matched as a SONG id, so pasting one into !play called getSongDetail() on
  a collection id and returned a confusing 'No song found' instead of falling
  back to a normal search. Guard the id= branch to exclude collection pages.
- The id: prefix captured trailing punctuation from a chat paste ('id:12345.' ->
  '12345.'), which then failed to resolve. Strip trailing .,;)] from the id.

Both fall back to safe behavior (plain search / clean id). Tests added for
collection URLs and pasted ids with punctuation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:06:17 +08:00
TIANYAO ZHANG 64328d7bdf Merge pull request #95 from ZHANGTIANYAO1/feat/play-by-id-and-search
feat(play): pick same-name songs via !search / #N / id: / URL (#90)
2026-06-16 21:52:36 +08:00
saopig1andClaude Opus 4.8 287dd240b1 feat(play): pick same-name songs via !search / #N / id: / URL (#90)
!play/!add/!playnext only ever searched with limit 1, so a same-name song could
never be reached from chat (e.g. 'Die For You' always returned the most popular
match, not The Weeknd's). Add three disambiguation paths via a shared resolver:

- !search <name> — list the top matches (numbered, with id), remembered per bot
- !play #N / !add #N — play/queue the Nth result of the last !search
- !play id:<id> and pasted NetEase/QQ/BiliBili song URLs — play an exact song

Pure parsing (parseSongRef / parseSelectionIndex) is unit-tested; plain-text
search keeps the historical top-hit behavior. WebUI search (20 results) already
allowed picking same-name songs and is unchanged.

Fixes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:49:45 +08:00
TIANYAO ZHANG 22ec7328fa Merge pull request #94 from ZHANGTIANYAO1/docs/update-readme-merged-features
docs(readme): document new features (permissions/favorites/scope/auto-pause/QQ FM) + recent fixes
2026-06-16 21:26:36 +08:00
saopig1andClaude Opus 4.8 540641700d docs(readme): document permissions, favorites, scope, auto-pause, QQ FM + recent fixes
Update the README to reflect everything merged recently:
- feature list: fine-grained permissions (capabilities + per-bot allow-list),
  local favorites, dedicated-link scope, channel-empty auto-pause, QQ radar FM
- first-run + WebUI page table + !fm command (-q) + architecture tree (new modules)
- config section: config.json now lives in data/config.json (+ migration note),
  complete the example with idleTimeoutMinutes/publicUrl/trustProxy
- FAQ: fine-grained member permissions, favorites, dedicated link, auto-pause
- changelog: new entry for the feature batch + bug fixes #84/#86/#89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:26:08 +08:00
saopig1 3422d45eeb Merge PR #93: fix(audio) smooth, monotonic volume curve (#84)
# Conflicts:
#	src/audio/player.test.ts
#	src/audio/player.ts
2026-06-16 16:29:27 +08:00
saopig1 f7626f40b3 Merge PR #92: fix(player) recover B站 long-stream playback stalls (#89) 2026-06-16 16:26:15 +08:00
saopig1 de2c956c31 Merge PR #91: fix(config) generate config.json under the persisted data dir (#86) 2026-06-16 16:26:15 +08:00
saopig1andClaude Opus 4.8 3802c90d2d fix(audio): smooth, monotonic volume curve (#84)
applyVolume() mapped 0-100 with a two-piece, discontinuous curve: gain =
(vol/100)*0.2 for vol<100 (so the whole 0-99 range only spanned 0..0.198, making
80->99 feel flat) then a raw passthrough at vol===100 (a ~5x jump to full
loudness). That produced the reported dead zone + sudden ear-blast at 100.

Replace it with a single continuous, strictly-monotonic curve
volumeToFactor(v) = 0.2*x + 0.8*x^8 (x = v/100): 0 at 0, exactly 1.0 at 100, no
flat region and no discontinuity, so the slider feels proportional and full
loudness is still reserved at 100. Extracted as an exported pure function and
unit-tested (boundaries, strict monotonicity, dead-zone removal, no jump at 100).

Note: per the maintainer's note on #84 the >80% suppression was intentional
ear-protection; this change makes the upper range (above ~75%) audibly louder
than before in exchange for a proportional slider — applied per maintainer
decision.

Fixes #84

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:21:09 +08:00
saopig1andClaude Opus 4.8 839f777a75 fix(player): recover B站 long-stream playback stalls instead of going silent (#89)
Two issues caused a long BiliBili stream to stop partway (~16 min) and never resume:

1. FFmpeg lacked -reconnect_at_eof. B站 CDN sessions can close the connection
   mid-file (premature EOF); without this flag FFmpeg treats that EOF as
   end-of-input and stops. Added it (HTTP only) so FFmpeg re-issues a Range
   request and finishes the stream.

2. The frame loop only ended a live-but-silent FFmpeg when within 5s of the song
   end (isNearEnd). Far from the end, emptyFrameAttempts grew unbounded, no
   trackEnd was emitted, and audio went permanently silent ('无法继续播放').
   Added a far-from-end stall watchdog (MAX_STALL_ATTEMPTS ~= 60s) via a pure,
   tested shouldEndOnStall() helper, so a genuinely dead stream advances instead
   of hanging — while a transient underrun on a healthy stream is left alone.

Tests: assert -reconnect_at_eof 1 is present (before -i) for HTTP and absent for
local files; shouldEndOnStall covers near-end fast end, far-from-end no-false-skip,
and far-from-end eventual recovery.

Fixes #89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:46:08 +08:00
saopig1andClaude Opus 4.8 dd6affca6d fix(config): store config.json under the persisted data dir (#86)
CONFIG_PATH resolved to ROOT_DIR/config.json (/app/config.json in Docker), but only
DATA_DIR (/app/data) is the mounted volume — every other artifact (DB, cookies, logs,
avatars) already lives under DATA_DIR. So on first run the default config was written
into the ephemeral image layer (never appearing in the volume), and a manually-placed
data/config.json was ignored because the bot read/wrote the root path.

- Move CONFIG_PATH to DATA_DIR/config.json so it lands in the volume and manual edits
  take effect.
- Add migrateLegacyConfig(): one-time move of an existing root-level config.json into
  the data dir, so existing local installs keep their settings (no silent reset).
- Tests for first-run persistence + the three migration cases.
- README directory tree updated to data/config.json.

Fixes #86

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:33:59 +08:00
saopig1 bea2f92508 Merge PR #80: feat(perm) fine-grained account permissions
Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
  requirePermission('player.control') so the new control endpoint honors #80's
  permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
  /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
  POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
  displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
  user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
  two-arg signature.

#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
  identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
2026-06-16 15:05:57 +08:00
saopig1 f19f56a666 fix(favorites): error handling + state hydration + input validation [#87 review]
- addFavorite/removeFavorite now wrap axios in try/catch: a 409 (already favorited,
  common on a stale heart) or 404 resyncs instead of throwing an unhandled promise
  rejection; other errors surface a toast.
- fetchHomeData refreshes favorites BEFORE the TTL cache-return (was appended after
  the early return, so warm-cache loads never refreshed); removed the now-redundant
  trailing call. App.vue onMounted also hydrates favorites so deep-links to Search/
  Playlist show correct hearts.
- favorites API: GET /check rejects non-string (array) query params with 400 instead
  of a 500; POST defaults req.body to {} so a missing JSON body yields the intended 400.
2026-06-16 14:53:18 +08:00
saopig1 140020f63a Merge PR #87: local favorites feature
# Conflicts:
#	web/src/stores/player.ts
2026-06-16 14:50:25 +08:00
saopig1 6e10764d28 fix(qq-fm): guard FM start when offline + reset radar page on re-login [#88 review]
- startFm() now refuses with 'Bot is not connected to TeamSpeak' before mutating the
  queue, so POST /api/player/:id/fm can no longer wipe the queue and flip the bot into
  FM mode while disconnected (the !fm chat command already had this guard).
- The /fm route's success detection also treats 'not connected' as a failure so the
  toast type is correct.
- QQMusicProvider.setCookie() resets radarPage to 1 so a re-login with a different
  account no longer inherits the previous account's radar pagination cursor.
2026-06-16 14:48:01 +08:00
saopig1 9bfe831022 Merge PR #88: feat(qq) QQ Music radar / personal FM stream 2026-06-16 14:45:51 +08:00
saopig1 bbdd4cbc78 fix(autopause): decouple auto-pause toggle from idle-timeout save [#81 review]
The checkbox @change was wired to saveIdleTimeout, which POSTed BOTH idleTimeoutMinutes
and autoPauseOnEmpty: toggling silently committed an unsaved idle edit, and an empty/
non-numeric idle field made the combined POST 400 (errors swallowed), leaving the
checkbox flipped but not persisted. Give the toggle its own saveAutoPause() sending only
the boolean; 保存 now sends only idleTimeoutMinutes.
2026-06-16 14:45:01 +08:00
saopig1 c57cd35f09 Merge PR #81: feat(autopause) pause when bot channel empties 2026-06-16 14:43:54 +08:00
saopig1 1a1f365cf1 fix(scope): clear scope when the scoped bot is removed [#82 review]
removeBotStatus (botRemoved WS frame or admin deleting the scoped bot) left
scopedBotId dangling: isScoped stayed true, displayedBots went empty, and activeBot
silently fell back to bots[0], locking the UI onto a phantom bot. Clear the scope
when the scoped bot disappears.
2026-06-16 14:43:18 +08:00
saopig1 d1544bab42 Merge PR #82: feat(scope) lock UI to a bot via dedicated link 2026-06-16 14:42:32 +08:00
lTinchl e0d17cf404 feat(qq): add radar FM stream 2026-06-06 21:09:57 +08:00
Kun-ovO b2de607391 本地收藏功能 2026-05-31 23:27:02 +08:00
saopig1 355793b7e6 fix(scope): keep mounting if initial navigation errors (parity with old unconditional mount) 2026-05-30 15:25:20 +08:00
saopig1 593b42830c fix(scope): await router.isReady before mount so refreshed ?bot locks the right bot 2026-05-30 15:22:51 +08:00
saopig1andClaude Opus 4.8 463a8e2f8a feat(scope): lock Navbar selector to scoped bot + apply scope on load
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:17:01 +08:00
saopig1 88ac7d2a68 feat(scope): dedicated link seeds ?bot scope instead of bare redirect 2026-05-30 15:14:54 +08:00
saopig1 53d28de17e feat(scope): router guard syncs + preserves ?bot across navigation 2026-05-30 15:11:42 +08:00
saopig1andClaude Opus 4.8 ca07ebc3b7 feat(scope): player store scopedBotId + resolveScopedBot helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:09:03 +08:00
saopig1 bf1fb1fd88 docs(plan): dedicated-link bot scoping implementation plan (#79 items 2,4) 2026-05-30 15:07:26 +08:00
saopig1andClaude Opus 4.8 846fb2c28c docs(spec): dedicated-link bot scoping + refresh fix design (#79 items 2,4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:06:09 +08:00
saopig1 34655e5f50 feat(autopause): autoPauseOnEmpty toggle in Settings 2026-05-30 14:58:35 +08:00
saopig1andClaude Opus 4.8 491bc53dec feat(autopause): expose autoPauseOnEmpty via /api/bot/settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:55:18 +08:00
saopig1 8f5bb26b3a feat(autopause): re-emit client enter/leave/move for instant pause/resume 2026-05-30 14:50:52 +08:00
saopig1andClaude Opus 4.8 4ba4b013b0 feat(autopause): drive pause/resume from channel occupancy in BotInstance
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:46:50 +08:00
saopig1 484202e90d feat(autopause): pure occupancy-decision function 2026-05-30 14:44:28 +08:00
saopig1 9f0ac74fbc docs(plan): auto-pause on empty channel implementation plan (#79 item 3) 2026-05-30 14:43:38 +08:00
saopig1andClaude Opus 4.8 51c954993a docs(spec): auto-pause on empty channel design (#79 item 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:42:13 +08:00
saopig1andClaude Opus 4.8 907a6651f5 fix(perm): access-check before bot-existence (no 403/404 leak); label permissions audit action
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:12:04 +08:00
saopig1andClaude Opus 4.8 1ca1ca9d0c test(perm): assert /me capabilities+bots; dry backfill token list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:06:59 +08:00
saopig1andClaude Opus 4.8 d70664067c feat(perm): admin permission editor in user management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:03:54 +08:00
saopig1 5177b41951 feat(perm): gate Queue.vue remove/clear/play-at controls by capability 2026-05-30 14:00:27 +08:00
saopig1 bb86f7e9ed feat(perm): gate idle-timeout + bot-profile settings on bot.manage 2026-05-30 13:56:46 +08:00
saopig1andClaude Opus 4.8 221f7c8dcf feat(perm): hide UI a member lacks capability for
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:55:31 +08:00
saopig1 cf76e0f69a feat(perm): frontend session capabilities + can()/canControlBot() 2026-05-30 13:51:34 +08:00
saopig1andClaude Opus 4.8 abf60141d9 feat(perm): one-time backfill of existing members to full access
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:47:44 +08:00
saopig1andClaude Opus 4.8 ce15f36e5e feat(perm): admin permissions API + audit + new-member basic tier
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:44:12 +08:00
saopig1andClaude Opus 4.8 1f0f162f66 feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:38:57 +08:00
saopig1andClaude Opus 4.8 cd6f2c6078 feat(perm): enforce capabilities + bot access on action routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:32:08 +08:00
saopig1andClaude Opus 4.8 696b224f8d feat(perm): load capabilities + bot access onto req.user; expose via /me
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:24 +08:00
saopig1 7a8666efbb feat(perm): resolvePermissionContext (admin = super-user) 2026-05-30 13:17:06 +08:00
saopig1 f0c979ce71 docs(spec): use 'capabilities' consistently for req.user field 2026-05-30 13:15:44 +08:00
saopig1 d810a2ec0f test(perm): cover requireBotAccess 401 + missing-param cases 2026-05-30 13:15:01 +08:00
saopig1andClaude Opus 4.8 554501cc74 feat(perm): requirePermission + requireBotAccess middleware
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:10:39 +08:00
saopig1andClaude Opus 4.8 aaf6ba2ab4 feat(perm): permission store + capability tokens + tables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:05:38 +08:00
saopig1andClaude Opus 4.8 547aaa304e docs(plan): account permissions implementation plan (#79-E)
11 TDD tasks: permission store + tables, requirePermission/requireBotAccess, req.user wiring, route enforcement, bot-list filtering, admin API + audit, one-time member backfill, and frontend gating + permission editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:49:57 +08:00
saopig1andClaude Opus 4.8 f09a589940 docs(spec): fine-grained account permissions design (#79-E)
Capability flags (player.control/player.queue/bot.manage/platform.auth/quality) + per-member bot allow-list, layered under the existing member role; admin is super-user. Backend-enforced via requirePermission/requireBotAccess; existing members backfilled to full on upgrade, new members get a basic tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:44:24 +08:00
TIANYAO ZHANG a861b41809 Merge pull request #78 from ZHANGTIANYAO1/feat/shuffle-bag-random-modes
feat(queue): 随机循环改为洗牌袋,每首歌播完一轮再重复 (优化随机循环逻辑)
2026-05-29 22:16:35 +08:00
saopig1andClaude Opus 4.8 e9b3ba0075 feat(queue): shuffle-bag random modes so every song plays before repeating
随机循环 (rloop) used true random-with-replacement, so some songs repeated constantly while others were starved (issue #70). Both random modes now draw from a shuffle bag: every song plays exactly once per cycle in random order. They differ only at cycle end — 随机 (random) stops, 随机循环 (rloop) reshuffles and continues, excluding the just-played song from the first pick of the new cycle to avoid a back-to-back repeat across the boundary. Songs added mid-cycle stay eligible within the current cycle.

随机's visible behavior is unchanged (it already avoided in-cycle repeats); the two branches now share one selection path. Adds shuffle-bag tests (per-cycle permutation, even distribution, no cross-boundary repeat, mid-cycle add).

Closes #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:08:40 +08:00
TIANYAO ZHANG 0401534b88 Merge pull request #77 from ZHANGTIANYAO1/fix/webui-referrer-policy-csrf
fix(web): referrer-policy same-origin 修复扫码登录不弹二维码 + cookie 无法保存
2026-05-29 21:30:35 +08:00
saopig1andClaude Opus 4.8 f720da49d6 fix(web): referrer-policy same-origin so same-origin POSTs keep a real Origin
no-referrer downgraded the Origin header to the literal "null" on same-origin non-GET requests (per the WHATWG Fetch "Append a request Origin header" algorithm), which the /api/* csrfOriginCheck then rejected with 403 "bad origin" — silently breaking QR login, cookie save, and every other WebUI POST/PUT/DELETE/PATCH (playback, bot management, user admin). /api/session/* was unaffected because it mounts before the CSRF gate, which is why WebUI login still worked.

same-origin keeps the real Origin on same-origin requests (CSRF passes) while still sending no Referer cross-origin, so B站/NetEase/QQ CDN cover thumbnails keep loading. Adds referrer-policy.test.ts pinning the policy and a csrf.test.ts case for the Origin: "null" rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:25:20 +08:00
TIANYAO ZHANG 3abb468cca Merge pull request #75 from ZHANGTIANYAO1/fix/ui-overflow-and-textarea-resize
fix(web): long artist + B站 card grid + B站 image referer
2026-05-27 20:10:21 +08:00
saopig1andClaude Opus 4.7 c8daa14219 fix(web): set no-referrer at document level so B站 cover thumbnails load
Bilibili's CDN (i*.hdslb.com) returns 403 with `x-error-info:
RefererWhite` for image requests whose Referer is not on their
whitelist. `CoverArt.vue` already sets `referrerpolicy="no-referrer"`
on its `<img>` tag, BUT the `.cover-shadow` div renders the same URL
as a CSS `background-image`, which ignores the img attribute and uses
the document default policy (`strict-origin-when-cross-origin` in
modern Firefox/Chrome) — that sends `Referer: http://localhost:3000/`
and triggers the block.

Setting `<meta name="referrer" content="no-referrer">` in index.html
applies no-referrer site-wide: covers <img> tags, CSS background-image
fetches, and anywhere else CDNs check referer. Doesn't affect our
/api/* CSRF middleware because that uses Origin (still sent by the
browser), not Referer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:07:18 +08:00
saopig1andClaude Opus 4.7 81cd8a2bec fix(web): revert textarea + actual culprit was B站热门 card grid
Previous commit misidentified the second bug. Reverting the
Settings.vue `resize: vertical` → `resize: none` change — that
wasn't the issue.

Real fix: `.daily-card` (used by B站热门 and 每日推荐 sections in
Home.vue) is a CSS Grid cell with default `min-width: auto`, which
refuses to shrink below its content. A long Bilibili video title
inside `.daily-name` expanded the cell past its 1fr column, breaking
the 6-column grid and creating empty/black space on the right. The
existing `text-overflow: ellipsis` on `.daily-name` couldn't engage.

Adding `min-width: 0` to `.daily-card` lets the cell shrink to the
1fr grid track size, and the ellipsis truncation now works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:20:46 +08:00
saopig1andClaude Opus 4.7 35210cf570 fix(web): long artist name overflow + textarea resize artifact
- Player.vue: wrap artist text in a span with ellipsis. The previous
  text node sat directly inside the flex `.song-artist` container with
  no overflow handling, so a long author name expanded the container
  past its 240px parent and broke the bottom Player bar layout. Also
  add `min-width: 0 + overflow: hidden` to `.song-info` and
  `.song-artist`, and a `:title` attribute for the full text on hover.

- Settings.vue: change `resize: vertical` on the cookie textareas
  to `resize: none`. The browser's resize grip rendered as a stray
  black triangle at the bottom-right corner in dark theme, and
  dragging it caused visual artifacts on the right edge. The
  textareas keep their `rows="3"` default height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:14:26 +08:00
TIANYAO ZHANG d2bad58aa8 Merge pull request #74 from ZHANGTIANYAO1/feat/webui-auth
Add WebUI authentication: multi-user, roles, audit log
2026-05-27 16:46:03 +08:00
saopig1 f73ca1f61b docs: README updates for WebUI auth feature + pre-auth upgrade guide 2026-05-27 16:40:41 +08:00
saopig1andClaude Opus 4.7 a0f290459d feat(auth): X-Frame-Options + CSP frame-ancestors clickjacking defence
Every response now carries:
  X-Frame-Options: DENY
  Content-Security-Policy: frame-ancestors 'none'

Prevents the WebUI from being embedded in a third-party iframe.
Combined with the existing CSRF Origin-host check, this closes the
last meaningful UI-redress surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:34:52 +08:00
saopig1 b6b9aa07bc feat(auth): atomic session cap + change-password UI + trustProxy docs 2026-05-27 16:29:16 +08:00
saopig1 a39fc25104 feat(auth): rate-limit /login+/setup, per-user session cap, periodic /me poll 2026-05-27 16:16:07 +08:00
saopig1 1a11489f2e fix(auth): atomic last-admin guards on role-change and delete 2026-05-27 15:55:11 +08:00
saopig1andClaude Opus 4.7 c0504d65a5 fix(web): localize user.role_changed audit label
Adds the missing case so role-change entries display in Chinese
instead of falling through to the generic key→target format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:43:09 +08:00
saopig1 780726a4e3 feat(web): role-aware UI (badge, selector, toggle button, hide admin-only sections for members) 2026-05-27 15:38:42 +08:00
saopig1andClaude Opus 4.7 a73f797bcb feat(auth): two-role permission system (admin/member)
Adds an admin/member role to WebUI auth. /api/users and /api/audit
are now gated by a requireAdmin middleware; all other authenticated
endpoints accept both roles. Schema migration defaults all existing
users to admin to preserve access. POST /api/users defaults new users
to member; first-run setup always creates an admin. Adds PATCH
/api/users/:id/role with last-admin demotion and deletion guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:35:15 +08:00
saopig1 b0b61f8fce fix(auth): defensive audit-record + self-reset preserves current session 2026-05-27 15:16:55 +08:00
saopig1 7be4f13774 feat(web): operation audit log section in Settings 2026-05-27 15:06:54 +08:00
saopig1andClaude Sonnet 4.6 6af0e97f51 feat(auth): user-management audit log (table + record sites + /api/audit endpoint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:04:19 +08:00
saopig1andClaude Sonnet 4.6 ceb24595e6 fix(auth): race-safe first-run setup + rolling cookie max-age refresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:54:19 +08:00
saopig1andClaude Sonnet 4.6 fb7feec5cf feat(web): user management section in Settings (list/create/delete/reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:44:42 +08:00
saopig1andClaude Sonnet 4.6 175b8e6065 feat(auth): add /api/users CRUD (list, create, delete, reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:41:58 +08:00
saopig1andClaude Opus 4.7 f46b37192f fix(web): break infinite recursion in apiFetch by capturing nativeFetch
apiFetch called window.fetch which installApiClient had reassigned to
call apiFetch — every request blew the stack. Capture the native fetch
at module load (before any wrap) and use it inside apiFetch.

Symptom: first-run / login redirect never fires because the router
guard hangs on session.refresh().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:34:56 +08:00
saopig1 a8b056d2aa fix(auth): WS Origin host check + Login next-param open-redirect guard 2026-05-27 14:02:33 +08:00
saopig1andClaude Sonnet 4.6 e148c1556e feat(web): show current user + logout button in nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:57:31 +08:00
saopig1 e2e888710a fix(web): exclude /api/session/* from 401 auto-refresh to prevent re-entrancy 2026-05-27 13:56:14 +08:00
saopig1andClaude Sonnet 4.6 7509814abc feat(web): install global fetch wrapper with credentials + 401 handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:54:24 +08:00
saopig1andClaude Sonnet 4.6 0dc8746914 feat(web): add /first-run + /login routes with auth guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:52:49 +08:00
saopig1 2560fc87c2 feat(web): add Login view 2026-05-27 13:51:20 +08:00
saopig1 6db35f704d feat(web): add useSession composable 2026-05-27 13:50:14 +08:00
saopig1andClaude Sonnet 4.6 490b2d57dc test(auth): verify ws upgrade gating end-to-end
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:49:05 +08:00
saopig1andClaude Sonnet 4.6 486979841e feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:46:59 +08:00
saopig1andClaude Sonnet 4.6 ee5673a22f feat(auth): add /api/session router (setup, login, logout, me, change-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:42:00 +08:00
saopig1andClaude Sonnet 4.6 d1c9e14bf0 feat(auth): add csrfOriginCheck middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:38:35 +08:00
saopig1andClaude Sonnet 4.6 17c11f0512 feat(auth): add requireAuth middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:36:26 +08:00
saopig1 df5d125279 feat(auth): add shared validateSessionFromHeaders helper 2026-05-27 13:34:47 +08:00
saopig1andClaude Sonnet 4.6 5914f41ea1 feat(auth): add SessionStore with rolling renewal and at-rest token hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:33:22 +08:00
saopig1 68a2fb2943 refactor(users): use SQLITE_CONSTRAINT_UNIQUE error code instead of message text 2026-05-27 13:31:31 +08:00
saopig1andClaude Sonnet 4.6 34523cb00f feat(auth): add UserStore with bcryptjs password hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:29:39 +08:00
saopig1andClaude Sonnet 4.6 8ea1a64c59 feat(db): add users and sessions tables for WebUI auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:27:12 +08:00
saopig1 df79976933 deps: add bcryptjs + cookie-parser + supertest for WebUI auth 2026-05-27 13:25:32 +08:00
saopig1andClaude Opus 4.7 d3af918f53 docs(plan): WebUI authentication implementation plan
16 bite-sized tasks with TDD discipline:
- 10 backend (schema, users, sessions, middleware, /api/session router,
  server.ts wiring, WS upgrade gating + integration test)
- 5 frontend (useSession composable, Login + FirstRunSetup views,
  router guard, fetch wrapper, Navbar logout)
- 1 manual smoke test gate before PR

Notes /first-run as the admin-setup route since /setup is already taken
by the bot-creation wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:18:10 +08:00
saopig1andClaude Opus 4.7 f7c16888e7 docs(spec): WebUI authentication design
Spec for adding username+password auth to the WebUI to close the
unauthenticated-API exposure (all /api/* and /ws currently open).

Design: SQLite users + sessions tables, bcryptjs, 7-day rolling
HTTP-only cookie sessions, first-run setup wizard, Origin/Referer
CSRF check, WebSocket upgrade gated on the same session cookie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:07:51 +08:00
TIANYAO ZHANG a2982948a7 Merge pull request #71 from EvolvedGhost/main
fix(player): 歌曲结尾后持续卡死不切换下一首歌
2026-05-25 18:56:14 +08:00
EvolvedGhost b7e1f9f30b fix(player): add force trackEnd when pcmBuffer less than PCM_FRAME_BYTES 2026-05-23 22:08:52 +08:00
TIANYAO ZHANG 7fc5186c24 Merge pull request #65 from ZHANGTIANYAO1/fix/bilibili-wbi-search
fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
2026-05-16 22:31:55 +08:00
saopig1andClaude Opus 4.7 de92c8bcd4 fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
Closes #64. Bilibili moved the unsigned /x/web-interface/search/type endpoint
behind their anti-bot wall; it now returns an HTML error page (出错啦!) even
with buvid3+buvid4 cookies, causing `play -b` to report "No results found".

Switch search() to /x/web-interface/wbi/search/type with proper wbi signing:
fetch img_key/sub_key from /nav, derive the mixin key via the standard
permutation, and sign each request with wts + w_rid (md5). Keys are cached
for 6h since they rotate ~daily. Other endpoints (view, playurl, popular,
top/rcmd) still work unsigned and are left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:29:12 +08:00
TIANYAO ZHANG 6b143e1a56 Merge pull request #63 from XuVIIJay/pr/setup-scripts
安装脚本优化(对国内网络环境安装友好)
2026-05-16 02:02:10 +08:00
XuVIIJay 5728209573 fix(setup.sh): add Node.js and npm version check before install 2026-05-15 21:03:28 +08:00
XuVIIJay 02d8b39d75 fix(setup.bat): remove hardcoded personal Node.js paths 2026-05-15 20:56:59 +08:00
XuVIIJay f87aaaf8c3 feat(scripts): optimize setup/start scripts for China network 2026-05-15 20:42:51 +08:00
93 changed files with 11097 additions and 471 deletions

No files matched your search

+155 -21
View File
@@ -23,13 +23,17 @@
## 功能特性
- **WebUI 鉴权与细粒度权限(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员);成员可进一步配置**细粒度能力**(播放控制 / 队列管理 / 机器人管理 / 平台登录 / 音质)和**按机器人授权白名单**,所有变更操作由后端逐请求强制校验。bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
- **四种播放模式** — 顺序播放/循环播放/随机播放/随机循环
- **实时歌词同步** — 歌词滚动显示,支持翻译歌词,服务端帧计数精确同步
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部
- **歌单管理** — 推荐歌单/我的歌单/每日推荐/私人FM,点击播放全部;私人 FM 支持网易云与 **QQ 音乐雷达推荐**(`!fm -q`)
- **音质选择** — 标准(128k) / 较高(192k) / 极高(320k) / 无损(FLAC) / Hi-Res / 超清母带
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
@@ -155,6 +159,49 @@ sudo ./scripts/install.sh
>
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
### 从 WebUI 无鉴权版本升级(重要)
本次更新引入了**强制 WebUI 鉴权**。从无鉴权旧版本升级后,**WebUI 必须先创建管理员账号才能使用**。所有 `/api/*` 端点(除少量公共白名单)和 `/ws` 现在都需要登录。
**升级行为**:
- 启动时数据库自动迁移:新增 `users`、`sessions`、`user_audit` 三张表;旧的 `bot_instances`、`play_history` 数据**完全保留**。
- 第一次打开 WebUI 自动跳转到 `/first-run` 引导创建首位管理员(角色固定为 `admin`)。
- 之后访问任何页面都会校验登录态,未登录跳转 `/login`。
**会话与 Cookie**:
- 登录态保存 7 天,每次请求滚动续期(活跃用户不会被踢出)。
- 同一账号最多保持 10 个并发会话(超过自动剔除最旧的)。
- Cookie 设置为 `HttpOnly; SameSite=Lax`,HTTPS 部署需配合 `trustProxy: true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
**多用户与角色**:
- 角色 `admin`:完整权限(用户管理、审计、机器人、音乐平台、播放控制)。
- 角色 `member`:除"用户管理"和"操作审计"外的所有功能(适合给团队成员开通播放权)。
- 在 **设置 → 用户管理**(仅管理员)中添加 / 删除 / 重置密码 / 切换角色。
- 至少保留一个管理员:系统会阻止删除或降级最后一位管理员。
**如何重置忘记的管理员密码**:
如果你忘记了管理员密码,可以直接编辑 SQLite 数据库 `data/tsmusicbot.db`:
```bash
# 方案 1:清空所有用户,重新进入 first-run 流程
sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"
# 然后重启机器人,浏览器再次访问会自动进入 /first-run
# 方案 2:把指定用户重置为已知密码(密码 'changeme-now' 的 bcrypt 哈希示例如下)
# 先用 node 生成哈希:
node -e "console.log(require('bcryptjs').hashSync('changeme-now', 12))"
# 把输出贴到 SQL 里:
sqlite3 data/tsmusicbot.db "UPDATE users SET passwordHash='<paste-hash-here>' WHERE username='你的用户名';"
```
**反向代理用户特别注意**:如果通过 nginx / Caddy / Cloudflare 暴露 WebUI,**必须**在 `config.json` 中设置 `"trustProxy": true`,否则 Cookie 不会带 `Secure` 标志,且登录限流会把所有用户合并到同一个桶。详见下方 [反向代理部署注意事项](#反向代理部署注意事项)。
**旧版 `config.adminPassword` / `adminGroups`**:这两个配置项在旧版本中预留但从未实际启用(TS-side admin 命令权限的占位字段)。保留以避免破坏旧 `config.json`,但不再影响任何行为。可以放心忽略。
### Windows 用户
```
@@ -221,21 +268,27 @@ sudo systemctl start tsmusicbot
### 首次配置
1. 打开 **http://localhost:3000/setup** 进入设置向导
2. 填写 TeamSpeak 服务器地址(默认端口:9987)
3. 设置机器人昵称
4. (可选)扫码登录网易云/QQ音乐账号以播放 VIP 歌曲
1. 启动机器人后打开 **http://localhost:3000/**
- 全新部署:自动跳转 `/first-run`,填写用户名(3-32 字符)和密码(≥8 位)创建首位**管理员**账号
- 之后所有 WebUI 操作都需要登录,登录态保持 7 天(活动会滚动续期)
2. 在 **设置 → 机器人管理** 中点击"创建新实例",填写:
- TeamSpeak 服务器地址(无端口,仅主机名,例如 `ts.example.com`)
- 端口(默认 9987,自托管或非标准端口请填写实际值)
- 机器人昵称
- 可选:服务器密码、默认频道
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员。成员默认可控制播放但无法管理其他用户;管理员还可为每个成员单独配置**能力**(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)和**可操作的机器人白名单**,未授权的机器人对该成员不可见、不可控
### WebUI 页面说明
| 页面 | 功能 |
|------|------|
| **首页** | 推荐歌单、每日推荐、私人FM、我的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
| **首页** | 推荐歌单、每日推荐、私人FM(网易云 / QQ 雷达)、我的歌单、收藏的歌单 |
| **搜索** | 三平台统一搜索,结果标注网易云/QQ/B站来源,可一键收藏歌单 |
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌),一键收藏 |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 行为设置(空闲超时、频道无人自动暂停) / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员,含成员能力与机器人白名单)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -243,11 +296,14 @@ sudo systemctl start tsmusicbot
| 命令 | 说明 |
|------|------|
| `!play <歌名>` | 搜索并播放 |
| `!play <歌名>` | 搜索并播放(取最热门的匹配项) |
| `!play -q <歌名>` | 从 QQ 音乐搜索 |
| `!play -b <关键词>` | 从哔哩哔哩搜索视频并播放音频 |
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!add <歌名>` | 添加到播放队列 |
| `!search <歌名>` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
| `!stop` | 停止播放并清空队列 |
@@ -260,6 +316,7 @@ sudo systemctl start tsmusicbot
| `!album <ID>` | 加载专辑 |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-q`/`-b`/`-y`) |
| `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
@@ -294,10 +351,12 @@ teamspeak-music-bot/
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
│ │ ├── manager.ts # 多实例生命周期管理
│ │ ├── auto-pause.ts # 频道无人自动暂停/恢复的决策逻辑
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
│ ├── data/ # 数据层
│ │ ├── config.ts # JSON 配置文件
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
│ │ ├── config.ts # JSON 配置文件(持久化到 data/config.json)
│ │ ├── permissions.ts # 细粒度能力 + 按机器人授权白名单
│ │ └── database.ts # SQLite 数据库(播放历史、实例、收藏、权限持久化)
│ ├── music/ # 音源服务
│ │ ├── provider.ts # 统一 MusicProvider 接口
│ │ ├── netease.ts # 网易云音乐适配器
@@ -314,10 +373,13 @@ teamspeak-music-bot/
│ ├── web/ # Web 后端
│ │ ├── server.ts # Express + WebSocket 服务
│ │ ├── websocket.ts # 实时状态广播
│ │ ├── middleware/ # requireAuth / requireAdmin / requirePermission / CSRF
│ │ └── api/ # REST API 路由
│ │ ├── bot.ts # 机器人管理 CRUD
│ │ ├── music.ts # 搜索/歌单/歌词/音质
│ │ ├── player.ts # 播放控制/队列/历史/跳转
│ │ ├── player.ts # 播放控制/队列/历史/跳转/FM
│ │ ├── favorites.ts # 本地收藏歌单 CRUD
│ │ ├── users.ts # 用户管理 + 成员权限
│ │ └── auth.ts # QR登录/Cookie/SMS
│ └── index.ts # 入口(启动所有服务)
├── web/src/ # 前端源码 (Vue 3)
@@ -333,11 +395,11 @@ teamspeak-music-bot/
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
│ └── docker-compose.yml
├── data/ # 运行时数据(自动创建,不上传)
│ ├── tsmusicbot.db # SQLite 数据库
│ ├── cookies/ # 登录 Cookie
│ └── logs/ # 日志文件
└── config.json # 配置文件(首次运行自动生成,不上传)
└── data/ # 运行时数据(自动创建,不上传)
├── config.json # 配置文件(首次运行自动生成,可手动编辑)
├── tsmusicbot.db # SQLite 数据库
├── cookies/ # 登录 Cookie
└── logs/ # 日志文件
```
## 技术栈
@@ -408,7 +470,7 @@ pip install -U yt-dlp
## 配置文件
`config.json` 在首次运行时自动生成,可手动编辑:
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
```json
{
@@ -422,10 +484,27 @@ pip install -U yt-dlp
"adminPassword": "",
"adminGroups": [],
"autoReturnDelay": 300,
"autoPauseOnEmpty": true
"autoPauseOnEmpty": false,
"idleTimeoutMinutes": 0,
"publicUrl": "",
"trustProxy": false
}
```
> **配置文件位置变更**:旧版本把 `config.json` 写在项目根目录(不在 Docker 挂载卷内,导致重启丢失、手动编辑不生效)。现在统一放在 `data/config.json`。升级时若检测到根目录存在旧的 `config.json`,会在首次启动时自动迁移到 `data/` 并保留你的设置,无需手动操作。
> **关于 `adminPassword` 和 `adminGroups`**:这两个字段保留是为了兼容旧 `config.json`,但当前版本未使用。WebUI 鉴权改为基于数据库的用户账号系统(见 [首次配置](#首次配置)),无需在 `config.json` 中设置密码。
### 反向代理部署注意事项
当 WebUI 部署在反向代理(nginx / Caddy / Cloudflare 等)之后时,请务必在 `config.json` 中设置 `"trustProxy": true`:
- **Cookie Secure 标志**:未启用 `trustProxy` 时,Express 无法从 `X-Forwarded-Proto` 正确判断请求实际是否为 HTTPS,会话 cookie 不会被标记为 `Secure`。
- **登录限流**:登录限流以 `req.ip` 为键,未启用 `trustProxy` 时所有请求都会被识别为代理本身的 IP,单个攻击者会拖累所有合法用户共用同一个限流桶。
- **审计日志的客户端 IP**(如果未来添加该字段)也需要 `trustProxy` 才能正确记录。
直接暴露端口(无代理)时无需启用该选项。
## 常见问题
**Q:支持 TeamSpeak 6 Server 吗?**
@@ -437,6 +516,9 @@ A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部
**Q:提示"无法获取播放链接"?**
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
**Q:如何更换机器人所在频道?**
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
@@ -465,6 +547,30 @@ A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:You
**Q:如何更新到新版本?**
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
**Q:忘记管理员密码怎么办?**
A:直接操作 SQLite 数据库。最简单的办法是清空 `users` 表然后重新进入 first-run 流程:`sqlite3 data/tsmusicbot.db "DELETE FROM users; DELETE FROM sessions;"`,重启后浏览器会自动跳转 `/first-run` 让你重新创建管理员。详细方法见 [从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
**Q:成员(member)能做什么?不能做什么?**
A:成员默认可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**始终不能**:管理其他用户、查看操作审计日志、降级或删除管理员。此外管理员可在 **设置 → 用户管理** 为每个成员单独**收紧权限**:勾选允许的能力(播放控制 / 队列 / 机器人管理 / 平台登录 / 音质)以及可操作的机器人白名单——未授权的能力会返回 403,未授权的机器人对该成员不可见也不可控。管理员不受任何限制。
**Q:收藏的歌单存在哪里?其他用户能看到吗?**
A:收藏按用户存储在本地 SQLite 数据库(`favorite_playlists` 表),仅本人可见,登录后跨设备同步。在首页、搜索结果或歌单页点击收藏图标即可增删。
**Q:什么是"专属链接"?怎么用?**
A:通过 `/bot/<机器人ID>` 打开 WebUI 会把界面锁定到该机器人(顶部显示"专属模式",刷新后保持),适合把单台机器人的控制页分享给特定用户。点击"退出"可返回多机器人视图。注意:专属链接只是 UI 层的锁定,真正的访问控制由成员权限(机器人白名单)在后端强制。
**Q:机器人播放时突然自动暂停了?**
A:这是"频道无人时自动暂停"功能:当机器人所在频道没有其他人时会自动暂停,有人加入后自动恢复,避免空播。该功能**默认关闭**,仅在你于 **设置 → 行为设置** 开启后生效;如需停用,在同一页面关闭即可。(占用检测依赖 TeamSpeak 的 `clientlist` 命令,部分服务器在频道有其他人时可能查询失败——此时机器人会按"占用情况未知"处理,不会误暂停。)
**Q:如何把某个用户从成员升级为管理员?**
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
**Q:登录之后多久会自动退出?**
A:登录态有效期 7 天,活跃使用会滚动续期(每次受保护请求都会刷新过期时间)。同一账号最多保持 10 个并发会话(多设备登录时超过的会自动剔除最旧的会话)。
**Q:部署到公网后如何防止暴力登录?**
A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部署建议同时在反向代理(nginx `limit_req` / Caddy 等)层加一层限流,并启用 HTTPS。反向代理部署务必设置 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
## 参与贡献
1. Fork 本仓库
@@ -479,6 +585,34 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
**Bug 修复**
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
**WebUI 鉴权与权限系统**
- **首次运行强制创建管理员账号**:浏览器打开 WebUI 自动跳转 `/first-run`;之后所有 `/api/*`(除少量公共白名单:`/api/health`、`/api/config/public-url`、`/api/session/*`)和 `/ws` 都需要登录。详见 [更新升级 → 从 WebUI 无鉴权版本升级](#从-webui-无鉴权版本升级重要)。
- **两种角色:admin / member**。`member` 可以管理机器人、控制播放、登录音乐平台账号、修改自己密码,但不能管理其他用户或查看审计日志。`admin` 拥有全部权限。
- **用户管理 UI**:管理员在 设置 → 用户管理 可以增删用户、切换角色、重置密码。系统强制保留至少一位管理员。
- **操作审计日志**:管理员在 设置 → 操作审计 可以查看用户管理相关事件(创建、删除、密码重置、角色变更、首位管理员创建、自助修改密码)。
- **自助修改密码**:所有用户都可在 设置 → 账户 修改自己密码。
- **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。
- **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。
- **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。
- **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminPassword` / `adminGroups` 字段保留以兼容旧 `config.json`,但不再影响任何行为。
### v0.x — Bot Profile 自动更新与协议层升级
**机器人形象自动更新(Bot Profile)**
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,811 @@
# Account Permissions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an admin grant each member account a set of capabilities and a list of bots they may control, enforced on the backend.
**Architecture:** Capability tokens + per-member bot allow-list stored in two new SQLite tables, loaded onto `req.user` per request (live, no re-login), enforced by `requirePermission` / `requireBotAccess` middleware mirroring the existing `requireAdmin`. Admin stays a super-user. Existing members are backfilled to full access on upgrade; new members get a basic tier. The Vue UI hides what a member can't do and gives admins a permission editor.
**Tech Stack:** Node ESM + TypeScript, Express, better-sqlite3, Vitest + supertest, Vue 3 + Pinia.
**Spec:** `docs/superpowers/specs/2026-05-30-account-permissions-design.md`
**Conventions:** All file paths are repo-relative. Tests run with `npx vitest run <path>`. Backend is TDD (test first, watch fail, implement, watch pass, commit). Commit after each task.
---
## File Structure
**Create:**
- `src/data/permissions.ts` — capability constants + `PermissionStore` (tables accessed here)
- `src/data/permissions.test.ts` — store + constants tests
- `src/web/middleware/requirePermission.ts` — `requirePermission(cap)` + `requireBotAccess(param)`
- `src/web/middleware/requirePermission.test.ts` — middleware tests
**Modify:**
- `src/data/database.ts` — `initTables`: add the two tables + index; migration backfill of existing members
- `src/data/audit.ts` — add `"user.permissions_changed"` to `AuditAction`
- `src/web/middleware/requireAuth.ts` — widen `req.user`; load capabilities + bot access
- `src/web/auth/validateSession.ts` — (no change; just confirm) — actually unchanged
- `src/web/api/session.ts` — `/me` returns capabilities + bots; inline auth attaches them
- `src/web/server.ts` — construct `PermissionStore`, pass into routers/middleware
- `src/web/api/player.ts` — `requireBotAccess` on `/:botId`; per-route `requirePermission`
- `src/web/api/bot.ts` — `requirePermission("bot.manage")` + `requireBotAccess("id")`
- `src/web/api/auth.ts` — `requirePermission("platform.auth")`
- `src/web/api/music.ts` — `requirePermission("quality")` on the quality POST; filter `GET /api/bot`? no — bot list is in bot.ts
- `src/web/api/bot.ts` — filter `GET /` to allowed bots for members
- `src/web/api/users.ts` — `GET/PUT /api/users/:id/permissions`
- `src/bot/manager.ts` — `removeBot` calls `permissions.pruneBot(botId)`
- Frontend: `web/src/composables/useSession.ts`, `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
---
## Task 1: Capability constants + PermissionStore + tables
**Files:**
- Create: `src/data/permissions.ts`
- Create: `src/data/permissions.test.ts`
- Modify: `src/data/database.ts` (initTables)
- [ ] **Step 1: Write the failing test**
`src/data/permissions.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
// a user row is required for FK; insert directly
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `createPermissionStore` / `CAPABILITIES` not found (module missing).
- [ ] **Step 3: Create `src/data/permissions.ts`**
```typescript
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
```
- [ ] **Step 4: Add tables in `src/data/database.ts` initTables**
Find `initTables` (creates users/sessions/user_audit). Add, after the `user_audit` CREATE:
```typescript
db.exec(`
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
`);
```
(If `initTables` uses individual `db.exec` calls, match that style. The `BotDatabase` type already exposes `.db` and `.close()` — confirm by reading the file; the test uses `db.db` and `db.close()`.)
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS (7 tests).
- [ ] **Step 6: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts src/data/database.ts
git commit -m "feat(perm): permission store + capability tokens + tables"
```
---
## Task 2: requirePermission + requireBotAccess middleware
**Files:**
- Create: `src/web/middleware/requirePermission.ts`
- Create: `src/web/middleware/requirePermission.test.ts`
- Modify: `src/web/middleware/requireAuth.ts` (widen `req.user`)
- [ ] **Step 1: Widen the `req.user` augmentation in `src/web/middleware/requireAuth.ts`**
Change the `declare module` block so `req.user` carries capabilities + bot access:
```typescript
declare module "express-serve-static-core" {
interface Request {
user?: {
id: string;
username: string;
role: "admin" | "member";
capabilities: Set<string>;
bots: "all" | Set<string>;
};
}
}
```
(The loading of these fields is done in Task 4 — for now this only widens the type. Existing assignments to `req.user` will fail to typecheck until Task 4; that is expected and Task 4 fixes them. If you need the build green between tasks, do Task 2 + Task 4 back-to-back before running `tsc`.)
- [ ] **Step 2: Write the failing middleware test**
`src/web/middleware/requirePermission.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import { requirePermission, requireBotAccess } from "./requirePermission.js";
function appWith(user: any) {
const app = express();
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1", username: "a", role: "member",
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
});
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
describe("requirePermission", () => {
it("401 when unauthenticated", async () => {
const app = express();
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/cap")).status).toBe(401);
});
it("403 when member lacks the capability", async () => {
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
});
it("200 when member has the capability", async () => {
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
});
it("200 for admin regardless of capabilities", async () => {
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
});
});
describe("requireBotAccess", () => {
it("200 when bots = all", async () => {
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
});
it("200 when botId in allow-list", async () => {
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
});
it("403 when botId not in allow-list", async () => {
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
});
it("200 for admin", async () => {
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: FAIL — module `./requirePermission.js` not found.
- [ ] **Step 4: Create `src/web/middleware/requirePermission.ts`**
```typescript
import type { Request, Response, NextFunction, RequestHandler } from "express";
export function requirePermission(capability: string): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.capabilities.has(capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
export function requireBotAccess(paramName = "botId"): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
const botId = req.params[paramName];
if (botId && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: PASS (8 tests).
- [ ] **Step 6: Commit**
```bash
git add src/web/middleware/requirePermission.ts src/web/middleware/requirePermission.test.ts src/web/middleware/requireAuth.ts
git commit -m "feat(perm): requirePermission + requireBotAccess middleware"
```
---
## Task 3: Effective-permissions resolver (admin = all)
**Files:**
- Modify: `src/data/permissions.ts` (add `resolveContext` helper)
- Modify: `src/data/permissions.test.ts` (add tests)
- [ ] **Step 1: Add failing tests** to `src/data/permissions.test.ts`:
```typescript
import { resolvePermissionContext } from "./permissions.js";
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
```
- [ ] **Step 2: Run to verify fail**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `resolvePermissionContext` not exported.
- [ ] **Step 3: Add to `src/data/permissions.ts`**
```typescript
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
}
export function resolvePermissionContext(
role: "admin" | "member",
userId: string,
store: PermissionStore
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
```
- [ ] **Step 4: Run to verify pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts
git commit -m "feat(perm): resolvePermissionContext (admin = super-user)"
```
---
## Task 4: Load permissions onto req.user (requireAuth + session inline + /me)
**Files:**
- Modify: `src/web/middleware/requireAuth.ts`
- Modify: `src/web/api/session.ts`
- Modify: `src/web/server.ts`
- [ ] **Step 1: Thread `PermissionStore` into `createRequireAuth`**
`src/web/middleware/requireAuth.ts` — change the factory signature and set the new fields:
```typescript
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
return function requireAuth(req, res, next) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
req.user = {
id: result.userId, username: result.username, role: result.role,
capabilities: ctx.capabilities, bots: ctx.bots,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true, sameSite: "lax", secure: req.secure, path: "/", maxAge: SESSION_TTL_MS,
});
}
next();
};
}
```
- [ ] **Step 2: Update `src/web/server.ts`**
Construct the store next to the others and pass it in:
```typescript
import { createPermissionStore } from "../data/permissions.js";
// ...
const permissions = createPermissionStore(options.database.db);
// ...
const requireAuth = createRequireAuth(sessions, permissions);
```
Keep `permissions` in scope — it's passed to routers in Tasks 5–7.
- [ ] **Step 3: Update session inline auth + `/me` in `src/web/api/session.ts`**
`createSessionRouter` must accept `permissions` and (a) attach capabilities in `requireAuthInline`, (b) include them in `/me`. Pass `permissions` from `server.ts` into `createSessionRouter(users, sessions, audit, logger, permissions)`. In the `/me` handler, return:
```typescript
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
res.json({
id: validation.userId, username: validation.username, role: validation.role,
capabilities: [...ctx.capabilities],
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
});
```
(Match the existing `/me` shape; just add `capabilities` + `bots`. Read the file to find the exact response object.)
- [ ] **Step 4: Verify build + existing tests**
Run: `npx tsc --noEmit`
Expected: exit 0 (the widened `req.user` is now populated everywhere it's read).
Run: `npx vitest run src/web`
Expected: PASS (existing auth/session/csrf tests still green; if a test constructs `createRequireAuth(sessions)` it must be updated to pass a `createPermissionStore(db)`).
- [ ] **Step 5: Commit**
```bash
git add src/web/middleware/requireAuth.ts src/web/server.ts src/web/api/session.ts
git commit -m "feat(perm): load capabilities + bot access onto req.user; expose via /me"
```
---
## Task 5: Enforce capabilities on the action routes
**Files:**
- Modify: `src/web/api/player.ts`, `src/web/api/bot.ts`, `src/web/api/auth.ts`, `src/web/api/music.ts`
- Modify: `src/web/api/player.test.ts` (or create `src/web/api/permissions-enforcement.test.ts`)
- [ ] **Step 1: Write a failing integration test** at `src/web/api/permissions-enforcement.test.ts` that builds the real app (or the relevant router) with a stubbed `req.user` and asserts:
- member without `player.control` → `POST /api/player/:botId/pause` → 403
- member with `player.control` + bot in allow-list → 200 (bot resolves)
- member with `player.control` but bot NOT in allow-list → 403
- member without `player.queue` → `POST /api/player/:botId/clear` → 403
- member without `bot.manage` → `POST /api/bot` → 403
- member without `platform.auth` → `POST /api/auth/cookie` → 403
- member without `quality` → `POST /api/music/quality` → 403
- admin → all 200/allowed
Use the same `appWith(user)` injection pattern as Task 2 (insert a middleware that sets `req.user` before the router) and a fake `BotManager`/providers so routes resolve. Model it on the existing `src/web/api/*.test.ts` setup (read one first for the harness).
- [ ] **Step 2: Run to verify fail** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → FAIL (routes currently allow everyone).
- [ ] **Step 3: Apply gates.**
`src/web/api/player.ts` — the shared `/:botId` middleware already resolves the bot. Add bot-access there, and add per-action capability guards. Define the queue-capability routes vs control routes:
```typescript
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
// after the existing router.use("/:botId", resolveBot):
router.use("/:botId", requireBotAccess("botId"));
const control = requirePermission("player.control");
const queue = requirePermission("player.queue");
// control: play, pause, resume, next, prev, stop, seek, volume, mode, play-song, play-at, play-by-id, play-playlist, play-album, play-next-song
// queue: add, add-song, add-by-id, clear, playlist, /queue/:index (DELETE)
// Apply per route, e.g.:
router.post("/:botId/pause", control, async (req, res) => { /* existing */ });
router.post("/:botId/add", queue, async (req, res) => { /* existing */ });
router.delete("/:botId/queue/:index", queue, async (req, res) => { /* existing */ });
```
(Insert the `control`/`queue` middleware as the 2nd arg of each existing `router.post/delete`. Do not change handler bodies. `PUT /:botId/profile` → `requirePermission("bot.manage")`.)
`src/web/api/bot.ts` — gate management + per-bot:
```typescript
const manage = requirePermission("bot.manage");
router.post("/", manage, ...); // create (no botId)
router.put("/:id", manage, requireBotAccess("id"), ...);
router.delete("/:id", manage, requireBotAccess("id"), ...);
router.post("/:id/start", manage, requireBotAccess("id"), ...);
router.post("/:id/stop", manage, requireBotAccess("id"), ...);
router.put("/:id/avatar", manage, requireBotAccess("id"), ...);
router.delete("/:id/avatar", manage, requireBotAccess("id"), ...);
router.post("/settings", manage, ...); // global idle timeout
```
`src/web/api/auth.ts` — gate every mutating route with `requirePermission("platform.auth")`:
`POST /qrcode`, `POST /sms/send`, `POST /sms/verify`, `POST /cookie`. (Leave `GET /status`, `GET /qrcode/status` open — read-only.)
`src/web/api/music.ts` — gate the one mutating route:
`router.post("/quality", requirePermission("quality"), ...)`.
- [ ] **Step 4: Run to verify pass** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → PASS. Then `npx vitest run src/web` → all green.
- [ ] **Step 5: Commit**
```bash
git add src/web/api/player.ts src/web/api/bot.ts src/web/api/auth.ts src/web/api/music.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): enforce capabilities + bot access on action routes"
```
---
## Task 6: Filter the bot list for members
**Files:**
- Modify: `src/web/api/bot.ts` (`GET /`)
- Modify: `src/bot/manager.ts` (`removeBot` → `permissions.pruneBot`)
- Modify: test from Task 5
- [ ] **Step 1: Add failing test** — member with `bots: ["b1"]` calling `GET /api/bot` sees only `b1`; admin sees all.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement.** In `GET /` of `bot.ts`:
```typescript
const all = getAllBots().map((b) => b.getStatus());
const u = req.user!;
const bots = u.role === "admin" || u.bots === "all"
? all
: all.filter((b) => (u.bots as Set<string>).has(b.id));
res.json({ bots });
```
In `src/bot/manager.ts`, give `BotManager` access to the `PermissionStore` (constructor param) and call `this.permissions.pruneBot(id)` inside `removeBot(id)` after deletion, so deleted bots drop out of allow-lists. Thread `permissions` from `index.ts`/`server.ts` into `BotManager`.
- [ ] **Step 4: Run → pass; `npx vitest run src/web src/bot` green.**
- [ ] **Step 5: Commit**
```bash
git add src/web/api/bot.ts src/bot/manager.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete"
```
---
## Task 7: Management API (GET/PUT permissions) + audit
**Files:**
- Modify: `src/data/audit.ts` (add action)
- Modify: `src/web/api/users.ts` (+ permissions endpoints; new-member default)
- Modify: `src/web/server.ts` (pass `permissions` into `createUsersRouter`)
- Create/extend: `src/web/api/users.test.ts`
- [ ] **Step 1: Add `"user.permissions_changed"`** to the `AuditAction` union in `src/data/audit.ts`.
- [ ] **Step 2: Write failing tests** for the users router (admin-only):
- `GET /api/users/:id/permissions` → `{ capabilities: [], bots: [] }` for a fresh member.
- `PUT /api/users/:id/permissions` with `{capabilities:["player.control"], bots:"all"}` → 200; subsequent GET reflects it; an audit row `user.permissions_changed` exists.
- `PUT` with an unknown capability token → it is dropped (not stored).
- New member created via `POST /api/users` → GET permissions returns basic tier (`["player.control","player.queue"]`, bots `"all"`).
- [ ] **Step 3: Run → fail.**
- [ ] **Step 4: Implement** in `src/web/api/users.ts` (router already admin-gated at mount). Accept `permissions: PermissionStore` param. Add:
```typescript
import { CAPABILITIES, isCapability, BASIC_TIER_CAPABILITIES } from "../../data/permissions.js";
router.get("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
res.json({ capabilities: permissions.getCapabilities(user.id), bots: permissions.getBotAccess(user.id) });
});
router.put("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
const body = req.body ?? {};
const caps = Array.isArray(body.capabilities) ? body.capabilities.filter(isCapability) : [];
const bots = body.bots === "all" ? "all" : (Array.isArray(body.bots) ? body.bots.map(String) : []);
permissions.setPermissions(user.id, { capabilities: caps, bots });
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: user.id, targetUsername: user.username,
action: "user.permissions_changed",
});
res.json({ success: true });
});
```
In the existing `POST /api/users` handler, after creating a member, seed the basic tier:
```typescript
if (created.role === "member") {
permissions.setPermissions(created.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
}
```
- [ ] **Step 5: Run → pass; `npx vitest run src/web` green.**
- [ ] **Step 6: Commit**
```bash
git add src/data/audit.ts src/web/api/users.ts src/web/server.ts src/web/api/users.test.ts
git commit -m "feat(perm): admin permissions API + audit + new-member basic tier"
```
---
## Task 8: One-time migration backfill (existing members → full)
**Files:**
- Modify: `src/data/database.ts` (`migrateSchema` or a dedicated backfill)
- Create: `src/data/permissions-migration.test.ts`
- [ ] **Step 1: Write failing test** — given a fresh db with an existing `member` user and NO permission rows, after `createDatabase()` runs the backfill, that member has all 5 capabilities + `bots.all`; an `admin` user gets nothing (bypasses). Backfill is idempotent (running twice does not duplicate / does not re-grant a member who was later restricted to empty).
Idempotency approach: store a one-shot marker. Use a `meta` row or check: only backfill members who currently have ZERO permission rows AND only on first introduction. Simplest robust marker: a row in a tiny `schema_meta(key TEXT PK, value TEXT)` table, key `perm_backfill_done`. If present, skip.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement** a `backfillMemberPermissions(db)` run once inside `createDatabase` after `initTables`:
```typescript
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (!done) {
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tx = db.transaction(() => {
for (const m of members) {
for (const c of ["player.control","player.queue","bot.manage","platform.auth","quality","bots.all"]) {
insCap.run(m.id, c);
}
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(Date.now()));
});
tx();
}
```
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/permissions-migration.test.ts
git commit -m "feat(perm): one-time backfill of existing members to full access"
```
---
## Task 9: Frontend — session capabilities + helpers
**Files:**
- Modify: `web/src/composables/useSession.ts`
- [ ] **Step 1:** Extend the `User` type with `capabilities: string[]` and `bots: 'all' | string[]`; populate from `/api/session/me`, `/login`, `/setup` responses (the backend now returns them).
- [ ] **Step 2:** Add computed helpers:
```typescript
function can(cap: string): boolean {
const u = currentUser.value;
return !!u && (u.role === 'admin' || (u.capabilities ?? []).includes(cap));
}
function canControlBot(botId: string): boolean {
const u = currentUser.value;
if (!u) return false;
if (u.role === 'admin' || u.bots === 'all') return true;
return Array.isArray(u.bots) && u.bots.includes(botId);
}
```
Export `can` and `canControlBot` from the composable.
- [ ] **Step 3:** Manual check: log in as admin → `can('quality')` true; (after backend done) a restricted member → false. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/composables/useSession.ts && git commit -m "feat(perm): frontend session capabilities + can()/canControlBot()"`
---
## Task 10: Frontend — gate UI by capability + filter bots
**Files:**
- Modify: `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
- [ ] **Step 1:** Navbar bot selector: render only controllable bots — `v-for="bot in store.bots"` becomes a filtered computed `controllableBots = store.bots.filter(b => session.canControlBot(b.id))`. (The backend already filters `GET /api/bot`, so this is belt-and-suspenders + correctness if both lists diverge.) Ensure `store.activeBot` fallback never lands on a bot the user can't control.
- [ ] **Step 2:** Player.vue: wrap control buttons with `v-if="session.can('player.control')"` and queue actions with `v-if="session.can('player.queue')"`.
- [ ] **Step 3:** Settings.vue: wrap the platform login cards with `v-if="session.can('platform.auth')"`, the audio-quality control with `v-if="session.can('quality')"`, and bot create/edit/delete with `v-if="session.can('bot.manage')"`.
- [ ] **Step 4:** Manual verification (see Verification section). Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 5: Commit** `git add web/src/components/Navbar.vue web/src/components/Player.vue web/src/views/Settings.vue web/src/stores/player.ts && git commit -m "feat(perm): hide UI a member lacks capability for"`
---
## Task 11: Frontend — admin permission editor
**Files:**
- Modify: `web/src/views/Settings.vue` (User Management section)
- [ ] **Step 1:** In each member row of the admin User-Management list, add a "权限" button opening an editor (inline panel or dialog) with: 5 capability checkboxes (labels: 播放控制 / 队列管理 / 机器人管理 / 平台登录凭据 / 音质设置), and a bot allow-list — an "全部机器人" toggle plus, when off, a checkbox per bot from `store.bots`.
- [ ] **Step 2:** On open, `GET /api/users/:id/permissions`; on save, `PUT /api/users/:id/permissions` with `{capabilities, bots}` then re-fetch. Admin rows show "全部权限(管理员)" and no editor.
- [ ] **Step 3:** Manual verification. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/views/Settings.vue && git commit -m "feat(perm): admin permission editor in user management"`
---
## Final verification
- [ ] `npx tsc --noEmit` → exit 0
- [ ] `npx vitest run src/` → all green (clean-checkout-equivalent; ignore stale `dist/` twins — see note)
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npm run build` → succeeds
- [ ] Manual (run the bot, log in): admin sees everything; create a member, restrict to `player.control` on one bot → member sees only that bot, can play/pause but cannot add to queue, cannot open platform login / quality / bot management; backend returns 403 on a forged request to a disallowed action (verify with curl + the member's session cookie).
> **Note (pre-existing):** `tsconfig.json` compiles `*.test.ts` into `dist/`, and vitest also runs the `dist/` twins after a build — so `npx vitest run` (no path) double-runs and can fail on stale artifacts. Scope verification to `npx vitest run src/`. (A separate cleanup PR could add `exclude: ['**/dist/**']` to a vitest config.)
## Out of scope (separate PRs, per spec)
#1 guest mode · #2 dedicated-link bot hiding UX · #3 auto-pause on empty channel · #4 dedicated-link refresh bug.
@@ -0,0 +1,196 @@
# Auto-pause on Empty Channel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
---
## Task 1: Pure occupancy-decision function
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { decideOccupancyAction } from "./auto-pause.js";
describe("decideOccupancyAction", () => {
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
```typescript
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Decide what auto-pause should do given the channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
```
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
---
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
**Files:** Modify `src/bot/instance.ts`.
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
```typescript
import { decideOccupancyAction } from "./auto-pause.js";
private handleOccupancy(userCount: number): void {
// idle-disconnect (unchanged behavior)
if (userCount <= 0) this._scheduleIdleCheck();
else this._cancelIdleTimer();
// auto-pause
const action = decideOccupancyAction(
this.player.getState() as "idle" | "playing" | "paused",
this.autoPaused,
this.config.autoPauseOnEmpty,
userCount,
);
if (action === "pause") {
this.player.pause();
this.autoPaused = true;
this.emit("stateChange");
} else if (action === "resume") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
```typescript
updateAutoPause(enabled: boolean): void {
this.config.autoPauseOnEmpty = enabled;
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
---
## Task 3: Re-emit TS member events for instant reaction
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
---
## Task 4: API wiring for the toggle
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
- [ ] **Step 2 — run, expect fail.**
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
---
## Task 5: Frontend toggle in Settings
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
---
## Final verification
- [ ] `npx tsc --noEmit` → 0
- [ ] `npx vitest run src/` → all pass
- [ ] `cd web && npx vue-tsc --noEmit` → 0
- [ ] `npm run build` → succeeds
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
@@ -0,0 +1,156 @@
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
---
## Task 1: Store scope state + pure resolve helper (with test)
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
```typescript
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
* effective scope. Returns the id if it exists, else null (graceful clear:
* a stale/forbidden id never locks the UI). */
export function resolveScopedBot(
requestedId: string | null | undefined,
knownBotIds: readonly string[],
): string | null {
if (!requestedId) return null;
return knownBotIds.includes(requestedId) ? requestedId : null;
}
```
`web/src/stores/scope.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { resolveScopedBot } from "./scope.js";
describe("resolveScopedBot", () => {
it("returns null when no id requested", () => {
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
expect(resolveScopedBot("", ["a"])).toBeNull();
});
it("returns the id when it exists in the bot list", () => {
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
});
it("clears (null) when the requested id is not a known bot", () => {
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
- [ ] **Step 3 — implement the helper** (code above).
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
- state: `scopedBotId: null as string | null`.
- getter: `isScoped: (state) => state.scopedBotId !== null`.
- actions:
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
- `clearScope()` → `this.scopedBotId = null;`.
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
---
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
**Files:** Modify `web/src/router/index.ts`.
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
```typescript
const store = usePlayerStore();
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
if (qBot) {
// entering/with a scope in the URL — store will validate against bots later
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
return next();
}
if (store.scopedBotId) {
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
if (to.query.bot !== store.scopedBotId) {
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
}
}
return next();
```
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
---
## Task 3: BotRedirect seeds the URL scope
**Files:** Modify `web/src/views/BotRedirect.vue`.
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
- keep the fetchBots + existence check,
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
- if not found: unchanged (show "机器人不存在或未加载").
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
---
## Task 4: Navbar lock UI + apply-scope-on-load
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
When not scoped: behavior unchanged.
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
---
## Final verification
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
- [ ] `npm run build` → succeeds
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
## Notes
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
@@ -0,0 +1,360 @@
# WebUI Authentication
**Date:** 2026-05-27
**Status:** Spec — pending implementation
**Branch:** `feat/webui-auth`
## Problem
WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权:
- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。
- 静态前端通过 `express.static()` 直接对外提供。
后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。
## Goal
为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。
## Out of Scope(明确不做)
- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做)
- 角色与权限(admin / viewer)—— 全员同权
- 密码重置流程(不挂邮件;仅提供登录后 `change-password`)
- 双因素认证(2FA)
- "记住我" / 绝对过期 vs 滑动过期的可配置
- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权
- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json`
## Non-functional Constraints
- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。
- Cookie 行为必须兼容现有 `trustProxy` 反代部署。
- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。
- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。
## Architecture
### 数据层(`src/data/`)
扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表:
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- uuid v4
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- sha256(rawToken) hex
userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL, -- ms epoch
lastSeenAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
```
**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。
新模块:
`src/data/users.ts`
- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError`
- `findByUsername(username): User | null`
- `verifyPassword(plain, hash): Promise<boolean>` — bcryptjs compare
- `countUsers(): number` — 用于 `/needs-setup`
- `changePassword(userId, newPassword): void`
`src/data/sessions.ts`
- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256
- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d`
- `deleteSession(rawToken): void` — 退出
- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话
- `cleanupExpired(): void` — 定时任务
### HTTP 层(`src/web/`)
#### 新增中间件
`src/web/middleware/requireAuth.ts`
```
读取 req.cookies.tsmb_session
→ 缺失 → 401 { error: "unauthenticated" }
→ 调 sessions.validateAndTouch
→ null → 清 cookie + 401
→ 有效 → req.user = { id, username }; next()
```
`src/web/middleware/csrf.ts`
```
若 method ∈ {GET, HEAD, OPTIONS} → next()
否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致
→ 不一致或两者都缺失 → 403 { error: "bad origin" }
```
#### 新路由:`src/web/api/session.ts`
挂在 `/api/session`,全部公共(不挂 requireAuth):
| Method | Path | 行为 |
|---|---|---|
| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` |
| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` |
| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) |
| POST | `/logout` | 删 session,清 cookie,204 |
| GET | `/me` | 走 requireAuth;返回 `{ id, username }` |
| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 |
> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。
#### Cookie 规范
- 名称:`tsmb_session`
- 值:32 字节 random → base64url
- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天)
- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃
#### 装配顺序(`src/web/server.ts`)
```ts
app.use(express.json({ limit: "400kb" }));
app.use(cookieParser()); // 新增
// 公共
app.get("/api/health", …);
app.get("/api/config/public-url", …);
app.use("/api/session", createSessionRouter(...));
// 闸门(仅作用于下方注册的 /api/* 路由)
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// 受保护
app.use("/api/bot", createBotRouter(...));
app.use("/api/music", createMusicRouter(...));
app.use("/api/player", createPlayerRouter(...));
app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR
// 静态 SPA(公共,前端自行判定登录态后跳转)
app.use(express.static(staticDir));
app.get(/^(?!\/api|\/ws)/, sendIndex);
```
> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。
#### 定时清理
`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。
### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`)
改造为手动 upgrade:
```ts
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") { socket.destroy(); return; }
const session = validateCookieFromHeaders(req.headers.cookie);
if (!session) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
(ws as any).userId = session.userId;
wss.emit("connection", ws, req);
});
});
```
`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。
不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。
### 前端层(`web/`)
#### 新视图
- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/`
- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/`
- 名称避免与既有 `Setup.vue`(bot 创建向导)冲突
#### Session 状态
新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref<User|null>`、`refresh()`、`logout()`、`needsSetup: Ref<boolean>`。在 `App.vue` mount 时调用 `refresh()`。
#### 路由守卫(`web/src/router/index.ts`)
- 公共路由:`/login`、`/setup`
- 全局 `beforeEach`:
1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存)
2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')`
3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=<path>')`
#### API 客户端
- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出)
- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')`
#### UI
- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`)
- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section)
### 依赖
新增到 `package.json`:
```
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7"
```
不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。
## Data Flow
### 首次启动
```
Browser → GET / → static SPA
SPA mounted → GET /api/session/needs-setup → { needsSetup: true }
SPA → router.replace('/setup')
User submits form → POST /api/session/setup
Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200
SPA → currentUser refresh → router.replace('/')
```
### 已部署用户升级
旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。
### 后续登录
```
SPA → GET /api/session/me → 401
SPA → router.replace('/login?next=/queue')
User submits → POST /api/session/login → Set-Cookie + 200
SPA → currentUser refresh → router.replace('/queue')
```
### 受保护请求
```
SPA → fetch('/api/bot', { credentials: 'same-origin' })
Server requireAuth: validateAndTouch(cookie)
→ ok → req.user 注入 → 业务路由处理
→ 不 ok → 401 → SPA 拦截器跳 /login
```
### WebSocket
```
SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie
Server upgrade handler: validateCookieFromHeaders
→ ok → handleUpgrade → connection event
→ 不 ok → HTTP 401 写回原始 socket → destroy
```
## Error Handling
| 场景 | HTTP 响应 | 备注 |
|---|---|---|
| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth |
| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 |
| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 |
| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 |
| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 |
| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | |
| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | |
| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | |
| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 |
所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。
## Testing Strategy
### 单元(vitest)
`src/data/users.test.ts`
- createUser 成功后 findByUsername 命中(大小写不敏感)
- 重复 username 抛 UsernameTakenError
- verifyPassword 正反例
- changePassword 之后旧哈希不再验证通过
`src/data/sessions.test.ts`
- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token))
- validateAndTouch 过期记录返回 null 且记录被删
- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证)
- deleteAllForUser(exceptToken) 保留指定会话
### 集成(vitest + supertest,真 SQLite in-memory)
`src/web/api/session.test.ts`
- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409
- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401
- /logout 之后同一 cookie 调受保护路由 401
- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用
`src/web/middleware/csrf.test.ts`
- 带匹配 Origin 的 POST 通过
- Origin 与 host 不匹配 → 403
- 同样规则适用 Referer
- GET 永远通过
`src/web/websocket.test.ts`(新增或扩展)
- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭
- 带有效 cookie → 握手成功,收到 init 消息
- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs)
### 前端
不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列):
- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面
- 退出 → 自动跳 /login
- 关闭浏览器 7 天内再开 → 仍登录
- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login
## Files Changed
```
src/data/database.ts (schema migration)
src/data/users.ts (new)
src/data/users.test.ts (new)
src/data/sessions.ts (new)
src/data/sessions.test.ts (new)
src/web/auth/validateSession.ts (new, shared by HTTP + WS)
src/web/middleware/requireAuth.ts (new)
src/web/middleware/csrf.ts (new)
src/web/middleware/csrf.test.ts (new)
src/web/api/session.ts (new)
src/web/api/session.test.ts (new)
src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用)
src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式)
src/web/websocket.test.ts (新增 / 扩展)
package.json (deps)
web/src/views/Login.vue (new)
web/src/views/FirstRunSetup.vue (new)
web/src/composables/useSession.ts (new)
web/src/router/index.ts (公共路由 + beforeEach 守卫)
web/src/api/*.ts (credentials: 'same-origin' + 401 拦截)
web/src/App.vue (顶栏 logout + 当前用户名)
```
## Trade-offs / 已知妥协
1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。
2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。
3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。
4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。
5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。
@@ -0,0 +1,167 @@
# Fine-grained account permissions — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) (item E — the maintainer's permission-management idea)
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Issue #79 bundles five things. This spec covers **only item E**: allow an admin to
grant each non-admin (member) account a set of capabilities and a list of bots they
may control. The other items are handled in separate PRs and are **out of scope**
here:
- #1 Guest mode (login-less playback)
- #2 Dedicated-link hides other bots (subsumed conceptually by E's bot allow-list, but the link-specific UX is separate)
- #3 Auto-pause when channel empty
- #4 Dedicated link loses bot binding on refresh (a bug)
## Problem
Today the bot has a coarse two-role system: `admin | member` (single `role` column,
read live per request). `requireAdmin` gates only `/api/users` and `/api/audit`.
**Every other action — create/edit/delete bots, start/stop, all playback & queue
control, set platform login cookies, set audio quality — is open to any logged-in
member, on every bot.** Admins want to delegate limited control to members without
handing them full power.
## Decisions (from brainstorm)
1. **Model = capability flags + per-member bot allow-list** (not a per-bot×per-action
matrix, not role templates).
2. **Defaults:** on upgrade, existing members are backfilled with full capabilities +
all bots (no behavior change); newly-created members get a **basic tier**.
3. **Bot allow-list semantics:** an explicit "all bots" toggle OR a specific list;
empty list = no bots controllable. Members **cannot see** bots outside their
allow-list (hidden, not merely disabled).
4. **Capability set (5 toggles)** — see below; basic tier = playback + queue + all bots.
5. **Admin is a super-user** (bypasses all checks). The last admin cannot be demoted
(existing invariant preserved). Permission grants/revokes are written to the
existing audit log.
## Capability taxonomy
| Capability token | Covers | Scope |
|---|---|---|
| `player.control` | play/pause/resume/next/prev/stop/seek/volume/mode | per-bot (allow-list) |
| `player.queue` | search-add / clear / remove / play-at / playlist / album / play-song | per-bot (allow-list) |
| `bot.manage` | create / edit / delete / start / stop / avatar / profile / idle settings | global (create) + per-bot (operate a specific bot) |
| `platform.auth` | set NetEase/QQ/Bilibili cookie, QR, SMS | **global** (shared credentials) |
| `quality` | set audio quality per platform | **global** |
Bot scope is independent of capabilities: a member with `player.control` can only
exercise it on bots in their allow-list (or all, if the "all bots" flag is set).
`platform.auth` and `quality` are global capabilities with no bot scope.
**Basic tier** (new members): `{ player.control, player.queue }` + `bots.all = true`.
A new member can play/queue on every bot but cannot manage bots, change credentials,
or change quality.
## Data model (SQLite, additive — follows existing `CREATE TABLE IF NOT EXISTS` pattern)
```sql
-- capability tokens + the "all bots" flag (stored as token 'bots.all')
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
-- specific bot allow-list (only consulted when 'bots.all' is NOT present)
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
```
- Admins have no rows (they bypass). Only members are constrained.
- `bots.all` present ⇒ all bots (incl. future ones). Absent ⇒ only `user_bot_access`
rows; empty ⇒ none.
- `foreign_keys = ON` and WAL are already enabled; cascade-on-user-delete works.
- `user_bot_access.botId` references bot instance ids; when a bot is deleted, its
access rows should be cleaned up (either an FK to the bot table if one exists, or an
explicit cleanup in `BotManager.removeBot` / `PermissionStore.pruneBot(botId)`).
New `PermissionStore` in `src/data/permissions.ts` (mirrors `createUserStore` /
`createSessionStore`: prepared statements + an interface). Methods:
`getCapabilities(userId)`, `getBotAccess(userId)` → `'all' | string[]`,
`setPermissions(userId, { capabilities, bots })`, `pruneBot(botId)`.
## Backend enforcement (real 403 — not just hidden UI)
- **`req.user` widened** to carry `capabilities: Set<string>` and bot access. Loaded in
`requireAuth` (one extra lookup, or a JOIN in the session query). The same
`{id,username,role,capabilities,bots}` shape must be kept in sync in the three places
it is built today: `requireAuth.ts`, `session.ts` `requireAuthInline`, and the WS
upgrade handler in `server.ts` (WS only needs it if a push action becomes gated).
Because it's read live, permission changes take effect immediately (no re-login).
- **`requirePermission(cap)`** middleware (new, mirrors `requireAdmin.ts`): 401 if no
user; allow if `role === 'admin'` or `capabilities.has(cap)`; else 403.
- **`requireBotAccess`** helper: allow if admin or `bots.all` or botId ∈ access list;
else 403. Mounted on the player router's existing `/:botId` choke-point
(`src/web/api/player.ts`) and on each `:id` route in `src/web/api/bot.ts`
(start/stop/edit/delete/avatar/profile).
- **Route → capability mapping:**
- `/api/player/:botId/*` playback actions → `player.control` (+ `requireBotAccess`)
- `/api/player/:botId/*` queue actions → `player.queue` (+ `requireBotAccess`)
- `/api/bot` create, `/api/bot/:id` edit/delete, `/api/bot/:id/start|stop|avatar|profile`, `/api/bot/settings` → `bot.manage` (+ `requireBotAccess` for the `:id` ones)
- `/api/auth/*` (cookie/QR/SMS) → `platform.auth`
- `/api/music/quality` POST → `quality`
- **`GET /api/bot`** filters its result to the caller's allowed bots for members
(admins see all). This is what "hides" disallowed bots in the UI.
## Management API (admin-only, added to the existing users router)
- `GET /api/users/:id/permissions` → `{ capabilities: string[], bots: 'all' | string[] }`
- `PUT /api/users/:id/permissions` → body `{ capabilities, bots }`; validates tokens
against the known set and botIds against existing bots; writes audit
`user.permissions_changed`.
- `GET /api/session/me` is extended to include the **current** user's
`{ capabilities, bots }` so the frontend can gate UI. (admins report effectively-all.)
## Frontend
- `useSession` extends `User` with `capabilities` + bot scope and exposes
`can(cap)` and `canControlBot(botId)` helpers.
- **Navbar bot selector** filters `store.bots` to controllable bots (others hidden);
`activeBot` fallback and `fetchBots` default only ever land on an allowed bot.
- **Player / Settings** hide controls and whole sections a member lacks: platform
login, audio quality, and bot create/edit/delete are hidden without the matching
capability; playback/queue buttons hidden without `player.control` / `player.queue`.
- **Admin permission editor:** in the Settings → User Management list, each member row
gets a "权限" editor — capability checkboxes + a bot allow-list with an "全部机器人"
toggle. Saving calls `PUT /api/users/:id/permissions`.
## Defaults & migration
- New tables created idempotently in `initTables`.
- **One-time backfill** (guarded so it runs once): every existing `member` gets all
five capabilities + `bots.all`. Admins are skipped (they bypass). This preserves
current behavior for existing members on upgrade.
- **New member default** (`POST /api/users` with role member): capabilities
`{ player.control, player.queue }` + `bots.all` (basic tier).
- Pre-existing accounts default to `role = 'admin'` per the current schema — those are
super-users and unaffected.
## Testing (TDD)
- `PermissionStore` unit tests (set/get capabilities + bot access; `'all'` vs list vs
empty; `pruneBot`).
- `requirePermission` / `requireBotAccess` middleware tests (admin bypass; has/lacks
cap → 200/403; bot in/out of allow-list; `bots.all`).
- API tests: member without cap → 403; with cap → 200; bot not allowed → 403/hidden;
`GET /api/bot` filtered for members, full for admin; `PUT .../permissions` validates
+ audits.
- Migration test: existing members backfilled to full + `bots.all`; new member gets
basic tier.
## Non-goals
- No per-bot×per-capability matrix, no custom role templates (YAGNI).
- Guest mode, dedicated-link UX, auto-pause, and the refresh bug (#1–#4) are separate.
- No change to the admin/member role concept itself; this layers capabilities under
the existing `member` role.
@@ -0,0 +1,101 @@
# Auto-pause on empty channel — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
and resume when someone returns.
## Decisions (from brainstorm)
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
enter/leave/move events; keep the existing 30s idle poll as a fallback.
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
emptiness signal but act independently (pause immediately; disconnect after N minutes).
## Current state (verified)
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
bot*; callers compute "others" as `length - 1`. No persistent roster.
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
*logs* `clientEnter` and does not re-emit leave/moved.
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
idle-disconnect after `idleTimeoutMinutes`.
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
auto-vs-user-pause distinction today.
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
## Design
### Occupancy signal (shared)
Extract the idle poller's count into one method on `BotInstance`:
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
auto-pause logic below. It is called by:
1. the existing 30s poll (fallback), and
2. new TS event handlers.
### Event subscription
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
the poll remains as a safety net.
### Auto-pause logic (inside `checkChannelOccupancy`)
Add a private `autoPaused = false` flag to `BotInstance`.
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
scheduled as today.)
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
### `autoPaused` bookkeeping (so user pauses are respected)
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
### Config wiring
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
can be as simple as updating the stored config reference / a field the check reads.
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
it on save.
## Components / files
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
connect/disconnect; `updateAutoPause(enabled)`.
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
- `src/data/config.ts` — field already exists (no change beyond confirming default).
## Testing
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
persists, propagates).
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
## Non-goals
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
- Reaction relies on events the bot can already see (same-channel members are always in view);
no extra channel subscription needed.
@@ -0,0 +1,65 @@
# Dedicated-link bot scoping (+ refresh fix) — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
## Decision (from brainstorm: Q2 = URL-carried scope)
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
## Design
### Scope state (store)
Add to the player store:
- `scopedBotId: string | null` — the bot the UI is locked to.
- getter `isScoped` = `scopedBotId !== null`.
- action `setScope(id)` / `clearScope()`.
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
### URL as the durable source of truth
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
- **Router `beforeEach` guard** (the heart of it):
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
- Else → no scope; continue.
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
### Exit
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
### Navbar (the lock UI)
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
- When not scoped: unchanged (full selector over `controllableBots`).
### Active-bot coherence
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
## Components / files
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
## Testing
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
- `clearScope` resets state.
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
## Non-goals
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
+218
View File
@@ -14,8 +14,10 @@
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10",
"axios": "^1.14.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"koa": "^3.2.0",
@@ -29,10 +31,14 @@
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
@@ -667,6 +673,29 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@paralleldrive/cuid2/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -1152,6 +1181,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1194,6 +1230,23 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1240,6 +1293,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
@@ -1285,6 +1345,30 @@
"@types/node": "*"
}
},
"node_modules/@types/superagent": {
"version": "8.1.10",
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz",
"integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cookiejar": "^2.1.5",
"@types/methods": "^1.1.4",
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/supertest": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
"integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/methods": "^1.1.4",
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -1501,6 +1585,13 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -1608,6 +1699,12 @@
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.8.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
@@ -2011,6 +2108,16 @@
"node": ">= 0.8"
}
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2076,6 +2183,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -2085,6 +2211,13 @@
"node": ">=6.6.0"
}
},
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
@@ -2265,6 +2398,17 @@
"node": ">=8"
}
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -2596,6 +2740,13 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2744,6 +2895,24 @@
"node": ">= 0.6"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5671,6 +5840,55 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+6
View File
@@ -19,8 +19,10 @@
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.10",
"axios": "^1.14.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"koa": "^3.2.0",
@@ -34,10 +36,14 @@
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
* Called by setup.bat after npm install --ignore-scripts.
*
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { get } from "node:https";
import { Readable } from "node:stream";
import { execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
function download(url) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 120000 }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
});
}
function log(msg) {
console.log(` [binary] ${msg}`);
}
function isValidSize(filePath, minBytes) {
try { return statSync(filePath).size >= minBytes; } catch { return false; }
}
async function downloadFfmpeg() {
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
const ffDest = join(ffDir, ffName);
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
if (existsSync(ffDest)) {
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
log("ffmpeg already exists, skipping");
return true;
}
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
log("Downloading ffmpeg...");
const buf = await download(url);
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
try { execSync(`chmod +x "${ffDest}"`); } catch {}
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
log(`ffmpeg OK (${size} MB)`);
return true;
}
async function downloadOpus() {
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
if (existsSync(opusDest)) {
if (isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus already exists, skipping");
return true;
}
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
log("Downloading @discordjs/opus...");
try {
const buf = await download(url);
mkdirSync(dirname(opusDest), { recursive: true });
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
log("@discordjs/opus OK");
return true;
} catch (err) {
log(`CDN download failed (${err.message}), trying to build from source...`);
try {
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus built from source OK");
return true;
}
log("Source build completed but .node file not found");
return false;
} catch (buildErr) {
log(`Source build failed: ${buildErr.message}`);
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
return false;
}
}
}
async function downloadBetterSqlite3() {
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
if (existsSync(dest)) {
if (isValidSize(dest, 500 * 1024)) {
log("better-sqlite3 already exists, skipping");
return true;
}
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
}
const version = "12.8.0";
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
log("Downloading better-sqlite3...");
const buf = await download(url);
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
mkdirSync(dirname(dest), { recursive: true });
await tar.extract({ cwd: pkgDir, file: tmpFile });
if (existsSync(dest)) {
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
return true;
}
log("better-sqlite3 extracted but .node file not found at expected path");
return false;
}
try {
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
if (results.some(Boolean)) {
console.log(" [binary] All downloads complete");
}
} catch (e) {
console.error(` [binary] ERROR: ${e.message}`);
process.exit(1);
}
+87 -114
View File
@@ -4,14 +4,13 @@ chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Robust Edition)
:: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror
:: - Strict error checking at every step
:: - Detailed logging to setup.log
:: - Skip already-completed steps on re-run
:: - Download native binaries from CDN (避开 GitHub)
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.0"
set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
@@ -44,42 +43,29 @@ echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/6" "Checking Node.js"
call :step "1/7" "Checking Node.js"
where node >nul 2>&1
if errorlevel 1 (
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
echo - https://nodejs.org/ ^(official^)
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
echo.
echo After installation:
echo 1. Close this window completely
echo 2. Open a NEW Command Prompt
echo 3. Run scripts\setup.bat again
echo.
pause
exit /b 1
)
if not errorlevel 1 goto :check_node_version
:: Check Node version >= 20
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a"
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do (
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b"
)
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
echo https://nodejs.org/ (official)
echo https://nodejs.cn/ (China mirror, recommended)
echo.
pause
exit /b 1
:: Robust version parse
:check_node_version
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
set "NODE_VER_NUM=%NODE_VER:v=%"
for /f "tokens=1 delims=." %%a in ("%NODE_VER_NUM%") do set "NODE_MAJOR=%%a"
for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
pause
exit /b 1
)
@@ -88,12 +74,11 @@ echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/6" "Checking npm"
call :step "2/7" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found. This is unusual since Node.js is installed."
echo Please reinstall Node.js to fix this.
call :error "npm not found."
pause
exit /b 1
)
@@ -106,117 +91,91 @@ echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/6" "Checking network"
call :step "3/7" "Checking network"
set "USE_MIRROR=0"
set "MIRROR_REGISTRY=https://registry.npmjs.org"
:: Try reaching npm registry with a short timeout
echo Testing connection to registry.npmjs.org...
echo Testing connection to npm registry...
call :log "Testing npm registry connectivity..."
:: Use curl if available (more reliable than ping for HTTPS)
where curl >nul 2>&1
if not errorlevel 1 (
curl -s -o nul -m 5 -w "%%{http_code}" https://registry.npmjs.org/ > "%TEMP%\npmtest.txt" 2>nul
set /p HTTP_CODE=<"%TEMP%\npmtest.txt"
del "%TEMP%\npmtest.txt" >nul 2>&1
if "!HTTP_CODE!"=="200" (
echo [OK] npm registry reachable.
call :log "npm registry HTTP 200 OK"
) else (
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
call :log "npm registry returned: !HTTP_CODE!"
set "USE_MIRROR=1"
)
ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly, using China mirror.
call :log "npm registry unreachable via ping"
set "USE_MIRROR=1"
) else (
:: Fallback to ping
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly.
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
)
echo [OK] npm registry reachable.
call :log "npm registry reachable"
)
if "%USE_MIRROR%"=="1" (
echo.
echo Slow connection detected. Switching to China mirror ^(npmmirror.com^)...
echo [INFO] Using China mirror (npmmirror.com)
call :log "Switching to npmmirror.com"
call npm config set registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
call npm config set disturl https://registry.npmmirror.com/-/binary/node >>"%LOG_FILE%" 2>&1
call npm config set electron_mirror https://registry.npmmirror.com/-/binary/electron/ >>"%LOG_FILE%" 2>&1
call npm config set sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set node_sqlite3_binary_host_mirror https://registry.npmmirror.com/-/binary/better-sqlite3 >>"%LOG_FILE%" 2>&1
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
echo [OK] Mirror configured.
set "MIRROR_REGISTRY=https://registry.npmmirror.com"
set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
) else (
set "CDN_MIRROR="
)
echo.
:: ============================================================
:: Step 4: Install backend dependencies
:: Step 4: Install backend dependencies (跳过二进制)
:: ============================================================
call :step "4/6" "Installing backend dependencies"
call :step "4/7" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
call :log "Existing node_modules detected, running npm install to verify"
)
echo Running: npm install ^(this can take 5-15 minutes on slow networks^)
echo Press Ctrl+C to abort.
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
echo.
call npm install >>"%LOG_FILE%" 2>&1
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo.
echo Common causes:
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
echo - Native module compile failure ^(missing Python/VS Build Tools^)
echo - Disk space full
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm install --verbose
echo.
echo Check the log: %LOG_FILE%
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 4b: Download native binaries from CDN
:: ============================================================
call :step "4b/7" "Downloading native binaries"
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
) else (
echo [OK] Native binaries installed.
)
echo.
:: ============================================================
:: Step 5: Install frontend dependencies
:: ============================================================
call :step "5/6" "Installing frontend dependencies"
call :step "5/7" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found. Repository may be incomplete."
echo Please re-clone the repository:
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
call :error "web\package.json not found."
pause
exit /b 1
)
echo Running: npm install ^(in web/ directory^)
echo Running: npm install (in web/)
echo.
pushd web >nul
call npm install >>"%LOG_FILE%" 2>&1
call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul
if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)."
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%\web"
echo npm install --verbose
echo.
call :error "Frontend npm install failed."
pause
exit /b 1
)
@@ -224,29 +183,48 @@ echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project (backend + frontend)
:: Step 6: Build project
:: ============================================================
call :step "6/6" "Building project"
call :step "6/7" "Building project"
echo Running: npm run build
echo.
call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Build failed."
echo.
echo Check the log file for details: %LOG_FILE%
echo.
echo Try manually:
echo cd /d "%PROJECT_ROOT%"
echo npm run build
echo.
call :error "Build failed. Check: %LOG_FILE%"
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ============================================================
:: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
:: ============================================================
call :step "7/7" "Checking PowerShell PATH"
where powershell >nul 2>&1
if errorlevel 1 (
echo [WARN] PowerShell not found in PATH.
echo Attempting to fix...
set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
if exist "!POWERSHELL_PATH!\powershell.exe" (
:: 为用户添加永久 PATH 环境变量
echo [INFO] Adding PowerShell to user PATH...
call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
echo [OK] PowerShell added to PATH. Please restart your terminal.
) else (
echo [WARN] Could not find powershell.exe on this system.
echo If you encounter playback issues with some NetEase songs,
echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
echo before running scripts\start.bat
)
) else (
echo [OK] PowerShell found in PATH.
)
echo.
:: ============================================================
:: Verify build outputs
:: ============================================================
@@ -263,18 +241,13 @@ if not exist "web\dist" (
)
if "!BUILD_OK!"=="0" (
echo.
echo Build completed but expected output is missing.
echo Check %LOG_FILE% for details.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
:: ============================================================
:: Optional: config.json hint
:: ============================================================
if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch.
) else (
@@ -294,9 +267,8 @@ echo.
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo 3. Follow the in-browser setup wizard.
echo.
echo Setup log saved to: %LOG_FILE%
echo Setup log: %LOG_FILE%
echo.
pause
exit /b 0
@@ -319,3 +291,4 @@ goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
#
# TSMusicBot Setup Script (Linux/macOS)
# - Auto-detect China network, switch to npmmirror
# - Download native binaries from CDN (避开 GitHub)
# - One-click setup, same as setup.bat for Windows
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_FILE="$PROJECT_DIR/setup.log"
echo "============================================"
echo " TSMusicBot - First-Time Setup (Linux)"
echo "============================================"
echo ""
echo "Log file: $LOG_FILE"
echo ""
# ---- Check Node.js ----
if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
fi
echo "[OK] Node.js $(node -v)"
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
fi
echo "[OK] npm v$(npm -v)"
echo ""
# ---- Detect China network ----
USE_MIRROR=0
MIRROR_REGISTRY="https://registry.npmjs.org"
CDN_MIRROR=""
echo "Testing connection to npm registry..."
if ping -c 1 -W 4 registry.npmjs.org &>/dev/null; then
echo "[OK] npm registry reachable."
else
echo "[WARN] Cannot reach npm registry, using China mirror."
USE_MIRROR=1
fi
if [ "$USE_MIRROR" = "1" ]; then
echo "[INFO] Using China mirror (npmmirror.com)"
MIRROR_REGISTRY="https://registry.npmmirror.com"
CDN_MIRROR="https://cdn.npmmirror.com/binaries"
export npm_config_registry="$MIRROR_REGISTRY"
fi
echo ""
# ---- Check build tools (needed for native module fallback) ----
if ! command -v gcc &>/dev/null && ! command -v clang &>/dev/null; then
echo "[INFO] No C compiler found. If CDN binaries are unavailable,"
echo " native modules may fail. Install build tools:"
echo " sudo apt install build-essential (Ubuntu/Debian)"
echo " sudo yum groupinstall 'Development Tools' (CentOS/RHEL)"
echo ""
fi
# ---- Step 1: Install dependencies (skip GitHub binaries) ----
echo "---- 1/5: Installing Node.js dependencies ----"
echo ""
cd "$PROJECT_DIR"
npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Dependencies installed."
echo ""
# ---- Step 2: Download native binaries from CDN ----
echo "---- 2/5: Downloading native binaries ----"
echo ""
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
echo "[OK] Native binaries installed."
else
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
fi
echo ""
# ---- Step 3: Install web panel dependencies ----
echo "---- 3/5: Installing web panel dependencies ----"
echo ""
if [ -f "web/package.json" ]; then
cd "$PROJECT_DIR/web"
npm install --registry="$MIRROR_REGISTRY" 2>&1 | tee -a "$LOG_FILE"
cd "$PROJECT_DIR"
echo "[OK] Web panel dependencies installed."
else
echo "[SKIP] web/package.json not found."
fi
echo ""
# ---- Step 4: Build project ----
echo "---- 4/5: Building project ----"
echo ""
npm run build 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Build succeeded."
echo ""
# ---- Step 5: Verify ----
echo "---- 5/5: Verifying build ----"
echo ""
BUILD_OK=1
if [ ! -d "dist" ]; then
echo "[ERROR] dist/ directory missing."
BUILD_OK=0
fi
if [ -d "web" ] && [ ! -d "web/dist" ]; then
echo "[ERROR] web/dist/ directory missing."
BUILD_OK=0
fi
if [ "$BUILD_OK" = "0" ]; then
echo "Build completed but expected output is missing."
exit 1
fi
echo "[OK] Build outputs verified."
echo ""
if [ ! -f "config.json" ]; then
echo "[INFO] config.json will be auto-generated on first launch."
fi
echo ""
echo "============================================"
echo " Setup Complete!"
echo "============================================"
echo ""
echo "Next steps:"
echo " 1. Run: npm start"
echo " 2. Open: http://localhost:3000"
echo ""
echo "Setup log: $LOG_FILE"
echo ""
+17 -20
View File
@@ -7,7 +7,7 @@ echo.
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat for automatic installation, or install Node.js 20+ from https://nodejs.org
echo Run scripts\setup.bat first.
pause
exit /b 1
)
@@ -15,33 +15,30 @@ if %errorlevel% neq 0 (
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Install dependencies if needed
:: Check if dependencies are installed
if not exist "node_modules" (
echo Installing dependencies...
call npm install --production
if %errorlevel% neq 0 (
echo Failed to install dependencies.
pause
exit /b 1
)
echo Dependencies not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Build if dist/ doesn't exist
:: Check if build output exists
if not exist "dist" (
echo Building project...
call npx tsc
if %errorlevel% neq 0 (
echo Build failed.
pause
exit /b 1
)
echo Build not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed.
echo FFmpeg is bundled via node_modules (ffmpeg-static).
echo.
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
where powershell >nul 2>&1
if errorlevel 1 (
if exist "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" (
set "PATH=%PATH%;C:\Windows\System32\WindowsPowerShell\v1.0\"
)
)
:: Start the application
node dist/index.js
pause
+68 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir } from "./player.js";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
@@ -45,6 +45,14 @@ describe("buildFfmpegArgs", () => {
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
});
it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => {
const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0);
const idx = args.indexOf("-reconnect_at_eof");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("1");
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
});
it("inserts -ss before -i when seekSeconds > 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
const ssIdx = args.indexOf("-ss");
@@ -62,6 +70,7 @@ describe("buildFfmpegArgs", () => {
it("omits HTTP-only flags when input is a local file path", () => {
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
expect(args).not.toContain("-reconnect");
expect(args).not.toContain("-reconnect_at_eof");
expect(args).not.toContain("-reconnect_on_network_error");
expect(args).not.toContain("-reconnect_on_http_error");
expect(args).not.toContain("-headers");
@@ -80,6 +89,37 @@ describe("buildFfmpegArgs", () => {
});
});
describe("volumeToFactor (#84 smooth volume curve)", () => {
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
expect(volumeToFactor(0)).toBe(0);
expect(volumeToFactor(100)).toBe(1);
});
it("clamps out-of-range input", () => {
expect(volumeToFactor(-20)).toBe(0);
expect(volumeToFactor(150)).toBe(1);
});
it("is strictly monotonic across the whole range (no dead zone)", () => {
for (let v = 0; v < 100; v++) {
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
}
});
it("removes the old flat 80-99 dead zone", () => {
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
});
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
});
it("keeps the low range gentle", () => {
expect(volumeToFactor(50)).toBeLessThan(0.12);
});
});
describe("shouldUsePowerShellDownload", () => {
const jdymusicUrl =
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
@@ -133,3 +173,30 @@ describe("cleanupTempDir", () => {
expect(() => cleanupTempDir(dir)).not.toThrow();
});
});
describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
const MAX_EMPTY = 250; // ~5s near-end threshold
const MAX_STALL = 3000; // ~60s far-from-end watchdog
it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => {
expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => {
// This is the core regression: a brief underrun mid-song must not end the track.
expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => {
// The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence.
expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true);
});
it("never ends before any threshold", () => {
expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
});
+115 -4
View File
@@ -91,6 +91,11 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
if (isHttp) {
args.push(
"-reconnect", "1",
// Long B站 streams sit on a CDN whose session/token can close the
// connection mid-file (premature EOF). Without this, FFmpeg treats that
// EOF as end-of-input and stops ~partway through (see #89); with it, it
// re-issues a Range request from the current offset to finish the stream.
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "30",
"-reconnect_on_network_error", "1",
@@ -103,6 +108,43 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
return args;
}
/**
* Decide whether to end the current track when FFmpeg is still alive but has
* produced no decodable audio for `emptyAttempts` consecutive frame ticks.
*
* - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF.
* - Far from the end we wait much longer (`maxStallAttempts`) before giving up,
* so a transient buffer underrun on a healthy stream does NOT cause a false
* skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session
* expired mid-playback, #89) still recovers by advancing instead of going
* permanently silent.
*/
export function shouldEndOnStall(
emptyAttempts: number,
isNearEnd: boolean,
maxEmptyAttempts: number,
maxStallAttempts: number,
): boolean {
if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true;
if (emptyAttempts >= maxStallAttempts) return true;
return false;
}
/**
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
*
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
* the low end gentle but ramps smoothly toward full loudness near the top, so the
* slider feels proportional with no dead zone and no discontinuity at 100.
*/
export function volumeToFactor(volume: number): number {
const x = Math.max(0, Math.min(100, volume)) / 100;
return 0.2 * x + 0.8 * Math.pow(x, 8);
}
export interface PlayerEvents {
frame: (opusFrame: Buffer) => void;
trackEnd: () => void;
@@ -136,6 +178,14 @@ export class AudioPlayer extends EventEmitter {
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
private downloader: ChildProcess | null = null;
private currentTempDir: string | null = null;
private emptyFrameAttempts = 0;
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
// Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio
// for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream
// as dead and advance instead of staying silent forever. Set high so a normal
// transient underrun never trips it.
private static readonly MAX_STALL_ATTEMPTS = 3000;
private currentSongDuration = 0; // 当前歌曲总时长(秒)
constructor(logger: Logger) {
super();
@@ -143,7 +193,7 @@ export class AudioPlayer extends EventEmitter {
this.logger = logger;
}
play(url: string, seekSeconds = 0): void {
play(url: string, seekSeconds = 0, songDuration = 0): void {
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
this.stop();
@@ -154,6 +204,8 @@ export class AudioPlayer extends EventEmitter {
this.healthyFrames = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
this.emptyFrameAttempts = 0;
this.currentSongDuration = songDuration;
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
@@ -420,6 +472,60 @@ export class AudioPlayer extends EventEmitter {
if (this.state === "playing") this.sendNextFrame();
else if (this.state === "paused") this.nextFrameTime = performance.now();
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
const elapsed = this.getElapsed();
const isNearEnd = this.currentSongDuration > 0
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
: true; // 未知时长时保守处理
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.emptyFrameAttempts++;
// End the track when FFmpeg has gone silent: quickly if we're near the
// end (normal EOF), or after a much longer stall window if we're not
// (a dead/expired stream — #89 — so playback recovers instead of going
// permanently silent).
if (
shouldEndOnStall(
this.emptyFrameAttempts,
isNearEnd,
AudioPlayer.MAX_EMPTY_ATTEMPTS,
AudioPlayer.MAX_STALL_ATTEMPTS,
)
) {
this.logger.info({
sessionId: this.sessionId,
emptyAttempts: this.emptyFrameAttempts,
bufferSize: this.pcmBuffer.length,
elapsed: Math.round(elapsed),
duration: this.currentSongDuration,
remaining: Math.round(this.currentSongDuration - elapsed),
nearEnd: isNearEnd,
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
return;
}
} else {
// 成功获取数据或FFmpeg已结束,重置计数器
this.emptyFrameAttempts = 0;
}
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
@@ -461,8 +567,9 @@ export class AudioPlayer extends EventEmitter {
}
private applyVolume(pcm: Buffer): Buffer {
if (this.volume === 100) return Buffer.from(pcm);
const factor = (this.volume / 100) * 0.2;
const factor = volumeToFactor(this.volume);
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
if (factor >= 1) return Buffer.from(pcm);
const out = Buffer.alloc(pcm.length);
for (let i = 0; i < pcm.length; i += 2) {
let sample = Math.round(pcm.readInt16LE(i) * factor);
@@ -472,7 +579,11 @@ export class AudioPlayer extends EventEmitter {
}
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); }
seek(seconds: number): void {
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
this.play(this.currentUrl, seconds, this.currentSongDuration);
}
}
pause(): void { if (this.state === "playing") this.state = "paused"; }
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
resetFailures(): void { this.consecutiveFailures = 0; }
+80
View File
@@ -484,4 +484,84 @@ describe("PlayQueue", () => {
expect(promoted?.id).toBe("x");
});
});
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
// songs repeated often while others were starved. It should behave like a
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
describe("random-loop shuffle bag (issue #70)", () => {
it("plays every song exactly once per cycle before repeating", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 12;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const cycle1 = [queue.current()!.id];
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
const cycle2: string[] = [];
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
// Each cycle is a full permutation of all N songs — zero repeats within
// a cycle, and both cycles cover the same complete set.
expect(new Set(cycle1).size).toBe(N);
expect(new Set(cycle2).size).toBe(N);
expect(new Set(cycle1)).toEqual(new Set(cycle2));
});
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 6;
const CYCLES = 20;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const counts = new Map<string, number>();
counts.set(queue.current()!.id, 1);
for (let i = 0; i < CYCLES * N - 1; i++) {
const id = queue.next()!.id;
counts.set(id, (counts.get(id) ?? 0) + 1);
}
// Shuffle bag => each song plays exactly CYCLES times. True random
// would skew heavily.
for (let i = 0; i < N; i++) {
expect(counts.get(`s${i}`)).toBe(CYCLES);
}
});
it("does not replay the same song across a cycle boundary", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 5;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
// Walk to the last song of cycle 1, then cross into cycle 2.
for (let i = 0; i < N - 1; i++) queue.next();
const lastOfCycle1 = queue.current()!.id;
const firstOfCycle2 = queue.next()!.id;
expect(firstOfCycle2).not.toBe(lastOfCycle1);
});
it("includes a song added mid-cycle within the current cycle", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // A
queue.next(); // B — both originals now played this cycle
queue.add(makeSong("C")); // added mid-cycle, still unplayed
// C is the only unplayed song, so it must come next (not a reshuffle).
expect(queue.next()?.id).toBe("C");
});
it("keeps looping forever with multiple songs (never returns null)", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
for (let i = 0; i < 30; i++) {
expect(queue.next()).not.toBeNull();
}
});
});
});
+30 -21
View File
@@ -155,33 +155,42 @@ export class PlayQueue {
return this.songs[target];
}
}
// 前进栈为空,走纯随机逻辑
if (this.mode === PlayMode.Random) {
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
} else {
// Shuffle bag: pick uniformly from the songs not yet played this
// cycle, so every song plays once before any repeats (NetEase/QQ
// style). Songs added mid-cycle aren't in playedIndices, so they're
// naturally eligible within the current cycle.
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) {
// Cycle complete.
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
// 随机循环:reshuffle and keep going forever.
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
this.playedIndices = new Set([0]);
return this.songs[0];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.pushHistory(this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
// Start a fresh cycle: every song is eligible again, but exclude
// the song that just played from THIS pick only, so it doesn't
// repeat back-to-back across the boundary. It stays eligible for
// the rest of the new cycle, so every song still plays exactly once.
this.playedIndices = new Set();
for (let i = 0; i < this.songs.length; i++) {
if (i !== this.currentIndex) unplayed.push(i);
}
}
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { decideOccupancyAction, occupancyFromClientList } from "./auto-pause.js";
describe("decideOccupancyAction", () => {
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
describe("occupancyFromClientList", () => {
it("returns null when the query failed (0 clients — bot itself is always present)", () => {
// This is the bug fix: a clientlist timeout makes getClientsInChannel()
// return [], which must be treated as "unknown", NOT as an empty channel.
expect(occupancyFromClientList(0)).toBeNull();
});
it("returns 0 other users when only the bot is in the channel", () => {
expect(occupancyFromClientList(1)).toBe(0);
});
it("excludes the bot itself from the count", () => {
expect(occupancyFromClientList(2)).toBe(1);
expect(occupancyFromClientList(5)).toBe(4);
});
it("never yields a negative count (guards the -1 that caused false pauses)", () => {
expect(occupancyFromClientList(-3)).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Convert a channel client-list length into the number of *other* users, or
* `null` when occupancy can't be determined.
*
* A connected bot is always a member of its own channel, so a valid query
* returns at least 1 (the bot itself). A length of 0 therefore does NOT mean
* "empty channel" — it means the underlying `clientlist` query failed (e.g. the
* full-client `clientlist` command times out when other clients are present,
* and `getClientsInChannel()` returns `[]` on error). Treating that failure as
* "empty" is what caused playback to auto-pause within seconds whenever a
* listener was actually in the channel. When the result is indeterminate we
* return `null` so callers skip the auto-pause/idle decision entirely rather
* than mis-reading an unknown state as empty.
*/
export function occupancyFromClientList(clientCount: number): number | null {
if (clientCount <= 0) return null; // query failed → occupancy unknown
return clientCount - 1; // exclude the bot itself
}
/**
* Decide what auto-pause should do given channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
+203 -60
View File
@@ -6,17 +6,19 @@ import {
} from "../ts-protocol/client.js";
import { AudioPlayer } from "../audio/player.js";
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
import type { MusicProvider } from "../music/provider.js";
import type { MusicProvider, Song } from "../music/provider.js";
import {
parseCommand,
isAdminCommand,
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
import type { AvatarStore } from "../data/avatars.js";
import { decideOccupancyAction, occupancyFromClientList } from "./auto-pause.js";
export interface BotInstanceOptions {
id: string;
@@ -66,8 +68,12 @@ export class BotInstance extends EventEmitter {
private isAdvancing = false;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
private autoPaused = false;
private profileManager: BotProfileManager;
private isFmMode = false;
private fmProvider: MusicProvider | null = null;
/** Results of the most recent !search, for "#N" selection (issue #90). */
private lastSearchResults: Song[] = [];
constructor(options: BotInstanceOptions) {
super();
@@ -143,6 +149,8 @@ export class BotInstance extends EventEmitter {
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.player.stop();
// A lifecycle change must not leave a stale auto-resume armed.
this.autoPaused = false;
// Only emit externally once per lifecycle so clients don't see a
// duplicate "disconnected" after an explicit disconnect() call.
if (this.disconnectEmitted) return;
@@ -151,8 +159,30 @@ export class BotInstance extends EventEmitter {
});
this.tsClient.on("connected", () => {
// Fresh connection — clear any stale auto-pause flag from a prior session.
this.autoPaused = false;
this._startIdlePoller();
});
// React near-instantly to channel membership changes. The 30s idle
// poller remains the fallback if any of these events are missed.
this.tsClient.on("clientEnter", () => void this.refreshOccupancy());
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
}
private async refreshOccupancy(): Promise<void> {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
// A 0-length result means the clientlist query failed (the bot is always
// in its own channel) — occupancy is unknown, so don't act. Acting on it
// would mis-read it as "empty" and falsely auto-pause / idle-disconnect.
const userCount = occupancyFromClientList(clients.length);
if (userCount !== null) this.handleOccupancy(userCount);
} catch {
// ignore — the 30s poll is the fallback
}
}
async connect(): Promise<void> {
@@ -187,24 +217,53 @@ export class BotInstance extends EventEmitter {
if (minutes === 0) this._cancelIdleTimer();
}
/** 外部更新 autoPauseOnEmpty(由 API 保存时调用) */
updateAutoPause(enabled: boolean): void {
this.config.autoPauseOnEmpty = enabled;
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
private _startIdlePoller(): void {
// 每 30 秒检查一次频道人数
const poll = async () => {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
const userCount = clients.length - 1; // 排除 bot 自身
if (userCount <= 0) {
this._scheduleIdleCheck();
} else {
this._cancelIdleTimer();
}
// null = clientlist query failed (occupancy unknown) → don't act.
const userCount = occupancyFromClientList(clients.length);
if (userCount !== null) this.handleOccupancy(userCount);
} catch { /* ignore */ }
setTimeout(poll, 30_000);
};
setTimeout(poll, 30_000);
}
private handleOccupancy(userCount: number): void {
// idle-disconnect (unchanged behavior)
if (userCount <= 0) this._scheduleIdleCheck();
else this._cancelIdleTimer();
// auto-pause
const action = decideOccupancyAction(
this.player.getState(),
this.autoPaused,
this.config.autoPauseOnEmpty,
userCount,
);
if (action === "pause") {
this.player.pause();
this.autoPaused = true;
this.emit("stateChange");
} else if (action === "resume") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
private _scheduleIdleCheck(): void {
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
const minutes = this.config.idleTimeoutMinutes ?? 0;
@@ -283,6 +342,9 @@ export class BotInstance extends EventEmitter {
throw new Error("Bot is not connected to TeamSpeak");
}
switch (cmd.name) {
case "search":
case "find":
return this.cmdSearch(cmd);
case "play":
return this.cmdPlay(cmd);
case "add":
@@ -319,7 +381,7 @@ export class BotInstance extends EventEmitter {
case "album":
return this.cmdAlbum(cmd);
case "fm":
return this.cmdFm();
return this.cmdFm(cmd);
case "artist":
return this.cmdArtist(cmd);
case "vote":
@@ -343,6 +405,11 @@ export class BotInstance extends EventEmitter {
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
private disableFmMode(): void {
this.isFmMode = false;
this.fmProvider = null;
}
private getProvider(flags: Set<string>): MusicProvider {
if (flags.has("b")) return this.bilibiliProvider;
if (flags.has("q")) return this.qqProvider;
@@ -380,7 +447,10 @@ export class BotInstance extends EventEmitter {
return false;
}
song.url = url;
this.player.play(url);
this.player.play(url, 0, song.duration);
// Fresh playback (re)start — clear auto-pause so a later occupancy
// change won't try to "resume" a track the user already restarted.
this.autoPaused = false;
this.database.addPlayHistory({
botId: this.id,
songId: song.id,
@@ -390,10 +460,8 @@ export class BotInstance extends EventEmitter {
platform: song.platform,
coverUrl: song.coverUrl,
});
// Update bot presence (fire-and-forget — never blocks playback)
this.profileManager.onSongChange(song).catch((err) => {
this.logger.warn({ err }, "Profile update failed after song change");
});
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
await this.syncProfileToSong(song);
this.emit("stateChange");
return true;
} catch (err) {
@@ -402,36 +470,92 @@ export class BotInstance extends EventEmitter {
}
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !play <song name or URL>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0)
return `No results found for: ${cmd.args}`;
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
try {
await this.profileManager.onSongChange(song);
} catch (err) {
this.logger.warn({ err }, "Profile update failed after song change");
}
}
const song = result.songs[0];
/**
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
* forms (issue #90):
* 1) "#N" — the Nth result of the previous !search
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
* 3) plain text — search, returning the single most-popular hit (legacy)
*/
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
const args = (cmd.args ?? "").trim();
const p = this.config.commandPrefix;
// 1) "#N" — pick from the previous !search.
const sel = parseSelectionIndex(args);
if (sel !== null) {
if (this.lastSearchResults.length === 0)
return { error: `No recent search. Use ${p}search <name> first.` };
if (sel > this.lastSearchResults.length)
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
return { song: this.lastSearchResults[sel - 1] };
}
// 2) id:/URL — fetch that exact song.
const ref = parseSongRef(args);
if (ref) {
const provider = ref.platform ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
const song = await provider.getSongDetail(ref.id);
if (!song) return { error: `No song found for ${ref.platform ?? provider.platform} id: ${ref.id}` };
return { song: { ...song, platform: provider.platform } };
}
// 3) Plain search term — single most-popular hit (historical behavior).
const provider = this.getProvider(cmd.flags);
const result = await provider.search(args, 1);
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
return { song: { ...result.songs[0], platform: provider.platform } };
}
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
const p = this.config.commandPrefix;
if (!cmd.args) return `Usage: ${p}search <name> [-q|-b|-y]`;
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 8);
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
this.lastSearchResults = result.songs.map((s) => ({ ...s, platform: provider.platform }));
const lines = this.lastSearchResults.map(
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
);
return [
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id:<id>):`,
...lines,
].join("\n");
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const song0 = song!;
this.queue.clear();
this.isFmMode = false;
this.queue.add({ ...song, platform: provider.platform });
this.disableFmMode();
this.queue.add({ ...song0 });
this.queue.play();
// Reset failure counter on user-initiated play
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`;
if (!ok) return `Cannot play: ${song0.name}`;
return `Now playing: ${song0.name} - ${song0.artist}`;
}
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !add <song name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0)
return `No results found for: ${cmd.args}`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
const song = result.songs[0];
const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...song, platform: provider.platform });
this.queue.add({ ...s });
// If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel
@@ -441,21 +565,19 @@ export class BotInstance extends EventEmitter {
this.player.resetFailures();
await this.resolveAndPlay(this.queue.current()!);
this.emit("stateChange");
return `Now playing: ${song.name} - ${song.artist}`;
return `Now playing: ${s.name} - ${s.artist}`;
}
this.emit("stateChange");
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
}
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playnext <song name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 1);
if (result.songs.length === 0)
return `No results found for: ${cmd.args}`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
const song = result.songs[0];
const wasIdle = this.player.getState() === "idle";
// Capture the slot addNext WILL insert at, before mutating the queue.
// addNext pushes when currentIndex<0 (slot = size); otherwise splices
@@ -466,37 +588,42 @@ export class BotInstance extends EventEmitter {
this.queue.getCurrentIndex() < 0
? this.queue.size()
: this.queue.getCurrentIndex() + 1;
this.queue.addNext({ ...song, platform: provider.platform });
this.queue.addNext({ ...s });
if (wasIdle) {
this.queue.playAt(insertedAt);
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
this.emit("stateChange");
if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`;
if (!ok) return `Cannot play: ${s.name}`;
return `Now playing: ${s.name} - ${s.artist}`;
}
this.emit("stateChange");
return `Up next: ${song.name} - ${song.artist}`;
return `Up next: ${s.name} - ${s.artist}`;
}
private cmdPause(): string {
this.player.pause();
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
this.autoPaused = false;
this.emit("stateChange");
return "Paused";
}
private cmdResume(): string {
this.player.resume();
// User-initiated resume — drop any auto-pause flag.
this.autoPaused = false;
this.emit("stateChange");
return "Resumed";
}
private cmdStop(): string {
this.player.stop();
this.autoPaused = false;
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
@@ -554,7 +681,7 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
@@ -627,7 +754,7 @@ export class BotInstance extends EventEmitter {
if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -662,7 +789,7 @@ export class BotInstance extends EventEmitter {
if (songs.length === 0) return "Album is empty or not found";
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -672,26 +799,38 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
private async cmdFm(): Promise<string> {
if (!this.neteaseProvider.getPersonalFm) {
return "Personal FM is only available for NetEase Cloud Music";
private async cmdFm(cmd: ParsedCommand): Promise<string> {
return this.startFm(this.getProvider(cmd.flags));
}
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
// Match the !fm chat-command guard: refuse before mutating the queue when
// offline, so the web /fm route can't wipe the queue + flip into FM mode
// while nothing can actually play.
if (!this.connected) {
return "Bot is not connected to TeamSpeak";
}
const songs = await this.neteaseProvider.getPersonalFm();
if (!provider.getPersonalFm) {
return `Personal FM is not available for ${provider.platform}`;
}
const songs = await provider.getPersonalFm();
if (songs.length === 0)
return "No FM songs available (need to login first)";
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
this.queue.add({ ...song, platform: provider.platform });
}
this.queue.setMode(PlayMode.Random);
this.isFmMode = true;
this.fmProvider = provider;
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
@@ -712,7 +851,7 @@ export class BotInstance extends EventEmitter {
}
this.queue.clear();
this.isFmMode = false;
this.disableFmMode();
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
}
@@ -726,14 +865,15 @@ export class BotInstance extends EventEmitter {
}
private async refillFm(): Promise<void> {
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
const provider = this.fmProvider;
if (!this.isFmMode || !provider?.getPersonalFm) return;
try {
const songs = await this.neteaseProvider.getPersonalFm();
const songs = await provider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
this.queue.add({ ...song, platform: provider.platform });
}
this.logger.debug({ count: songs.length }, "FM queue refilled");
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
} catch (err) {
this.logger.error({ err }, "Failed to refill FM queue");
}
@@ -785,11 +925,14 @@ export class BotInstance extends EventEmitter {
const p = this.config.commandPrefix;
return [
"TSMusicBot Commands:",
`${p}play <song> — Search and play`,
`${p}play <song> — Search and play (most popular match)`,
`${p}play -q <song> — Search from QQ Music`,
`${p}play -b <song> — Search from BiliBili`,
`${p}play -y <song> — Search from YouTube (yt-dlp)`,
`${p}add <song> — Add to queue`,
`${p}search <name> — List top matches to pick a specific (same-name) song`,
`${p}play #N — Play the Nth result of the last ${p}search`,
`${p}play id:<id> — Play an exact song by id / URL (NetEase·QQ·BiliBili)`,
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
+6 -1
View File
@@ -12,6 +12,7 @@ import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.js";
import type { PermissionStore } from "../data/permissions.js";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
@@ -76,6 +77,7 @@ export class BotManager extends EventEmitter {
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private permissions: PermissionStore;
constructor(
neteaseProvider: MusicProvider,
@@ -84,7 +86,8 @@ export class BotManager extends EventEmitter {
database: BotDatabase,
config: BotConfig,
logger: Logger,
avatarStore: AvatarStore
avatarStore: AvatarStore,
permissions: PermissionStore
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -95,6 +98,7 @@ export class BotManager extends EventEmitter {
this.config = config;
this.logger = logger;
this.avatarStore = avatarStore;
this.permissions = permissions;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -152,6 +156,7 @@ export class BotManager extends EventEmitter {
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
this.permissions.pruneBot(id);
this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed");
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
expect(parseSongRef("Die For You")).toBeNull();
expect(parseSongRef("周杰伦 晴天")).toBeNull();
expect(parseSongRef("")).toBeNull();
// A bare number is NOT treated as an id (a song may be named "2002").
expect(parseSongRef("2002")).toBeNull();
});
it("parses an explicit id: prefix with no platform (defer to flags)", () => {
expect(parseSongRef("id:185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("ID: 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
});
it("strips trailing punctuation from a pasted id:", () => {
expect(parseSongRef("id:185868.")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id:185868)")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
});
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
// These reuse ?id= but are not songs — they should fall through to search,
// not misresolve to getSongDetail(collectionId) and error "no song".
expect(parseSongRef("https://music.163.com/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/#/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/album?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/artist?id=185858")).toBeNull();
// A genuine song URL is still parsed.
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses NetEase song URLs", () => {
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("https://music.163.com/#/song?id=185868&userid=1")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("music.163.com/song/185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses QQ song URLs", () => {
expect(parseSongRef("https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
expect(parseSongRef("https://y.qq.com/n/yqq/song/abc.html?songmid=004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
});
it("parses BiliBili BV ids (bare or in a URL)", () => {
expect(parseSongRef("BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://www.bilibili.com/video/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://b23.tv/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
});
});
describe("parseSelectionIndex (#90 pick from last search)", () => {
it("parses #N tokens (1-based)", () => {
expect(parseSelectionIndex("#1")).toBe(1);
expect(parseSelectionIndex("#2")).toBe(2);
expect(parseSelectionIndex("# 3")).toBe(3);
expect(parseSelectionIndex(" #10 ")).toBe(10);
});
it("rejects non-selections", () => {
expect(parseSelectionIndex("2")).toBeNull();
expect(parseSelectionIndex("#0")).toBeNull();
expect(parseSelectionIndex("#-1")).toBeNull();
expect(parseSelectionIndex("Die For You")).toBeNull();
expect(parseSelectionIndex("#2 extra")).toBeNull();
expect(parseSelectionIndex("")).toBeNull();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single
* most-popular search hit (issue #90).
*
* Two mechanisms:
* - A song reference: an explicit id / platform URL → play that exact song.
* - A selection index: "#N" → the Nth result of the previous !search.
*/
export interface SongRef {
id: string;
/**
* Platform inferred from a URL. `null` means the platform wasn't encoded in
* the reference (e.g. a bare `id:`), so the caller should fall back to the
* command's flags / default provider.
*/
platform: "netease" | "qq" | "bilibili" | null;
}
/**
* Detect an explicit song reference in a query. Recognizes:
* - `id:<id>` → platform from flags/default
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
* Returns `null` for a plain search term (the common case).
*/
export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return null;
// Explicit "id:<id>" — platform decided by the command's flags/default.
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
// a "BV..." token never collides with them).
const bv = /BV[0-9A-Za-z]{8,12}/.exec(q);
if (bv && (/^BV[0-9A-Za-z]{8,12}$/.test(q) || /bilibili\.com|b23\.tv/i.test(q))) {
return { id: bv[0], platform: "bilibili" };
}
// NetEase song URL. Only treat `id=N` as a SONG id when the URL is not a
// collection page (playlist/album/artist/toplist/djradio) — those reuse the
// same `id=` param but are NOT songs; getSongDetail() would 404 them into a
// confusing "no song" error instead of falling back to a normal search.
if (/music\.163\.com/i.test(q) && !/(playlist|album|artist|toplist|djradio)/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/song\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
// QQ song URL.
if (/y\.qq\.com/i.test(q)) {
const m = /songDetail\/([0-9A-Za-z]+)/.exec(q) ?? /[?&]songmid=([0-9A-Za-z]+)/i.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
return null;
}
/**
* Detect a "#N" selection token (1-based) referencing the previous !search.
* Returns the positive integer, or `null` when the query isn't a selection.
*/
export function parseSelectionIndex(raw: string): number | null {
const m = /^#\s*(\d+)$/.exec((raw ?? "").trim());
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createAuditStore, type AuditStore } from "./audit.js";
describe("AuditStore", () => {
let botDb: BotDatabase;
let audit: AuditStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
audit = createAuditStore(botDb.db);
});
afterEach(() => botDb.close());
it("records and lists entries newest-first", async () => {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.created",
});
await new Promise((r) => setTimeout(r, 5));
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.deleted",
});
const list = audit.list(10, 0);
expect(list).toHaveLength(2);
expect(list[0].action).toBe("user.deleted");
expect(list[1].action).toBe("user.created");
});
it("supports limit and offset", () => {
for (let i = 0; i < 5; i++) {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: null, targetUsername: null,
action: "user.password_changed",
});
}
expect(audit.list(2, 0)).toHaveLength(2);
expect(audit.list(2, 4)).toHaveLength(1);
expect(audit.list(10, 10)).toHaveLength(0);
});
it("stores nullable fields correctly", () => {
audit.record({
actorId: null, actorUsername: null,
targetUserId: "x", targetUsername: "deleted-user",
action: "admin.first_created",
});
const e = audit.list(1, 0)[0];
expect(e.actorId).toBeNull();
expect(e.actorUsername).toBeNull();
expect(e.targetUserId).toBe("x");
});
});
+58
View File
@@ -0,0 +1,58 @@
import type Database from "better-sqlite3";
export type AuditAction =
| "admin.first_created"
| "user.created"
| "user.deleted"
| "user.password_reset"
| "user.password_changed"
| "user.role_changed"
| "user.permissions_changed";
export interface AuditEntry {
id: number;
timestamp: number;
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditRecordInput {
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditStore {
record(input: AuditRecordInput): void;
list(limit: number, offset: number): AuditEntry[];
}
export function createAuditStore(db: Database.Database): AuditStore {
const insertStmt = db.prepare(
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
);
const listStmt = db.prepare(
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
);
return {
record(input) {
insertStmt.run(
Date.now(),
input.actorId,
input.actorUsername,
input.targetUserId,
input.targetUsername,
input.action
);
},
list(limit, offset) {
return listStmt.all(limit, offset) as AuditEntry[];
},
};
}
+57 -3
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { getDefaultConfig, loadConfig, saveConfig } from "./config.js";
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
describe("config", () => {
const dirs: string[] = [];
@@ -49,6 +49,60 @@ describe("config", () => {
// defaults should fill in the rest
expect(loaded.theme).toBe("dark");
expect(loaded.commandPrefix).toBe("!");
expect(loaded.autoPauseOnEmpty).toBe(true);
// auto-pause defaults OFF (occupancy detection is unreliable on some servers)
expect(loaded.autoPauseOnEmpty).toBe(false);
});
// --- #86: config.json must live under (and be created in) the persisted data dir ---
it("first run writes config.json into the data dir and reads it back", () => {
const root = makeTmpDir();
const dataDir = join(root, "data");
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
// Boot sequence: load (missing -> defaults) then save.
const config = loadConfig(configPath);
saveConfig(configPath, config);
expect(existsSync(configPath)).toBe(true);
// A subsequent hand-edited file under the SAME persisted path is honored.
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
expect(loadConfig(configPath).webPort).toBe(9999);
});
it("migrates a legacy root config into the data dir, preserving values", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(true);
expect(existsSync(newPath)).toBe(true);
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
const loaded = loadConfig(newPath);
expect(loaded.webPort).toBe(4242);
expect(loaded.publicUrl).toBe("http://x");
});
it("does NOT overwrite an existing data-dir config during migration", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(false); // new location wins, untouched
expect(loadConfig(newPath).webPort).toBe(2222);
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
});
it("migration is a no-op when there is no legacy config", () => {
const root = makeTmpDir();
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
expect(migrated).toBe(false);
});
});
+37 -2
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
import { dirname } from "node:path";
export interface BotConfig {
@@ -36,7 +36,10 @@ export function getDefaultConfig(): BotConfig {
adminPassword: "",
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
// Default OFF: occupancy detection relies on the full-client `clientlist`
// command, which is unreliable on some servers (it can time out when other
// clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false,
idleTimeoutMinutes: 0,
publicUrl: "",
trustProxy: false,
@@ -58,3 +61,35 @@ export function saveConfig(path: string, config: BotConfig): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
}
/**
* One-time migration for the config location fix (#86).
*
* Older versions wrote config.json to the app/repo ROOT, which is NOT inside the
* persisted data directory (the Docker volume is mounted at data/). That meant the
* file never landed in the volume on first run and a manually-placed data/config.json
* was ignored. config.json now lives under the data dir alongside the DB/cookies/logs.
*
* If a legacy root-level config exists and the new data-dir config does not yet exist,
* move it so existing local installs keep their customized settings. Best-effort:
* any failure is swallowed and loadConfig falls back to defaults.
*
* @returns true if a legacy config was migrated, false otherwise.
*/
export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean {
try {
if (legacyPath === newPath) return false;
if (existsSync(newPath)) return false; // new location already populated — leave it
if (!existsSync(legacyPath)) return false; // nothing to migrate
mkdirSync(dirname(newPath), { recursive: true });
copyFileSync(legacyPath, newPath); // copy first (works across filesystems)
try {
rmSync(legacyPath);
} catch {
/* leave the legacy file if it can't be removed; the new one wins */
}
return true;
} catch {
return false;
}
}
+24
View File
@@ -23,6 +23,30 @@ describe("database", () => {
expect(names).toContain("bot_instances");
});
it("creates users and sessions tables on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
const names = tables.map((t) => t.name);
expect(names).toContain("users");
expect(names).toContain("sessions");
const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userCols.map((c) => c.name).sort();
expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "role", "updatedAt", "username"]);
const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
const sessionColNames = sessionCols.map((c) => c.name).sort();
expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]);
});
it("creates user_audit table on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
expect(tables.map((t) => t.name)).toContain("user_audit");
});
it("records and retrieves play history", () => {
botDb.addPlayHistory({
botId: "bot1",
+144
View File
@@ -1,4 +1,5 @@
import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
export interface PlayHistoryEntry {
botId: string;
@@ -51,6 +52,17 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
nowPlayingMsgEnabled: true,
};
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -62,6 +74,10 @@ export interface BotDatabase {
saveProfileConfig(botId: string, config: ProfileConfig): void;
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean;
close(): void;
}
@@ -97,6 +113,12 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name);
if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
}
}
function initTables(db: Database.Database): void {
@@ -127,14 +149,100 @@ function initTables(db: Database.Database): void {
serverPassword TEXT NOT NULL DEFAULT '',
identity TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL,
lastSeenAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
CREATE TABLE IF NOT EXISTS user_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
actorId TEXT,
actorUsername TEXT,
targetUserId TEXT,
targetUsername TEXT,
action TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
CREATE TABLE IF NOT EXISTS favorite_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId TEXT NOT NULL,
platform TEXT NOT NULL,
playlistId TEXT NOT NULL,
name TEXT NOT NULL,
coverUrl TEXT NOT NULL DEFAULT '',
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(userId, platform, playlistId)
);
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
`);
}
/**
* One-time backfill: existing `member` users created before the
* account-permissions feature are granted full access (all 5 capabilities +
* the `bots.all` marker), exactly once per database. Admins are skipped (they
* bypass permission checks). New members created after this runs are not
* affected — they get the basic tier via POST /api/users. A marker row in
* `schema_meta` makes this idempotent.
*/
export function backfillMemberPermissions(db: Database.Database): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (done) return;
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tokens = [...CAPABILITIES, BOTS_ALL];
const tx = db.transaction(() => {
for (const m of members) {
for (const t of tokens) insCap.run(m.id, t);
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length));
});
tx();
}
export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initTables(db);
migrateSchema(db);
backfillMemberPermissions(db);
const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
@@ -187,6 +295,24 @@ export function createDatabase(dbPath: string): BotDatabase {
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
const insertFavorite = db.prepare(`
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
`);
const deleteFavorite = db.prepare(`
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
const selectFavorites = db.prepare(`
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
`);
const checkFavorited = db.prepare(`
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
return {
db,
@@ -258,6 +384,24 @@ export function createDatabase(dbPath: string): BotDatabase {
updateCustomAvatar.run(path, botId);
},
addFavorite(userId, playlist) {
insertFavorite.run({ userId, ...playlist });
},
removeFavorite(userId, playlistId, platform) {
const result = deleteFavorite.run(userId, playlistId, platform);
return result.changes > 0;
},
getFavorites(userId) {
return selectFavorites.all(userId) as FavoritePlaylist[];
},
isFavorited(userId, playlistId, platform) {
const row = checkFavorited.get(userId, playlistId, platform);
return row !== undefined;
},
close() {
db.close();
},
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js";
import { createPermissionStore, CAPABILITIES } from "./permissions.js";
describe("backfillMemberPermissions", () => {
let dbFile: string;
let db: BotDatabase;
function fresh() {
dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
}
afterEach(() => {
db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.rmSync(dbFile + s, { force: true });
} catch {}
}
});
it("grants existing members full access + bots.all, skips admins, once", () => {
fresh();
// simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
const ins = db.db.prepare(
"INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)"
);
ins.run("m1", "mem", "x", now, now, "member");
ins.run("a1", "adm", "x", now, now, "admin");
backfillMemberPermissions(db.db);
const store = createPermissionStore(db.db);
expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort());
expect(store.getBotAccess("m1")).toBe("all");
expect(store.getCapabilities("a1")).toEqual([]);
expect(store.getBotAccess("a1")).toEqual([]);
});
it("is idempotent — running again does not change or re-grant", () => {
fresh();
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
db.db
.prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)")
.run("m1", "mem", "x", now, now, "member");
backfillMemberPermissions(db.db);
// member restricted afterwards
createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] });
// second run must NOT re-grant (marker present)
backfillMemberPermissions(db.db);
expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES, resolvePermissionContext } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
});
+89
View File
@@ -0,0 +1,89 @@
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
}
export function resolvePermissionContext(
role: "admin" | "member",
userId: string,
store: PermissionStore
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.js";
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js";
function sha256(token: string) {
return createHash("sha256").update(token).digest("hex");
}
describe("SessionStore", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let userId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
userId = u.id;
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
const { token } = sessions.createSession(userId);
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
expect(row.id).toBe(sha256(token));
expect(row.id).not.toBe(token);
});
it("validateAndTouch returns the user for a fresh token", () => {
const { token } = sessions.createSession(userId);
const result = sessions.validateAndTouch(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(userId);
expect(result!.username).toBe("alice");
expect(result!.role).toBe("admin");
});
it("validateAndTouch returns null and deletes the row for an expired session", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
expect(sessions.validateAndTouch(token)).toBeNull();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
});
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
sessions.validateAndTouch(token);
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
expect(after.lastSeenAt).toBe(before.lastSeenAt);
});
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
sessions.validateAndTouch(token);
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
expect(row.lastSeenAt).toBe(Date.now());
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
});
it("deleteSession removes the row", () => {
const { token } = sessions.createSession(userId);
sessions.deleteSession(token);
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
expect(sessions.validateAndTouch(token)).toBeNull();
});
it("deleteAllForUser keeps the exceptToken session", () => {
const a = sessions.createSession(userId);
const b = sessions.createSession(userId);
sessions.deleteAllForUser(userId, a.token);
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
expect(sessions.validateAndTouch(b.token)).toBeNull();
});
it("cleanupExpired removes only expired rows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
sessions.createSession(userId); // expires later
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
sessions.createSession(userId); // fresh
sessions.cleanupExpired();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(1);
});
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
// Create MAX + 2 sessions for the same user.
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
tokens.push(sessions.createSession(userId).token);
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
}
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
// The first two should have been evicted, the last MAX remain
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
});
it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => {
// better-sqlite3 transactions are serialised at the engine level. Calling
// createSession N times sequentially via Promise.all proves atomic check+insert.
const N = MAX_SESSIONS_PER_USER + 3;
await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId))));
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { createHash, randomBytes } from "node:crypto";
import type Database from "better-sqlite3";
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const MAX_SESSIONS_PER_USER = 10;
export interface SessionValidation {
userId: string;
username: string;
role: "admin" | "member";
}
export interface SessionStore {
createSession(userId: string): { token: string; expiresAt: number };
validateAndTouch(rawToken: string): SessionValidation | null;
deleteSession(rawToken: string): void;
deleteAllForUser(userId: string, exceptToken?: string): void;
cleanupExpired(): void;
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function createSessionStore(db: Database.Database): SessionStore {
const insertStmt = db.prepare(
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
);
const selectStmt = db.prepare(`
SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username, u.role
FROM sessions s INNER JOIN users u ON u.id = s.userId
WHERE s.id = ?
`);
const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
const touchStmt = db.prepare(
"UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?"
);
const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?");
const deleteAllForUserExceptStmt = db.prepare(
"DELETE FROM sessions WHERE userId = ? AND id != ?"
);
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?");
const deleteOldestForUserStmt = db.prepare(
"DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)"
);
return {
createSession(userId) {
// Cap concurrent sessions per user — oldest gets evicted on overflow.
// Wrap the count → delete → insert in a transaction so concurrent logins
// for the same user can't both pass the cap check and both insert,
// ending up 1 over cap (race window between count and insert).
const token = randomBytes(32).toString("base64url");
const id = hashToken(token);
const now = Date.now();
const expiresAt = now + SESSION_TTL_MS;
const tx = db.transaction(() => {
const existing = (countForUserStmt.get(userId) as { n: number }).n;
if (existing >= MAX_SESSIONS_PER_USER) {
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
}
insertStmt.run(id, userId, now, expiresAt, now);
});
tx();
return { token, expiresAt };
},
validateAndTouch(rawToken) {
if (!rawToken) return null;
const id = hashToken(rawToken);
const row = selectStmt.get(id) as
| { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string; role: string }
| undefined;
if (!row) return null;
const now = Date.now();
if (row.expiresAt < now) {
deleteByIdStmt.run(id);
return null;
}
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
touchStmt.run(now, now + SESSION_TTL_MS, id);
}
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" };
},
deleteSession(rawToken) {
deleteByIdStmt.run(hashToken(rawToken));
},
deleteAllForUser(userId, exceptToken) {
if (exceptToken) {
deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken));
} else {
deleteAllForUserStmt.run(userId);
}
},
cleanupExpired() {
cleanupStmt.run(Date.now());
},
};
}
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, UsernameTakenError, type UserStore } from "./users.js";
describe("UserStore", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers is 0 on a fresh db", () => {
expect(users.countUsers()).toBe(0);
});
it("createUser stores the user and bumps countUsers", async () => {
const u = await users.createUser("alice", "pw-hunter2", "member");
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
expect(u.username).toBe("alice");
expect(users.countUsers()).toBe(1);
});
it("findByUsername is case-insensitive and returns null for missing", async () => {
await users.createUser("Alice", "pw-alice", "member");
expect(users.findByUsername("ALICE")).not.toBeNull();
expect(users.findByUsername("alice")).not.toBeNull();
expect(users.findByUsername("bob")).toBeNull();
});
it("createUser rejects duplicate usernames (case-insensitive)", async () => {
await users.createUser("Alice", "pw-alice", "member");
await expect(users.createUser("alice", "pw-alice-2", "member")).rejects.toBeInstanceOf(UsernameTakenError);
});
it("verifyPassword accepts correct password and rejects wrong one", async () => {
await users.createUser("alice", "correct-horse-battery-staple", "member");
const row = users.findByUsername("alice");
expect(row).not.toBeNull();
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true);
expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false);
});
it("changePassword updates the hash so the old password no longer verifies", async () => {
const u = await users.createUser("alice", "old-pw-pw", "member");
await users.changePassword(u.id, "new-pw-pw");
const row = users.findByUsername("alice");
expect(await users.verifyPassword("old-pw-pw", row!.passwordHash)).toBe(false);
expect(await users.verifyPassword("new-pw-pw", row!.passwordHash)).toBe(true);
});
it("listUsers returns id+username+createdAt ascending, no password hash", async () => {
await users.createUser("alice", "pw-alice", "member");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
expect(list).toHaveLength(2);
expect(list[0].username).toBe("alice");
expect(list[1].username).toBe("bob");
expect(list[0]).not.toHaveProperty("passwordHash");
expect(list[0].id).toMatch(/^[0-9a-f-]{36}$/);
expect(typeof list[0].createdAt).toBe("number");
});
it("deleteUser removes the row and returns true; returns false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.deleteUser(u.id)).toBe(true);
expect(users.countUsers()).toBe(0);
expect(users.deleteUser("not-a-real-id")).toBe(false);
});
it("createFirstUser succeeds on empty db, returns null when a user already exists", async () => {
const a = await users.createFirstUser("alice", "pw-alice");
expect(a).not.toBeNull();
expect(a!.username).toBe("alice");
const b = await users.createFirstUser("bob", "pw-bob-bob");
expect(b).toBeNull();
expect(users.countUsers()).toBe(1);
});
it("createFirstUser is race-safe: concurrent calls produce exactly one user", async () => {
const [a, b, c] = await Promise.all([
users.createFirstUser("alice", "pw-alice"),
users.createFirstUser("bob", "pw-bob-bob"),
users.createFirstUser("charlie", "pw-charlie-pw"),
]);
const created = [a, b, c].filter((u) => u !== null);
expect(created).toHaveLength(1);
expect(users.countUsers()).toBe(1);
});
it("createFirstUser always creates an admin", async () => {
const u = await users.createFirstUser("alice", "pw-alice");
expect(u).not.toBeNull();
expect(u!.role).toBe("admin");
});
it("countAdmins reflects only role=admin", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
expect(users.countUsers()).toBe(2);
expect(users.countAdmins()).toBe(1);
});
it("setRole changes the role and returns true; false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.setRole(u.id, "admin")).toBe(true);
expect(users.findById(u.id)!.role).toBe("admin");
expect(users.setRole("nope", "admin")).toBe(false);
});
it("listUsers includes role", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
const alice = list.find((u) => u.username === "alice")!;
const bob = list.find((u) => u.username === "bob")!;
expect(alice.role).toBe("admin");
expect(bob.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'would_orphan' for the only admin being demoted", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("would_orphan");
expect(users.findById(alice.id)!.role).toBe("admin"); // unchanged
});
it("setRoleIfNotLastAdmin allows demotion when another admin exists", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("ok");
expect(users.findById(alice.id)!.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'not_found' for unknown id", () => {
expect(users.setRoleIfNotLastAdmin("not-a-real-id", "member")).toBe("not_found");
});
it("setRoleIfNotLastAdmin: concurrent demotions of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
// Concurrent demotion of both
const [r1, r2] = await Promise.all([
Promise.resolve(users.setRoleIfNotLastAdmin(alice.id, "member")),
Promise.resolve(users.setRoleIfNotLastAdmin(bob.id, "member")),
]);
// Exactly one should succeed; the other gets "would_orphan"
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
// System retains at least one admin
expect(users.countAdmins()).toBe(1);
});
it("deleteUserIfNotLastAdmin returns 'would_orphan' for the only admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.deleteUserIfNotLastAdmin(alice.id)).toBe("would_orphan");
expect(users.findById(alice.id)).not.toBeNull();
});
it("deleteUserIfNotLastAdmin allows deleting a member at any count", async () => {
await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "member");
expect(users.deleteUserIfNotLastAdmin(bob.id)).toBe("ok");
expect(users.findById(bob.id)).toBeNull();
});
it("deleteUserIfNotLastAdmin: concurrent deletes of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
const [r1, r2] = await Promise.all([
Promise.resolve(users.deleteUserIfNotLastAdmin(alice.id)),
Promise.resolve(users.deleteUserIfNotLastAdmin(bob.id)),
]);
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
expect(users.countAdmins()).toBe(1);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member";
export interface UserRow {
id: string;
username: string;
passwordHash: string;
createdAt: number;
updatedAt: number;
role: UserRole;
}
export interface UserStore {
countUsers(): number;
countAdmins(): number;
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
createFirstUser(username: string, password: string): Promise<UserRow | null>;
findByUsername(username: string): UserRow | null;
findById(id: string): UserRow | null;
verifyPassword(plain: string, hash: string): Promise<boolean>;
changePassword(userId: string, newPassword: string): Promise<void>;
setRole(userId: string, role: UserRole): boolean;
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
deleteUser(id: string): boolean;
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
}
export class UsernameTakenError extends Error {
constructor(username: string) {
super(`username taken: ${username}`);
this.name = "UsernameTakenError";
}
}
export function createUserStore(db: Database.Database): UserStore {
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users");
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
const insertStmt = db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
);
const findByUsernameStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE"
);
const findByIdStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE id = ?"
);
const updatePasswordStmt = db.prepare(
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
);
const updateRoleStmt = db.prepare(
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
);
const listUsersStmt = db.prepare(
"SELECT id, username, createdAt, role FROM users ORDER BY createdAt ASC"
);
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
return {
countUsers() {
return (countStmt.get() as { n: number }).n;
},
countAdmins() {
return (countAdminsStmt.get() as { n: number }).n;
},
async createUser(username, password, role) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
try {
insertStmt.run(id, username, hash, now, now, role);
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new UsernameTakenError(username);
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role };
},
async createFirstUser(username, password) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
const run = db.transaction(() => {
const count = (countStmt.get() as { n: number }).n;
if (count !== 0) return null;
try {
insertStmt.run(id, username, hash, now, now, "admin");
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
return null;
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow;
});
return run();
},
findByUsername(username) {
return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null;
},
findById(id) {
return (findByIdStmt.get(id) as UserRow | undefined) ?? null;
},
verifyPassword(plain, hash) {
return bcrypt.compare(plain, hash);
},
async changePassword(userId, newPassword) {
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
updatePasswordStmt.run(hash, Date.now(), userId);
},
setRole(userId, role) {
const result = updateRoleStmt.run(role, Date.now(), userId);
return result.changes > 0;
},
setRoleIfNotLastAdmin(id, newRole) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === newRole) return "ok" as const; // no-op
if (row.role === "admin" && newRole === "member") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
updateRoleStmt.run(newRole, Date.now(), id);
return "ok" as const;
});
return tx();
},
listUsers() {
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
},
deleteUser(id) {
const result = deleteUserStmt.run(id);
return result.changes > 0;
},
deleteUserIfNotLastAdmin(id) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === "admin") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
deleteUserStmt.run(id);
return "ok" as const;
});
return tx();
},
};
}
+14 -3
View File
@@ -1,6 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfig, saveConfig } from "./data/config.js";
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js";
import { createDatabase } from "./data/database.js";
import { createLogger } from "./logger.js";
import { createApiServerManager } from "./music/api-server.js";
@@ -9,13 +9,18 @@ import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const DATA_DIR = path.join(ROOT_DIR, "data");
const CONFIG_PATH = path.join(ROOT_DIR, "config.json");
// config.json lives under the persisted data dir (the Docker volume) alongside the
// DB/cookies/logs, so it survives container restarts and manual edits take effect
// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once.
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json");
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
const LOG_DIR = path.join(DATA_DIR, "logs");
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
@@ -23,6 +28,9 @@ const AVATAR_DIR = path.join(DATA_DIR, "avatars");
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
// Migrate a pre-#86 root-level config.json into the data dir so existing
// installs keep their settings; no-op if already migrated or none exists.
migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH);
const config = loadConfig(CONFIG_PATH);
saveConfig(CONFIG_PATH, config);
@@ -56,6 +64,8 @@ async function main() {
const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const permissions = createPermissionStore(db.db);
const botManager = new BotManager(
neteaseProvider,
qqProvider,
@@ -63,7 +73,8 @@ async function main() {
db,
config,
logger,
avatarStore
avatarStore,
permissions
);
await botManager.loadSavedBots();
+65 -6
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import axios, { type AxiosInstance } from "axios";
import type {
MusicProvider,
@@ -15,6 +16,16 @@ const BILIBILI_HEADERS = {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
// Permutation used by B站 to derive the wbi mixin key from img_key+sub_key.
const WBI_MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
20, 34, 44, 52,
];
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -24,6 +35,8 @@ export class BiliBiliProvider implements MusicProvider {
private cidCache = new Map<string, number>();
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
private buvidInitialized = false;
private wbiMixinKey = "";
private wbiKeyFetchedAt = 0;
constructor() {
this.api = axios.create({
@@ -62,6 +75,50 @@ export class BiliBiliProvider implements MusicProvider {
return combined ? { Cookie: combined } : {};
}
/**
* Fetch wbi img_key/sub_key from /x/web-interface/nav and derive the
* mixin key used to sign search params. Required since B站 moved the
* search endpoint behind wbi signing — unsigned /search/type now
* returns an anti-bot HTML page.
*/
private async ensureWbiKeys(): Promise<void> {
if (this.wbiMixinKey && Date.now() - this.wbiKeyFetchedAt < WBI_KEY_TTL_MS) {
return;
}
const res = await this.api.get("/x/web-interface/nav", {
headers: this.cookieHeaders,
validateStatus: () => true, // nav returns -101 when not logged in but still includes wbi_img
});
const wbi = res.data?.data?.wbi_img;
const imgUrl: string = wbi?.img_url ?? "";
const subUrl: string = wbi?.sub_url ?? "";
const imgKey = imgUrl.split("/").pop()?.split(".")[0] ?? "";
const subKey = subUrl.split("/").pop()?.split(".")[0] ?? "";
if (!imgKey || !subKey) {
throw new Error("Bilibili wbi keys unavailable");
}
const raw = imgKey + subKey;
this.wbiMixinKey = WBI_MIXIN_KEY_ENC_TAB.map((i) => raw[i] ?? "")
.join("")
.slice(0, 32);
this.wbiKeyFetchedAt = Date.now();
}
/** Sign params for wbi-protected endpoints. Returns a new params object including wts and w_rid. */
private signWbi(params: Record<string, string | number>): Record<string, string> {
const withTs: Record<string, string> = {};
for (const [k, v] of Object.entries(params)) withTs[k] = String(v);
withTs.wts = String(Math.floor(Date.now() / 1000));
const sorted = Object.keys(withTs)
.sort()
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(withTs[k])}`)
.join("&");
withTs.w_rid = createHash("md5")
.update(sorted + this.wbiMixinKey)
.digest("hex");
return withTs;
}
setQuality(quality: string): void {
this.quality = quality;
}
@@ -91,12 +148,14 @@ export class BiliBiliProvider implements MusicProvider {
async search(query: string, limit = 20): Promise<SearchResult> {
await this.ensureBuvidCookie();
const res = await this.api.get("/x/web-interface/search/type", {
params: {
search_type: "video",
keyword: query,
page_size: limit,
},
await this.ensureWbiKeys();
const signed = this.signWbi({
search_type: "video",
keyword: query,
page_size: limit,
});
const res = await this.api.get("/x/web-interface/wbi/search/type", {
params: signed,
headers: this.cookieHeaders,
});
+25 -1
View File
@@ -1,7 +1,31 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums } from "./qq.js";
import { mapQqAlbums, mapQqSongs } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => {
const out = mapQqSongs([
{
mid: "001abc",
name: "Radar Song",
singer: [{ name: "Singer A" }, { name: "Singer B" }],
album: { name: "Album A", mid: "alb001" },
interval: 243,
},
]);
expect(out).toEqual([
{
id: "001abc",
name: "Radar Song",
artist: "Singer A / Singer B",
album: "Album A",
duration: 243,
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
platform: "qq",
},
]);
});
it("mapQqAlbums maps albumMID-style raw entries", () => {
const raw = [
{
+109 -46
View File
@@ -39,6 +39,24 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
if (!Array.isArray(raw)) return [];
return raw.map((s) => {
const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? "";
return {
id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""),
name: s.title ?? s.name ?? s.songname ?? "",
artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
album: s.album?.name ?? s.album?.title ?? s.albumname ?? "",
duration: s.interval ?? Math.round((s.duration ?? 0) / 1000),
coverUrl: albumMid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
: "",
platform: "qq" as const,
};
}).filter((s) => s.id);
}
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => {
@@ -72,6 +90,7 @@ export class QQMusicProvider implements MusicProvider {
private api: AxiosInstance;
private cookie = "";
private quality = "exhigh";
private radarPage = 1;
constructor(baseUrl: string) {
this.api = axios.create({
@@ -92,6 +111,30 @@ export class QQMusicProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {};
}
private get directCookieHeaders(): Record<string, string> {
return this.cookie ? { Cookie: this.cookie } : {};
}
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie);
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
return {
comm: {
ct: 24,
cv: 4747474,
platform: "yqq.json",
uin: uinMatch ? uinMatch[1] : "0",
g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381,
format: "json",
inCharset: "utf-8",
outCharset: "utf-8",
notice: 0,
need_new_code: 1,
},
req_0: { module, method, param },
};
}
async search(query: string, limit = 20): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
@@ -141,17 +184,7 @@ export class QQMusicProvider implements MusicProvider {
res.data?.req_0?.data?.body?.song?.list ?? [];
if (songList.length === 0) return null;
const songs: Song[] = songList.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.album?.name ?? s.album?.title ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: "",
platform: "qq",
}));
const songs = mapQqSongs(songList);
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
@@ -203,17 +236,7 @@ export class QQMusicProvider implements MusicProvider {
? (songRes.value.data?.data?.song?.list ?? [])
: [];
const songs: Song[] = songList.map((s: any) => ({
id: String(s.songmid ?? s.songid ?? ""),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
const songs = mapQqSongs(songList);
const albumList: any[] =
albumRes.status === "fulfilled"
@@ -339,19 +362,7 @@ export class QQMusicProvider implements MusicProvider {
});
const cdlist = res.data?.response?.cdlist ?? [];
if (cdlist.length === 0) return [];
return (cdlist[0].songlist ?? []).map((s: any) => ({
id: String(s.mid ?? s.songmid ?? s.songid),
name: s.songname ?? s.name ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
return mapQqSongs(cdlist[0].songlist ?? []);
}
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
@@ -386,17 +397,7 @@ export class QQMusicProvider implements MusicProvider {
const res = await this.api.get("/getAlbumInfo", {
params: { albummid: albumId, ...this.cookieParams },
});
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
id: String(s.songmid ?? s.songid),
name: s.songname ?? "",
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
album: s.albumname ?? "",
duration: s.interval ?? 0,
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
return mapQqSongs(res.data?.response?.data?.list ?? []);
}
async getLyrics(songId: string): Promise<LyricLine[]> {
@@ -461,6 +462,9 @@ export class QQMusicProvider implements MusicProvider {
setCookie(cookie: string): void {
this.cookie = cookie;
// Reset radar pagination so a re-login (different account) starts from the
// first page rather than inheriting the previous account's cursor.
this.radarPage = 1;
}
getCookie(): string {
@@ -522,6 +526,65 @@ export class QQMusicProvider implements MusicProvider {
}
}
async getPersonalFm(): Promise<Song[]> {
const radarSongs = await this.getRadarRecommendSongs();
if (radarSongs.length > 0) return radarSongs;
return this.getGuessRecommendSongs();
}
private async getRadarRecommendSongs(): Promise<Song[]> {
try {
const page = this.radarPage;
const res = await qqMusicuApi.post(
"/cgi-bin/musicu.fcg",
this.buildMusicuPayload(
"music.recommend.TrackRelationServer",
"GetRadarSong",
{
Page: page,
ReqType: 0,
FavSongs: [],
EntranceSongs: [],
}
),
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
);
const tracks = (res.data?.req_0?.data?.VecSongs ?? [])
.map((item: any) => item?.Track)
.filter(Boolean);
const songs = mapQqSongs(tracks);
if (songs.length > 0) {
this.radarPage = page + 1;
}
return songs;
} catch {
return [];
}
}
private async getGuessRecommendSongs(): Promise<Song[]> {
try {
const res = await qqMusicuApi.post(
"/cgi-bin/musicu.fcg",
this.buildMusicuPayload(
"music.radioProxy.MbTrackRadioSvr",
"get_radio_track",
{
id: 99,
num: 5,
from: 0,
scene: 0,
song_ids: [],
}
),
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
);
return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []);
} catch {
return [];
}
}
async getUserPlaylists(): Promise<Playlist[]> {
if (!this.cookie) return [];
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
+16
View File
@@ -12,6 +12,8 @@ import {
type Identity,
type TextMessage,
type ClientInfo,
type ClientLeftViewEvent,
type ClientMovedEvent,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
@@ -221,6 +223,20 @@ export class TS3Client extends EventEmitter {
{ nickname: info.nickname, id: info.id },
"Client entered"
);
this.emit("clientEnter", info);
});
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
this.logger.debug({ id: ev.id }, "Client left");
this.emit("clientLeave", ev);
});
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
this.logger.debug(
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
"Client moved"
);
this.emit("clientMoved", ev);
});
await this.client.connect();
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createAuditRouter } from "./audit.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("audit router", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
for (let i = 0; i < 3; i++) {
audit.record({
actorId: alice.id, actorUsername: "alice",
targetUserId: "x", targetUsername: "x",
action: "user.created",
});
}
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, permissions));
app.use("/api/audit", createAuditRouter(audit));
});
afterEach(() => botDb.close());
it("requires auth", async () => {
const res = await request(app).get("/api/audit");
expect(res.status).toBe(401);
});
it("returns entries newest-first", async () => {
const res = await request(app).get("/api/audit").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.entries).toHaveLength(3);
});
it("honors limit query param", async () => {
const res = await request(app).get("/api/audit?limit=1").set("Cookie", cookie);
expect(res.body.entries).toHaveLength(1);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { Router } from "express";
import type { AuditStore } from "../../data/audit.js";
export function createAuditRouter(audit: AuditStore): Router {
const router = Router();
router.get("/", (req, res) => {
const limit = clampInt(req.query.limit, 1, 500, 100);
const offset = clampInt(req.query.offset, 0, 100_000, 0);
res.json({ entries: audit.list(limit, offset) });
});
return router;
}
function clampInt(v: unknown, min: number, max: number, def: number): number {
const n = typeof v === "string" ? parseInt(v, 10) : NaN;
if (!Number.isFinite(n)) return def;
return Math.min(Math.max(n, min), max);
}
+5 -4
View File
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { CookieStore } from "../../music/auth.js";
import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js";
export function createAuthRouter(
neteaseProvider: MusicProvider,
@@ -35,7 +36,7 @@ export function createAuthRouter(
}
});
router.post("/qrcode", async (req, res) => {
router.post("/qrcode", requirePermission("platform.auth"), async (req, res) => {
try {
const { platform } = req.body;
const provider = getProvider(platform);
@@ -77,7 +78,7 @@ export function createAuthRouter(
}
});
router.post("/sms/send", async (req, res) => {
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
try {
const { phone } = req.body;
if (!phone) {
@@ -97,7 +98,7 @@ export function createAuthRouter(
}
});
router.post("/sms/verify", async (req, res) => {
router.post("/sms/verify", requirePermission("platform.auth"), async (req, res) => {
try {
const { phone, code } = req.body;
if (!phone || !code) {
@@ -118,7 +119,7 @@ export function createAuthRouter(
}
});
router.post("/cookie", (req, res) => {
router.post("/cookie", requirePermission("platform.auth"), (req, res) => {
const { platform, cookie } = req.body;
if (!cookie) {
res.status(400).json({ error: "cookie is required" });
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createBotRouter } from "./bot.js";
const logger = pino({ level: "silent" });
// Fake bot whose getStatus() exposes its id, matching the real status shape.
function makeFakeBot(id: string) {
return {
id,
getStatus: () => ({ id }),
};
}
function makeBotManager() {
const b1 = makeFakeBot("b1");
const b2 = makeFakeBot("b2");
return {
getBot: (id: string) => (id === "b1" ? b1 : id === "b2" ? b2 : undefined),
getAllBots: () => [b1, b2],
getBotConfig: () => undefined,
createBot: async () => b1,
updateBot: () => {},
removeBot: async () => {},
startBot: async () => {},
stopBot: () => {},
} as any;
}
function makeApp(user: any) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.use(
"/api/bot",
createBotRouter(
makeBotManager(),
{ idleTimeoutMinutes: 0 } as any,
"/tmp/config.json",
logger,
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
{ read: () => null, write: () => "x", remove: () => {} } as any,
),
);
return app;
}
const member = (bots: "all" | string[]) => ({
id: "u1",
username: "alice",
role: "member" as const,
capabilities: new Set<string>(),
bots: bots === "all" ? ("all" as const) : new Set(bots),
});
const admin = {
id: "a",
username: "admin",
role: "admin" as const,
capabilities: new Set<string>(),
bots: "all" as const,
};
describe("GET /api/bot bot-list filtering", () => {
it("member with bots:Set([b1]) sees only b1", async () => {
const app = makeApp(member(["b1"]));
const res = await request(app).get("/api/bot");
expect(res.status).toBe(200);
const ids = (res.body.bots as { id: string }[]).map((b) => b.id);
expect(ids).toEqual(["b1"]);
});
it("admin sees both b1 and b2", async () => {
const app = makeApp(admin);
const res = await request(app).get("/api/bot");
expect(res.status).toBe(200);
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
expect(ids).toEqual(["b1", "b2"]);
});
it("member with bots:'all' sees both b1 and b2", async () => {
const app = makeApp(member("all"));
const res = await request(app).get("/api/bot");
expect(res.status).toBe(200);
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
expect(ids).toEqual(["b1", "b2"]);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import type { BotManager } from "../../bot/manager.js";
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
function makeFakeBot() {
return {
idleTimeoutCalls: [] as number[],
autoPauseCalls: [] as boolean[],
updateIdleTimeout(minutes: number) {
this.idleTimeoutCalls.push(minutes);
},
updateAutoPause(enabled: boolean) {
this.autoPauseCalls.push(enabled);
},
};
}
describe("bot router /settings", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let config: BotConfig;
let configPath: string;
let tmpDir: string;
let fakeBots: ReturnType<typeof makeFakeBot>[];
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-"));
configPath = join(tmpDir, "config.json");
config = { ...getDefaultConfig(), idleTimeoutMinutes: 15, autoPauseOnEmpty: true };
fakeBots = [makeFakeBot(), makeFakeBot()];
const fakeManager = {
getAllBots: () => fakeBots,
} as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("requires auth", async () => {
const res = await request(app).get("/api/bot/settings");
expect(res.status).toBe(401);
});
it("GET /settings includes autoPauseOnEmpty reflecting config", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.idleTimeoutMinutes).toBe(15);
expect(res.body.autoPauseOnEmpty).toBe(true);
});
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
// in-memory config mutated
expect(config.autoPauseOnEmpty).toBe(false);
// propagated to every live bot
for (const bot of fakeBots) {
expect(bot.autoPauseCalls).toEqual([false]);
}
// follow-up GET reflects the new value
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.autoPauseOnEmpty).toBe(false);
});
it("POST /settings still handles idleTimeoutMinutes (no regression)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ idleTimeoutMinutes: 42 });
expect(res.status).toBe(200);
expect(config.idleTimeoutMinutes).toBe(42);
for (const bot of fakeBots) {
expect(bot.idleTimeoutCalls).toEqual([42]);
}
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.idleTimeoutMinutes).toBe(42);
});
it("POST /settings handles both fields together", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ idleTimeoutMinutes: 7, autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(config.idleTimeoutMinutes).toBe(7);
expect(config.autoPauseOnEmpty).toBe(false);
for (const bot of fakeBots) {
expect(bot.idleTimeoutCalls).toEqual([7]);
expect(bot.autoPauseCalls).toEqual([false]);
}
});
it("POST /settings with only autoPauseOnEmpty does not touch idleTimeout bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
for (const bot of fakeBots) {
expect(bot.idleTimeoutCalls).toEqual([]);
expect(bot.autoPauseCalls).toEqual([false]);
}
});
it("POST /settings ignores non-boolean autoPauseOnEmpty without 400", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ idleTimeoutMinutes: 5, autoPauseOnEmpty: "yes" });
expect(res.status).toBe(200);
// idleTimeout still applied
expect(config.idleTimeoutMinutes).toBe(5);
// autoPause left at its prior value, not propagated
expect(config.autoPauseOnEmpty).toBe(true);
for (const bot of fakeBots) {
expect(bot.autoPauseCalls).toEqual([]);
}
});
});
+60 -34
View File
@@ -5,6 +5,7 @@ import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js";
import type { AvatarStore } from "../../data/avatars.js";
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
export function createBotRouter(
botManager: BotManager,
@@ -16,12 +17,55 @@ export function createBotRouter(
): Router {
const router = Router();
router.get("/", (_req, res) => {
const bots = botManager.getAllBots().map((b) => b.getStatus());
router.get("/", (req, res) => {
const all = botManager.getAllBots().map((b) => b.getStatus());
const u = req.user!;
const bots =
u.role === "admin" || u.bots === "all"
? all
: all.filter((b) => u.bots instanceof Set && u.bots.has(b.id));
res.json({ bots });
});
router.get("/:id", (req, res) => {
// GET /api/bot/settings — 读取全局 bot 行为设置
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route.
router.get("/settings", (_req, res) => {
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
});
});
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
return;
}
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
saveConfig(configPath, config);
// 通知所有 bot 实例更新
for (const bot of botManager.getAllBots()) {
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
}
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
});
});
router.get("/:id", requireBotAccess("id"), (req, res) => {
const bot = botManager.getBot(req.params.id);
if (!bot) {
res.status(404).json({ error: "Bot not found" });
@@ -31,16 +75,19 @@ export function createBotRouter(
});
// Get saved config for a bot
router.get("/:id/config", (req, res) => {
router.get("/:id/config", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const saved = botManager.getBotConfig(req.params.id);
if (!saved) {
res.status(404).json({ error: "Bot config not found" });
return;
}
res.json(saved);
// Never expose the TS identity / API key to the client; the edit form only
// consumes channel/server passwords.
const { ts6ApiKey: _ts6ApiKey, identity: _identity, ...safe } = saved as unknown as Record<string, unknown>;
res.json(safe);
});
router.get("/:id/avatar", (req, res) => {
router.get("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (!path) {
res.status(404).end();
@@ -62,7 +109,7 @@ export function createBotRouter(
res.send(buf);
});
router.put("/:id/avatar", (req, res) => {
router.put("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const exists =
botManager.getBot(req.params.id) ||
botDb.getBotInstances().some((b) => b.id === req.params.id);
@@ -96,7 +143,7 @@ export function createBotRouter(
res.json({ path: rel });
});
router.delete("/:id/avatar", (req, res) => {
router.delete("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (path) avatarStore.remove(path);
botDb.setCustomAvatarPath(req.params.id, null);
@@ -104,7 +151,7 @@ export function createBotRouter(
res.status(204).end();
});
router.post("/", async (req, res) => {
router.post("/", requirePermission("bot.manage"), async (req, res) => {
try {
const {
name,
@@ -140,7 +187,7 @@ export function createBotRouter(
});
// Update bot config (must be stopped first to apply connection changes)
router.put("/:id", async (req, res) => {
router.put("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
try {
const bot = botManager.getBot(req.params.id);
if (!bot) {
@@ -159,7 +206,7 @@ export function createBotRouter(
}
});
router.delete("/:id", async (req, res) => {
router.delete("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
try {
await botManager.removeBot(req.params.id);
res.json({ success: true });
@@ -168,7 +215,7 @@ export function createBotRouter(
}
});
router.post("/:id/start", async (req, res) => {
router.post("/:id/start", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
try {
await botManager.startBot(req.params.id);
res.json({ success: true });
@@ -177,7 +224,7 @@ export function createBotRouter(
}
});
router.post("/:id/stop", (req, res) => {
router.post("/:id/stop", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
try {
botManager.stopBot(req.params.id);
res.json({ success: true });
@@ -186,26 +233,5 @@ export function createBotRouter(
}
});
// GET /api/bot/settings — 读取全局 bot 行为设置
router.get("/settings", (_req, res) => {
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
});
// POST /api/bot/settings — 保存全局 bot 行为设置
router.post("/settings", (req, res) => {
const { idleTimeoutMinutes } = req.body;
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
return;
}
config.idleTimeoutMinutes = idleTimeoutMinutes;
saveConfig(configPath, config);
// 通知所有 bot 实例更新定时器
for (const bot of botManager.getAllBots()) {
bot.updateIdleTimeout(idleTimeoutMinutes);
}
res.json({ ok: true });
});
return router;
}
+76
View File
@@ -0,0 +1,76 @@
import { Router } from "express";
import type { BotDatabase } from "../../data/database.js";
import type { Logger } from "../../logger.js";
export function createFavoritesRouter(database: BotDatabase, logger: Logger): Router {
const router = Router();
// GET /api/favorites — 获取当前用户的所有收藏
router.get("/", (req, res) => {
const userId = req.user!.id;
const favorites = database.getFavorites(userId);
res.json({ favorites });
});
// POST /api/favorites — 添加收藏
router.post("/", (req, res) => {
const userId = req.user!.id;
const { platform, playlistId, name, coverUrl, songCount } = req.body ?? {};
if (!platform || !playlistId || !name) {
res.status(400).json({ error: "platform, playlistId, name are required" });
return;
}
try {
database.addFavorite(userId, {
platform,
playlistId,
name,
coverUrl: coverUrl ?? "",
songCount: songCount ?? 0,
});
logger.info({ userId, platform, playlistId, name }, "Playlist favorited");
res.json({ success: true });
} catch (err: unknown) {
const e = err as { code?: string };
if (e?.code === "SQLITE_CONSTRAINT_UNIQUE") {
res.status(409).json({ error: "already favorited" });
return;
}
logger.error({ err }, "Failed to add favorite");
res.status(500).json({ error: "internal error" });
}
});
// DELETE /api/favorites/:id — 取消收藏(只允许删除自己的)
router.delete("/:id", (req, res) => {
const userId = req.user!.id;
const favId = parseInt(req.params.id, 10);
if (isNaN(favId)) {
res.status(400).json({ error: "invalid id" });
return;
}
const favorites = database.getFavorites(userId);
const fav = favorites.find((f) => f.id === favId);
if (!fav) {
res.status(404).json({ error: "favorite not found" });
return;
}
database.removeFavorite(userId, fav.playlistId, fav.platform);
logger.info({ userId, playlistId: fav.playlistId, platform: fav.platform }, "Playlist unfavorited");
res.json({ success: true });
});
// GET /api/favorites/check?platform=netease&playlistId=xxx — 检查是否已收藏
router.get("/check", (req, res) => {
const userId = req.user!.id;
const { platform, playlistId } = req.query;
if (typeof platform !== "string" || typeof playlistId !== "string") {
res.status(400).json({ error: "platform and playlistId required" });
return;
}
const favorited = database.isFavorited(userId, playlistId, platform);
res.json({ favorited });
});
return router;
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Router } from "express";
import type { MusicProvider } from "../../music/provider.js";
import { YouTubeProvider } from "../../music/youtube.js";
import type { Logger } from "../../logger.js";
import { requirePermission } from "../middleware/requirePermission.js";
export function createMusicRouter(
neteaseProvider: MusicProvider,
@@ -217,7 +218,7 @@ export function createMusicRouter(
});
// Set quality
router.post("/quality", (req, res) => {
router.post("/quality", requirePermission("quality"), (req, res) => {
const { quality, platform } = req.body;
if (!quality) {
res.status(400).json({ error: "quality is required" });
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createPlayerRouter } from "./player.js";
import { createBotRouter } from "./bot.js";
import { createAuthRouter } from "./auth.js";
import { createMusicRouter } from "./music.js";
const logger = pino({ level: "silent" });
// --- minimal stubs --------------------------------------------------------
const ALLOWED_BOT = "bot-allowed";
// A fake bot whose methods all no-op / return benign values so the real
// handlers run to completion without 500ing. We only assert that the
// permission/bot-access gate let the request THROUGH (status !== 403).
function makeFakeBot(id: string) {
return {
id,
executeCommand: async () => "ok",
getStatus: () => ({ id }),
getQueue: () => [],
getProfileManager: () => ({ getConfig: () => ({}), updateConfig: () => {}, setCustomAvatar: () => {} }),
};
}
function makeBotManager() {
const bot = makeFakeBot(ALLOWED_BOT);
return {
getBot: (id: string) => (id === ALLOWED_BOT ? bot : undefined),
getAllBots: () => [bot],
getBotConfig: () => undefined,
createBot: async () => bot,
updateBot: () => {},
removeBot: async () => {},
startBot: async () => {},
stopBot: () => {},
} as any;
}
function makeProvider() {
return {
platform: "netease",
getQuality: () => "high",
setQuality: () => {},
getAuthStatus: async () => ({ loggedIn: false }),
getQrCode: async () => ({ key: "k", url: "u" }),
getCookie: () => "c",
setCookie: () => {},
search: async () => ({ songs: [], albums: [], playlists: [] }),
} as any;
}
// Build one app mounting all four real routers, with req.user injected by a
// middleware placed BEFORE the routers (mimicking what requireAuth does).
function makeApp(user: any) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as any).user = user; next(); });
const botManager = makeBotManager();
const provider = makeProvider();
app.use("/api/player", createPlayerRouter(botManager, logger));
app.use(
"/api/bot",
createBotRouter(
botManager,
{ idleTimeoutMinutes: 0 } as any,
"/tmp/config.json",
logger,
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
{ read: () => null, write: () => "x", remove: () => {} } as any,
),
);
app.use("/api/auth", createAuthRouter(provider, provider, provider, logger));
app.use("/api/music", createMusicRouter(provider, provider, provider, logger));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1",
username: "alice",
role: "member" as const,
capabilities: new Set(caps),
bots: bots === "all" ? ("all" as const) : new Set(bots),
});
const admin = {
id: "a",
username: "admin",
role: "admin" as const,
capabilities: new Set<string>(),
bots: "all" as const,
};
describe("permission enforcement on action routes", () => {
describe("player.control", () => {
it("403 for member WITHOUT player.control", async () => {
const app = makeApp(member([], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH player.control + bot in allow-list", async () => {
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
expect(res.status).not.toBe(403);
});
it("403 for member WITH player.control but bot NOT in allow-list", async () => {
const app = makeApp(member(["player.control"], ["other-bot"]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
expect(res.status).toBe(403);
});
});
describe("player.queue", () => {
it("403 for member WITHOUT player.queue", async () => {
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH player.queue", async () => {
const app = makeApp(member(["player.queue"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
expect(res.status).not.toBe(403);
});
});
describe("bot.manage", () => {
it("403 for member WITHOUT bot.manage on POST /api/bot", async () => {
const app = makeApp(member([], "all"));
const res = await request(app)
.post("/api/bot")
.send({ name: "n", serverAddress: "s", nickname: "nick" });
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH bot.manage on POST /api/bot", async () => {
const app = makeApp(member(["bot.manage"], "all"));
const res = await request(app)
.post("/api/bot")
.send({ name: "n", serverAddress: "s", nickname: "nick" });
expect(res.status).not.toBe(403);
});
it("403 for member WITH bot.manage but bot NOT in allow-list on POST /api/bot/:id/start", async () => {
const app = makeApp(member(["bot.manage"], ["other-bot"]));
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH bot.manage + bot in allow-list on POST /api/bot/:id/start", async () => {
const app = makeApp(member(["bot.manage"], [ALLOWED_BOT]));
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
expect(res.status).not.toBe(403);
});
});
describe("platform.auth", () => {
it("403 for member WITHOUT platform.auth on POST /api/auth/cookie", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH platform.auth on POST /api/auth/cookie", async () => {
const app = makeApp(member(["platform.auth"], "all"));
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
expect(res.status).not.toBe(403);
});
});
describe("quality", () => {
it("403 for member WITHOUT quality on POST /api/music/quality", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
expect(res.status).toBe(403);
});
it("NOT 403 for member WITH quality on POST /api/music/quality", async () => {
const app = makeApp(member(["quality"], "all"));
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
expect(res.status).not.toBe(403);
});
});
describe("read-only routes stay open", () => {
it("GET /api/auth/status not gated", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).get("/api/auth/status");
expect(res.status).not.toBe(403);
});
it("GET /api/music/quality not gated", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).get("/api/music/quality");
expect(res.status).not.toBe(403);
});
it("GET /api/bot not gated", async () => {
const app = makeApp(member([], "all"));
const res = await request(app).get("/api/bot");
expect(res.status).not.toBe(403);
});
});
describe("admin bypasses every gate", () => {
let app: express.Express;
beforeEach(() => { app = makeApp(admin); });
it("player.control", async () => {
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
});
it("player.queue", async () => {
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
});
it("bot.manage POST /api/bot", async () => {
const res = await request(app).post("/api/bot").send({ name: "n", serverAddress: "s", nickname: "nick" });
expect(res.status).not.toBe(403);
});
it("bot.manage POST /api/bot/:id/start", async () => {
expect((await request(app).post(`/api/bot/${ALLOWED_BOT}/start`)).status).not.toBe(403);
});
it("platform.auth POST /api/auth/cookie", async () => {
expect((await request(app).post("/api/auth/cookie").send({ cookie: "c" })).status).not.toBe(403);
});
it("quality POST /api/music/quality", async () => {
expect((await request(app).post("/api/music/quality").send({ quality: "high" })).status).not.toBe(403);
});
});
});
+51 -21
View File
@@ -4,6 +4,7 @@ import type { BotDatabase } from "../../data/database.js";
import type { MusicProvider } from "../../music/provider.js";
import type { Logger } from "../../logger.js";
import { parseCommand } from "../../bot/commands.js";
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
export function createPlayerRouter(
botManager: BotManager,
@@ -15,6 +16,13 @@ export function createPlayerRouter(
): Router {
const router = Router();
// Access check runs BEFORE the existence/resolver check so a member who is
// not allowed a bot always gets a uniform 403 — whether or not the bot
// exists — instead of a 404 that would leak which bot IDs are real.
// requireBotAccess only needs req.params.botId and req.user (set by the
// global requireAuth mounted earlier), so it works before the resolver.
router.use("/:botId", requireBotAccess("botId"));
router.use("/:botId", (req, res, next) => {
const bot = botManager.getBot(req.params.botId);
if (!bot) {
@@ -33,7 +41,7 @@ export function createPlayerRouter(
return "";
};
router.post("/:botId/play", async (req, res) => {
router.post("/:botId/play", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
@@ -53,7 +61,7 @@ export function createPlayerRouter(
}
});
router.post("/:botId/add", async (req, res) => {
router.post("/:botId/add", requirePermission("player.queue"), async (req, res) => {
try {
const bot = (req as any).bot;
const { query, platform } = req.body;
@@ -80,14 +88,36 @@ export function createPlayerRouter(
}
};
router.post("/:botId/pause", simpleCommand("!pause"));
router.post("/:botId/resume", simpleCommand("!resume"));
router.post("/:botId/next", simpleCommand("!next"));
router.post("/:botId/prev", simpleCommand("!prev"));
router.post("/:botId/stop", simpleCommand("!stop"));
router.post("/:botId/clear", simpleCommand("!clear"));
router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause"));
router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume"));
router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next"));
router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev"));
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear"));
router.post("/:botId/volume", async (req, res) => {
router.post("/:botId/fm", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
const message = await bot.startFm(provider);
res.json({
ok:
!message.startsWith("No FM songs") &&
!message.includes("not available") &&
!message.includes("not connected"),
message,
});
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { volume } = req.body;
@@ -115,7 +145,7 @@ export function createPlayerRouter(
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
router.post("/:botId/mode", async (req, res) => {
router.post("/:botId/mode", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { mode } = req.body;
@@ -140,7 +170,7 @@ export function createPlayerRouter(
});
// Seek to position
router.post("/:botId/seek", async (req, res) => {
router.post("/:botId/seek", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { position } = req.body; // seconds
@@ -164,7 +194,7 @@ export function createPlayerRouter(
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
});
router.delete("/:botId/queue/:index", async (req, res) => {
router.delete("/:botId/queue/:index", requirePermission("player.queue"), async (req, res) => {
try {
const bot = (req as any).bot;
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
@@ -176,7 +206,7 @@ export function createPlayerRouter(
});
// Jump to a specific index in the queue (without clearing it)
router.post("/:botId/play-at", async (req, res) => {
router.post("/:botId/play-at", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { index } = req.body;
@@ -210,7 +240,7 @@ export function createPlayerRouter(
}
});
router.post("/:botId/playlist", async (req, res) => {
router.post("/:botId/playlist", requirePermission("player.queue"), async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
@@ -227,7 +257,7 @@ export function createPlayerRouter(
// Play a playlist by ID — stores metadata only, resolves URL for first song
// Respects current play mode (random = pick random first song)
router.post("/:botId/play-playlist", async (req, res) => {
router.post("/:botId/play-playlist", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { playlistId, platform } = req.body;
@@ -314,7 +344,7 @@ export function createPlayerRouter(
});
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
router.post("/:botId/play-album", async (req, res) => {
router.post("/:botId/play-album", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { albumId, platform } = req.body;
@@ -386,7 +416,7 @@ export function createPlayerRouter(
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
router.post("/:botId/play-song", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
@@ -414,7 +444,7 @@ export function createPlayerRouter(
// Insert a single song to play right after the current one.
// If nothing is playing, behaves like /play-song (start immediately).
router.post("/:botId/play-next-song", async (req, res) => {
router.post("/:botId/play-next-song", requirePermission("player.control"), async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
@@ -452,7 +482,7 @@ export function createPlayerRouter(
}
});
router.post("/:botId/add-song", async (req, res) => {
router.post("/:botId/add-song", requirePermission("player.queue"), async (req, res) => {
try {
const bot = (req as any).bot;
const { song } = req.body;
@@ -480,7 +510,7 @@ export function createPlayerRouter(
});
// Add a song to queue by ID — metadata only
router.post("/:botId/add-by-id", async (req, res) => {
router.post("/:botId/add-by-id", requirePermission("player.queue"), async (req, res) => {
try {
const bot = (req as any).bot;
const { songId, platform } = req.body;
@@ -518,7 +548,7 @@ export function createPlayerRouter(
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
router.put("/:botId/profile", requirePermission("bot.manage"), (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createSessionRouter } from "./session.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
return app;
}
function extractCookie(res: request.Response): string {
const header = res.headers["set-cookie"];
const arr = Array.isArray(header) ? header : header ? [header] : [];
const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
if (!found) throw new Error("no session cookie set");
return found.split(";")[0]; // "tsmb_session=xxxx"
}
describe("session router", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let app: express.Express;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
app = makeApp(botDb, users, sessions);
});
afterEach(() => botDb.close());
it("GET /needs-setup returns true on an empty db", async () => {
const res = await request(app).get("/api/session/needs-setup");
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsSetup: true });
});
it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => {
const setupRes = await request(app)
.post("/api/session/setup")
.send({ username: "alice", password: "hunter2-hunter2" });
expect(setupRes.status).toBe(200);
expect(setupRes.body.username).toBe("alice");
extractCookie(setupRes);
const needs = await request(app).get("/api/session/needs-setup");
expect(needs.body).toEqual({ needsSetup: false });
});
it("POST /setup returns 409 once a user already exists", async () => {
await users.createUser("admin", "pw-admin-pw", "admin");
const res = await request(app)
.post("/api/session/setup")
.send({ username: "alice", password: "pw" });
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: "already initialized" });
});
it("POST /login returns 401 with constant-time delay on bad credentials", async () => {
await users.createUser("alice", "correct-pw-pw", "admin");
const start = Date.now();
const res = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "wrong" });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "invalid credentials" });
expect(Date.now() - start).toBeGreaterThanOrEqual(200);
}, 10_000);
it("POST /login sets a session cookie on success", async () => {
await users.createUser("alice", "pw-alice", "admin");
const res = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
expect(res.status).toBe(200);
expect(res.body.username).toBe("alice");
extractCookie(res);
});
it("GET /me returns the current user when cookie is present, 401 otherwise", async () => {
await users.createUser("alice", "pw-alice", "admin");
const loginRes = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes);
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.status).toBe(200);
expect(me.body.username).toBe("alice");
// alice is the first user (an admin), so /me exposes all capabilities and full bot access.
expect(Array.isArray(me.body.capabilities)).toBe(true);
expect(me.body.capabilities).toEqual(
expect.arrayContaining(["player.control", "player.queue", "bot.manage", "platform.auth", "quality"])
);
expect(me.body.bots).toBe("all");
const anon = await request(app).get("/api/session/me");
expect(anon.status).toBe(401);
});
it("POST /logout deletes the session and clears the cookie", async () => {
await users.createUser("alice", "pw-alice", "admin");
const loginRes = await request(app)
.post("/api/session/login")
.send({ username: "alice", password: "pw-alice" });
const cookie = extractCookie(loginRes);
const logout = await request(app).post("/api/session/logout").set("Cookie", cookie);
expect(logout.status).toBe(204);
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
expect(me.status).toBe(401);
});
it("POST /change-password requires old password and invalidates other sessions", async () => {
const u = await users.createUser("alice", "old-pw-pw", "admin");
const cookieA = extractCookie(
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
);
const cookieB = extractCookie(
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
);
const wrongOld = await request(app)
.post("/api/session/change-password")
.set("Cookie", cookieA)
.send({ oldPassword: "WRONG", newPassword: "newpassword" });
expect(wrongOld.status).toBe(401);
const ok = await request(app)
.post("/api/session/change-password")
.set("Cookie", cookieA)
.send({ oldPassword: "old-pw-pw", newPassword: "newpassword" });
expect(ok.status).toBe(204);
const meA = await request(app).get("/api/session/me").set("Cookie", cookieA);
expect(meA.status).toBe(200);
const meB = await request(app).get("/api/session/me").set("Cookie", cookieB);
expect(meB.status).toBe(401);
expect(u.id).toBe(meA.body.id);
});
});
+181
View File
@@ -0,0 +1,181 @@
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
const FAILED_LOGIN_DELAY_MS = 250;
function setSessionCookie(res: Response, token: string): void {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: res.req.secure,
path: "/",
maxAge: SESSION_TTL_MS,
});
}
function clearSessionCookie(res: Response): void {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isValidUsername(v: unknown): v is string {
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
}
function isValidPassword(v: unknown): v is string {
return typeof v === "string" && v.length >= 8 && v.length <= 200;
}
function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
if (!cookieHeader) return null;
const match = cookieHeader
.split(";")
.map((p) => p.trim())
.find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`));
if (!match) return null;
return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1));
}
export function createSessionRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger,
permissions: PermissionStore
): Router {
const router = Router();
const requireAuthInline = (req: Request, res: Response, next: NextFunction) => {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
clearSessionCookie(res);
res.status(401).json({ error: "unauthenticated" });
return;
}
req.user = { id: result.userId, username: result.username, role: result.role };
const token = extractSessionToken(req.headers.cookie);
if (token) setSessionCookie(res, token);
next();
};
router.get("/needs-setup", (_req, res) => {
res.json({ needsSetup: users.countUsers() === 0 });
});
router.post("/setup", async (req, res) => {
const { username, password } = req.body ?? {};
if (users.countUsers() !== 0) {
res.status(409).json({ error: "already initialized" });
return;
}
if (!isValidUsername(username) || !isValidPassword(password)) {
res.status(400).json({ error: "invalid username or password" });
return;
}
try {
const user = await users.createFirstUser(username, password);
if (!user) {
res.status(409).json({ error: "already initialized" });
return;
}
const { token } = sessions.createSession(user.id);
setSessionCookie(res, token);
try {
audit.record({
actorId: user.id, actorUsername: user.username,
targetUserId: user.id, targetUsername: user.username,
action: "admin.first_created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed");
}
logger.info({ userId: user.id, username }, "First admin created");
res.json({ id: user.id, username: user.username, role: user.role });
} catch (err) {
logger.error({ err }, "setup failed");
res.status(500).json({ error: "internal" });
}
});
router.post("/login", async (req, res) => {
const { username, password } = req.body ?? {};
if (typeof username !== "string" || typeof password !== "string") {
res.status(400).json({ error: "invalid request" });
return;
}
const user = users.findByUsername(username);
const ok = user ? await users.verifyPassword(password, user.passwordHash) : false;
if (!user || !ok) {
await delay(FAILED_LOGIN_DELAY_MS);
res.status(401).json({ error: "invalid credentials" });
return;
}
const { token } = sessions.createSession(user.id);
setSessionCookie(res, token);
res.json({ id: user.id, username: user.username, role: user.role });
});
router.post("/logout", (req, res) => {
const token = parseTokenFromCookie(req.headers.cookie);
if (token) {
sessions.deleteSession(token);
}
clearSessionCookie(res);
res.status(204).end();
});
router.get("/me", requireAuthInline, (req, res) => {
const user = req.user!;
const ctx = resolvePermissionContext(user.role, user.id, permissions);
res.json({
id: user.id,
username: user.username,
role: user.role,
capabilities: [...ctx.capabilities],
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
});
});
router.post("/change-password", requireAuthInline, async (req, res) => {
const { oldPassword, newPassword } = req.body ?? {};
if (typeof oldPassword !== "string") {
res.status(400).json({ error: "invalid request" });
return;
}
const u = users.findById(req.user!.id);
if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) {
await delay(FAILED_LOGIN_DELAY_MS);
res.status(401).json({ error: "invalid credentials" });
return;
}
if (!isValidPassword(newPassword)) {
res.status(400).json({ error: "invalid request" });
return;
}
await users.changePassword(u.id, newPassword);
const currentToken = parseTokenFromCookie(req.headers.cookie);
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
try {
audit.record({
actorId: u.id, actorUsername: u.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.password_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.password_changed" }, "audit insert failed");
}
res.status(204).end();
});
return router;
}
+344
View File
@@ -0,0 +1,344 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore, type UserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore, type AuditStore } from "../../data/audit.js";
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createUsersRouter } from "./users.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
const app = express();
app.use(express.json());
app.use(cookieParser());
const permissions = createPermissionStore(botDb.db);
const requireAuth = createRequireAuth(sessions, permissions);
const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
return { app, permissions, audit };
}
describe("users router", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let app: express.Express;
let permissions: PermissionStore;
let audit: AuditStore;
let aliceId: string;
let aliceCookie: string;
let bobId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
({ app, permissions, audit } = makeApp(botDb, users, sessions));
const alice = await users.createUser("alice", "pw-alice", "admin");
aliceId = alice.id;
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
const bob = await users.createUser("bob", "pw-bob-bob", "member");
bobId = bob.id;
});
afterEach(() => botDb.close());
it("requires auth for all routes", async () => {
expect((await request(app).get("/api/users")).status).toBe(401);
expect((await request(app).post("/api/users").send({ username: "x", password: "yyyyyyyy" })).status).toBe(401);
expect((await request(app).delete(`/api/users/${bobId}`)).status).toBe(401);
});
it("GET / lists users with id+username+createdAt, no password hash", async () => {
const res = await request(app).get("/api/users").set("Cookie", aliceCookie);
expect(res.status).toBe(200);
expect(res.body.users).toHaveLength(2);
for (const u of res.body.users) {
expect(u).toHaveProperty("id");
expect(u).toHaveProperty("username");
expect(u).toHaveProperty("createdAt");
expect(u).not.toHaveProperty("passwordHash");
}
});
it("POST / creates a user", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "charlie", password: "charlie-pw" });
expect(res.status).toBe(201);
expect(res.body.username).toBe("charlie");
expect(users.countUsers()).toBe(3);
});
it("POST / returns 409 on duplicate username", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "BOB", password: "another-pw" });
expect(res.status).toBe(409);
});
it("POST / returns 400 on invalid input", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "x", password: "short" });
expect(res.status).toBe(400);
});
it("DELETE /:id removes the user and their sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app).delete(`/api/users/${bobId}`).set("Cookie", aliceCookie);
expect(res.status).toBe(204);
expect(users.countUsers()).toBe(1);
expect(sessions.validateAndTouch(bobToken)).toBeNull();
});
it("DELETE /:id of self returns 400", async () => {
const res = await request(app).delete(`/api/users/${aliceId}`).set("Cookie", aliceCookie);
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "cannot delete self" });
expect(users.countUsers()).toBe(2);
});
it("DELETE /:id of nonexistent returns 404", async () => {
const res = await request(app).delete(`/api/users/not-a-real-id`).set("Cookie", aliceCookie);
expect(res.status).toBe(404);
});
it("POST /:id/reset-password updates the hash and invalidates target's sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "bob-new-pw" });
expect(res.status).toBe(204);
expect(sessions.validateAndTouch(bobToken)).toBeNull();
const bob = users.findByUsername("bob");
expect(await users.verifyPassword("bob-new-pw", bob!.passwordHash)).toBe(true);
expect(await users.verifyPassword("pw-bob-bob", bob!.passwordHash)).toBe(false);
});
it("POST /:id/reset-password 404 on unknown user", async () => {
const res = await request(app)
.post(`/api/users/not-a-real-id/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "anything-here" });
expect(res.status).toBe(404);
});
it("POST /:id/reset-password 400 on short password", async () => {
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "short" });
expect(res.status).toBe(400);
});
it("returns 201 even if audit insert fails (POST /api/users)", async () => {
// Build a broken audit store that throws on record()
const brokenAudit = {
record: () => { throw new Error("simulated disk-full"); },
list: () => [],
};
// Reassemble app with the broken audit
const localApp = express();
localApp.use(express.json());
localApp.use(cookieParser());
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
localApp.use(
"/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
);
const res = await request(localApp)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "charlie", password: "charlie-pw" });
expect(res.status).toBe(201);
expect(users.countUsers()).toBe(3);
});
it("POST /:id/reset-password on self preserves the actor's current session", async () => {
// Alice resets her OWN password
const res = await request(app)
.post(`/api/users/${aliceId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "alice-new-pw" });
expect(res.status).toBe(204);
// Alice's CURRENT session should still work
// (we'd need a protected endpoint to verify; use GET /api/users which is already mounted)
const followUp = await request(app).get("/api/users").set("Cookie", aliceCookie);
expect(followUp.status).toBe(200);
// The password hash IS updated (sanity check)
const alice = users.findById(aliceId);
expect(await users.verifyPassword("alice-new-pw", alice!.passwordHash)).toBe(true);
});
it("POST /:id/reset-password on another user does NOT preserve any of target's sessions", async () => {
const bobToken = sessions.createSession(bobId).token;
const res = await request(app)
.post(`/api/users/${bobId}/reset-password`)
.set("Cookie", aliceCookie)
.send({ newPassword: "bob-new-pw" });
expect(res.status).toBe(204);
// Bob's session should be dead
expect(sessions.validateAndTouch(bobToken)).toBeNull();
});
it("POST / defaults new user to role=member when role omitted", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("member");
});
it("POST / accepts role=admin", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw", role: "admin" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("admin");
expect(users.countAdmins()).toBe(2);
});
it("PATCH /:id/role can change role between admin and member", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(204);
expect(users.findById(bobId)!.role).toBe("admin");
});
it("PATCH /:id/role blocks demoting the last admin", async () => {
// alice is the only admin. Demoting her would leave 0 admins. Block.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "cannot demote last admin" });
});
it("PATCH /:id/role allows demoting an admin when other admins exist", async () => {
// Promote bob first
users.setRole(bobId, "admin");
// Now both are admins. Demoting alice should work.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(204);
});
it("PATCH /:id/role 400 on invalid role", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "superuser" });
expect(res.status).toBe(400);
});
it("PATCH /:id/role 404 on unknown user", async () => {
const res = await request(app)
.patch(`/api/users/not-a-real-id/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(404);
});
it("GET /:id/permissions returns empty arrays for a fresh member", async () => {
const res = await request(app)
.get(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie);
expect(res.status).toBe(200);
expect(res.body).toEqual({ capabilities: [], bots: [] });
});
it("GET /:id/permissions 404 on unknown user", async () => {
const res = await request(app)
.get(`/api/users/not-a-real-id/permissions`)
.set("Cookie", aliceCookie);
expect(res.status).toBe(404);
});
it("PUT /:id/permissions sets permissions, persists, and audits", async () => {
const before = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
expect(before).toHaveLength(0);
const put = await request(app)
.put(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie)
.send({ capabilities: ["player.control"], bots: "all" });
expect(put.status).toBe(200);
const get = await request(app)
.get(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie);
expect(get.status).toBe(200);
expect(get.body).toEqual({ capabilities: ["player.control"], bots: "all" });
const rows = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
expect(rows).toHaveLength(1);
expect(rows[0].actorId).toBe(aliceId);
expect(rows[0].targetUserId).toBe(bobId);
});
it("PUT /:id/permissions drops unknown capability tokens", async () => {
const put = await request(app)
.put(`/api/users/${bobId}/permissions`)
.set("Cookie", aliceCookie)
.send({ capabilities: ["player.control", "bogus"], bots: [] });
expect(put.status).toBe(200);
expect(permissions.getCapabilities(bobId)).toEqual(["player.control"]);
});
it("PUT /:id/permissions 404 on unknown user", async () => {
const res = await request(app)
.put(`/api/users/not-a-real-id/permissions`)
.set("Cookie", aliceCookie)
.send({ capabilities: ["player.control"], bots: "all" });
expect(res.status).toBe(404);
});
it("POST / seeds the basic tier for a new member", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "dave", password: "dave-pw-pw" });
expect(res.status).toBe(201);
const perms = await request(app)
.get(`/api/users/${res.body.id}/permissions`)
.set("Cookie", aliceCookie);
expect(perms.status).toBe(200);
expect(perms.body).toEqual({
capabilities: ["player.control", "player.queue"],
bots: "all",
});
});
it("POST / does NOT seed permissions for a new admin", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "erin", password: "erin-pw-pw", role: "admin" });
expect(res.status).toBe(201);
const perms = await request(app)
.get(`/api/users/${res.body.id}/permissions`)
.set("Cookie", aliceCookie);
expect(perms.status).toBe(200);
expect(perms.body).toEqual({ capabilities: [], bots: [] });
});
});
+209
View File
@@ -0,0 +1,209 @@
import { Router } from "express";
import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import { UsernameTakenError } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { AuditStore } from "../../data/audit.js";
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
import { extractSessionToken } from "../auth/validateSession.js";
function isValidUsername(v: unknown): v is string {
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
}
function isValidPassword(v: unknown): v is string {
return typeof v === "string" && v.length >= 8 && v.length <= 200;
}
export function createUsersRouter(
users: UserStore,
sessions: SessionStore,
audit: AuditStore,
logger: Logger,
permissions: PermissionStore
): Router {
const router = Router();
router.get("/", (_req, res) => {
res.json({ users: users.listUsers() });
});
router.post("/", async (req, res) => {
const { username, password, role: roleInput } = req.body ?? {};
if (!isValidUsername(username) || !isValidPassword(password)) {
res.status(400).json({ error: "invalid username or password" });
return;
}
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
try {
const u = await users.createUser(username, password, role);
if (u.role === "member") {
permissions.setPermissions(u.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
}
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: u.id, targetUsername: u.username,
action: "user.created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
}
logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
res.status(201).json({ id: u.id, username: u.username, role: u.role });
} catch (err) {
if (err instanceof UsernameTakenError) {
res.status(409).json({ error: "username taken" });
return;
}
logger.error({ err }, "createUser failed");
res.status(500).json({ error: "internal" });
}
});
router.delete("/:id", (req, res) => {
const targetId = req.params.id;
// Snapshot target's username BEFORE deletion for audit
const target = users.findById(targetId);
if (!target) {
res.status(404).json({ error: "not found" });
return;
}
if (targetId === req.user!.id) {
res.status(400).json({ error: "cannot delete self" });
return;
}
const result = users.deleteUserIfNotLastAdmin(targetId);
if (result === "not_found") {
res.status(404).json({ error: "not found" });
return;
}
if (result === "would_orphan") {
res.status(400).json({ error: "cannot delete last admin" });
return;
}
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
sessions.deleteAllForUser(targetId);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.deleted",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.deleted" }, "audit insert failed");
}
logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted");
res.status(204).end();
});
router.post("/:id/reset-password", async (req, res) => {
const { newPassword } = req.body ?? {};
if (!isValidPassword(newPassword)) {
res.status(400).json({ error: "invalid password" });
return;
}
const targetId = req.params.id;
const target = users.findById(targetId);
if (!target) {
res.status(404).json({ error: "not found" });
return;
}
await users.changePassword(targetId, newPassword);
// Invalidate all sessions for the target user (except current actor's if it's the same user)
const exceptToken = targetId === req.user!.id
? (extractSessionToken(req.headers.cookie) ?? undefined)
: undefined;
sessions.deleteAllForUser(targetId, exceptToken);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: target.id, targetUsername: target.username,
action: "user.password_reset",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.password_reset" }, "audit insert failed");
}
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
res.status(204).end();
});
router.patch("/:id/role", (req, res) => {
const targetId = req.params.id;
const { role: newRole } = req.body ?? {};
if (newRole !== "admin" && newRole !== "member") {
res.status(400).json({ error: "invalid role" });
return;
}
// Snapshot the target's old role and username for audit (BEFORE the atomic update,
// so we record what actually changed; if the user is gone we'll skip audit).
const targetBefore = users.findById(targetId);
if (!targetBefore) {
res.status(404).json({ error: "not found" });
return;
}
const result = users.setRoleIfNotLastAdmin(targetId, newRole);
if (result === "not_found") {
res.status(404).json({ error: "not found" });
return;
}
if (result === "would_orphan") {
res.status(400).json({ error: "cannot demote last admin" });
return;
}
// Only audit when the role actually changed
if (targetBefore.role !== newRole) {
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: targetBefore.id, targetUsername: targetBefore.username,
action: "user.role_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
}
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
}
res.status(204).end();
});
router.get("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) {
res.status(404).json({ error: "not_found" });
return;
}
res.json({
capabilities: permissions.getCapabilities(user.id),
bots: permissions.getBotAccess(user.id),
});
});
router.put("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) {
res.status(404).json({ error: "not_found" });
return;
}
const body = req.body ?? {};
const caps: string[] = Array.isArray(body.capabilities)
? body.capabilities.filter(isCapability)
: [];
const bots: "all" | string[] =
body.bots === "all" ? "all" : Array.isArray(body.bots) ? body.bots.map(String) : [];
permissions.setPermissions(user.id, { capabilities: caps, bots });
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: user.id, targetUsername: user.username,
action: "user.permissions_changed",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "user.permissions_changed" }, "audit insert failed");
}
logger.info({ actorId: req.user!.id, targetUserId: user.id }, "User permissions changed");
res.json({ success: true });
});
return router;
}
+38
View File
@@ -0,0 +1,38 @@
import type { SessionStore, SessionValidation } from "../../data/sessions.js";
export const SESSION_COOKIE_NAME = "tsmb_session";
/**
* Validate the session cookie carried on an arbitrary HTTP-like header bag.
* Used by Express middleware (req.headers.cookie) AND by the raw WebSocket
* upgrade handler (req.headers.cookie) — they share this exact behavior.
*/
export function validateSessionFromHeaders(
rawCookieHeader: string | undefined,
sessions: SessionStore
): SessionValidation | null {
if (!rawCookieHeader) return null;
const token = parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
if (!token) return null;
return sessions.validateAndTouch(token);
}
export function extractSessionToken(rawCookieHeader: string | undefined): string | null {
if (!rawCookieHeader) return null;
return parseCookie(rawCookieHeader, SESSION_COOKIE_NAME);
}
function parseCookie(header: string, name: string): string | null {
for (const part of header.split(";")) {
const trimmed = part.trim();
const eq = trimmed.indexOf("=");
if (eq < 1) continue;
if (trimmed.slice(0, eq) !== name) continue;
try {
return decodeURIComponent(trimmed.slice(eq + 1));
} catch {
return null;
}
}
return null;
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { csrfOriginCheck } from "./csrf.js";
describe("csrfOriginCheck middleware", () => {
let app: express.Express;
beforeEach(() => {
app = express();
app.use(csrfOriginCheck);
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/", (_req, res) => res.json({ ok: true }));
});
it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => {
const res = await request(app).get("/");
expect(res.status).toBe(200);
});
it("rejects POST without Origin or Referer", async () => {
const res = await request(app).post("/");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "bad origin" });
});
it("accepts POST when Origin host matches request host", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://example.com");
expect(res.status).toBe(200);
});
it("rejects POST when Origin host does not match request host", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://evil.com");
expect(res.status).toBe(403);
});
it("accepts POST when Referer host matches and Origin is absent", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Referer", "https://example.com/some/path");
expect(res.status).toBe(200);
});
it("rejects POST when Referer host does not match", async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Referer", "https://evil.com/some/path");
expect(res.status).toBe(403);
});
// Documents the server side of the QR-login outage: a `no-referrer` document
// policy makes the browser send the literal `Origin: null` on same-origin
// POSTs, which this guard cannot parse a host from and therefore rejects.
// The fix lives in the frontend (referrer policy -> same-origin); this test
// pins the gate behavior so the interaction stays understood. See
// src/web/referrer-policy.test.ts.
it('rejects POST with the literal Origin: "null" (no-referrer downgrade)', async () => {
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "null");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "bad origin" });
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { Request, Response, NextFunction } from "express";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* Same-origin CSRF protection. For mutating requests, the Origin or Referer
* header must indicate a host equal to the request's own host.
*
* SameSite=Lax on the session cookie blocks classic cross-site form posts;
* this header check covers the remaining attack surface.
*/
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method)) {
next();
return;
}
const expectedHost = req.get("host");
const originHeader = req.get("origin");
const refererHeader = req.get("referer");
const headerHost = hostOf(originHeader) ?? hostOf(refererHeader);
if (!headerHost || !expectedHost || headerHost !== expectedHost) {
res.status(403).json({ error: "bad origin" });
return;
}
next();
}
function hostOf(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).host;
} catch {
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import { createRateLimit } from "./rateLimit.js";
describe("createRateLimit", () => {
let app: express.Express;
beforeEach(() => {
app = express();
// capacity=3, refill=1/sec → first 3 succeed, then 429 until refill.
app.use(createRateLimit({ capacity: 3, refillPerSec: 1 }));
app.get("/", (_req, res) => res.json({ ok: true }));
});
it("allows up to capacity bursts then rejects with 429", async () => {
expect((await request(app).get("/")).status).toBe(200);
expect((await request(app).get("/")).status).toBe(200);
expect((await request(app).get("/")).status).toBe(200);
const denied = await request(app).get("/");
expect(denied.status).toBe(429);
expect(denied.body).toEqual({ error: "rate limit exceeded" });
expect(denied.headers["retry-after"]).toBeDefined();
});
it("uses per-key buckets when keyFn is provided", async () => {
const customApp = express();
customApp.use(
createRateLimit({
capacity: 1,
refillPerSec: 0.001,
keyFn: (req) => req.get("x-user") ?? "anon",
})
);
customApp.get("/", (_req, res) => res.json({ ok: true }));
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(200);
expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(429);
// Different user, separate bucket → still has a token.
expect((await request(customApp).get("/").set("X-User", "bob")).status).toBe(200);
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
interface Bucket {
tokens: number;
lastRefillMs: number;
}
interface RateLimitOptions {
/** Bucket capacity (max burst). */
capacity: number;
/** Tokens refilled per second. */
refillPerSec: number;
/** Optional key function; defaults to req.ip. */
keyFn?: (req: Request) => string;
}
/**
* In-memory token-bucket rate limiter.
*
* Each unique key (default: req.ip) gets its own bucket. Refills continuously
* at `refillPerSec` up to `capacity`. Each request consumes 1 token; if no
* token is available, returns 429 with Retry-After.
*
* Buckets evict themselves after 10 minutes of inactivity to bound memory.
*/
export function createRateLimit(options: RateLimitOptions): RequestHandler {
const buckets = new Map<string, Bucket>();
const EVICT_AFTER_MS = 10 * 60 * 1000;
// Periodic eviction to bound memory under attack.
const evict = setInterval(() => {
const cutoff = Date.now() - EVICT_AFTER_MS;
for (const [k, b] of buckets) {
if (b.lastRefillMs < cutoff) buckets.delete(k);
}
}, 60_000);
// Unref the timer so it doesn't keep the process alive in tests.
if (typeof (evict as { unref?: () => void }).unref === "function") {
(evict as { unref: () => void }).unref();
}
const keyFn = options.keyFn ?? ((req) => req.ip ?? "unknown");
return function rateLimit(req: Request, res: Response, next: NextFunction): void {
const key = keyFn(req);
const now = Date.now();
let b = buckets.get(key);
if (!b) {
b = { tokens: options.capacity, lastRefillMs: now };
buckets.set(key, b);
}
const elapsedSec = (now - b.lastRefillMs) / 1000;
b.tokens = Math.min(options.capacity, b.tokens + elapsedSec * options.refillPerSec);
b.lastRefillMs = now;
if (b.tokens < 1) {
const waitSec = Math.ceil((1 - b.tokens) / options.refillPerSec);
res.setHeader("Retry-After", String(waitSec));
res.status(429).json({ error: "rate limit exceeded" });
return;
}
b.tokens -= 1;
next();
};
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("requireAdmin middleware", () => {
let botDb: BotDatabase;
let app: express.Express;
let adminCookie: string;
let memberCookie: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin-pw", "admin");
const member = await users.createUser("member", "pw-member-pw", "member");
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions, permissions));
app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
});
afterEach(() => botDb.close());
it("rejects unauthenticated requests with 401", async () => {
const res = await request(app).get("/admin-only");
expect(res.status).toBe(401);
});
it("rejects member with 403", async () => {
const res = await request(app).get("/admin-only").set("Cookie", memberCookie);
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "forbidden" });
});
it("allows admin", async () => {
const res = await request(app).get("/admin-only").set("Cookie", adminCookie);
expect(res.status).toBe(200);
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { Request, Response, NextFunction } from "express";
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: "unauthenticated" });
return;
}
if (req.user.role !== "admin") {
res.status(403).json({ error: "forbidden" });
return;
}
next();
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("requireAuth middleware", () => {
let botDb: BotDatabase;
let app: express.Express;
let validToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions, permissions));
app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user });
});
});
afterEach(() => {
botDb.close();
});
it("rejects requests without a session cookie", async () => {
const res = await request(app).get("/protected");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "unauthenticated" });
});
it("rejects requests with an unknown session cookie", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`);
expect(res.status).toBe(401);
});
it("allows requests with a valid session cookie and attaches req.user", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.user.username).toBe("alice");
expect(res.body.user.role).toBe("admin");
});
it("rolls the cookie max-age forward on successful auth", async () => {
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
expect(res.status).toBe(200);
const setCookieHeaders = res.headers["set-cookie"];
const arr = Array.isArray(setCookieHeaders) ? setCookieHeaders : setCookieHeaders ? [setCookieHeaders] : [];
const refreshed = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
expect(refreshed).toBeDefined();
expect(refreshed!).toMatch(/Max-Age=\d+/);
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
import {
validateSessionFromHeaders,
extractSessionToken,
SESSION_COOKIE_NAME,
} from "../auth/validateSession.js";
declare module "express-serve-static-core" {
interface Request {
user?: {
id: string;
username: string;
role: "admin" | "member";
capabilities?: Set<string>;
bots?: "all" | Set<string>;
};
}
}
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
req.user = {
id: result.userId,
username: result.username,
role: result.role,
capabilities: ctx.capabilities,
bots: ctx.bots,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: req.secure,
path: "/",
maxAge: SESSION_TTL_MS,
});
}
next();
};
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import { requirePermission, requireBotAccess } from "./requirePermission.js";
function appWith(user: any) {
const app = express();
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1", username: "a", role: "member",
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
});
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
describe("requirePermission", () => {
it("401 when unauthenticated", async () => {
const app = express();
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/cap")).status).toBe(401);
});
it("403 when member lacks the capability", async () => {
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
});
it("200 when member has the capability", async () => {
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
});
it("200 for admin regardless of capabilities", async () => {
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
});
});
describe("requireBotAccess", () => {
it("200 when bots = all", async () => {
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
});
it("200 when botId in allow-list", async () => {
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
});
it("403 when botId not in allow-list", async () => {
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
});
it("200 for admin", async () => {
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
});
it("401 when unauthenticated", async () => {
const app = express();
app.post("/bot/:botId", requireBotAccess("botId"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/bot/b1")).status).toBe(401);
});
it("403 when the route param is absent", async () => {
const app = express();
app.use((req, _res, next) => { (req as any).user = member([], ["b1"]); next(); });
app.post("/bot/:botId", requireBotAccess("nope"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/bot/b1")).status).toBe(403);
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
// Generic over the route-param shape (`P`) so Express can keep inferring
// `req.params` from the route string (e.g. `/:id` → `{ id: string }`) when
// these are passed as a per-route middleware argument. Pinning the default
// `ParamsDictionary` here would otherwise force the broad
// `string | string[]` param overload on every route they guard.
export function requirePermission<P = Record<string, string>>(capability: string): RequestHandler<P> {
return (req: Request<P>, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.capabilities?.has(capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
export function requireBotAccess<P = Record<string, string>>(paramName = "botId"): RequestHandler<P> {
return (req: Request<P>, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
const botId = (req.params as Record<string, string | undefined>)[paramName];
if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Regression guard for the QR-login / cookie-save outage (and in fact every
* mutating WebUI action). On 2026-05-27 the WebUI-auth feature added the
* same-origin CSRF gate `app.use("/api", csrfOriginCheck)` in server.ts, and
* the same day a `<meta name="referrer" content="no-referrer">` was added to
* web/index.html so cross-origin CDN cover thumbnails would load.
*
* Those two changes conflict: per the WHATWG Fetch "Append a request Origin
* header" algorithm, the `no-referrer` policy sets the Origin header to the
* literal string "null" on same-origin non-GET requests. csrfOriginCheck then
* fails to parse a host (`new URL("null")` throws) and returns 403 "bad
* origin", so POST /api/auth/qrcode (and every other POST/PUT/DELETE under
* /api/* except /api/session/*) never reaches its handler.
*
* `same-origin` is the correct policy: it keeps the real Origin on same-origin
* requests (CSRF passes) while still sending no Referer cross-origin (CDN
* thumbnails keep loading). Never switch this back to `no-referrer`.
*/
describe("frontend referrer policy (CSRF / Origin-header regression)", () => {
const indexHtmlPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../web/index.html"
);
const html = fs.readFileSync(indexHtmlPath, "utf-8");
const referrerMeta = html.match(
/<meta\s+name=["']referrer["']\s+content=["']([^"']+)["']\s*\/?>/i
);
it("declares a referrer policy meta tag", () => {
expect(referrerMeta).not.toBeNull();
});
it("uses same-origin (NOT no-referrer, which sends Origin: null and 403s every POST)", () => {
expect(referrerMeta?.[1]).toBe("same-origin");
});
it("does not contain no-referrer anywhere in the document head", () => {
expect(html).not.toMatch(/content=["']no-referrer["']/i);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
/**
* The clickjacking-defence middleware is mounted at the top of
* `createWebServer` in `server.ts`. This test asserts the exact behavior
* we expect from that middleware in isolation. The wiring inside
* `server.ts` is verified by code review (git diff).
*/
describe("security headers (anti-clickjacking)", () => {
function buildApp() {
const app = express();
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
next();
});
app.get("/", (_req, res) => res.json({ ok: true }));
app.post("/", (_req, res) => res.json({ ok: true }));
return app;
}
it("sets X-Frame-Options: DENY on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.status).toBe(200);
expect(res.headers["x-frame-options"]).toBe("DENY");
});
it("sets Content-Security-Policy frame-ancestors 'none' on GET responses", async () => {
const res = await request(buildApp()).get("/");
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
});
it("sets both headers on POST responses too", async () => {
const res = await request(buildApp()).post("/");
expect(res.headers["x-frame-options"]).toBe("DENY");
expect(res.headers["content-security-policy"]).toBe("frame-ancestors 'none'");
});
});
+104 -5
View File
@@ -1,6 +1,7 @@
import express from "express";
import http from "node:http";
import path from "node:path";
import cookieParser from "cookie-parser";
import { WebSocketServer } from "ws";
import type { BotManager } from "../bot/manager.js";
import type { MusicProvider } from "../music/provider.js";
@@ -13,7 +14,22 @@ import { createBotRouter } from "./api/bot.js";
import { createMusicRouter } from "./api/music.js";
import { createPlayerRouter } from "./api/player.js";
import { createAuthRouter } from "./api/auth.js";
import { createSessionRouter } from "./api/session.js";
import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js";
import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { createPermissionStore } from "../data/permissions.js";
import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js";
import { csrfOriginCheck } from "./middleware/csrf.js";
import { createRateLimit } from "./middleware/rateLimit.js";
import { validateSessionFromHeaders } from "./auth/validateSession.js";
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export interface WebServerOptions {
port: number;
@@ -41,17 +57,52 @@ export function createWebServer(options: WebServerOptions): WebServer {
const logger = options.logger.child({ component: "web" });
if (options.config.trustProxy) {
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
app.set("trust proxy", true);
}
// Security headers: prevent the WebUI from being embedded in a third-party
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
// of X-Frame-Options; both are set for compatibility across browsers.
app.use((_req, res, next) => {
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
next();
});
app.use(express.json({ limit: "400kb" }));
app.use(cookieParser());
const users = createUserStore(options.database.db);
const sessions = createSessionStore(options.database.db);
const audit = createAuditStore(options.database.db);
const permissions = createPermissionStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
app.get("/api/config/public-url", (_req, res) => {
const raw = (options.config.publicUrl ?? "").trim();
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
});
// Anti-DoS: throttle expensive (bcrypt) auth endpoints.
// 5 req per minute per IP for /login (capacity 5, refill 5/60 = ~0.083/sec).
// 3 req per minute per IP for /setup (more limited; first-run is rare).
const loginLimit = createRateLimit({ capacity: 5, refillPerSec: 5 / 60 });
const setupLimit = createRateLimit({ capacity: 3, refillPerSec: 3 / 60 });
app.use("/api/session/login", loginLimit);
app.use("/api/session/setup", setupLimit);
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions, permissions);
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// ─── Protected routes ───────────────────────────────────────────────────
app.use(
"/api/bot",
createBotRouter(
@@ -75,11 +126,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
);
app.use("/api/favorites", createFavoritesRouter(options.database, logger));
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
// ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) {
app.use(express.static(options.staticDir));
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
@@ -91,22 +144,68 @@ export function createWebServer(options: WebServerOptions): WebServer {
logger.error({ err }, "HTTP server error");
});
const wss = new WebSocketServer({ server, path: "/ws" });
// ─── WebSocket with manual upgrade auth ────────────────────────────────
const wss = new WebSocketServer({ noServer: true });
wss.on("error", (err) => {
logger.error({ err }, "WebSocket server error");
});
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") {
socket.destroy();
return;
}
const reqHost = req.headers.host;
const originHeader = req.headers.origin;
if (originHeader) {
let originHost: string | null = null;
try {
originHost = new URL(originHeader).host;
} catch {
// fall through; treat as missing/invalid origin
}
if (!originHost || originHost !== reqHost) {
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
}
const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
if (!result) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
(ws as unknown as { userId: string }).userId = result.userId;
wss.emit("connection", ws, req);
});
});
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
// ─── Session cleanup interval ──────────────────────────────────────────
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
return {
async start(): Promise<void> {
return new Promise((resolve) => {
server.listen(options.port, () => {
logger.info({ port: options.port }, "Web server started");
cleanupTimer = setInterval(() => {
try {
sessions.cleanupExpired();
} catch (err) {
logger.error({ err }, "session cleanup failed");
}
}, SESSION_CLEANUP_INTERVAL_MS);
resolve();
});
});
},
stop(): void {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
cleanupWs();
wss.close();
server.close();
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { WebSocketServer, WebSocket as WSClient } from "ws";
import { AddressInfo } from "node:net";
import { createDatabase, type BotDatabase } from "../data/database.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js";
function buildServer(sessions: ReturnType<typeof createSessionStore>) {
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
wss.on("connection", (ws) => ws.send("hello"));
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") return socket.destroy();
const r = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions);
if (!r) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});
return { server, wss };
}
describe("WebSocket auth at upgrade", () => {
let botDb: BotDatabase;
let httpServer: http.Server;
let port: number;
let validToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
const { server } = buildServer(sessions);
httpServer = server;
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
port = (httpServer.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
botDb.close();
});
it("rejects upgrade without cookie (server-side close before open)", async () => {
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`);
const result = await new Promise<string>((resolve) => {
ws.on("open", () => resolve("opened"));
ws.on("unexpected-response", (_req, res) => resolve(`status:${res.statusCode}`));
ws.on("error", () => resolve("error"));
});
expect(result).toMatch(/^status:401$|^error$/);
});
it("accepts upgrade with a valid cookie", async () => {
const ws = new WSClient(`ws://127.0.0.1:${port}/ws`, {
headers: { Cookie: `${SESSION_COOKIE_NAME}=${validToken}` },
});
const msg = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
});
expect(msg).toBe("hello");
ws.close();
});
});
+19
View File
@@ -0,0 +1,19 @@
@echo off
title TSMusicBot
:: Check node
where node >nul 2>&1
if errorlevel 1 (
echo Node.js not found. Run scripts\setup.bat first.
pause
exit /b 1
)
echo Starting TSMusicBot...
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
node dist\index.js
pause
+12
View File
@@ -3,6 +3,18 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
does exactly that: full Referer for our own requests, none for cross-origin
ones — so cover thumbnails (<img> AND CSS background-image) still load.
Do NOT switch this back to "no-referrer": per the WHATWG Fetch spec
("Append a request Origin header") no-referrer downgrades the Origin header
to the literal string "null" on same-origin non-GET requests. The /api/*
CSRF guard (src/web/middleware/csrf.ts) then can't parse a host from it and
responds 403 "bad origin", silently breaking EVERY POST/PUT/DELETE — QR
login, cookie save, playback controls, bot management, user admin, etc.
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
<meta name="referrer" content="same-origin">
<title>TSMusicBot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
+14 -2
View File
@@ -140,12 +140,24 @@ function cycleMobileMode() {
playerStore.setMode(nextMode);
}
onMounted(() => {
onMounted(async () => {
playerStore.loadTheme();
connect();
playerStore.fetchBots();
// Hydrate favorites once per session so deep-links / hard refreshes onto
// Search or Playlist render hearts correctly without first visiting Home.
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
playerStore.fetchFavorites();
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
mobileRaf = requestAnimationFrame(updateMobileProgress);
// Reconcile the dedicated-link scope only after the bot list is known: the
// router guard sets scopedBotId tentatively from ?bot, but applyScopeFromQuery
// validates it against the loaded bots (locks if it exists, clears if stale).
await playerStore.fetchBots();
// Read from the authoritative current route (not a possibly-stale reactive
// snapshot) so the scope reconciles against the ?bot present at refresh time.
const routeBot = router.currentRoute.value.query.bot;
const qBot = typeof routeBot === 'string' ? routeBot : null;
playerStore.applyScopeFromQuery(qBot);
});
onUnmounted(() => {
+49
View File
@@ -0,0 +1,49 @@
import router from '../router/index.js';
import { useSession } from '../composables/useSession.js';
let installed = false;
const nativeFetch: typeof window.fetch = window.fetch.bind(window);
/**
* Wraps fetch so every call:
* - sends cookies (`credentials: 'same-origin'`)
* - on 401 from /api/*: clear local session, redirect to /login
*
* Always uses the captured native fetch, never the (possibly wrapped) global.
*/
export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const merged: RequestInit = {
credentials: 'same-origin',
...init,
headers: { ...(init.headers ?? {}) },
};
return nativeFetch(input, merged).then(async (res) => {
if (res.status === 401 && shouldTriggerRefresh(input)) {
const session = useSession();
await session.refresh();
const current = router.currentRoute.value;
if (current.name !== 'login' && current.name !== 'first-run') {
await router.replace({ name: 'login', query: { next: current.fullPath } });
}
}
return res;
});
}
function shouldTriggerRefresh(input: RequestInfo | URL): boolean {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return url.startsWith('/api/') && !url.startsWith('/api/session/');
}
/**
* Replaces window.fetch with apiFetch so existing call sites do not need to be touched.
* Call once at app startup.
*/
export function installApiClient(): void {
if (installed) return;
installed = true;
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
return apiFetch(input, init ?? {});
}) as typeof window.fetch;
(window as unknown as { __originalFetch?: typeof fetch }).__originalFetch = nativeFetch;
}
+129 -3
View File
@@ -10,8 +10,21 @@
</div>
<div class="nav-right">
<!-- Bot selector (always shown when at least one bot exists) -->
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
<!-- Scoped (dedicated link): static label locked to the one bot, no switching -->
<div v-if="store.isScoped" class="bot-selector scoped" ref="selectorRef">
<div class="bot-selector-btn static">
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '专属机器人' }}</span>
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
<span class="scope-badge">专属模式</span>
</div>
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
</div>
<!-- Normal: full selector with switching (shown when at least one
controllable bot exists — scope ∩ permission via displayedBots) -->
<div v-else-if="displayedBots.length > 0" class="bot-selector" ref="selectorRef">
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
@@ -22,7 +35,7 @@
<div v-if="dropdownOpen" class="bot-dropdown">
<div class="bot-dropdown-header">机器人</div>
<div
v-for="bot in store.bots"
v-for="bot in displayedBots"
:key="bot.id"
class="bot-card"
:class="{ active: bot.id === store.activeBotId }"
@@ -88,6 +101,16 @@
<RouterLink to="/settings" class="settings-btn">
<Icon icon="mdi:cog" />
</RouterLink>
<div v-if="session.currentUser.value" class="nav-user">
<span class="nav-user-name">{{ session.currentUser.value.username }}</span>
<span class="nav-user-role" :class="`role-${session.currentUser.value.role}`">
{{ session.currentUser.value.role === 'admin' ? '管理员' : '成员' }}
</span>
<button class="nav-user-logout" @click="onLogout" title="退出">
<Icon icon="mdi:logout" />
</button>
</div>
</div>
</nav>
@@ -114,11 +137,34 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
const store = usePlayerStore();
const session = useSession();
const { canControlBot } = session;
const navRouter = useRouter();
async function onLogout() {
await session.logout();
navRouter.replace({ name: 'login' });
}
// Belt-and-suspenders: the backend already scopes store.bots to the allowed
// set for members, but filtering here keeps the UI correct if an admin (who
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
const activeBot = computed(() => store.activeBot);
// The bots shown in the selector are the INTERSECTION of the permission
// allow-list (controllableBots) and the dedicated-link scope: while scoped the
// selector is locked to the single scoped bot, otherwise the full controllable
// list is shown and switching is allowed.
const displayedBots = computed(() =>
store.isScoped
? controllableBots.value.filter((b) => b.id === store.scopedBotId)
: controllableBots.value,
);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
const togglingBots = ref<Record<string, boolean>>({});
@@ -137,6 +183,14 @@ function selectBot(id: string) {
dropdownOpen.value = false;
}
// Leave dedicated-link mode. Clear scope BEFORE navigating so the router guard
// (which re-attaches ?bot from scopedBotId) sees a null scope and lets us out.
function exitScope() {
store.clearScope();
dropdownOpen.value = false;
navRouter.push('/');
}
function resolveBaseUrl(): string {
const base = publicBaseUrl.value;
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
@@ -351,6 +405,60 @@ onUnmounted(() => {
}
}
/* Scoped (dedicated-link) selector: locked, non-interactive label + exit */
.bot-selector.scoped {
display: flex;
align-items: center;
gap: 8px;
}
.bot-selector-btn.static {
cursor: default;
&:hover {
background: var(--hover-bg);
border-color: var(--border-color);
}
}
.scope-badge {
font-size: 10px;
font-weight: 700;
color: var(--color-primary);
padding: 2px 6px;
border-radius: 4px;
background: var(--color-primary-15);
flex-shrink: 0;
white-space: nowrap;
@media (max-width: 768px) {
display: none;
}
}
.scope-exit-btn {
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-md);
background: var(--hover-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
transition: background var(--transition-fast), border-color var(--transition-fast);
&:hover {
background: var(--bg-card);
border-color: var(--color-primary);
}
@media (max-width: 768px) {
padding: 6px 10px;
font-size: 11px;
}
}
.bot-state-mini {
font-size: 14px;
&.playing { color: var(--color-online); }
@@ -643,4 +751,22 @@ onUnmounted(() => {
}
}
}
.nav-user {
display: flex; align-items: center; gap: 8px; margin-left: 12px;
color: var(--text-secondary); font-size: 13px;
}
.nav-user-logout {
height: 28px; width: 28px; display: grid; place-items: center;
border: 0; background: transparent; color: var(--text-secondary); cursor: pointer;
border-radius: var(--radius-sm);
&:hover { background: var(--bg-secondary); color: var(--text-primary); }
}
.nav-user-role {
font-size: 11px; padding: 2px 6px; border-radius: 4px;
font-weight: 500;
}
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
</style>
+57 -25
View File
@@ -3,9 +3,10 @@
<Queue :open="showQueue" @close="showQueue = false" />
<div class="player-bar frosted-glass">
<!-- Progress bar -->
<!-- Progress bar (read-only display; seek interaction gated on player.control) -->
<div
class="progress-bar-container"
:class="{ 'no-seek': !canControl }"
ref="progressBarRef"
@click="onProgressClick"
@mousemove="onProgressHover"
@@ -27,42 +28,48 @@
<div class="player-left" @click="toggleLyrics">
<CoverArt :url="currentSong.coverUrl" :size="40" />
<div class="song-info">
<div class="song-name">{{ currentSong.name }}</div>
<div class="song-name" :title="currentSong.name">{{ currentSong.name }}</div>
<div class="song-artist">
<span v-if="showBotBadge" class="bot-badge">{{ activeBot?.name }}</span>
{{ currentSong.artist }}
<span class="artist-name" :title="currentSong.artist">{{ currentSong.artist }}</span>
</div>
</div>
</div>
<div class="player-center">
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
<button class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="play-btn" @click="togglePlay">
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="control-btn" @click="store.next()">
<Icon icon="mdi:skip-next" />
</button>
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
<Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span>
</button>
<!-- Transport controls require player.control -->
<template v-if="canControl">
<button class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="play-btn" @click="togglePlay">
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button>
<button class="control-btn" @click="store.next()">
<Icon icon="mdi:skip-next" />
</button>
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
<Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span>
</button>
</template>
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
</div>
<div class="player-right">
<Icon icon="mdi:volume-high" class="volume-icon" />
<input
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
@change="onVolumeChange"
class="volume-slider"
/>
<!-- Volume requires player.control -->
<template v-if="canControl">
<Icon icon="mdi:volume-high" class="volume-icon" />
<input
type="range"
min="0"
max="100"
:value="activeBot?.volume ?? 75"
@change="onVolumeChange"
class="volume-slider"
/>
</template>
<button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue">
<Icon icon="mdi:playlist-music" />
</button>
@@ -79,6 +86,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import { useRoute, useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue';
import Queue from './Queue.vue';
@@ -86,6 +94,9 @@ const route = useRoute();
const router = useRouter();
const showQueue = ref(false);
const { can } = useSession();
const canControl = computed(() => can('player.control'));
const store = usePlayerStore();
const activeBot = computed(() => store.activeBot);
const currentSong = computed(() => store.currentSong);
@@ -133,6 +144,7 @@ function updateProgress() {
}
async function onProgressClick(e: MouseEvent) {
if (!canControl.value) return; // seek requires player.control
const bar = progressBarRef.value;
if (!bar) return;
const rect = bar.getBoundingClientRect();
@@ -237,6 +249,14 @@ function cycleMode() {
.progress-bar-bg { height: 4px; }
.progress-bar-thumb { opacity: 1; transform: scale(1); }
}
&.no-seek {
cursor: default;
&:hover {
.progress-bar-bg { height: 2px; }
.progress-bar-thumb { opacity: 0; transform: scale(0); }
}
}
}
.progress-bar-bg {
@@ -310,6 +330,8 @@ function cycleMode() {
.song-info {
min-width: 0;
flex: 1;
overflow: hidden;
}
.song-name {
@@ -326,6 +348,16 @@ function cycleMode() {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.artist-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.bot-badge {
+5 -2
View File
@@ -4,7 +4,7 @@
<h3 class="queue-title">播放队列</h3>
<span class="queue-count">{{ botQueue.length }} 首</span>
<button
v-if="botQueue.length > 0"
v-if="botQueue.length > 0 && can('player.control')"
class="clear-btn"
@click="clearAndStop"
title="清空队列并停止播放"
@@ -33,7 +33,7 @@
<div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</div>
</div>
<button class="remove-btn" @click="removeSong(i)" title="移除">
<button v-if="can('player.queue')" class="remove-btn" @click="removeSong(i)" title="移除">
<Icon icon="mdi:close" />
</button>
</div>
@@ -46,6 +46,7 @@ import { watch, computed } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import CoverArt from './CoverArt.vue';
const props = defineProps<{
@@ -57,6 +58,7 @@ defineEmits<{
}>();
const store = usePlayerStore();
const { can } = useSession();
const botQueue = computed(() => store.queue);
// Fetch queue when panel opens
@@ -65,6 +67,7 @@ watch(() => props.open, (isOpen) => {
});
async function playAtIndex(index: number) {
if (!can('player.control')) return;
await store.playAtIndex(index);
await store.fetchQueue();
}
+128
View File
@@ -0,0 +1,128 @@
import { ref, computed, readonly } from "vue";
interface User {
id: string;
username: string;
role: 'admin' | 'member';
capabilities?: string[];
bots?: "all" | string[];
}
const currentUser = ref<User | null>(null);
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
const ready = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const POLL_INTERVAL_MS = 60_000;
function ensurePollStarted() {
if (pollTimer !== null) return;
pollTimer = setInterval(() => {
if (currentUser.value !== null) {
// Best-effort refresh; ignore errors (network blips etc.)
refreshMe().catch(() => {});
}
}, POLL_INTERVAL_MS);
}
function stopPoll() {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function refreshNeedsSetup(): Promise<void> {
const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" });
if (res.ok) {
const body = await res.json();
needsSetup.value = Boolean(body.needsSetup);
}
}
async function refreshMe(): Promise<void> {
const res = await fetch("/api/session/me", { credentials: "same-origin" });
if (res.status === 200) {
currentUser.value = (await res.json()) as User;
} else {
currentUser.value = null;
}
}
async function refresh(): Promise<void> {
await refreshNeedsSetup();
if (needsSetup.value) {
currentUser.value = null;
} else {
await refreshMe();
}
ready.value = true;
ensurePollStarted();
}
async function login(username: string, password: string): Promise<void> {
const res = await fetch("/api/session/login", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `login failed (${res.status})`);
}
currentUser.value = (await res.json()) as User;
// Login response omits capabilities/bots; fetch the authoritative ones from /me.
await refreshMe();
}
async function setup(username: string, password: string): Promise<void> {
const res = await fetch("/api/session/setup", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `setup failed (${res.status})`);
}
currentUser.value = (await res.json()) as User;
needsSetup.value = false;
// Setup response omits capabilities/bots; fetch the authoritative ones from /me.
await refreshMe();
}
async function logout(): Promise<void> {
stopPoll();
await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" });
currentUser.value = null;
}
function can(cap: string): boolean {
const u = currentUser.value;
return !!u && (u.role === "admin" || (u.capabilities ?? []).includes(cap));
}
function canControlBot(botId: string): boolean {
const u = currentUser.value;
if (!u) return false;
if (u.role === "admin" || u.bots === "all") return true;
return Array.isArray(u.bots) && u.bots.includes(botId);
}
export function useSession() {
return {
currentUser: readonly(currentUser),
needsSetup: readonly(needsSetup),
isAuthenticated: computed(() => currentUser.value !== null),
isAdmin: computed(() => currentUser.value?.role === 'admin'),
ready: readonly(ready),
refresh,
login,
logout,
setup,
can,
canControlBot,
};
}
+10 -1
View File
@@ -2,10 +2,19 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router/index.js';
import { installApiClient } from './api/http.js';
import './styles/global.scss';
import './styles/mobile.scss';
installApiClient();
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
// Wait for the initial navigation (and the beforeEach guard that reads ?bot)
// to fully resolve before mounting, so the reactive route query is populated
// when App.onMounted runs and the dedicated-bot scope locks the right bot.
// .catch keeps parity with the old unconditional mount: if the initial
// navigation errors (e.g. a transient network failure in the auth guard),
// still render the shell rather than leaving a blank page.
router.isReady().catch(() => {}).then(() => app.mount('#app'));
+58 -41
View File
@@ -1,23 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useSession } from '../composables/useSession.js';
import { usePlayerStore } from '../stores/player.js';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/Home.vue'),
},
{
path: '/search',
name: 'search',
component: () => import('../views/Search.vue'),
},
{
path: '/library',
name: 'library',
component: () => import('../views/Library.vue'),
},
{ path: '/', name: 'home', component: () => import('../views/Home.vue') },
{ path: '/search', name: 'search', component: () => import('../views/Search.vue') },
{ path: '/library', name: 'library', component: () => import('../views/Library.vue') },
{
path: '/playlist/:id',
name: 'playlist',
@@ -30,33 +20,60 @@ const router = createRouter({
component: () => import('../views/Playlist.vue'),
meta: { kind: 'album' },
},
{
path: '/lyrics',
name: 'lyrics',
component: () => import('../views/Lyrics.vue'),
},
{
path: '/history',
name: 'history',
component: () => import('../views/History.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/Settings.vue'),
},
{
path: '/setup',
name: 'setup',
component: () => import('../views/Setup.vue'),
},
{
// Per-bot URL: /bot/:id — sets active bot then redirects to home
path: '/bot/:id',
name: 'bot',
component: () => import('../views/BotRedirect.vue'),
},
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
{ path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') },
{ path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') },
{ path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') },
// Auth views
{ path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { public: true } },
{ path: '/first-run', name: 'first-run', component: () => import('../views/FirstRunSetup.vue'), meta: { public: true } },
],
});
const PUBLIC_NAMES = new Set(['login', 'first-run']);
router.beforeEach(async (to) => {
const session = useSession();
if (!session.ready.value) {
await session.refresh();
}
if (session.needsSetup.value && to.name !== 'first-run') {
return { name: 'first-run' };
}
if (!session.needsSetup.value && to.name === 'first-run') {
return { name: 'home' };
}
if (PUBLIC_NAMES.has(to.name as string)) {
if (to.name === 'login' && session.isAuthenticated.value) {
return { name: 'home' };
}
return true;
}
if (!session.isAuthenticated.value) {
return { name: 'login', query: { next: to.fullPath } };
}
// Navigation is allowed to proceed to `to` past here (auth/setup redirects above take precedence).
// Sync + preserve the dedicated-link scope carried by ?bot.
const store = usePlayerStore();
const qBot = typeof to.query.bot === 'string' && to.query.bot ? to.query.bot : null;
if (qBot) {
// URL carries a scope — set tentatively; App.vue's applyScopeFromQuery (after fetchBots) validates/clears it.
store.scopedBotId = qBot;
return true;
}
if (store.scopedBotId) {
// scoped, but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh.
if (to.query.bot !== store.scopedBotId) {
return { path: to.path, query: { ...to.query, bot: store.scopedBotId }, hash: to.hash };
}
}
return true;
});
export default router;
+113
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import { resolveScopedBot } from './scope.js';
export interface Song {
id: string;
@@ -34,6 +35,17 @@ export interface PlaylistItem {
platform: string;
}
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
interface TimingState {
serverElapsed: number;
serverSyncTime: number;
@@ -50,6 +62,9 @@ export const usePlayerStore = defineStore('player', {
state: () => ({
bots: [] as BotStatus[],
activeBotId: null as string | null,
/** When set, the UI is locked to a single bot (dedicated link, from ?bot).
* Source of truth is the URL — never persisted to localStorage. */
scopedBotId: null as string | null,
/** Per-bot queues keyed by botId */
queues: {} as Record<string, Song[]>,
/** Per-bot timing state keyed by botId */
@@ -64,6 +79,9 @@ export const usePlayerStore = defineStore('player', {
authStatus: { netease: false, qq: false },
lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user)
favoritedPlaylists: [] as FavoritePlaylist[],
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
@@ -73,6 +91,10 @@ export const usePlayerStore = defineStore('player', {
activeBot(): BotStatus | null {
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
},
/** True when the UI is locked to a single bot via a dedicated link. */
isScoped(): boolean {
return this.scopedBotId !== null;
},
currentSong(): Song | null {
return this.activeBot?.currentSong ?? null;
},
@@ -125,6 +147,8 @@ export const usePlayerStore = defineStore('player', {
},
setActiveBotId(id: string) {
// While scoped to a dedicated link, switching bots is blocked.
if (this.scopedBotId !== null && id !== this.scopedBotId) return;
this.activeBotId = id;
// Fetch queue for newly active bot if we don't have it yet
if (!this.queues[id]) {
@@ -132,6 +156,32 @@ export const usePlayerStore = defineStore('player', {
}
},
/** Lock the UI to a single bot (dedicated link). Sets scope first so the
* setActiveBotId guard does not block the switch to the scoped bot. */
setScope(id: string) {
this.scopedBotId = id;
this.activeBotId = id;
// Lazily fetch this bot's queue, mirroring setActiveBotId.
if (!this.queues[id]) {
this.fetchQueue();
}
},
clearScope() {
this.scopedBotId = null;
},
/** Reconcile the scope with the desired id from the URL (?bot). A stale or
* forbidden id resolves to null and clears the scope rather than locking. */
applyScopeFromQuery(requestedId: string | null) {
const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id));
if (r) {
this.setScope(r);
} else if (requestedId) {
this.clearScope();
}
},
updateBotStatus(botId: string, status: BotStatus) {
const prev = this.bots.find((b) => b.id === botId);
const prevSongId = prev?.currentSong?.id;
@@ -166,6 +216,11 @@ export const usePlayerStore = defineStore('player', {
this.bots = this.bots.filter((b) => b.id !== botId);
delete this.queues[botId];
delete this.timings[botId];
// If the bot we were locked to is gone, drop the scope so the UI does not
// stay 'locked' onto a phantom (activeBot would silently fall back to bots[0]).
if (this.scopedBotId === botId) {
this.clearScope();
}
},
setQueue(botId: string, queue: Song[]) {
@@ -397,6 +452,60 @@ export const usePlayerStore = defineStore('player', {
if (bot) bot.playMode = mode;
},
async startFm(platform: Source = 'netease') {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
this.fetchQueue();
},
async fetchFavorites() {
try {
const res = await axios.get('/api/favorites');
this.favoritedPlaylists = res.data.favorites ?? [];
} catch {
// not critical
}
},
async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) {
try {
await axios.post('/api/favorites', playlist);
await this.fetchFavorites();
this.notify('已收藏', 'info');
} catch (err: any) {
// 409 = already favorited (e.g. stale heart); just resync so the UI converges.
if (err?.response?.status === 409) {
await this.fetchFavorites();
return;
}
this.notify('收藏失败', 'error');
}
},
async removeFavorite(id: number) {
try {
await axios.delete(`/api/favorites/${id}`);
await this.fetchFavorites();
this.notify('已取消收藏', 'info');
} catch (err: any) {
// 404 = already gone; resync. Otherwise report failure.
if (err?.response?.status === 404) {
await this.fetchFavorites();
return;
}
this.notify('取消收藏失败', 'error');
}
},
isFavorited(playlistId: string, platform: string): boolean {
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
},
async fetchHomeData() {
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
@@ -414,6 +523,10 @@ export const usePlayerStore = defineStore('player', {
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
// Favorites are user-local and cheap; always refresh them, even on a
// home-data cache hit, so hearts stay correct across tabs/sessions.
this.fetchFavorites();
// Cache hit only if auth is unchanged AND within TTL.
if (
!authChanged &&
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { resolveScopedBot } from "./scope.js";
describe("resolveScopedBot", () => {
it("returns null when no id requested", () => {
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
expect(resolveScopedBot("", ["a"])).toBeNull();
});
it("returns the id when it exists in the bot list", () => {
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
});
it("clears (null) when the requested id is not a known bot", () => {
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
});
});
+10
View File
@@ -0,0 +1,10 @@
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
* effective scope. Returns the id if it exists, else null (graceful clear:
* a stale/forbidden id never locks the UI). */
export function resolveScopedBot(
requestedId: string | null | undefined,
knownBotIds: readonly string[],
): string | null {
if (!requestedId) return null;
return knownBotIds.includes(requestedId) ? requestedId : null;
}
+2 -2
View File
@@ -22,8 +22,8 @@ onMounted(async () => {
}
const bot = store.bots.find((b) => b.id === botId);
if (bot) {
store.setActiveBotId(botId);
router.replace('/');
store.setScope(botId);
router.replace({ path: '/', query: { bot: botId } });
} else {
notFound.value = true;
}
+75
View File
@@ -0,0 +1,75 @@
<template>
<div class="auth-page">
<form class="auth-card" @submit.prevent="submit">
<h1>首次使用</h1>
<p class="auth-hint">创建管理员账号。该账号将拥有 WebUI 的全部权限。</p>
<label>
<span>用户名</span>
<input v-model="username" type="text" autocomplete="username" autofocus required />
</label>
<label>
<span>密码 (≥8 位)</span>
<input v-model="password" type="password" autocomplete="new-password" minlength="8" required />
</label>
<label>
<span>再次输入密码</span>
<input v-model="confirm" type="password" autocomplete="new-password" minlength="8" required />
</label>
<p v-if="error" class="auth-error">{{ error }}</p>
<button type="submit" :disabled="loading">{{ loading ? '创建中…' : '创建管理员' }}</button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSession } from '../composables/useSession.js';
const username = ref('');
const password = ref('');
const confirm = ref('');
const error = ref('');
const loading = ref(false);
const router = useRouter();
const session = useSession();
async function submit() {
error.value = '';
if (password.value !== confirm.value) {
error.value = '两次输入的密码不一致';
return;
}
loading.value = true;
try {
await session.setup(username.value, password.value);
router.replace('/');
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
</script>
<style scoped lang="scss">
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-primary); }
.auth-card {
width: 360px; padding: 32px; background: var(--bg-secondary);
border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 12px;
box-shadow: var(--shadow-dropdown);
}
.auth-card h1 { margin: 0; font-size: 20px; color: var(--text-primary); }
.auth-hint { margin: 0 0 4px; font-size: 12px; color: var(--text-secondary); }
.auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text-secondary); }
.auth-card input {
height: 36px; padding: 0 10px; border-radius: var(--radius-sm);
background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);
}
.auth-card button {
height: 38px; border-radius: var(--radius-sm); border: 0;
background: var(--color-primary); color: #fff; font-weight: 500; cursor: pointer;
}
.auth-card button:disabled { opacity: 0.6; cursor: progress; }
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; }
</style>
+42 -16
View File
@@ -21,7 +21,7 @@
<!-- 私人FM -->
<section class="section">
<h2 class="section-title">私人FM</h2>
<div class="fm-card hover-scale" @click="playFm">
<div class="fm-card hover-scale" @click="playFm('netease')">
<div class="fm-icon-wrapper">
<Icon icon="mdi:radio" class="fm-icon" />
</div>
@@ -31,6 +31,16 @@
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
<div class="fm-icon-wrapper qq">
<Icon icon="mdi:radar" class="fm-icon" />
</div>
<div class="fm-info">
<div class="fm-title">QQ音乐雷达</div>
<div class="fm-desc">猜你喜欢 / 雷达推荐歌曲流</div>
</div>
<Icon icon="mdi:play-circle" class="fm-play-icon" />
</div>
</section>
<!-- 每日推荐 -->
@@ -72,6 +82,27 @@
</div>
</section>
<!-- 我的收藏 -->
<section class="section" v-if="store.favoritedPlaylists.length > 0">
<h2 class="section-title">
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
我的收藏
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="fav in store.favoritedPlaylists"
:key="fav.id"
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ fav.name }}</div>
<div class="playlist-count">{{ fav.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
@@ -125,9 +156,9 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { usePlayerStore, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue';
import SourceTabs from '../components/SourceTabs.vue';
@@ -180,19 +211,8 @@ const visibleUserPlaylists = computed(() =>
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
);
async function playFm() {
try {
const res = await axios.get('/api/music/personal/fm');
const songs: Song[] = res.data.songs;
if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) {
await store.addToQueue(songs[i].name, songs[i].platform);
}
}
} catch {
// Ignore
}
async function playFm(platform: Source) {
await store.startFm(platform);
}
onMounted(() => {
@@ -318,6 +338,7 @@ onMounted(() => {
border-radius: var(--radius-lg);
cursor: pointer;
transition: background var(--transition-fast);
margin-bottom: 12px;
&:hover {
background: var(--hover-bg);
@@ -333,6 +354,10 @@ onMounted(() => {
align-items: center;
justify-content: center;
flex-shrink: 0;
&.qq {
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
}
}
.fm-icon {
@@ -379,6 +404,7 @@ onMounted(() => {
.daily-card {
cursor: pointer;
min-width: 0;
}
.daily-name {
+22
View File
@@ -2,6 +2,27 @@
<div class="library-page">
<h1 class="page-title">音乐库</h1>
<!-- 我的收藏 -->
<section class="section" v-if="store.favoritedPlaylists.length > 0">
<h2 class="section-title">
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
我的收藏
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="fav in store.favoritedPlaylists"
:key="fav.id"
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ fav.name }}</div>
<div class="playlist-count">{{ fav.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
@@ -51,6 +72,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="auth-page">
<form class="auth-card" @submit.prevent="submit">
<h1>登录 TSMusicBot</h1>
<label>
<span>用户名</span>
<input v-model="username" type="text" autocomplete="username" autofocus required />
</label>
<label>
<span>密码</span>
<input v-model="password" type="password" autocomplete="current-password" required />
</label>
<p v-if="error" class="auth-error">{{ error }}</p>
<button type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSession } from '../composables/useSession.js';
const username = ref('');
const password = ref('');
const error = ref('');
const loading = ref(false);
const router = useRouter();
const route = useRoute();
const session = useSession();
async function submit() {
error.value = '';
loading.value = true;
try {
await session.login(username.value, password.value);
const rawNext = typeof route.query.next === 'string' ? route.query.next : '/';
const next = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/';
router.replace(next);
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
</script>
<style scoped lang="scss">
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
}
.auth-card {
width: 360px;
padding: 32px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: var(--shadow-dropdown);
}
.auth-card h1 { margin: 0 0 8px; font-size: 20px; color: var(--text-primary); }
.auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text-secondary); }
.auth-card input {
height: 36px; padding: 0 10px; border-radius: var(--radius-sm);
background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);
}
.auth-card button {
height: 38px; border-radius: var(--radius-sm); border: 0;
background: var(--color-primary); color: #fff; font-weight: 500; cursor: pointer;
}
.auth-card button:disabled { opacity: 0.6; cursor: progress; }
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; }
</style>
+80 -4
View File
@@ -16,10 +16,21 @@
<div class="playlist-stats">
{{ songs.length }} 首歌曲
</div>
<button class="play-all-btn" @click="playAll">
<Icon icon="mdi:play" />
播放全部
</button>
<div class="playlist-actions">
<button class="play-all-btn" @click="playAll">
<Icon icon="mdi:play" />
播放全部
</button>
<button
v-if="kind === 'playlist'"
class="fav-btn"
:class="{ favorited }"
@click="toggleFavorite"
>
<Icon :icon="favorited ? 'mdi:heart' : 'mdi:heart-outline'" />
{{ favorited ? '已收藏' : '收藏' }}
</button>
</div>
</div>
</div>
@@ -69,6 +80,7 @@ const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]);
const loading = ref(true);
const favorited = ref(false);
async function playAll() {
const id = route.params.id as string;
@@ -126,8 +138,35 @@ onMounted(async () => {
}
}
songs.value = songList;
// Check favorite status after songs are resolved
if (kind === 'playlist') {
favorited.value = store.isFavorited(id, platform);
}
loading.value = false;
});
async function toggleFavorite() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
if (favorited.value) {
const fav = store.favoritedPlaylists.find((f) => f.playlistId === id && f.platform === platform);
if (fav) {
await store.removeFavorite(fav.id);
favorited.value = false;
}
} else {
await store.addFavorite({
platform,
playlistId: id,
name: playlist.value?.name ?? '未知歌单',
coverUrl: playlist.value?.coverUrl ?? '',
songCount: songs.value.length,
});
favorited.value = true;
}
}
</script>
<style lang="scss" scoped>
@@ -193,6 +232,43 @@ onMounted(async () => {
&:active { transform: scale(0.96); }
}
.playlist-actions {
display: flex;
align-items: center;
gap: 12px;
}
.fav-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
font-size: 14px;
font-weight: 500;
transition: all var(--transition-fast);
cursor: pointer;
&:hover {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-8);
}
&.favorited {
color: #e74c3c;
border-color: #e74c3c;
background: rgba(231, 76, 60, 0.08);
&:hover {
background: rgba(231, 76, 60, 0.15);
}
}
}
.song-list {
display: flex;
flex-direction: column;
+62
View File
@@ -92,6 +92,13 @@
class="card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<button
class="fav-badge"
:class="{ favorited: isFav(pl) }"
@click.prevent.stop="toggleFavPlaylist(pl)"
>
<Icon :icon="isFav(pl) ? 'mdi:heart' : 'mdi:heart-outline'" />
</button>
<div class="card-name">
{{ pl.name }}
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
@@ -179,6 +186,25 @@ watch(selectedSource, (src) => {
}
});
function isFav(pl: { id: string; platform: string }): boolean {
return store.isFavorited(pl.id, pl.platform);
}
async function toggleFavPlaylist(pl: { id: string; platform: string; name: string; coverUrl: string; songCount?: number }) {
if (isFav(pl)) {
const fav = store.favoritedPlaylists.find((f) => f.playlistId === pl.id && f.platform === pl.platform);
if (fav) await store.removeFavorite(fav.id);
} else {
await store.addFavorite({
platform: pl.platform,
playlistId: pl.id,
name: pl.name,
coverUrl: pl.coverUrl,
songCount: pl.songCount ?? 0,
});
}
}
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
@@ -356,6 +382,7 @@ onMounted(() => {
gap: 16px 28px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
@@ -394,4 +421,39 @@ onMounted(() => {
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
.fav-badge {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
z-index: 2;
.card:hover & {
opacity: 1;
}
&.favorited {
color: #e74c3c;
opacity: 1;
}
&:hover {
color: #e74c3c;
background: rgba(0, 0, 0, 0.7);
}
}
</style>
+656 -7
View File
@@ -21,8 +21,37 @@
</div>
</section>
<!-- Bot Management -->
<!-- Account: own password change -->
<section class="settings-section">
<h2 class="section-title">账户</h2>
<div class="account-info-card">
<div class="account-row">
<span class="account-label">用户名</span>
<span class="account-value">{{ session.currentUser.value?.username ?? '—' }}</span>
</div>
<div class="account-row">
<span class="account-label">角色</span>
<span class="account-value">
<span class="user-role-badge" :class="`role-${session.currentUser.value?.role}`">
{{ session.currentUser.value?.role === 'admin' ? '管理员' : '成员' }}
</span>
</span>
</div>
</div>
<form class="change-pw-form" @submit.prevent="onChangeOwnPassword">
<input v-model="ownPw.old" type="password" autocomplete="current-password" class="input" placeholder="当前密码" required />
<input v-model="ownPw.new" type="password" autocomplete="new-password" minlength="8" class="input" placeholder="新密码 (≥8 位)" required />
<input v-model="ownPw.confirm" type="password" autocomplete="new-password" minlength="8" class="input" placeholder="再次输入新密码" required />
<button class="btn-sm btn-primary" type="submit" :disabled="changingOwnPw">
{{ changingOwnPw ? '更新中…' : '修改密码' }}
</button>
</form>
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
</section>
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">机器人管理</h2>
<div class="bot-list">
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
@@ -128,8 +157,8 @@
</div>
</section>
<!-- Music Account - QR Code Login -->
<section class="settings-section">
<!-- Music Account - QR Code Login (platform auth) requires platform.auth -->
<section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">音乐账号</h2>
<!-- NetEase -->
@@ -342,8 +371,8 @@
</div>
</section>
<!-- Audio Quality -->
<section class="settings-section">
<!-- Audio Quality requires quality -->
<section v-if="can('quality')" class="settings-section">
<h2 class="section-title">音质设置</h2>
<div class="setting-row">
<div class="setting-label">
@@ -381,7 +410,7 @@
</section>
<!-- Idle Timeout -->
<section class="settings-section">
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">行为设置</h2>
<div class="setting-row">
<div class="setting-label">
@@ -404,10 +433,22 @@
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
</div>
</div>
<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="autoPauseOnEmpty"
type="checkbox"
class="profile-toggle-switch"
@change="saveAutoPause"
/>
</label>
</section>
<!-- Bot Profile (TeamSpeak Behavior) -->
<section class="settings-section">
<section v-if="can('bot.manage')" class="settings-section">
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
@@ -458,6 +499,158 @@
</div>
</div>
</section>
<!-- User Management -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">用户管理</h2>
<div class="user-list">
<div v-for="u in userList" :key="u.id" class="user-row-wrap">
<div class="user-item">
<div class="user-info">
<div class="user-name">
{{ u.username }}
<span class="user-role-badge" :class="`role-${u.role}`">
{{ u.role === 'admin' ? '管理员' : '成员' }}
</span>
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
</div>
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
</div>
<div class="user-actions">
<span v-if="u.role === 'admin'" class="perm-admin-label">全部权限(管理员)</span>
<button
v-else
class="btn-sm"
:class="{ 'btn-primary': permEditingId === u.id }"
@click="onTogglePermEditor(u)"
>
<Icon icon="mdi:shield-key" /> 权限
</button>
<button class="btn-sm" @click="openResetPassword(u)">
<Icon icon="mdi:lock-reset" /> 重置密码
</button>
<button
class="btn-sm"
:disabled="changingRoleId === u.id || isLastAdmin(u)"
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
@click="onToggleRole(u)"
>
<Icon icon="mdi:account-cog" />
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
</button>
<button
class="btn-sm btn-delete"
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
@click="onDeleteUser(u)"
>
<Icon icon="mdi:delete" />
</button>
</div>
</div>
<!-- Inline permission editor (members only) -->
<div v-if="permEditingId === u.id" class="perm-editor">
<div v-if="permLoading" class="user-empty">加载权限中…</div>
<template v-else>
<div class="perm-group">
<div class="perm-group-title">能力</div>
<div class="perm-checks">
<label v-for="cap in CAPABILITIES" :key="cap.token" class="perm-check">
<input
type="checkbox"
:checked="permDraft.capabilities.includes(cap.token)"
@change="toggleCapability(cap.token, ($event.target as HTMLInputElement).checked)"
/>
{{ cap.label }}
</label>
</div>
</div>
<div class="perm-group">
<div class="perm-group-title">机器人</div>
<label class="perm-check">
<input type="checkbox" v-model="permDraft.botsAll" />
全部机器人
</label>
<div v-if="!permDraft.botsAll" class="perm-checks perm-bots">
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
<input
type="checkbox"
:checked="permDraft.selectedBotIds.includes(bot.id)"
@change="toggleBotSelection(bot.id, ($event.target as HTMLInputElement).checked)"
/>
{{ bot.name }}
</label>
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
</div>
</div>
<p v-if="permError" class="user-error">{{ permError }}</p>
<div class="form-actions">
<button class="btn-sm" @click="permEditingId = null">取消</button>
<button class="btn-sm btn-primary" :disabled="permSaving" @click="onSavePermissions(u)">
{{ permSaving ? '保存中…' : '保存' }}
</button>
</div>
</template>
</div>
</div>
<div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div>
<div v-if="userLoadError" class="user-error">{{ userLoadError }}</div>
</div>
<form class="user-add-form" @submit.prevent="onCreateUser">
<input v-model="newUser.username" class="input" placeholder="新用户名 (3-32 字符)" required />
<input v-model="newUser.password" type="password" class="input" placeholder="密码 (≥8 位)" minlength="8" required />
<select v-model="newUser.role" class="input user-role-select">
<option value="member">成员</option>
<option value="admin">管理员</option>
</select>
<button class="btn-sm btn-primary" type="submit" :disabled="creatingUser">
{{ creatingUser ? '创建中…' : '添加用户' }}
</button>
</form>
<p v-if="userMutationError" class="user-error">{{ userMutationError }}</p>
<!-- Reset password modal -->
<div v-if="resetTarget" class="edit-modal-overlay" @click.self="resetTarget = null">
<div class="edit-modal">
<h3 class="modal-title">重置 {{ resetTarget.username }} 的密码</h3>
<p class="modal-hint">该用户的所有会话将被强制下线。</p>
<div class="form-group">
<label>新密码 (≥8 位)</label>
<input v-model="resetPassword" type="password" class="input" minlength="8" />
</div>
<p v-if="resetError" class="user-error">{{ resetError }}</p>
<div class="form-actions">
<button class="btn-sm" @click="resetTarget = null">取消</button>
<button class="btn-sm btn-primary" :disabled="resettingPw" @click="onConfirmReset">
{{ resettingPw ? '保存中…' : '确认重置' }}
</button>
</div>
</div>
</div>
</section>
<!-- Audit Log -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">
操作审计
<button class="audit-refresh-btn" @click="loadAudit" :disabled="auditLoading" title="刷新">
<Icon icon="mdi:refresh" :class="{ spinning: auditLoading }" />
</button>
</h2>
<div v-if="auditLoadError" class="user-error">{{ auditLoadError }}</div>
<div v-else-if="auditEntries.length === 0 && !auditLoading" class="user-empty">暂无操作记录</div>
<div v-else class="audit-list">
<div v-for="e in auditEntries" :key="e.id" class="audit-row">
<div class="audit-time">{{ formatDateTime(e.timestamp) }}</div>
<div class="audit-actor">{{ e.actorUsername ?? '—' }}</div>
<div class="audit-action" :class="auditActionClass(e.action)">{{ describeAction(e) }}</div>
</div>
</div>
</section>
</div>
</template>
@@ -469,6 +662,7 @@ import AvatarUpload from '../components/AvatarUpload.vue';
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
import QRCode from 'qrcode';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
const store = usePlayerStore();
@@ -761,11 +955,13 @@ async function savePrefix() {
// Idle timeout
const idleTimeout = ref(0);
const autoPauseOnEmpty = ref(true);
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? true;
} catch { /* ignore */ }
}
@@ -775,6 +971,12 @@ async function saveIdleTimeout() {
} catch { /* ignore */ }
}
async function saveAutoPause() {
try {
await axios.post('/api/bot/settings', { autoPauseOnEmpty: autoPauseOnEmpty.value });
} catch { /* ignore */ }
}
// --- Bot Profile config ---
interface ProfileConfig {
avatarEnabled: boolean;
@@ -841,11 +1043,329 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
}
}
// --- User Management ---
const session = useSession();
const { can } = session;
// --- Own password change (available to all authenticated users) ---
const ownPw = reactive({ old: '', new: '', confirm: '' });
const ownPwError = ref('');
const ownPwSuccess = ref('');
const changingOwnPw = ref(false);
async function onChangeOwnPassword() {
ownPwError.value = '';
ownPwSuccess.value = '';
if (ownPw.new !== ownPw.confirm) {
ownPwError.value = '两次输入的新密码不一致';
return;
}
if (ownPw.new.length < 8) {
ownPwError.value = '新密码至少 8 位';
return;
}
changingOwnPw.value = true;
try {
const res = await fetch('/api/session/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPassword: ownPw.old, newPassword: ownPw.new }),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
ownPw.old = '';
ownPw.new = '';
ownPw.confirm = '';
ownPwSuccess.value = '密码已更新';
// The server kills other sessions but keeps the current one. No reload needed.
} catch (e) {
ownPwError.value = (e as Error).message;
} finally {
changingOwnPw.value = false;
}
}
interface UserListEntry { id: string; username: string; createdAt: number; role: 'admin' | 'member' }
const userList = ref<UserListEntry[]>([]);
const userLoadError = ref('');
const userMutationError = ref('');
const newUser = reactive({ username: '', password: '', role: 'member' as 'admin' | 'member' });
const creatingUser = ref(false);
const resetTarget = ref<UserListEntry | null>(null);
const resetPassword = ref('');
const resetError = ref('');
const resettingPw = ref(false);
const changingRoleId = ref<string | null>(null);
function isLastAdmin(u: UserListEntry): boolean {
if (u.role !== 'admin') return false;
const adminCount = userList.value.filter((x) => x.role === 'admin').length;
return adminCount <= 1;
}
async function onToggleRole(u: UserListEntry) {
const newRole = u.role === 'admin' ? 'member' : 'admin';
if (!confirm(`确认将 ${u.username} 切换为${newRole === 'admin' ? '管理员' : '成员'}?`)) return;
userMutationError.value = '';
changingRoleId.value = u.id;
try {
const res = await fetch(`/api/users/${u.id}/role`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
await loadUsers();
} catch (e) {
userMutationError.value = (e as Error).message;
} finally {
changingRoleId.value = null;
}
}
async function loadUsers() {
userLoadError.value = '';
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
userList.value = body.users ?? [];
} catch (e) {
userLoadError.value = (e as Error).message;
}
}
async function onCreateUser() {
userMutationError.value = '';
creatingUser.value = true;
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: newUser.username, password: newUser.password, role: newUser.role }),
});
if (!res.ok) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
newUser.username = '';
newUser.password = '';
newUser.role = 'member';
await loadUsers();
} catch (e) {
userMutationError.value = (e as Error).message;
} finally {
creatingUser.value = false;
}
}
async function onDeleteUser(u: UserListEntry) {
if (!confirm(`确认删除用户 ${u.username}?`)) return;
userMutationError.value = '';
try {
const res = await fetch(`/api/users/${u.id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
await loadUsers();
} catch (e) {
userMutationError.value = (e as Error).message;
}
}
function openResetPassword(u: UserListEntry) {
resetTarget.value = u;
resetPassword.value = '';
resetError.value = '';
}
async function onConfirmReset() {
if (!resetTarget.value) return;
if (resetPassword.value.length < 8) {
resetError.value = '密码至少 8 位';
return;
}
resettingPw.value = true;
resetError.value = '';
try {
const res = await fetch(`/api/users/${resetTarget.value.id}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword: resetPassword.value }),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
resetTarget.value = null;
} catch (e) {
resetError.value = (e as Error).message;
} finally {
resettingPw.value = false;
}
}
// --- Per-user permission editor (members only) ---
const CAPABILITIES: { token: string; label: string }[] = [
{ token: 'player.control', label: '播放控制' },
{ token: 'player.queue', label: '队列管理' },
{ token: 'bot.manage', label: '机器人管理' },
{ token: 'platform.auth', label: '平台登录凭据' },
{ token: 'quality', label: '音质设置' },
];
const permEditingId = ref<string | null>(null);
const permLoading = ref(false);
const permSaving = ref(false);
const permError = ref('');
const permDraft = reactive<{ capabilities: string[]; botsAll: boolean; selectedBotIds: string[] }>({
capabilities: [],
botsAll: true,
selectedBotIds: [],
});
async function onTogglePermEditor(u: UserListEntry) {
if (permEditingId.value === u.id) {
permEditingId.value = null;
return;
}
permEditingId.value = u.id;
permError.value = '';
permLoading.value = true;
permDraft.capabilities = [];
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
try {
const res = await fetch(`/api/users/${u.id}/permissions`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
permDraft.capabilities = Array.isArray(body.capabilities) ? [...body.capabilities] : [];
if (body.bots === 'all') {
permDraft.botsAll = true;
permDraft.selectedBotIds = [];
} else {
permDraft.botsAll = false;
permDraft.selectedBotIds = Array.isArray(body.bots) ? [...body.bots] : [];
}
} catch (e) {
permError.value = (e as Error).message;
} finally {
permLoading.value = false;
}
}
function toggleCapability(token: string, checked: boolean) {
const has = permDraft.capabilities.includes(token);
if (checked && !has) permDraft.capabilities.push(token);
else if (!checked && has) permDraft.capabilities = permDraft.capabilities.filter((t) => t !== token);
}
function toggleBotSelection(id: string, checked: boolean) {
const has = permDraft.selectedBotIds.includes(id);
if (checked && !has) permDraft.selectedBotIds.push(id);
else if (!checked && has) permDraft.selectedBotIds = permDraft.selectedBotIds.filter((b) => b !== id);
}
async function onSavePermissions(u: UserListEntry) {
permSaving.value = true;
permError.value = '';
try {
const res = await fetch(`/api/users/${u.id}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
capabilities: [...permDraft.capabilities],
bots: permDraft.botsAll ? 'all' : [...permDraft.selectedBotIds],
}),
});
if (!res.ok && res.status !== 204) {
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `HTTP ${res.status}`);
}
permEditingId.value = null;
} catch (e) {
permError.value = (e as Error).message;
} finally {
permSaving.value = false;
}
}
function formatDate(ms: number): string {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// --- Audit Log ---
interface AuditEntry {
id: number;
timestamp: number;
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: string;
}
const auditEntries = ref<AuditEntry[]>([]);
const auditLoadError = ref('');
const auditLoading = ref(false);
async function loadAudit() {
auditLoadError.value = '';
auditLoading.value = true;
try {
const res = await fetch('/api/audit?limit=100');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
auditEntries.value = body.entries ?? [];
} catch (e) {
auditLoadError.value = (e as Error).message;
} finally {
auditLoading.value = false;
}
}
function formatDateTime(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function describeAction(e: AuditEntry): string {
const target = e.targetUsername ?? e.targetUserId ?? '—';
switch (e.action) {
case 'admin.first_created': return `创建首位管理员 ${target}`;
case 'user.created': return `创建用户 ${target}`;
case 'user.deleted': return `删除用户 ${target}`;
case 'user.password_reset': return `重置 ${target} 的密码`;
case 'user.password_changed': return `修改自己的密码`;
case 'user.role_changed': return `变更 ${target} 的角色`;
case 'user.permissions_changed': return `权限变更 → ${target}`;
default: return `${e.action} → ${target}`;
}
}
function auditActionClass(action: string): string {
if (action === 'user.deleted') return 'audit-action-danger';
if (action === 'user.password_reset' || action === 'user.password_changed') return 'audit-action-warn';
return 'audit-action-ok';
}
onMounted(() => {
store.fetchBots(); // Refresh bot status on page visit
checkAuthStatus();
loadQuality();
loadIdleTimeout();
if (session.isAdmin.value) {
loadUsers();
loadAudit();
}
});
onUnmounted(() => {
@@ -1439,6 +1959,12 @@ onUnmounted(() => {
align-items: flex-start;
}
// Standalone toggle inside 行为设置 (not part of a bordered list)
.behavior-toggle {
border-bottom: none;
padding-top: 4px;
}
@media (max-width: 768px) {
.profile-bot-header {
padding: 14px 12px;
@@ -1467,4 +1993,127 @@ onUnmounted(() => {
}
}
}
// --- User Management ---
.user-list { display: flex; flex-direction: column; gap: 8px; }
.user-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-sm);
}
.user-info { display: flex; flex-direction: column; gap: 4px; }
.user-name { font-weight: 500; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
.user-self-badge {
font-size: 11px; padding: 2px 6px; border-radius: 4px;
background: var(--color-primary); color: #fff;
}
.user-created { font-size: 12px; color: var(--text-secondary); }
.user-actions { display: flex; gap: 8px; }
.user-add-form {
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
}
.user-add-form .input { flex: 1; min-width: 140px; }
.user-empty, .user-error { font-size: 12px; color: var(--text-secondary); padding: 8px 0; }
.user-error { color: #e26a6a; }
.modal-hint { color: var(--text-secondary); font-size: 12px; margin: 0 0 8px; }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
.audit-refresh-btn {
margin-left: 10px;
border: 0; background: transparent;
color: var(--text-secondary); cursor: pointer;
display: inline-flex; align-items: center;
font-size: 16px;
&:hover { color: var(--text-primary); }
&:disabled { opacity: 0.5; cursor: progress; }
}
.spinning { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.audit-list {
display: flex; flex-direction: column;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
max-height: 480px;
overflow-y: auto;
}
.audit-row {
display: grid;
grid-template-columns: 170px 120px 1fr;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 13px;
&:last-child { border-bottom: 0; }
}
.audit-time {
color: var(--text-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 12px;
white-space: nowrap;
}
.audit-actor {
color: var(--text-primary);
font-weight: 500;
}
.audit-action { color: var(--text-primary); }
.audit-action-ok { color: var(--text-primary); }
.audit-action-warn { color: #d3a44b; }
.audit-action-danger { color: #e26a6a; }
@media (max-width: 640px) {
.audit-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
.user-role-badge {
font-size: 11px; padding: 2px 6px; border-radius: 4px; margin-left: 6px;
font-weight: 500;
}
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
.user-role-select { flex: 0 0 110px; }
.user-row-wrap { display: flex; flex-direction: column; gap: 0; }
.perm-admin-label { font-size: 12px; color: var(--text-secondary); align-self: center; }
.perm-editor {
margin-top: -2px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
}
.perm-group { display: flex; flex-direction: column; gap: 8px; }
.perm-group-title { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.perm-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.perm-bots { padding-left: 16px; }
.perm-check {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary); cursor: pointer;
}
.perm-check input { cursor: pointer; }
// --- Account section (own password change) ---
.account-info-card {
display: flex; flex-direction: column; gap: 8px;
padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-sm);
margin-bottom: 12px;
}
.account-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px;
}
.account-label { color: var(--text-secondary); }
.account-value { color: var(--text-primary); font-weight: 500; }
.change-pw-form {
display: flex; flex-direction: column; gap: 8px;
max-width: 360px;
}
.change-pw-form .input { width: 100%; }
.change-pw-form button { align-self: flex-start; }
.user-success { color: #4caf7a; font-size: 13px; margin: 4px 0 0; }
</style>