Compare commits

...
Author SHA1 Message Date
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
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
TIANYAO ZHANG 8861f39ecc Merge pull request #62 from NoSetViolin/main
fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
2026-05-14 22:44:41 +08:00
NoSetViolinandClaude Opus 4.7 6d5755ced7 feat(search): source filter bar, album search, and UX polish
- Add platform source filter bar (网易云/QQ/B站) above category tabs with
  localStorage persistence to remember user preference
- Remove "全部" option, single-source view only
- Increase album/playlist card column-gap to 28px for better spacing
- Sync search query to URL via router.replace so back-navigation from
  album/playlist detail pages restores search results
- Fix !album command: add name-based album search (matching !playlist
  behavior) so "!album APT." searches by name instead of treating it as ID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:56:14 +08:00
NoSetViolinandClaude Opus 4.7 997bb17ceb feat(search): add tabbed category navigation + align NetEase result counts
- Replace single-page layout with pill-slider tabs (单曲/专辑/歌单) under
  the search box, showing only one category at a time with result counts
- NetEase album/playlist search limit raised from 5 to 10 to match QQ

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:56:18 +08:00
NoSetViolinandClaude Opus 4.7 ea6501ea81 feat(web): add platform badges to album and playlist cards in search
Album and playlist results now show source tags (网易云, QQ, B站, YouTube)
matching the existing SongCard platform badge style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:34:26 +08:00
NoSetViolinandClaude Opus 4.7 1d2da33d3c fix(qq): apply musicu.fcg upstream fixes + add playlist search
Per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61:
- Remove searchid param (its presence now causes empty results)
- Enforce num_per_page >= 10 (lower values return empty)
- Fix search_type: 2 for albums, 3 for playlists (8 was "user")

Now uses the fixed musicu.fcg as primary (supports songs + albums +
playlists), with client_search_cp as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:28:12 +08:00
阿梓喵_あずにゃんandClaude Opus 4.7 0e68e287b7 fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
u.y.qq.com/cgi-bin/musicu.fcg started returning is_filter=-9 (empty results)
for unauthenticated requests. Primary search now uses the classic
c.y.qq.com/soso/fcgi-bin/client_search_cp endpoint, with the old musicu.fcg
path kept as a fallback in case the primary endpoint changes format or goes
down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:55:21 +08:00
TIANYAO ZHANG ecdb2d253e Merge pull request #60 from XuVIIJay/feat/forward-stack
随机播放模式双向可回溯
2026-05-12 22:23:35 +08:00
XuVIIJay 8860347bfe Update README.md 2026-05-12 00:04:20 +08:00
XuVIIJay beb99f1d8e Update README.md 2026-05-11 23:49:34 +08:00
XuVIIJay 63f1ced2bc feat(queue): add forwardStack for reversible prev/next navigation
In Random/RandomLoop modes, prev now records the current position
to a forwardStack, and next pops from it first. This ensures prev→next
navigation is fully reversible for any number of steps (up to 50).

Also rebuild playedIndices in prev() so songs reached via backward
navigation become available for random selection again.
2026-05-11 23:45:04 +08:00
saopig1andClaude Opus 4.7 fa6013d22e docs(commands): surface existing !remove <position> in help and README
Closes #59. The command was already implemented but not advertised in
!help output or the README command table, so users assumed it was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:49 +08:00
saopig1andClaude Opus 4.7 6b60792277 feat(web): custom avatar field in edit-bot modal
User reported that the edit-bot dialog had no avatar option (only
create-bot did). Reuses the same CustomAvatarRow component, which
auto-loads on mount and PUT/DELETEs on change. Each bot's avatar
is bound by botId — independent across bots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:23:21 +08:00
saopig1andClaude Opus 4.7 c562fe05b0 fix(player): start frame loop only after ffmpeg spawns in jdymusic path
PR #54's playViaPowerShellDownload called startFrameLoop() right after
spawning the PowerShell downloader, before ffmpeg existed. The loop's
"no ffmpeg + empty buffer → emit trackEnd" branch fired on the very
first tick (~25ms), skipping every jdymusic song. Visible as: each
PowerShell download sessionId logged "Track ended, advancing queue"
before the download completed, so the queue burned through every
track in a few seconds.

Move startFrameLoop() into spawnFfmpegFromFile() where ffmpeg is
known to be alive and producing PCM. state = "playing" still flips
in playViaPowerShellDownload so external observers see the right
status during download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:04:27 +08:00
TIANYAO ZHANG 9efa818bbb Merge pull request #57 from ZHANGTIANYAO1/feat/album-search
feat(search): album section + album playback
2026-05-07 20:50:02 +08:00
TIANYAO ZHANG d757e69bf4 Merge pull request #56 from ZHANGTIANYAO1/feat/custom-bot-avatar
feat(profile): custom bot avatar
2026-05-07 20:49:58 +08:00
saopig1andClaude Opus 4.7 8cccf2ed24 fix(web): album hero shows real album name from songs[0].album
The album detail endpoint intentionally 404s (no /api/music/album/:id/detail
route), so we fall through to the stub built from songs. The album name is
already on every song (Song.album), so use it instead of the literal "专辑".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:35:35 +08:00
saopig1andClaude Sonnet 4.6 f6b82b8e21 feat(web): show album + playlist sections in search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:26:03 +08:00
saopig1andClaude Sonnet 4.6 51d7ce61bd feat(web): /album/:id route reusing Playlist view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:24:20 +08:00
saopig1andClaude Sonnet 4.6 20f4cfacea feat(api): /search/all returns albums and playlists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:21:07 +08:00
saopig1andClaude Sonnet 4.6 7d9081efc1 feat(qq): include albums in search results
Add mapQqAlbums pure helper (exported, TDD-covered) and wire a parallel
req_album sub-request (search_type: 8) into search(), populating the
albums field of SearchResult.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:20:05 +08:00
saopig1andClaude Sonnet 4.6 a2453784c1 feat(netease): include albums in search results
Add mapNeteaseAlbums pure helper and wire cloudsearch?type=10 as the third Promise.all arm in search(), replacing the hardcoded albums:[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:17:46 +08:00
66 changed files with 7858 additions and 532 deletions

No files matched your search

-2
View File
@@ -19,8 +19,6 @@
"Read(//tmp/**)"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
+97 -5
View File
@@ -23,6 +23,7 @@
## 功能特性
- **WebUI 鉴权(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员),bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
@@ -155,6 +156,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,10 +265,16 @@ 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 页面说明
@@ -235,7 +285,7 @@ sudo systemctl start tsmusicbot
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
| **历史** | 播放历史记录 |
| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 |
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员)/ 操作审计(仅管理员) |
### TeamSpeak 文字命令
@@ -253,6 +303,7 @@ sudo systemctl start tsmusicbot
| `!stop` | 停止播放并清空队列 |
| `!vol <0-100>` | 设置音量 |
| `!queue` | 查看播放队列 |
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
@@ -425,6 +476,18 @@ pip install -U yt-dlp
}
```
> **关于 `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 吗?**
@@ -464,6 +527,21 @@ 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:成员可以:管理机器人(启动/停止/创建/编辑)、控制播放(搜索/播放/队列)、登录音乐平台账号、修改自己的密码。成员**不能**:管理其他用户、查看操作审计日志、降级或删除管理员。
**Q:如何把某个用户从成员升级为管理员?**
A:管理员登录后进入 **设置 → 用户管理**,点击对应用户的"提升管理员"按钮即可。降级同理("降为成员"按钮)。系统会阻止降级最后一位管理员。
**Q:登录之后多久会自动退出?**
A:登录态有效期 7 天,活跃使用会滚动续期(每次受保护请求都会刷新过期时间)。同一账号最多保持 10 个并发会话(多设备登录时超过的会自动剔除最旧的会话)。
**Q:部署到公网后如何防止暴力登录?**
A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部署建议同时在反向代理(nginx `limit_req` / Caddy 等)层加一层限流,并启用 HTTPS。反向代理部署务必设置 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。
## 参与贡献
1. Fork 本仓库
@@ -478,6 +556,20 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
### 最新版本
**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,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,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);
}
+294 -321
View File
@@ -1,321 +1,294 @@
@echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Robust Edition)
:: - 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
:: ============================================================
set "SCRIPT_VERSION=2.0"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." || (
echo [FATAL] Cannot change to project directory.
pause
exit /b 1
)
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
echo. > "%LOG_FILE%"
call :log "============================================"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " Started: %date% %time%"
call :log " Project root: %PROJECT_ROOT%"
call :log "============================================"
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo Version %SCRIPT_VERSION%
echo ============================================
echo.
echo Log file: %LOG_FILE%
echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/6" "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
)
:: 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"
)
:: Robust version parse
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"
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
)
echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/6" "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.
pause
exit /b 1
)
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%"
echo [OK] npm found: %NPM_VER%
echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/6" "Checking network"
set "USE_MIRROR=0"
:: Try reaching npm registry with a short timeout
echo Testing connection to registry.npmjs.org...
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"
)
) 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.
)
)
if "%USE_MIRROR%"=="1" (
echo.
echo Slow connection detected. Switching to 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.
)
echo.
:: ============================================================
:: Step 4: Install backend dependencies
:: ============================================================
call :step "4/6" "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.
call npm install >>"%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.
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 5: Install frontend dependencies
:: ============================================================
call :step "5/6" "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
pause
exit /b 1
)
echo Running: npm install ^(in web/ directory^)
echo.
pushd web >nul
call npm install >>"%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.
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project (backend + frontend)
:: ============================================================
call :step "6/6" "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.
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ============================================================
:: Verify build outputs
:: ============================================================
echo Verifying build outputs...
set "BUILD_OK=1"
if not exist "dist" (
call :error "dist/ directory missing after build."
set "BUILD_OK=0"
)
if not exist "web\dist" (
call :error "web\dist/ directory missing after build."
set "BUILD_OK=0"
)
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 (
echo [OK] config.json already exists.
)
echo.
:: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================
echo Setup Complete!
echo ============================================
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.
pause
exit /b 0
:: ============================================================
:: Subroutines
:: ============================================================
:step
echo ---- Step %~1: %~2 ----
call :log ""
call :log "---- Step %~1: %~2 ----"
goto :eof
:error
echo.
echo [ERROR] %~1
call :log "[ERROR] %~1"
goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
@echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror
:: - Download native binaries from CDN (避开 GitHub)
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." || (
echo [FATAL] Cannot change to project directory.
pause
exit /b 1
)
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
echo. > "%LOG_FILE%"
call :log "============================================"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " Started: %date% %time%"
call :log " Project root: %PROJECT_ROOT%"
call :log "============================================"
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo Version %SCRIPT_VERSION%
echo ============================================
echo.
echo Log file: %LOG_FILE%
echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/7" "Checking Node.js"
where node >nul 2>&1
if not errorlevel 1 goto :check_node_version
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
:check_node_version
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
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%."
pause
exit /b 1
)
echo.
:: ============================================================
:: Step 2: Check npm
:: ============================================================
call :step "2/7" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found."
pause
exit /b 1
)
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%"
echo [OK] npm found: %NPM_VER%
echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/7" "Checking network"
set "USE_MIRROR=0"
set "MIRROR_REGISTRY=https://registry.npmjs.org"
echo Testing connection to npm registry...
call :log "Testing npm registry connectivity..."
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 (
echo [OK] npm registry reachable.
call :log "npm registry reachable"
)
if "%USE_MIRROR%"=="1" (
echo.
echo [INFO] Using China mirror (npmmirror.com)
call :log "Switching to npmmirror.com"
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 (跳过二进制)
:: ============================================================
call :step "4/7" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
)
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
echo.
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
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/7" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found."
pause
exit /b 1
)
echo Running: npm install (in web/)
echo.
pushd web >nul
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."
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build 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. 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
:: ============================================================
echo Verifying build outputs...
set "BUILD_OK=1"
if not exist "dist" (
call :error "dist/ directory missing after build."
set "BUILD_OK=0"
)
if not exist "web\dist" (
call :error "web\dist/ directory missing after build."
set "BUILD_OK=0"
)
if "!BUILD_OK!"=="0" (
echo Build completed but expected output is missing.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch.
) else (
echo [OK] config.json already exists.
)
echo.
:: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================
echo Setup Complete!
echo ============================================
echo.
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo.
echo Setup log: %LOG_FILE%
echo.
pause
exit /b 0
:: ============================================================
:: Subroutines
:: ============================================================
:step
echo ---- Step %~1: %~2 ----
call :log ""
call :log "---- Step %~1: %~2 ----"
goto :eof
:error
echo.
echo [ERROR] %~1
call :log "[ERROR] %~1"
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 ""
+43 -46
View File
@@ -1,47 +1,44 @@
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat first.
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Check if dependencies are installed
if not exist "node_modules" (
echo Dependencies not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Check if build output exists
if not exist "dist" (
echo Build not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: 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
:: Check if node is available
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
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Install dependencies if needed
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
)
)
:: Build if dist/ doesn't exist
if not exist "dist" (
echo Building project...
call npx tsc
if %errorlevel% neq 0 (
echo Build failed.
pause
exit /b 1
)
)
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed.
echo FFmpeg is bundled via node_modules (ffmpeg-static).
echo.
:: Start the application
node dist/index.js
pause
+63 -4
View File
@@ -136,6 +136,9 @@ 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帧循环(增加容错)
private currentSongDuration = 0; // 当前歌曲总时长(秒)
constructor(logger: Logger) {
super();
@@ -143,7 +146,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 +157,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");
@@ -183,7 +188,7 @@ export class AudioPlayer extends EventEmitter {
if (this.sessionId !== currentSessionId) {
return;
}
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
@@ -279,8 +284,12 @@ export class AudioPlayer extends EventEmitter {
this.emit("error", err);
});
// Mark playing but DO NOT start the frame loop here — the loop's
// "no ffmpeg + empty buffer → trackEnd" branch would fire on the very
// first tick, before the PowerShell download even completes. The
// frame loop is started inside spawnFfmpegFromFile() once ffmpeg is
// alive and producing PCM.
this.state = "playing";
this.startFrameLoop();
}
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
@@ -329,6 +338,9 @@ export class AudioPlayer extends EventEmitter {
this.emit("error", err);
}
});
// Now that ffmpeg is producing PCM, run the frame loop.
this.startFrameLoop();
}
stop(): void {
@@ -413,6 +425,49 @@ 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++;
// 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束
if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) {
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)
}, "FFmpeg stopped outputting data near end, 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") {
@@ -465,7 +520,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; }
+47 -24
View File
@@ -22,6 +22,7 @@ export class PlayQueue {
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
private history: number[] = [];
private forwardStack: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
@@ -100,12 +101,14 @@ export class PlayQueue {
this.currentIndex = -1;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
@@ -118,6 +121,7 @@ export class PlayQueue {
// shuffle from this point. History tracking is independent and
// unaffected by this clear.
this.playedIndices.clear();
this.forwardStack = [];
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
@@ -139,32 +143,45 @@ export class PlayQueue {
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex];
}
case 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];
}
case PlayMode.Random:
case PlayMode.RandomLoop: {
if (this.songs.length === 1) {
// 优先回到前进栈记录的位置(prev 退回的歌)
if (this.forwardStack.length > 0) {
const target = this.forwardStack.pop()!;
if (target !== this.currentIndex) {
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
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 = 0;
return this.songs[0];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
} else {
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 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];
}
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];
}
}
}
@@ -172,13 +189,18 @@ export class PlayQueue {
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// 记录当前位置到前进栈,供 next 优先返回
if (this.currentIndex >= 0 && this.forwardStack.length < PlayQueue.HISTORY_LIMIT) {
this.forwardStack.push(this.currentIndex);
}
// Preferred: pop from the back-stack so prev means "the song I
// actually played before this one," not "the previous array slot."
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices.add(idx);
this.playedIndices = new Set([...this.history, this.currentIndex]);
return this.songs[idx];
}
// Stale entry (song removed) — keep popping.
@@ -227,6 +249,7 @@ export class PlayQueue {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
+22 -3
View File
@@ -380,7 +380,7 @@ export class BotInstance extends EventEmitter {
return false;
}
song.url = url;
this.player.play(url);
this.player.play(url, 0, song.duration);
this.database.addPlayHistory({
botId: this.id,
songId: song.id,
@@ -638,9 +638,27 @@ export class BotInstance extends EventEmitter {
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !album <album ID>";
if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags);
const songs = await provider.getAlbumSongs(cmd.args);
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let albumId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use directly
albumId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
const albums = result.albums ?? [];
if (albums.length === 0)
return `No albums found for: ${cmd.args}`;
albumId = albums[0].id;
}
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) return "Album is empty or not found";
this.queue.clear();
@@ -778,6 +796,7 @@ export class BotInstance extends EventEmitter {
`${p}stop — Stop and clear queue`,
`${p}vol <0-100> — Set volume`,
`${p}queue — Show queue`,
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
`${p}mode <seq|loop|random|rloop> — Play mode`,
`${p}playlist <name or id> — Load playlist by name or ID`,
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
+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");
});
});
+57
View File
@@ -0,0 +1,57 @@
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";
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[];
},
};
}
+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",
+39
View File
@@ -97,6 +97,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,12 +133,45 @@ 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);
`);
}
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);
+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();
},
};
}
+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,
});
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseLyrics } from "./netease.js";
import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,32 @@ describe("NetEase adapter", () => {
expect(lines[0].text).toBe("Hello world");
expect(lines[0].translation).toBe("你好世界");
});
it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
const raw = [
{
id: 42,
name: "Album A",
picUrl: "https://x/p.jpg",
artists: [{ name: "Artist X" }, { name: "Featured Y" }],
size: 12,
},
{
id: 99,
name: "Album B",
picUrl: "",
artists: [],
},
];
expect(mapNeteaseAlbums(raw)).toEqual([
{ id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
{ id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
]);
});
it("mapNeteaseAlbums returns [] for empty/null input", () => {
expect(mapNeteaseAlbums([])).toEqual([]);
expect(mapNeteaseAlbums(null as any)).toEqual([]);
expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
});
});
+21 -3
View File
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
return lines.sort((a, b) => a.time - b.time);
}
export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => ({
id: String(a.id),
name: a.name ?? "",
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
coverUrl: a.picUrl ?? "",
songCount: a.size ?? 0,
platform: "netease",
}));
}
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
export const NETEASE_QUALITY_LEVELS = [
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const [songRes, playlistRes] = await Promise.all([
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
@@ -99,10 +112,13 @@ export class NeteaseProvider implements MusicProvider {
params: {
keywords: query,
type: 1000,
limit: 5,
limit: 10,
...this.cookieParams,
},
}),
this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
}),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider {
platform: "netease",
}));
return { songs, playlists, albums: [] };
const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { mapQqAlbums } from "./qq.js";
describe("QQ adapter", () => {
it("mapQqAlbums maps albumMID-style raw entries", () => {
const raw = [
{
albumMID: "abc",
albumName: "Aero",
singerName: "Singer A",
},
{
albumMID: "xyz",
albumName: "Beta",
singer: [{ name: "Singer B" }, { name: "Singer C" }],
},
];
const out = mapQqAlbums(raw);
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
id: "abc",
name: "Aero",
artist: "Singer A",
platform: "qq",
});
expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
expect(out[1].artist).toBe("Singer B / Singer C");
expect(out[1].coverUrl).toContain("xyz");
});
it("mapQqAlbums returns [] for empty/null input", () => {
expect(mapQqAlbums([])).toEqual([]);
expect(mapQqAlbums(null as any)).toEqual([]);
expect(mapQqAlbums(undefined as any)).toEqual([]);
});
it("mapQqAlbums falls back to albumPic when no albumMID", () => {
const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
const out = mapQqAlbums(raw);
expect(out[0].coverUrl).toBe("https://x/p.jpg");
expect(out[0].id).toBe("");
});
});
+157 -26
View File
@@ -8,17 +8,29 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
Album,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
// Direct QQ Music API client — bypasses the local API server for search
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
const qqDirectApi = axios.create({
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
// batch). Was broken ca. 2026-05 due to two upstream API changes:
// 1. searchid param must NOT be present (causes all lists to be empty)
// 2. num_per_page must be >= 10 (lower values return empty)
// Both fixes applied per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
const qqMusicuApi = axios.create({
baseURL: "https://u.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Fallback search client: c.y.qq.com/soso/fcgi-bin/client_search_cp (classic
// endpoint, song + album only, no playlist support).
const qqSearchApi = axios.create({
baseURL: "https://c.y.qq.com",
timeout: 10000,
headers: { referer: "https://y.qq.com" },
});
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
// The bundled qq-music-api wrapper doesn't expose these endpoints.
const qqFavApi = axios.create({
@@ -27,6 +39,26 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
if (!Array.isArray(raw)) return [];
return raw.map((a) => {
const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
const artist = a.singerName
?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
const coverUrl = id
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
: (a.albumPic ?? "");
return {
id,
name: a.albumName ?? a.title ?? "",
artist,
coverUrl,
songCount: a.song_count ?? a.songCount ?? 0,
platform: "qq" as const,
};
});
}
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -61,36 +93,135 @@ export class QQMusicProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: {
searchid: "1",
query,
num_per_page: Math.min(limit, 50),
},
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const list: any[] =
res.data?.req_0?.data?.body?.song?.list ?? [];
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit);
if (primary) return primary;
const songs: Song[] = list.map((s: any) => ({
id: String(s.mid ?? s.id),
name: s.title ?? s.name ?? "",
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit);
}
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
*
* Two upstream API changes (2026-05) required fixes:
* 1. Omit `searchid` — its presence now causes all lists to be empty.
* 2. `num_per_page` >= 10 — lower values return empty.
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg(
query: string,
limit: number
): Promise<SearchResult | null> {
try {
const numPerPage = Math.max(10, Math.min(limit, 50));
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 },
},
req_album: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 },
},
req_playlist: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 },
},
});
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
params: { format: "json", data: reqData },
});
const songList: any[] =
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 albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums = mapQqAlbums(albumList);
const playlistList: any[] = res.data?.req_playlist?.data?.body?.songlist?.list ?? [];
const playlists: Playlist[] = playlistList.map((p: any) => ({
id: String(p.dissid ?? p.id ?? ""),
name: p.dissname ?? p.title ?? "",
coverUrl: p.imgurl ?? p.logo ?? "",
songCount: p.songnum ?? p.song_count ?? 0,
platform: "qq" as const,
}));
return { songs, playlists, albums };
} catch {
return null;
}
}
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp(
query: string,
limit: number
): Promise<SearchResult> {
const songParams = {
w: query,
format: "json",
p: 1,
n: Math.min(limit, 50),
type: 0,
cr: 1,
};
const albumParams = {
w: query,
format: "json",
p: 1,
n: 5,
t: 8,
cr: 1,
};
const [songRes, albumRes] = await Promise.allSettled([
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: songParams }),
qqSearchApi.get("/soso/fcgi-bin/client_search_cp", { params: albumParams }),
]);
const songList: any[] =
songRes.status === "fulfilled"
? (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.album?.name ?? s.album?.title ?? "",
album: s.albumname ?? s.album?.name ?? "",
duration: s.interval ?? 0,
coverUrl: s.album?.mid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
coverUrl: s.albummid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
: "",
platform: "qq",
}));
return { songs, playlists: [], albums: [] };
const albumList: any[] =
albumRes.status === "fulfilled"
? (albumRes.value.data?.data?.album?.list ?? [])
: [];
const albums = mapQqAlbums(albumList);
return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
+56
View File
@@ -0,0 +1,56 @@
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 { 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 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));
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);
}
+10 -4
View File
@@ -52,14 +52,20 @@ export function createMusicRouter(
]);
const songs = [
...(neteaseResult.status === "fulfilled"
? neteaseResult.value.songs
: []),
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
const albums = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
];
const playlists = [
...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
];
res.json({ songs });
res.json({ songs, albums, playlists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
+72
View File
@@ -313,6 +313,78 @@ export function createPlayerRouter(
}
});
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
router.post("/:botId/play-album", async (req, res) => {
try {
const bot = (req as any).bot;
const { albumId, platform } = req.body;
const provider = bot.getProviderFor(
platform === "bilibili" || platform === "qq" || platform === "youtube"
? platform
: "netease"
);
// Stop current playback
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getAlbumSongs(albumId);
if (songs.length === 0) {
res.json({ message: "Album is empty" });
return;
}
// QQ-specific optimization: batch-resolve playable IDs to avoid
// wasting retries on region/copyright-restricted tracks.
let queueable: { id: string }[] = songs;
const totalCount = songs.length;
const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
if (typeof qqLike.getPlayableSongIds === "function") {
const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
if (playable !== null) {
queueable = songs.filter((s: { id: string }) => playable.has(s.id));
}
}
if (queueable.length === 0) {
res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
return;
}
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
}
const mode = queue.getMode();
let first;
if (mode === "random" || mode === "rloop") {
const idx = Math.floor(Math.random() * queue.size());
first = queue.playAt(idx);
} else {
first = queue.play();
}
let started = first ? await bot.resolveAndPlay(first) : false;
if (first && !started) {
started = await bot.playNext(20);
}
const playing = queue.current();
const loadedMsg = queueable.length < totalCount
? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
: `已加载 ${queueable.length} 首`;
if (started && playing) {
res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
} else {
res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
}
} catch (err) {
logger.error({ err }, "play-album failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
try {
+151
View File
@@ -0,0 +1,151 @@
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 { 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);
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" })));
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");
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);
});
});
+171
View File
@@ -0,0 +1,171 @@
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 { 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
): 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) => {
res.json(req.user);
});
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;
}
+257
View File
@@ -0,0 +1,257 @@
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 { 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 requireAuth = createRequireAuth(sessions);
const audit = createAuditStore(botDb.db);
app.use("/api", requireAuth);
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" })));
return app;
}
describe("users router", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let app: express.Express;
let aliceId: string;
let aliceCookie: string;
let bobId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
app = 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));
localApp.use(
"/api/users",
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }))
);
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);
});
});
+166
View File
@@ -0,0 +1,166 @@
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 { 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
): 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);
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();
});
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;
}
+58
View File
@@ -0,0 +1,58 @@
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);
});
});
+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();
};
}
+50
View File
@@ -0,0 +1,50 @@
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 { 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 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));
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();
}
+69
View File
@@ -0,0 +1,69 @@
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 { 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 u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions));
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+/);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.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" };
}
}
export function createRequireAuth(sessions: SessionStore): 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;
}
req.user = { id: result.userId, username: result.username, role: result.role };
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();
};
}
+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'");
});
});
+100 -6
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,20 @@ 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 { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.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 +55,51 @@ 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);
// ─── 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));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions);
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// ─── Protected routes ───────────────────────────────────────────────────
app.use(
"/api/bot",
createBotRouter(
@@ -75,11 +123,11 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
);
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
// ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) {
app.use(express.static(options.staticDir));
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
@@ -91,22 +139,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();
});
});
+4
View File
@@ -3,6 +3,10 @@
<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.
Setting no-referrer at the document level covers <img> tags AND CSS background-image fetches.
Our own /api/* CSRF check uses Origin (not Referer), so this doesn't break auth. -->
<meta name="referrer" content="no-referrer">
<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">
+10 -2
View File
@@ -140,12 +140,20 @@ function cycleMobileMode() {
playerStore.setMode(nextMode);
}
onMounted(() => {
onMounted(async () => {
playerStore.loadTheme();
connect();
playerStore.fetchBots();
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;
}
+119 -3
View File
@@ -10,8 +10,20 @@
</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 bot exists) -->
<div v-else-if="store.bots.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 +34,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 +100,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 +136,25 @@
<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 navRouter = useRouter();
async function onLogout() {
await session.logout();
navRouter.replace({ name: 'login' });
}
const activeBot = computed(() => store.activeBot);
// While scoped (dedicated link), the selector is locked to the single scoped
// bot; otherwise the full list is shown and switching is allowed.
const displayedBots = computed(() =>
store.isScoped ? store.bots.filter((b) => b.id === store.scopedBotId) : store.bots,
);
const dropdownOpen = ref(false);
const selectorRef = ref<HTMLElement | null>(null);
const togglingBots = ref<Record<string, boolean>>({});
@@ -137,6 +173,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 +395,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 +741,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>
+14 -2
View File
@@ -27,10 +27,10 @@
<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>
@@ -310,6 +310,8 @@ function cycleMode() {
.song-info {
min-width: 0;
flex: 1;
overflow: hidden;
}
.song-name {
@@ -326,6 +328,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 {
+108
View File
@@ -0,0 +1,108 @@
import { ref, computed, readonly } from "vue";
interface User {
id: string;
username: string;
role: 'admin' | 'member';
}
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;
}
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;
}
async function logout(): Promise<void> {
stopPoll();
await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" });
currentUser.value = null;
}
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,
};
}
+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'));
+63 -39
View File
@@ -1,55 +1,79 @@
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',
component: () => import('../views/Playlist.vue'),
meta: { kind: 'playlist' },
},
{
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: '/album/:id',
name: 'album',
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') },
{ 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;
+46
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import { resolveScopedBot } from './scope.js';
export interface Song {
id: string;
@@ -50,6 +51,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 */
@@ -73,6 +77,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 +133,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 +142,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;
@@ -322,6 +358,16 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
async playAlbum(albumId: string, platform = 'netease') {
if (!this.activeBotId) return;
const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
}
this._setTiming(this.activeBotId, { serverElapsed: 0 });
this._syncAfterAction();
},
async pause() {
if (!this.activeBotId) return;
// Freeze elapsed at current interpolated value
+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>
+1
View File
@@ -379,6 +379,7 @@ onMounted(() => {
.daily-card {
cursor: pointer;
min-width: 0;
}
.daily-name {
+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>
+26 -8
View File
@@ -38,7 +38,7 @@
</div>
</template>
<div v-else class="loading">歌单不存在或加载失败</div>
<div v-else class="loading">{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败</div>
</div>
</template>
@@ -64,6 +64,8 @@ interface PlaylistDetail {
songCount: number;
}
const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]);
const loading = ref(true);
@@ -71,20 +73,32 @@ const loading = ref(true);
async function playAll() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
await store.playPlaylist(id, platform);
if (kind === 'album') {
await store.playAlbum(id, platform);
} else {
await store.playPlaylist(id, platform);
}
}
onMounted(async () => {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
const detailUrl = kind === 'album'
? `/api/music/album/${id}/detail`
: `/api/music/playlist/${id}/detail`;
const songsUrl = kind === 'album'
? `/api/music/album/${id}`
: `/api/music/playlist/${id}`;
// allSettled, not Promise.all — if detail 404s but songs is fine
// (e.g., a QQ playlist whose detail endpoint flaked but the song
// list resolved), we still want to show the songs rather than
// the "歌单不存在" empty state.
// the "不存在" empty state. For albums, detail always 404s — that
// is intentional; the fallback stub below handles it.
const [detailRes, songsRes] = await Promise.allSettled([
axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
axios.get(detailUrl, { params: { platform } }),
axios.get(songsUrl, { params: { platform } }),
]);
const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null;
@@ -93,10 +107,14 @@ onMounted(async () => {
if (detail) {
playlist.value = detail;
} else if (songList.length > 0) {
// Fall back to a stub built from the route + first song's cover.
// Fall back to a stub built from the route + first song. For albums,
// every song's `album` field carries the real album name.
const fallbackName = kind === 'album'
? (songList[0]?.album || '专辑')
: '歌单';
playlist.value = {
id,
name: '歌单',
name: fallbackName,
description: '',
coverUrl: songList[0]?.coverUrl ?? '',
songCount: songList.length,
@@ -104,7 +122,7 @@ onMounted(async () => {
} else {
playlist.value = null;
if (detailRes.status === 'rejected') {
console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
}
}
songs.value = songList;
+277 -24
View File
@@ -20,59 +20,197 @@
<div v-if="loading" class="loading">搜索中...</div>
<div v-else-if="results.length > 0" class="results">
<SongCard
v-for="(song, i) in results"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playSong(song)"
@playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/>
</div>
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
<div class="source-bar">
<button
class="source-btn"
:class="{ active: selectedSource === 'netease' }"
@click="selectedSource = 'netease'"
>网易云</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'qq' }"
@click="selectedSource = 'qq'"
>QQ</button>
<button
class="source-btn"
:class="{ active: selectedSource === 'bilibili' }"
@click="selectedSource = 'bilibili'"
>B站</button>
</div>
<div v-else-if="searched" class="empty">
未找到相关结果
</div>
<div class="tab-bar">
<button
class="tab"
:class="{ active: activeTab === 'songs' }"
@click="activeTab = 'songs'"
>
单曲<span class="tab-count">{{ filteredSongs.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'albums' }"
@click="activeTab = 'albums'"
>
专辑<span class="tab-count">{{ filteredAlbums.length }}</span>
</button>
<button
v-if="selectedSource !== 'bilibili'"
class="tab"
:class="{ active: activeTab === 'playlists' }"
@click="activeTab = 'playlists'"
>
歌单<span class="tab-count">{{ filteredPlaylists.length }}</span>
</button>
</div>
<section v-if="activeTab === 'albums' && filteredAlbums.length" class="result-section">
<div class="card-grid">
<router-link
v-for="al in filteredAlbums"
:key="`${al.platform}-${al.id}`"
:to="`/album/${al.id}?platform=${al.platform}`"
class="card hover-scale"
>
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">
{{ al.name }}
<span class="platform-badge" :class="badgeClass(al.platform)">{{ badgeLabel(al.platform) }}</span>
</div>
<div class="card-sub">{{ al.artist }}</div>
</router-link>
</div>
</section>
<section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
<div class="card-grid">
<router-link
v-for="pl in filteredPlaylists"
:key="`${pl.platform}-${pl.id}`"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="card-name">
{{ pl.name }}
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
</div>
</router-link>
</div>
</section>
<section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
<SongCard
v-for="(song, i) in filteredSongs"
:key="`${song.platform}-${song.id}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.playSong(song)"
@playNext="store.playNextSong(song)"
@add="store.addSong(song)"
/>
</section>
</template>
<div v-else-if="searched" class="empty">未找到相关结果</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue';
import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const route = useRoute();
const router = useRouter();
const SOURCE_STORAGE_KEY = 'search-source';
function loadSource(): 'netease' | 'qq' | 'bilibili' {
try {
const stored = localStorage.getItem(SOURCE_STORAGE_KEY);
if (stored === 'netease' || stored === 'qq' || stored === 'bilibili') return stored;
} catch { /* localStorage blocked */ }
return 'netease';
}
const query = ref((route.query.q as string) || '');
import { Song } from '../stores/player.js';
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
const results = ref<Song[]>([]);
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
const allSongs = ref<Song[]>([]);
const allAlbums = ref<Album[]>([]);
const allPlaylists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
const filteredSongs = computed(() =>
allSongs.value.filter((s) => s.platform === selectedSource.value)
);
const filteredAlbums = computed(() =>
allAlbums.value.filter((a) => a.platform === selectedSource.value)
);
const filteredPlaylists = computed(() =>
allPlaylists.value.filter((p) => p.platform === selectedSource.value)
);
// Persist source preference
watch(selectedSource, (src) => {
try { localStorage.setItem(SOURCE_STORAGE_KEY, src); } catch { /* ignore */ }
});
// B站 has no albums/playlists — force songs tab when switching to B站
watch(selectedSource, (src) => {
if (src === 'bilibili' && activeTab.value !== 'songs') {
activeTab.value = 'songs';
}
});
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
searched.value = true;
activeTab.value = 'songs';
router.replace({ query: { q: query.value } });
try {
const res = await axios.get('/api/music/search/all', {
params: { q: query.value },
});
results.value = res.data.songs;
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
allSongs.value = res.data.songs ?? [];
allAlbums.value = res.data.albums ?? [];
allPlaylists.value = res.data.playlists ?? [];
} catch {
results.value = [];
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
} finally {
loading.value = false;
}
}
function badgeLabel(platform: string): string {
if (platform === 'qq') return 'QQ';
if (platform === 'bilibili') return 'B站';
if (platform === 'youtube') return 'YouTube';
return '网易云';
}
function badgeClass(platform: string): string {
if (platform === 'qq') return 'badge-qq';
if (platform === 'bilibili') return 'badge-bilibili';
if (platform === 'youtube') return 'badge-youtube';
return 'badge-netease';
}
onMounted(() => {
if (query.value) doSearch();
});
@@ -141,4 +279,119 @@ onMounted(() => {
flex-direction: column;
gap: 2px;
}
.source-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.source-btn {
padding: 5px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: var(--bg-card);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
color: var(--color-primary);
background: rgba(51, 94, 234, 0.12);
}
}
.tab-bar {
display: flex;
gap: 6px;
margin-bottom: 24px;
padding: 4px;
background: var(--bg-card);
border-radius: var(--radius-md);
width: fit-content;
}
.tab {
padding: 8px 20px;
border-radius: calc(var(--radius-md) - 2px);
font-size: 14px;
font-family: inherit;
font-weight: var(--fw-semi);
color: var(--text-secondary);
background: transparent;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
&:hover { color: var(--text-primary); }
&.active {
background: var(--color-primary);
color: #fff;
.tab-count { opacity: 0.85; }
}
}
.tab-count {
margin-left: 5px;
opacity: 0.55;
font-weight: var(--fw-regular);
font-size: 13px;
&::before { content: '('; }
&::after { content: ')'; }
}
.result-section {
margin-bottom: 32px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px 28px;
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
text-decoration: none;
color: inherit;
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
.card-sub { font-size: 12px; opacity: 0.6; }
}
.platform-badge {
vertical-align: middle;
flex-shrink: 0;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
}
.badge-netease {
background: var(--brand-netease-15);
color: var(--brand-netease);
}
.badge-qq {
background: var(--brand-qq-15);
color: var(--brand-qq);
}
.badge-bilibili {
background: var(--brand-bilibili-15);
color: var(--brand-bilibili);
}
.badge-youtube {
background: var(--brand-youtube-12);
color: var(--brand-youtube);
}
</style>
+460
View File
@@ -21,6 +21,35 @@
</div>
</section>
<!-- 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 -->
<section class="settings-section">
<h2 class="section-title">机器人管理</h2>
@@ -80,6 +109,10 @@
<label>服务器密码(可选)</label>
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
</div>
<div class="form-group">
<label>自定义头像</label>
<CustomAvatarRow :bot-id="editingBot" />
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="editingBot = null">取消</button>
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
@@ -454,6 +487,100 @@
</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-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">
<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>
<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>
@@ -465,6 +592,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();
@@ -837,11 +965,242 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
}
}
// --- User Management ---
const session = useSession();
// --- 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;
}
}
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} 的角色`;
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(() => {
@@ -1463,4 +1822,105 @@ 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; }
// --- 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>