mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3422d45eeb | ||
|
|
f7626f40b3 | ||
|
|
de2c956c31 | ||
|
|
3802c90d2d | ||
|
|
839f777a75 | ||
|
|
dd6affca6d | ||
|
|
bea2f92508 | ||
|
|
f19f56a666 | ||
|
|
140020f63a | ||
|
|
6e10764d28 | ||
|
|
9bfe831022 | ||
|
|
bbdd4cbc78 | ||
|
|
c57cd35f09 | ||
|
|
1a1f365cf1 | ||
|
|
d1544bab42 | ||
|
|
e0d17cf404 | ||
|
|
b2de607391 | ||
|
|
355793b7e6 | ||
|
|
593b42830c | ||
|
|
463a8e2f8a | ||
|
|
88ac7d2a68 | ||
|
|
53d28de17e | ||
|
|
ca07ebc3b7 | ||
|
|
bf1fb1fd88 | ||
|
|
846fb2c28c | ||
|
|
34655e5f50 | ||
|
|
491bc53dec | ||
|
|
8f5bb26b3a | ||
|
|
4ba4b013b0 | ||
|
|
484202e90d | ||
|
|
9f0ac74fbc | ||
|
|
51c954993a | ||
|
|
907a6651f5 | ||
|
|
1ca1ca9d0c | ||
|
|
d70664067c | ||
|
|
5177b41951 | ||
|
|
bb86f7e9ed | ||
|
|
221f7c8dcf | ||
|
|
cf76e0f69a | ||
|
|
abf60141d9 | ||
|
|
ce15f36e5e | ||
|
|
1f0f162f66 | ||
|
|
cd6f2c6078 | ||
|
|
696b224f8d | ||
|
|
7a8666efbb | ||
|
|
f0c979ce71 | ||
|
|
d810a2ec0f | ||
|
|
554501cc74 | ||
|
|
aaf6ba2ab4 | ||
|
|
547aaa304e | ||
|
|
f09a589940 | ||
|
|
a861b41809 | ||
|
|
e9b3ba0075 | ||
|
|
0401534b88 | ||
|
|
f720da49d6 | ||
|
|
3abb468cca | ||
|
|
c8daa14219 | ||
|
|
81cd8a2bec | ||
|
|
35210cf570 | ||
|
|
d2bad58aa8 | ||
|
|
f73ca1f61b | ||
|
|
a0f290459d | ||
|
|
b6b9aa07bc | ||
|
|
a39fc25104 | ||
|
|
1a11489f2e | ||
|
|
c0504d65a5 | ||
|
|
780726a4e3 | ||
|
|
a73f797bcb | ||
|
|
b0b61f8fce | ||
|
|
7be4f13774 | ||
|
|
6af0e97f51 | ||
|
|
ceb24595e6 | ||
|
|
fb7feec5cf | ||
|
|
175b8e6065 | ||
|
|
f46b37192f | ||
|
|
a8b056d2aa | ||
|
|
e148c1556e | ||
|
|
e2e888710a | ||
|
|
7509814abc | ||
|
|
0dc8746914 | ||
|
|
2560fc87c2 | ||
|
|
6db35f704d | ||
|
|
490b2d57dc | ||
|
|
486979841e | ||
|
|
ee5673a22f | ||
|
|
d1c9e14bf0 | ||
|
|
17c11f0512 | ||
|
|
df5d125279 | ||
|
|
5914f41ea1 | ||
|
|
68a2fb2943 | ||
|
|
34523cb00f | ||
|
|
8ea1a64c59 | ||
|
|
df79976933 | ||
|
|
d3af918f53 | ||
|
|
f7c16888e7 | ||
|
|
a2982948a7 | ||
|
|
b7e1f9f30b | ||
|
|
7fc5186c24 | ||
|
|
de92c8bcd4 | ||
|
|
6b143e1a56 | ||
|
|
5728209573 | ||
|
|
02d8b39d75 | ||
|
|
f87aaaf8c3 | ||
|
|
8861f39ecc | ||
|
|
6d5755ced7 | ||
|
|
997bb17ceb | ||
|
|
ea6501ea81 | ||
|
|
1d2da33d3c | ||
|
|
0e68e287b7 | ||
|
|
ecdb2d253e | ||
|
|
8860347bfe | ||
|
|
beb99f1d8e | ||
|
|
63f1ced2bc | ||
|
|
fa6013d22e | ||
|
|
6b60792277 | ||
|
|
c562fe05b0 | ||
|
|
9efa818bbb | ||
|
|
d757e69bf4 | ||
|
|
88ff62c829 | ||
|
|
0f9c7b3c4c | ||
|
|
914180792d | ||
|
|
935656dc4f | ||
|
|
2dd6a9e20d | ||
|
|
ffa27d7224 | ||
|
|
edd0fc58eb | ||
|
|
366edf7843 |
No files matched your search
@@ -19,8 +19,6 @@
|
|||||||
"Read(//tmp/**)"
|
"Read(//tmp/**)"
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push * main)",
|
|
||||||
"Bash(git push * master)",
|
|
||||||
"Bash(git push --force *)",
|
"Bash(git push --force *)",
|
||||||
"Bash(rm -rf /)"
|
"Bash(rm -rf /)"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
|
- **WebUI 鉴权(必选)** — 用户名 + 密码登录,多用户、两种角色(管理员 / 成员),bcrypt 加密、HttpOnly 会话 Cookie,CSRF 防护,WebSocket 同样鉴权。首次访问引导创建管理员。从无鉴权旧版本升级时请参阅 [更新升级](#更新升级) 章节
|
||||||
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
- **多平台音源** — 网易云音乐 + QQ 音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
|
||||||
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
|
||||||
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
|
||||||
@@ -155,6 +156,49 @@ sudo ./scripts/install.sh
|
|||||||
>
|
>
|
||||||
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `identity` 字段已经是空的,则**无需任何操作**。完成上述步骤后,按下面对应的系统升级步骤执行即可。
|
> **如何判断是否需要迁移**:如果你是全新安装,或者你的机器人数据库中 `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 用户
|
### Windows 用户
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -221,10 +265,16 @@ sudo systemctl start tsmusicbot
|
|||||||
|
|
||||||
### 首次配置
|
### 首次配置
|
||||||
|
|
||||||
1. 打开 **http://localhost:3000/setup** 进入设置向导
|
1. 启动机器人后打开 **http://localhost:3000/**
|
||||||
2. 填写 TeamSpeak 服务器地址(默认端口:9987)
|
- 全新部署:自动跳转 `/first-run`,填写用户名(3-32 字符)和密码(≥8 位)创建首位**管理员**账号
|
||||||
3. 设置机器人昵称
|
- 之后所有 WebUI 操作都需要登录,登录态保持 7 天(活动会滚动续期)
|
||||||
4. (可选)扫码登录网易云/QQ音乐账号以播放 VIP 歌曲
|
2. 在 **设置 → 机器人管理** 中点击"创建新实例",填写:
|
||||||
|
- TeamSpeak 服务器地址(无端口,仅主机名,例如 `ts.example.com`)
|
||||||
|
- 端口(默认 9987,自托管或非标准端口请填写实际值)
|
||||||
|
- 机器人昵称
|
||||||
|
- 可选:服务器密码、默认频道
|
||||||
|
3. 在 **设置 → 音乐账号** 扫码登录网易云 / QQ 音乐 / B 站账号(可选,登录后可播放 VIP 歌曲)
|
||||||
|
4. 在 **设置 → 用户管理**(仅管理员可见)按需添加成员,成员账号可以控制播放但无法管理其他用户
|
||||||
|
|
||||||
### WebUI 页面说明
|
### WebUI 页面说明
|
||||||
|
|
||||||
@@ -235,7 +285,7 @@ sudo systemctl start tsmusicbot
|
|||||||
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
|
| **歌单** | 查看歌单详情,播放全部(根据当前播放模式选择首歌) |
|
||||||
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
| **歌词** | 全屏歌词页,实时同步滚动,模糊专辑封面背景 |
|
||||||
| **历史** | 播放历史记录 |
|
| **历史** | 播放历史记录 |
|
||||||
| **设置** | 主题切换、机器人管理、三平台账号登录、音质选择、命令前缀 |
|
| **设置** | 账户(修改自己密码) / 主题切换 / 机器人管理 / 三平台账号登录 / 音质选择 / 命令前缀 / 用户管理(仅管理员)/ 操作审计(仅管理员) |
|
||||||
|
|
||||||
### TeamSpeak 文字命令
|
### TeamSpeak 文字命令
|
||||||
|
|
||||||
@@ -253,6 +303,7 @@ sudo systemctl start tsmusicbot
|
|||||||
| `!stop` | 停止播放并清空队列 |
|
| `!stop` | 停止播放并清空队列 |
|
||||||
| `!vol <0-100>` | 设置音量 |
|
| `!vol <0-100>` | 设置音量 |
|
||||||
| `!queue` | 查看播放队列 |
|
| `!queue` | 查看播放队列 |
|
||||||
|
| `!remove <位置>` | 从队列中删除指定位置的歌曲(位置从 1 开始,见 `!queue`) |
|
||||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
|
||||||
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID) |
|
||||||
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
|
||||||
@@ -332,11 +383,11 @@ teamspeak-music-bot/
|
|||||||
│ └── docker/ # Docker 部署文件
|
│ └── docker/ # Docker 部署文件
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── docker-compose.yml
|
│ └── docker-compose.yml
|
||||||
├── data/ # 运行时数据(自动创建,不上传)
|
└── data/ # 运行时数据(自动创建,不上传)
|
||||||
│ ├── tsmusicbot.db # SQLite 数据库
|
├── config.json # 配置文件(首次运行自动生成,可手动编辑)
|
||||||
│ ├── cookies/ # 登录 Cookie
|
├── tsmusicbot.db # SQLite 数据库
|
||||||
│ └── logs/ # 日志文件
|
├── cookies/ # 登录 Cookie
|
||||||
└── config.json # 配置文件(首次运行自动生成,不上传)
|
└── logs/ # 日志文件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@@ -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 吗?**
|
**Q:支持 TeamSpeak 6 Server 吗?**
|
||||||
@@ -464,6 +527,21 @@ A:YouTube 是可选音源,需要手动安装 `yt-dlp`。详见 [可选:You
|
|||||||
**Q:如何更新到新版本?**
|
**Q:如何更新到新版本?**
|
||||||
A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm start` 重新构建启动。Docker 用户执行 `docker-compose up -d --build`。
|
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 本仓库
|
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)**
|
**机器人形象自动更新(Bot Profile)**
|
||||||
|
|
||||||
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
|
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,811 @@
|
|||||||
|
# Account Permissions Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let an admin grant each member account a set of capabilities and a list of bots they may control, enforced on the backend.
|
||||||
|
|
||||||
|
**Architecture:** Capability tokens + per-member bot allow-list stored in two new SQLite tables, loaded onto `req.user` per request (live, no re-login), enforced by `requirePermission` / `requireBotAccess` middleware mirroring the existing `requireAdmin`. Admin stays a super-user. Existing members are backfilled to full access on upgrade; new members get a basic tier. The Vue UI hides what a member can't do and gives admins a permission editor.
|
||||||
|
|
||||||
|
**Tech Stack:** Node ESM + TypeScript, Express, better-sqlite3, Vitest + supertest, Vue 3 + Pinia.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-30-account-permissions-design.md`
|
||||||
|
|
||||||
|
**Conventions:** All file paths are repo-relative. Tests run with `npx vitest run <path>`. Backend is TDD (test first, watch fail, implement, watch pass, commit). Commit after each task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `src/data/permissions.ts` — capability constants + `PermissionStore` (tables accessed here)
|
||||||
|
- `src/data/permissions.test.ts` — store + constants tests
|
||||||
|
- `src/web/middleware/requirePermission.ts` — `requirePermission(cap)` + `requireBotAccess(param)`
|
||||||
|
- `src/web/middleware/requirePermission.test.ts` — middleware tests
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `src/data/database.ts` — `initTables`: add the two tables + index; migration backfill of existing members
|
||||||
|
- `src/data/audit.ts` — add `"user.permissions_changed"` to `AuditAction`
|
||||||
|
- `src/web/middleware/requireAuth.ts` — widen `req.user`; load capabilities + bot access
|
||||||
|
- `src/web/auth/validateSession.ts` — (no change; just confirm) — actually unchanged
|
||||||
|
- `src/web/api/session.ts` — `/me` returns capabilities + bots; inline auth attaches them
|
||||||
|
- `src/web/server.ts` — construct `PermissionStore`, pass into routers/middleware
|
||||||
|
- `src/web/api/player.ts` — `requireBotAccess` on `/:botId`; per-route `requirePermission`
|
||||||
|
- `src/web/api/bot.ts` — `requirePermission("bot.manage")` + `requireBotAccess("id")`
|
||||||
|
- `src/web/api/auth.ts` — `requirePermission("platform.auth")`
|
||||||
|
- `src/web/api/music.ts` — `requirePermission("quality")` on the quality POST; filter `GET /api/bot`? no — bot list is in bot.ts
|
||||||
|
- `src/web/api/bot.ts` — filter `GET /` to allowed bots for members
|
||||||
|
- `src/web/api/users.ts` — `GET/PUT /api/users/:id/permissions`
|
||||||
|
- `src/bot/manager.ts` — `removeBot` calls `permissions.pruneBot(botId)`
|
||||||
|
- Frontend: `web/src/composables/useSession.ts`, `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Capability constants + PermissionStore + tables
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/data/permissions.ts`
|
||||||
|
- Create: `src/data/permissions.test.ts`
|
||||||
|
- Modify: `src/data/database.ts` (initTables)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/data/permissions.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { createDatabase, type BotDatabase } from "./database.js";
|
||||||
|
import { createPermissionStore } from "./permissions.js";
|
||||||
|
import { CAPABILITIES, BASIC_TIER_CAPABILITIES } from "./permissions.js";
|
||||||
|
|
||||||
|
describe("PermissionStore", () => {
|
||||||
|
let dbFile: string;
|
||||||
|
let db: BotDatabase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||||
|
db = createDatabase(dbFile);
|
||||||
|
// a user row is required for FK; insert directly
|
||||||
|
db.db.prepare(
|
||||||
|
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
|
||||||
|
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
try { fs.rmSync(dbFile, { force: true }); } catch {}
|
||||||
|
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
|
||||||
|
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the five capability tokens and a basic tier", () => {
|
||||||
|
expect(CAPABILITIES).toEqual([
|
||||||
|
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
|
||||||
|
]);
|
||||||
|
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to no capabilities and no bots", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
expect(store.getCapabilities("u1")).toEqual([]);
|
||||||
|
expect(store.getBotAccess("u1")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips capabilities and a specific bot list", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
|
||||||
|
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
|
||||||
|
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the all-bots flag as 'all'", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
|
||||||
|
expect(store.getBotAccess("u1")).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPermissions replaces prior capabilities and bots", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
|
||||||
|
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
|
||||||
|
expect(store.getCapabilities("u1")).toEqual(["quality"]);
|
||||||
|
expect(store.getBotAccess("u1")).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown capability tokens", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
|
||||||
|
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pruneBot removes a bot from every user's allow-list", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
|
||||||
|
store.pruneBot("botA");
|
||||||
|
expect(store.getBotAccess("u1")).toEqual(["botB"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/data/permissions.test.ts`
|
||||||
|
Expected: FAIL — `createPermissionStore` / `CAPABILITIES` not found (module missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `src/data/permissions.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export const CAPABILITIES = [
|
||||||
|
"player.control",
|
||||||
|
"player.queue",
|
||||||
|
"bot.manage",
|
||||||
|
"platform.auth",
|
||||||
|
"quality",
|
||||||
|
] as const;
|
||||||
|
export type Capability = (typeof CAPABILITIES)[number];
|
||||||
|
|
||||||
|
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
|
||||||
|
export const BOTS_ALL = "bots.all";
|
||||||
|
|
||||||
|
/** Capabilities granted to a newly-created member by default. */
|
||||||
|
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
|
||||||
|
|
||||||
|
export function isCapability(x: string): x is Capability {
|
||||||
|
return (CAPABILITIES as readonly string[]).includes(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BotAccess = "all" | string[];
|
||||||
|
|
||||||
|
export interface PermissionStore {
|
||||||
|
getCapabilities(userId: string): Capability[];
|
||||||
|
getBotAccess(userId: string): BotAccess;
|
||||||
|
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
|
||||||
|
pruneBot(botId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPermissionStore(db: Database.Database): PermissionStore {
|
||||||
|
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
|
||||||
|
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
|
||||||
|
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||||
|
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
|
||||||
|
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
|
||||||
|
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
|
||||||
|
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCapabilities(userId) {
|
||||||
|
return (selCaps.all(userId) as { permission: string }[])
|
||||||
|
.map((r) => r.permission)
|
||||||
|
.filter((p): p is Capability => isCapability(p));
|
||||||
|
},
|
||||||
|
getBotAccess(userId) {
|
||||||
|
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
|
||||||
|
if (all) return "all";
|
||||||
|
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
|
||||||
|
},
|
||||||
|
setPermissions(userId, input) {
|
||||||
|
const caps = input.capabilities.filter(isCapability);
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
delCaps.run(userId);
|
||||||
|
delBots.run(userId);
|
||||||
|
for (const c of caps) insCap.run(userId, c);
|
||||||
|
if (input.bots === "all") {
|
||||||
|
insCap.run(userId, BOTS_ALL);
|
||||||
|
} else {
|
||||||
|
for (const b of input.bots) insBot.run(userId, b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
},
|
||||||
|
pruneBot(botId) {
|
||||||
|
pruneBotStmt.run(botId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add tables in `src/data/database.ts` initTables**
|
||||||
|
|
||||||
|
Find `initTables` (creates users/sessions/user_audit). Add, after the `user_audit` CREATE:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (userId, permission),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_bot_access (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
botId TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (userId, botId),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
(If `initTables` uses individual `db.exec` calls, match that style. The `BotDatabase` type already exposes `.db` and `.close()` — confirm by reading the file; the test uses `db.db` and `db.close()`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/data/permissions.test.ts`
|
||||||
|
Expected: PASS (7 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/data/permissions.ts src/data/permissions.test.ts src/data/database.ts
|
||||||
|
git commit -m "feat(perm): permission store + capability tokens + tables"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: requirePermission + requireBotAccess middleware
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/web/middleware/requirePermission.ts`
|
||||||
|
- Create: `src/web/middleware/requirePermission.test.ts`
|
||||||
|
- Modify: `src/web/middleware/requireAuth.ts` (widen `req.user`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Widen the `req.user` augmentation in `src/web/middleware/requireAuth.ts`**
|
||||||
|
|
||||||
|
Change the `declare module` block so `req.user` carries capabilities + bot access:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
declare module "express-serve-static-core" {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
capabilities: Set<string>;
|
||||||
|
bots: "all" | Set<string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(The loading of these fields is done in Task 4 — for now this only widens the type. Existing assignments to `req.user` will fail to typecheck until Task 4; that is expected and Task 4 fixes them. If you need the build green between tasks, do Task 2 + Task 4 back-to-back before running `tsc`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing middleware test**
|
||||||
|
|
||||||
|
`src/web/middleware/requirePermission.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { requirePermission, requireBotAccess } from "./requirePermission.js";
|
||||||
|
|
||||||
|
function appWith(user: any) {
|
||||||
|
const app = express();
|
||||||
|
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||||
|
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
|
||||||
|
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = (caps: string[], bots: "all" | string[]) => ({
|
||||||
|
id: "u1", username: "a", role: "member",
|
||||||
|
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
|
||||||
|
});
|
||||||
|
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
|
||||||
|
|
||||||
|
describe("requirePermission", () => {
|
||||||
|
it("401 when unauthenticated", async () => {
|
||||||
|
const app = express();
|
||||||
|
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
|
||||||
|
expect((await request(app).post("/cap")).status).toBe(401);
|
||||||
|
});
|
||||||
|
it("403 when member lacks the capability", async () => {
|
||||||
|
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
|
||||||
|
});
|
||||||
|
it("200 when member has the capability", async () => {
|
||||||
|
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
|
||||||
|
});
|
||||||
|
it("200 for admin regardless of capabilities", async () => {
|
||||||
|
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireBotAccess", () => {
|
||||||
|
it("200 when bots = all", async () => {
|
||||||
|
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
|
||||||
|
});
|
||||||
|
it("200 when botId in allow-list", async () => {
|
||||||
|
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
|
||||||
|
});
|
||||||
|
it("403 when botId not in allow-list", async () => {
|
||||||
|
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
|
||||||
|
});
|
||||||
|
it("200 for admin", async () => {
|
||||||
|
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
|
||||||
|
Expected: FAIL — module `./requirePermission.js` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `src/web/middleware/requirePermission.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||||
|
|
||||||
|
export function requirePermission(capability: string): RequestHandler {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||||
|
if (req.user.role === "admin" || req.user.capabilities.has(capability)) { next(); return; }
|
||||||
|
res.status(403).json({ error: "forbidden" });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireBotAccess(paramName = "botId"): RequestHandler {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||||
|
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
|
||||||
|
const botId = req.params[paramName];
|
||||||
|
if (botId && req.user.bots.has(botId)) { next(); return; }
|
||||||
|
res.status(403).json({ error: "forbidden" });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
|
||||||
|
Expected: PASS (8 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/web/middleware/requirePermission.ts src/web/middleware/requirePermission.test.ts src/web/middleware/requireAuth.ts
|
||||||
|
git commit -m "feat(perm): requirePermission + requireBotAccess middleware"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Effective-permissions resolver (admin = all)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/data/permissions.ts` (add `resolveContext` helper)
|
||||||
|
- Modify: `src/data/permissions.test.ts` (add tests)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests** to `src/data/permissions.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { resolvePermissionContext } from "./permissions.js";
|
||||||
|
|
||||||
|
describe("resolvePermissionContext", () => {
|
||||||
|
it("admin gets all capabilities and all bots regardless of stored rows", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
const ctx = resolvePermissionContext("admin", "u1", store);
|
||||||
|
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
|
||||||
|
expect(ctx.bots).toBe("all");
|
||||||
|
});
|
||||||
|
it("member reflects stored capabilities + bot access", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
|
||||||
|
const ctx = resolvePermissionContext("member", "u1", store);
|
||||||
|
expect([...ctx.capabilities]).toEqual(["player.control"]);
|
||||||
|
expect(ctx.bots).toEqual(new Set(["b1"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/data/permissions.test.ts`
|
||||||
|
Expected: FAIL — `resolvePermissionContext` not exported.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add to `src/data/permissions.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface PermissionContext {
|
||||||
|
capabilities: Set<string>;
|
||||||
|
bots: "all" | Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePermissionContext(
|
||||||
|
role: "admin" | "member",
|
||||||
|
userId: string,
|
||||||
|
store: PermissionStore
|
||||||
|
): PermissionContext {
|
||||||
|
if (role === "admin") {
|
||||||
|
return { capabilities: new Set(CAPABILITIES), bots: "all" };
|
||||||
|
}
|
||||||
|
const access = store.getBotAccess(userId);
|
||||||
|
return {
|
||||||
|
capabilities: new Set(store.getCapabilities(userId)),
|
||||||
|
bots: access === "all" ? "all" : new Set(access),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/data/permissions.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/data/permissions.ts src/data/permissions.test.ts
|
||||||
|
git commit -m "feat(perm): resolvePermissionContext (admin = super-user)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Load permissions onto req.user (requireAuth + session inline + /me)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/web/middleware/requireAuth.ts`
|
||||||
|
- Modify: `src/web/api/session.ts`
|
||||||
|
- Modify: `src/web/server.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Thread `PermissionStore` into `createRequireAuth`**
|
||||||
|
|
||||||
|
`src/web/middleware/requireAuth.ts` — change the factory signature and set the new fields:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||||
|
|
||||||
|
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
|
||||||
|
return function requireAuth(req, res, next) {
|
||||||
|
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||||
|
if (!result) {
|
||||||
|
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||||
|
res.status(401).json({ error: "unauthenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
|
||||||
|
req.user = {
|
||||||
|
id: result.userId, username: result.username, role: result.role,
|
||||||
|
capabilities: ctx.capabilities, bots: ctx.bots,
|
||||||
|
};
|
||||||
|
const token = extractSessionToken(req.headers.cookie);
|
||||||
|
if (token) {
|
||||||
|
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true, sameSite: "lax", secure: req.secure, path: "/", maxAge: SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `src/web/server.ts`**
|
||||||
|
|
||||||
|
Construct the store next to the others and pass it in:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createPermissionStore } from "../data/permissions.js";
|
||||||
|
// ...
|
||||||
|
const permissions = createPermissionStore(options.database.db);
|
||||||
|
// ...
|
||||||
|
const requireAuth = createRequireAuth(sessions, permissions);
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `permissions` in scope — it's passed to routers in Tasks 5–7.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update session inline auth + `/me` in `src/web/api/session.ts`**
|
||||||
|
|
||||||
|
`createSessionRouter` must accept `permissions` and (a) attach capabilities in `requireAuthInline`, (b) include them in `/me`. Pass `permissions` from `server.ts` into `createSessionRouter(users, sessions, audit, logger, permissions)`. In the `/me` handler, return:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
|
||||||
|
res.json({
|
||||||
|
id: validation.userId, username: validation.username, role: validation.role,
|
||||||
|
capabilities: [...ctx.capabilities],
|
||||||
|
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
(Match the existing `/me` shape; just add `capabilities` + `bots`. Read the file to find the exact response object.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify build + existing tests**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit`
|
||||||
|
Expected: exit 0 (the widened `req.user` is now populated everywhere it's read).
|
||||||
|
|
||||||
|
Run: `npx vitest run src/web`
|
||||||
|
Expected: PASS (existing auth/session/csrf tests still green; if a test constructs `createRequireAuth(sessions)` it must be updated to pass a `createPermissionStore(db)`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/web/middleware/requireAuth.ts src/web/server.ts src/web/api/session.ts
|
||||||
|
git commit -m "feat(perm): load capabilities + bot access onto req.user; expose via /me"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Enforce capabilities on the action routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/web/api/player.ts`, `src/web/api/bot.ts`, `src/web/api/auth.ts`, `src/web/api/music.ts`
|
||||||
|
- Modify: `src/web/api/player.test.ts` (or create `src/web/api/permissions-enforcement.test.ts`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a failing integration test** at `src/web/api/permissions-enforcement.test.ts` that builds the real app (or the relevant router) with a stubbed `req.user` and asserts:
|
||||||
|
- member without `player.control` → `POST /api/player/:botId/pause` → 403
|
||||||
|
- member with `player.control` + bot in allow-list → 200 (bot resolves)
|
||||||
|
- member with `player.control` but bot NOT in allow-list → 403
|
||||||
|
- member without `player.queue` → `POST /api/player/:botId/clear` → 403
|
||||||
|
- member without `bot.manage` → `POST /api/bot` → 403
|
||||||
|
- member without `platform.auth` → `POST /api/auth/cookie` → 403
|
||||||
|
- member without `quality` → `POST /api/music/quality` → 403
|
||||||
|
- admin → all 200/allowed
|
||||||
|
|
||||||
|
Use the same `appWith(user)` injection pattern as Task 2 (insert a middleware that sets `req.user` before the router) and a fake `BotManager`/providers so routes resolve. Model it on the existing `src/web/api/*.test.ts` setup (read one first for the harness).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify fail** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → FAIL (routes currently allow everyone).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Apply gates.**
|
||||||
|
|
||||||
|
`src/web/api/player.ts` — the shared `/:botId` middleware already resolves the bot. Add bot-access there, and add per-action capability guards. Define the queue-capability routes vs control routes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||||
|
|
||||||
|
// after the existing router.use("/:botId", resolveBot):
|
||||||
|
router.use("/:botId", requireBotAccess("botId"));
|
||||||
|
|
||||||
|
const control = requirePermission("player.control");
|
||||||
|
const queue = requirePermission("player.queue");
|
||||||
|
// control: play, pause, resume, next, prev, stop, seek, volume, mode, play-song, play-at, play-by-id, play-playlist, play-album, play-next-song
|
||||||
|
// queue: add, add-song, add-by-id, clear, playlist, /queue/:index (DELETE)
|
||||||
|
// Apply per route, e.g.:
|
||||||
|
router.post("/:botId/pause", control, async (req, res) => { /* existing */ });
|
||||||
|
router.post("/:botId/add", queue, async (req, res) => { /* existing */ });
|
||||||
|
router.delete("/:botId/queue/:index", queue, async (req, res) => { /* existing */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
(Insert the `control`/`queue` middleware as the 2nd arg of each existing `router.post/delete`. Do not change handler bodies. `PUT /:botId/profile` → `requirePermission("bot.manage")`.)
|
||||||
|
|
||||||
|
`src/web/api/bot.ts` — gate management + per-bot:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const manage = requirePermission("bot.manage");
|
||||||
|
router.post("/", manage, ...); // create (no botId)
|
||||||
|
router.put("/:id", manage, requireBotAccess("id"), ...);
|
||||||
|
router.delete("/:id", manage, requireBotAccess("id"), ...);
|
||||||
|
router.post("/:id/start", manage, requireBotAccess("id"), ...);
|
||||||
|
router.post("/:id/stop", manage, requireBotAccess("id"), ...);
|
||||||
|
router.put("/:id/avatar", manage, requireBotAccess("id"), ...);
|
||||||
|
router.delete("/:id/avatar", manage, requireBotAccess("id"), ...);
|
||||||
|
router.post("/settings", manage, ...); // global idle timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/web/api/auth.ts` — gate every mutating route with `requirePermission("platform.auth")`:
|
||||||
|
`POST /qrcode`, `POST /sms/send`, `POST /sms/verify`, `POST /cookie`. (Leave `GET /status`, `GET /qrcode/status` open — read-only.)
|
||||||
|
|
||||||
|
`src/web/api/music.ts` — gate the one mutating route:
|
||||||
|
`router.post("/quality", requirePermission("quality"), ...)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify pass** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → PASS. Then `npx vitest run src/web` → all green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/web/api/player.ts src/web/api/bot.ts src/web/api/auth.ts src/web/api/music.ts src/web/api/permissions-enforcement.test.ts
|
||||||
|
git commit -m "feat(perm): enforce capabilities + bot access on action routes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Filter the bot list for members
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/web/api/bot.ts` (`GET /`)
|
||||||
|
- Modify: `src/bot/manager.ts` (`removeBot` → `permissions.pruneBot`)
|
||||||
|
- Modify: test from Task 5
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing test** — member with `bots: ["b1"]` calling `GET /api/bot` sees only `b1`; admin sees all.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run → fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement.** In `GET /` of `bot.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const all = getAllBots().map((b) => b.getStatus());
|
||||||
|
const u = req.user!;
|
||||||
|
const bots = u.role === "admin" || u.bots === "all"
|
||||||
|
? all
|
||||||
|
: all.filter((b) => (u.bots as Set<string>).has(b.id));
|
||||||
|
res.json({ bots });
|
||||||
|
```
|
||||||
|
|
||||||
|
In `src/bot/manager.ts`, give `BotManager` access to the `PermissionStore` (constructor param) and call `this.permissions.pruneBot(id)` inside `removeBot(id)` after deletion, so deleted bots drop out of allow-lists. Thread `permissions` from `index.ts`/`server.ts` into `BotManager`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run → pass; `npx vitest run src/web src/bot` green.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/web/api/bot.ts src/bot/manager.ts src/web/api/permissions-enforcement.test.ts
|
||||||
|
git commit -m "feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Management API (GET/PUT permissions) + audit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/data/audit.ts` (add action)
|
||||||
|
- Modify: `src/web/api/users.ts` (+ permissions endpoints; new-member default)
|
||||||
|
- Modify: `src/web/server.ts` (pass `permissions` into `createUsersRouter`)
|
||||||
|
- Create/extend: `src/web/api/users.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `"user.permissions_changed"`** to the `AuditAction` union in `src/data/audit.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing tests** for the users router (admin-only):
|
||||||
|
- `GET /api/users/:id/permissions` → `{ capabilities: [], bots: [] }` for a fresh member.
|
||||||
|
- `PUT /api/users/:id/permissions` with `{capabilities:["player.control"], bots:"all"}` → 200; subsequent GET reflects it; an audit row `user.permissions_changed` exists.
|
||||||
|
- `PUT` with an unknown capability token → it is dropped (not stored).
|
||||||
|
- New member created via `POST /api/users` → GET permissions returns basic tier (`["player.control","player.queue"]`, bots `"all"`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run → fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement** in `src/web/api/users.ts` (router already admin-gated at mount). Accept `permissions: PermissionStore` param. Add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CAPABILITIES, isCapability, BASIC_TIER_CAPABILITIES } from "../../data/permissions.js";
|
||||||
|
|
||||||
|
router.get("/:id/permissions", (req, res) => {
|
||||||
|
const user = users.findById(req.params.id);
|
||||||
|
if (!user) { res.status(404).json({ error: "not_found" }); return; }
|
||||||
|
res.json({ capabilities: permissions.getCapabilities(user.id), bots: permissions.getBotAccess(user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/:id/permissions", (req, res) => {
|
||||||
|
const user = users.findById(req.params.id);
|
||||||
|
if (!user) { res.status(404).json({ error: "not_found" }); return; }
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const caps = Array.isArray(body.capabilities) ? body.capabilities.filter(isCapability) : [];
|
||||||
|
const bots = body.bots === "all" ? "all" : (Array.isArray(body.bots) ? body.bots.map(String) : []);
|
||||||
|
permissions.setPermissions(user.id, { capabilities: caps, bots });
|
||||||
|
audit.record({
|
||||||
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
|
targetUserId: user.id, targetUsername: user.username,
|
||||||
|
action: "user.permissions_changed",
|
||||||
|
});
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
In the existing `POST /api/users` handler, after creating a member, seed the basic tier:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (created.role === "member") {
|
||||||
|
permissions.setPermissions(created.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run → pass; `npx vitest run src/web` green.**
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/data/audit.ts src/web/api/users.ts src/web/server.ts src/web/api/users.test.ts
|
||||||
|
git commit -m "feat(perm): admin permissions API + audit + new-member basic tier"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: One-time migration backfill (existing members → full)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/data/database.ts` (`migrateSchema` or a dedicated backfill)
|
||||||
|
- Create: `src/data/permissions-migration.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test** — given a fresh db with an existing `member` user and NO permission rows, after `createDatabase()` runs the backfill, that member has all 5 capabilities + `bots.all`; an `admin` user gets nothing (bypasses). Backfill is idempotent (running twice does not duplicate / does not re-grant a member who was later restricted to empty).
|
||||||
|
|
||||||
|
Idempotency approach: store a one-shot marker. Use a `meta` row or check: only backfill members who currently have ZERO permission rows AND only on first introduction. Simplest robust marker: a row in a tiny `schema_meta(key TEXT PK, value TEXT)` table, key `perm_backfill_done`. If present, skip.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run → fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** a `backfillMemberPermissions(db)` run once inside `createDatabase` after `initTables`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
|
||||||
|
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
|
||||||
|
if (!done) {
|
||||||
|
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
|
||||||
|
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const m of members) {
|
||||||
|
for (const c of ["player.control","player.queue","bot.manage","platform.auth","quality","bots.all"]) {
|
||||||
|
insCap.run(m.id, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(Date.now()));
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run → pass.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/data/database.ts src/data/permissions-migration.test.ts
|
||||||
|
git commit -m "feat(perm): one-time backfill of existing members to full access"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Frontend — session capabilities + helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/composables/useSession.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Extend the `User` type with `capabilities: string[]` and `bots: 'all' | string[]`; populate from `/api/session/me`, `/login`, `/setup` responses (the backend now returns them).
|
||||||
|
- [ ] **Step 2:** Add computed helpers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function can(cap: string): boolean {
|
||||||
|
const u = currentUser.value;
|
||||||
|
return !!u && (u.role === 'admin' || (u.capabilities ?? []).includes(cap));
|
||||||
|
}
|
||||||
|
function canControlBot(botId: string): boolean {
|
||||||
|
const u = currentUser.value;
|
||||||
|
if (!u) return false;
|
||||||
|
if (u.role === 'admin' || u.bots === 'all') return true;
|
||||||
|
return Array.isArray(u.bots) && u.bots.includes(botId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Export `can` and `canControlBot` from the composable.
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Manual check: log in as admin → `can('quality')` true; (after backend done) a restricted member → false. Build: `cd web && npx vue-tsc --noEmit`.
|
||||||
|
- [ ] **Step 4: Commit** `git add web/src/composables/useSession.ts && git commit -m "feat(perm): frontend session capabilities + can()/canControlBot()"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Frontend — gate UI by capability + filter bots
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Navbar bot selector: render only controllable bots — `v-for="bot in store.bots"` becomes a filtered computed `controllableBots = store.bots.filter(b => session.canControlBot(b.id))`. (The backend already filters `GET /api/bot`, so this is belt-and-suspenders + correctness if both lists diverge.) Ensure `store.activeBot` fallback never lands on a bot the user can't control.
|
||||||
|
- [ ] **Step 2:** Player.vue: wrap control buttons with `v-if="session.can('player.control')"` and queue actions with `v-if="session.can('player.queue')"`.
|
||||||
|
- [ ] **Step 3:** Settings.vue: wrap the platform login cards with `v-if="session.can('platform.auth')"`, the audio-quality control with `v-if="session.can('quality')"`, and bot create/edit/delete with `v-if="session.can('bot.manage')"`.
|
||||||
|
- [ ] **Step 4:** Manual verification (see Verification section). Build: `cd web && npx vue-tsc --noEmit`.
|
||||||
|
- [ ] **Step 5: Commit** `git add web/src/components/Navbar.vue web/src/components/Player.vue web/src/views/Settings.vue web/src/stores/player.ts && git commit -m "feat(perm): hide UI a member lacks capability for"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Frontend — admin permission editor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/views/Settings.vue` (User Management section)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In each member row of the admin User-Management list, add a "权限" button opening an editor (inline panel or dialog) with: 5 capability checkboxes (labels: 播放控制 / 队列管理 / 机器人管理 / 平台登录凭据 / 音质设置), and a bot allow-list — an "全部机器人" toggle plus, when off, a checkbox per bot from `store.bots`.
|
||||||
|
- [ ] **Step 2:** On open, `GET /api/users/:id/permissions`; on save, `PUT /api/users/:id/permissions` with `{capabilities, bots}` then re-fetch. Admin rows show "全部权限(管理员)" and no editor.
|
||||||
|
- [ ] **Step 3:** Manual verification. Build: `cd web && npx vue-tsc --noEmit`.
|
||||||
|
- [ ] **Step 4: Commit** `git add web/src/views/Settings.vue && git commit -m "feat(perm): admin permission editor in user management"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] `npx tsc --noEmit` → exit 0
|
||||||
|
- [ ] `npx vitest run src/` → all green (clean-checkout-equivalent; ignore stale `dist/` twins — see note)
|
||||||
|
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
|
||||||
|
- [ ] `npm run build` → succeeds
|
||||||
|
- [ ] Manual (run the bot, log in): admin sees everything; create a member, restrict to `player.control` on one bot → member sees only that bot, can play/pause but cannot add to queue, cannot open platform login / quality / bot management; backend returns 403 on a forged request to a disallowed action (verify with curl + the member's session cookie).
|
||||||
|
|
||||||
|
> **Note (pre-existing):** `tsconfig.json` compiles `*.test.ts` into `dist/`, and vitest also runs the `dist/` twins after a build — so `npx vitest run` (no path) double-runs and can fail on stale artifacts. Scope verification to `npx vitest run src/`. (A separate cleanup PR could add `exclude: ['**/dist/**']` to a vitest config.)
|
||||||
|
|
||||||
|
## Out of scope (separate PRs, per spec)
|
||||||
|
|
||||||
|
#1 guest mode · #2 dedicated-link bot hiding UX · #3 auto-pause on empty channel · #4 dedicated-link refresh bug.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Auto-pause on Empty Channel — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
|
||||||
|
|
||||||
|
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
|
||||||
|
|
||||||
|
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Pure occupancy-decision function
|
||||||
|
|
||||||
|
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
describe("decideOccupancyAction", () => {
|
||||||
|
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
|
||||||
|
it("pauses when empty while playing and enabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||||
|
});
|
||||||
|
it("does not pause when the feature is disabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when idle (nothing playing)", () => {
|
||||||
|
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when already paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resumes when re-populated and we auto-paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||||
|
});
|
||||||
|
it("does NOT resume a user-paused track on re-population", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does nothing when re-populated and already playing", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||||
|
export type OccupancyAction = "pause" | "resume" | "none";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what auto-pause should do given the channel occupancy.
|
||||||
|
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||||
|
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||||
|
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||||
|
*/
|
||||||
|
export function decideOccupancyAction(
|
||||||
|
playerState: PlayerStateName,
|
||||||
|
autoPaused: boolean,
|
||||||
|
enabled: boolean,
|
||||||
|
userCount: number,
|
||||||
|
): OccupancyAction {
|
||||||
|
const empty = userCount <= 0;
|
||||||
|
if (empty) {
|
||||||
|
if (enabled && playerState === "playing") return "pause";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
if (autoPaused && playerState === "paused") return "resume";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
|
||||||
|
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
|
||||||
|
|
||||||
|
**Files:** Modify `src/bot/instance.ts`.
|
||||||
|
|
||||||
|
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
private handleOccupancy(userCount: number): void {
|
||||||
|
// idle-disconnect (unchanged behavior)
|
||||||
|
if (userCount <= 0) this._scheduleIdleCheck();
|
||||||
|
else this._cancelIdleTimer();
|
||||||
|
|
||||||
|
// auto-pause
|
||||||
|
const action = decideOccupancyAction(
|
||||||
|
this.player.getState() as "idle" | "playing" | "paused",
|
||||||
|
this.autoPaused,
|
||||||
|
this.config.autoPauseOnEmpty,
|
||||||
|
userCount,
|
||||||
|
);
|
||||||
|
if (action === "pause") {
|
||||||
|
this.player.pause();
|
||||||
|
this.autoPaused = true;
|
||||||
|
this.emit("stateChange");
|
||||||
|
} else if (action === "resume") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
|
||||||
|
|
||||||
|
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
updateAutoPause(enabled: boolean): void {
|
||||||
|
this.config.autoPauseOnEmpty = enabled;
|
||||||
|
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
|
||||||
|
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
|
||||||
|
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Re-emit TS member events for instant reaction
|
||||||
|
|
||||||
|
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
|
||||||
|
|
||||||
|
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
|
||||||
|
|
||||||
|
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
|
||||||
|
|
||||||
|
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
|
||||||
|
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: API wiring for the toggle
|
||||||
|
|
||||||
|
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
|
||||||
|
|
||||||
|
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail.**
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
|
||||||
|
|
||||||
|
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
|
||||||
|
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend toggle in Settings
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
|
||||||
|
|
||||||
|
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
- [ ] `npx tsc --noEmit` → 0
|
||||||
|
- [ ] `npx vitest run src/` → all pass
|
||||||
|
- [ ] `cd web && npx vue-tsc --noEmit` → 0
|
||||||
|
- [ ] `npm run build` → succeeds
|
||||||
|
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
|
||||||
|
|
||||||
|
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
|
||||||
|
|
||||||
|
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Store scope state + pure resolve helper (with test)
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
|
||||||
|
|
||||||
|
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
|
||||||
|
* effective scope. Returns the id if it exists, else null (graceful clear:
|
||||||
|
* a stale/forbidden id never locks the UI). */
|
||||||
|
export function resolveScopedBot(
|
||||||
|
requestedId: string | null | undefined,
|
||||||
|
knownBotIds: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
if (!requestedId) return null;
|
||||||
|
return knownBotIds.includes(requestedId) ? requestedId : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`web/src/stores/scope.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveScopedBot } from "./scope.js";
|
||||||
|
|
||||||
|
describe("resolveScopedBot", () => {
|
||||||
|
it("returns null when no id requested", () => {
|
||||||
|
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
|
||||||
|
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
|
||||||
|
expect(resolveScopedBot("", ["a"])).toBeNull();
|
||||||
|
});
|
||||||
|
it("returns the id when it exists in the bot list", () => {
|
||||||
|
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
|
||||||
|
});
|
||||||
|
it("clears (null) when the requested id is not a known bot", () => {
|
||||||
|
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
|
||||||
|
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement the helper** (code above).
|
||||||
|
|
||||||
|
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
|
||||||
|
- state: `scopedBotId: null as string | null`.
|
||||||
|
- getter: `isScoped: (state) => state.scopedBotId !== null`.
|
||||||
|
- actions:
|
||||||
|
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
|
||||||
|
- `clearScope()` → `this.scopedBotId = null;`.
|
||||||
|
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
|
||||||
|
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
|
||||||
|
|
||||||
|
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/router/index.ts`.
|
||||||
|
|
||||||
|
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
|
||||||
|
if (qBot) {
|
||||||
|
// entering/with a scope in the URL — store will validate against bots later
|
||||||
|
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (store.scopedBotId) {
|
||||||
|
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
|
||||||
|
if (to.query.bot !== store.scopedBotId) {
|
||||||
|
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
|
||||||
|
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
|
||||||
|
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: BotRedirect seeds the URL scope
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/views/BotRedirect.vue`.
|
||||||
|
|
||||||
|
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
|
||||||
|
- keep the fetchBots + existence check,
|
||||||
|
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
|
||||||
|
- if not found: unchanged (show "机器人不存在或未加载").
|
||||||
|
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Navbar lock UI + apply-scope-on-load
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
|
||||||
|
|
||||||
|
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
|
||||||
|
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
|
||||||
|
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
|
||||||
|
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
|
||||||
|
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
|
||||||
|
When not scoped: behavior unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
|
||||||
|
|
||||||
|
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
|
||||||
|
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
|
||||||
|
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
|
||||||
|
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
|
||||||
|
- [ ] `npm run build` → succeeds
|
||||||
|
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
|
||||||
|
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
|
||||||
|
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
# WebUI Authentication
|
||||||
|
|
||||||
|
**Date:** 2026-05-27
|
||||||
|
**Status:** Spec — pending implementation
|
||||||
|
**Branch:** `feat/webui-auth`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权:
|
||||||
|
|
||||||
|
- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。
|
||||||
|
- 静态前端通过 `express.static()` 直接对外提供。
|
||||||
|
|
||||||
|
后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。
|
||||||
|
|
||||||
|
## Out of Scope(明确不做)
|
||||||
|
|
||||||
|
- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做)
|
||||||
|
- 角色与权限(admin / viewer)—— 全员同权
|
||||||
|
- 密码重置流程(不挂邮件;仅提供登录后 `change-password`)
|
||||||
|
- 双因素认证(2FA)
|
||||||
|
- "记住我" / 绝对过期 vs 滑动过期的可配置
|
||||||
|
- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权
|
||||||
|
- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json`
|
||||||
|
|
||||||
|
## Non-functional Constraints
|
||||||
|
|
||||||
|
- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。
|
||||||
|
- Cookie 行为必须兼容现有 `trustProxy` 反代部署。
|
||||||
|
- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。
|
||||||
|
- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 数据层(`src/data/`)
|
||||||
|
|
||||||
|
扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY, -- uuid v4
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY, -- sha256(rawToken) hex
|
||||||
|
userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
expiresAt INTEGER NOT NULL, -- ms epoch
|
||||||
|
lastSeenAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。
|
||||||
|
|
||||||
|
新模块:
|
||||||
|
|
||||||
|
`src/data/users.ts`
|
||||||
|
- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError`
|
||||||
|
- `findByUsername(username): User | null`
|
||||||
|
- `verifyPassword(plain, hash): Promise<boolean>` — bcryptjs compare
|
||||||
|
- `countUsers(): number` — 用于 `/needs-setup`
|
||||||
|
- `changePassword(userId, newPassword): void`
|
||||||
|
|
||||||
|
`src/data/sessions.ts`
|
||||||
|
- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256
|
||||||
|
- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d`
|
||||||
|
- `deleteSession(rawToken): void` — 退出
|
||||||
|
- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话
|
||||||
|
- `cleanupExpired(): void` — 定时任务
|
||||||
|
|
||||||
|
### HTTP 层(`src/web/`)
|
||||||
|
|
||||||
|
#### 新增中间件
|
||||||
|
|
||||||
|
`src/web/middleware/requireAuth.ts`
|
||||||
|
```
|
||||||
|
读取 req.cookies.tsmb_session
|
||||||
|
→ 缺失 → 401 { error: "unauthenticated" }
|
||||||
|
→ 调 sessions.validateAndTouch
|
||||||
|
→ null → 清 cookie + 401
|
||||||
|
→ 有效 → req.user = { id, username }; next()
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/web/middleware/csrf.ts`
|
||||||
|
```
|
||||||
|
若 method ∈ {GET, HEAD, OPTIONS} → next()
|
||||||
|
否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致
|
||||||
|
→ 不一致或两者都缺失 → 403 { error: "bad origin" }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新路由:`src/web/api/session.ts`
|
||||||
|
|
||||||
|
挂在 `/api/session`,全部公共(不挂 requireAuth):
|
||||||
|
|
||||||
|
| Method | Path | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` |
|
||||||
|
| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` |
|
||||||
|
| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) |
|
||||||
|
| POST | `/logout` | 删 session,清 cookie,204 |
|
||||||
|
| GET | `/me` | 走 requireAuth;返回 `{ id, username }` |
|
||||||
|
| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 |
|
||||||
|
|
||||||
|
> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。
|
||||||
|
|
||||||
|
#### Cookie 规范
|
||||||
|
|
||||||
|
- 名称:`tsmb_session`
|
||||||
|
- 值:32 字节 random → base64url
|
||||||
|
- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天)
|
||||||
|
- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃
|
||||||
|
|
||||||
|
#### 装配顺序(`src/web/server.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.use(express.json({ limit: "400kb" }));
|
||||||
|
app.use(cookieParser()); // 新增
|
||||||
|
|
||||||
|
// 公共
|
||||||
|
app.get("/api/health", …);
|
||||||
|
app.get("/api/config/public-url", …);
|
||||||
|
app.use("/api/session", createSessionRouter(...));
|
||||||
|
|
||||||
|
// 闸门(仅作用于下方注册的 /api/* 路由)
|
||||||
|
app.use("/api", csrfOriginCheck);
|
||||||
|
app.use("/api", requireAuth);
|
||||||
|
|
||||||
|
// 受保护
|
||||||
|
app.use("/api/bot", createBotRouter(...));
|
||||||
|
app.use("/api/music", createMusicRouter(...));
|
||||||
|
app.use("/api/player", createPlayerRouter(...));
|
||||||
|
app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR
|
||||||
|
|
||||||
|
// 静态 SPA(公共,前端自行判定登录态后跳转)
|
||||||
|
app.use(express.static(staticDir));
|
||||||
|
app.get(/^(?!\/api|\/ws)/, sendIndex);
|
||||||
|
```
|
||||||
|
|
||||||
|
> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。
|
||||||
|
|
||||||
|
#### 定时清理
|
||||||
|
|
||||||
|
`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。
|
||||||
|
|
||||||
|
### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`)
|
||||||
|
|
||||||
|
改造为手动 upgrade:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on("upgrade", (req, socket, head) => {
|
||||||
|
if (req.url !== "/ws") { socket.destroy(); return; }
|
||||||
|
const session = validateCookieFromHeaders(req.headers.cookie);
|
||||||
|
if (!session) {
|
||||||
|
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
(ws as any).userId = session.userId;
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。
|
||||||
|
|
||||||
|
不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。
|
||||||
|
|
||||||
|
### 前端层(`web/`)
|
||||||
|
|
||||||
|
#### 新视图
|
||||||
|
|
||||||
|
- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/`
|
||||||
|
- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/`
|
||||||
|
- 名称避免与既有 `Setup.vue`(bot 创建向导)冲突
|
||||||
|
|
||||||
|
#### Session 状态
|
||||||
|
|
||||||
|
新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref<User|null>`、`refresh()`、`logout()`、`needsSetup: Ref<boolean>`。在 `App.vue` mount 时调用 `refresh()`。
|
||||||
|
|
||||||
|
#### 路由守卫(`web/src/router/index.ts`)
|
||||||
|
|
||||||
|
- 公共路由:`/login`、`/setup`
|
||||||
|
- 全局 `beforeEach`:
|
||||||
|
1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存)
|
||||||
|
2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')`
|
||||||
|
3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=<path>')`
|
||||||
|
|
||||||
|
#### API 客户端
|
||||||
|
|
||||||
|
- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出)
|
||||||
|
- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')`
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`)
|
||||||
|
- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section)
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
新增到 `package.json`:
|
||||||
|
|
||||||
|
```
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cookie-parser": "^1.4.7"
|
||||||
|
```
|
||||||
|
|
||||||
|
不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### 首次启动
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → GET / → static SPA
|
||||||
|
SPA mounted → GET /api/session/needs-setup → { needsSetup: true }
|
||||||
|
SPA → router.replace('/setup')
|
||||||
|
User submits form → POST /api/session/setup
|
||||||
|
Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200
|
||||||
|
SPA → currentUser refresh → router.replace('/')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 已部署用户升级
|
||||||
|
|
||||||
|
旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。
|
||||||
|
|
||||||
|
### 后续登录
|
||||||
|
|
||||||
|
```
|
||||||
|
SPA → GET /api/session/me → 401
|
||||||
|
SPA → router.replace('/login?next=/queue')
|
||||||
|
User submits → POST /api/session/login → Set-Cookie + 200
|
||||||
|
SPA → currentUser refresh → router.replace('/queue')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 受保护请求
|
||||||
|
|
||||||
|
```
|
||||||
|
SPA → fetch('/api/bot', { credentials: 'same-origin' })
|
||||||
|
Server requireAuth: validateAndTouch(cookie)
|
||||||
|
→ ok → req.user 注入 → 业务路由处理
|
||||||
|
→ 不 ok → 401 → SPA 拦截器跳 /login
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
|
||||||
|
```
|
||||||
|
SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie
|
||||||
|
Server upgrade handler: validateCookieFromHeaders
|
||||||
|
→ ok → handleUpgrade → connection event
|
||||||
|
→ 不 ok → HTTP 401 写回原始 socket → destroy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| 场景 | HTTP 响应 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth |
|
||||||
|
| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 |
|
||||||
|
| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 |
|
||||||
|
| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 |
|
||||||
|
| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 |
|
||||||
|
| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | |
|
||||||
|
| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | |
|
||||||
|
| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | |
|
||||||
|
| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 |
|
||||||
|
|
||||||
|
所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 单元(vitest)
|
||||||
|
|
||||||
|
`src/data/users.test.ts`
|
||||||
|
- createUser 成功后 findByUsername 命中(大小写不敏感)
|
||||||
|
- 重复 username 抛 UsernameTakenError
|
||||||
|
- verifyPassword 正反例
|
||||||
|
- changePassword 之后旧哈希不再验证通过
|
||||||
|
|
||||||
|
`src/data/sessions.test.ts`
|
||||||
|
- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token))
|
||||||
|
- validateAndTouch 过期记录返回 null 且记录被删
|
||||||
|
- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证)
|
||||||
|
- deleteAllForUser(exceptToken) 保留指定会话
|
||||||
|
|
||||||
|
### 集成(vitest + supertest,真 SQLite in-memory)
|
||||||
|
|
||||||
|
`src/web/api/session.test.ts`
|
||||||
|
- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409
|
||||||
|
- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401
|
||||||
|
- /logout 之后同一 cookie 调受保护路由 401
|
||||||
|
- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用
|
||||||
|
|
||||||
|
`src/web/middleware/csrf.test.ts`
|
||||||
|
- 带匹配 Origin 的 POST 通过
|
||||||
|
- Origin 与 host 不匹配 → 403
|
||||||
|
- 同样规则适用 Referer
|
||||||
|
- GET 永远通过
|
||||||
|
|
||||||
|
`src/web/websocket.test.ts`(新增或扩展)
|
||||||
|
- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭
|
||||||
|
- 带有效 cookie → 握手成功,收到 init 消息
|
||||||
|
- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs)
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列):
|
||||||
|
- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面
|
||||||
|
- 退出 → 自动跳 /login
|
||||||
|
- 关闭浏览器 7 天内再开 → 仍登录
|
||||||
|
- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
```
|
||||||
|
src/data/database.ts (schema migration)
|
||||||
|
src/data/users.ts (new)
|
||||||
|
src/data/users.test.ts (new)
|
||||||
|
src/data/sessions.ts (new)
|
||||||
|
src/data/sessions.test.ts (new)
|
||||||
|
src/web/auth/validateSession.ts (new, shared by HTTP + WS)
|
||||||
|
src/web/middleware/requireAuth.ts (new)
|
||||||
|
src/web/middleware/csrf.ts (new)
|
||||||
|
src/web/middleware/csrf.test.ts (new)
|
||||||
|
src/web/api/session.ts (new)
|
||||||
|
src/web/api/session.test.ts (new)
|
||||||
|
src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用)
|
||||||
|
src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式)
|
||||||
|
src/web/websocket.test.ts (新增 / 扩展)
|
||||||
|
package.json (deps)
|
||||||
|
|
||||||
|
web/src/views/Login.vue (new)
|
||||||
|
web/src/views/FirstRunSetup.vue (new)
|
||||||
|
web/src/composables/useSession.ts (new)
|
||||||
|
web/src/router/index.ts (公共路由 + beforeEach 守卫)
|
||||||
|
web/src/api/*.ts (credentials: 'same-origin' + 401 拦截)
|
||||||
|
web/src/App.vue (顶栏 logout + 当前用户名)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trade-offs / 已知妥协
|
||||||
|
|
||||||
|
1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。
|
||||||
|
2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。
|
||||||
|
3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。
|
||||||
|
4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。
|
||||||
|
5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Fine-grained account permissions — design
|
||||||
|
|
||||||
|
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) (item E — the maintainer's permission-management idea)
|
||||||
|
**Date:** 2026-05-30
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Issue #79 bundles five things. This spec covers **only item E**: allow an admin to
|
||||||
|
grant each non-admin (member) account a set of capabilities and a list of bots they
|
||||||
|
may control. The other items are handled in separate PRs and are **out of scope**
|
||||||
|
here:
|
||||||
|
|
||||||
|
- #1 Guest mode (login-less playback)
|
||||||
|
- #2 Dedicated-link hides other bots (subsumed conceptually by E's bot allow-list, but the link-specific UX is separate)
|
||||||
|
- #3 Auto-pause when channel empty
|
||||||
|
- #4 Dedicated link loses bot binding on refresh (a bug)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Today the bot has a coarse two-role system: `admin | member` (single `role` column,
|
||||||
|
read live per request). `requireAdmin` gates only `/api/users` and `/api/audit`.
|
||||||
|
**Every other action — create/edit/delete bots, start/stop, all playback & queue
|
||||||
|
control, set platform login cookies, set audio quality — is open to any logged-in
|
||||||
|
member, on every bot.** Admins want to delegate limited control to members without
|
||||||
|
handing them full power.
|
||||||
|
|
||||||
|
## Decisions (from brainstorm)
|
||||||
|
|
||||||
|
1. **Model = capability flags + per-member bot allow-list** (not a per-bot×per-action
|
||||||
|
matrix, not role templates).
|
||||||
|
2. **Defaults:** on upgrade, existing members are backfilled with full capabilities +
|
||||||
|
all bots (no behavior change); newly-created members get a **basic tier**.
|
||||||
|
3. **Bot allow-list semantics:** an explicit "all bots" toggle OR a specific list;
|
||||||
|
empty list = no bots controllable. Members **cannot see** bots outside their
|
||||||
|
allow-list (hidden, not merely disabled).
|
||||||
|
4. **Capability set (5 toggles)** — see below; basic tier = playback + queue + all bots.
|
||||||
|
5. **Admin is a super-user** (bypasses all checks). The last admin cannot be demoted
|
||||||
|
(existing invariant preserved). Permission grants/revokes are written to the
|
||||||
|
existing audit log.
|
||||||
|
|
||||||
|
## Capability taxonomy
|
||||||
|
|
||||||
|
| Capability token | Covers | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| `player.control` | play/pause/resume/next/prev/stop/seek/volume/mode | per-bot (allow-list) |
|
||||||
|
| `player.queue` | search-add / clear / remove / play-at / playlist / album / play-song | per-bot (allow-list) |
|
||||||
|
| `bot.manage` | create / edit / delete / start / stop / avatar / profile / idle settings | global (create) + per-bot (operate a specific bot) |
|
||||||
|
| `platform.auth` | set NetEase/QQ/Bilibili cookie, QR, SMS | **global** (shared credentials) |
|
||||||
|
| `quality` | set audio quality per platform | **global** |
|
||||||
|
|
||||||
|
Bot scope is independent of capabilities: a member with `player.control` can only
|
||||||
|
exercise it on bots in their allow-list (or all, if the "all bots" flag is set).
|
||||||
|
`platform.auth` and `quality` are global capabilities with no bot scope.
|
||||||
|
|
||||||
|
**Basic tier** (new members): `{ player.control, player.queue }` + `bots.all = true`.
|
||||||
|
A new member can play/queue on every bot but cannot manage bots, change credentials,
|
||||||
|
or change quality.
|
||||||
|
|
||||||
|
## Data model (SQLite, additive — follows existing `CREATE TABLE IF NOT EXISTS` pattern)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- capability tokens + the "all bots" flag (stored as token 'bots.all')
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (userId, permission),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- specific bot allow-list (only consulted when 'bots.all' is NOT present)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_bot_access (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
botId TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (userId, botId),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Admins have no rows (they bypass). Only members are constrained.
|
||||||
|
- `bots.all` present ⇒ all bots (incl. future ones). Absent ⇒ only `user_bot_access`
|
||||||
|
rows; empty ⇒ none.
|
||||||
|
- `foreign_keys = ON` and WAL are already enabled; cascade-on-user-delete works.
|
||||||
|
- `user_bot_access.botId` references bot instance ids; when a bot is deleted, its
|
||||||
|
access rows should be cleaned up (either an FK to the bot table if one exists, or an
|
||||||
|
explicit cleanup in `BotManager.removeBot` / `PermissionStore.pruneBot(botId)`).
|
||||||
|
|
||||||
|
New `PermissionStore` in `src/data/permissions.ts` (mirrors `createUserStore` /
|
||||||
|
`createSessionStore`: prepared statements + an interface). Methods:
|
||||||
|
`getCapabilities(userId)`, `getBotAccess(userId)` → `'all' | string[]`,
|
||||||
|
`setPermissions(userId, { capabilities, bots })`, `pruneBot(botId)`.
|
||||||
|
|
||||||
|
## Backend enforcement (real 403 — not just hidden UI)
|
||||||
|
|
||||||
|
- **`req.user` widened** to carry `capabilities: Set<string>` and bot access. Loaded in
|
||||||
|
`requireAuth` (one extra lookup, or a JOIN in the session query). The same
|
||||||
|
`{id,username,role,capabilities,bots}` shape must be kept in sync in the three places
|
||||||
|
it is built today: `requireAuth.ts`, `session.ts` `requireAuthInline`, and the WS
|
||||||
|
upgrade handler in `server.ts` (WS only needs it if a push action becomes gated).
|
||||||
|
Because it's read live, permission changes take effect immediately (no re-login).
|
||||||
|
- **`requirePermission(cap)`** middleware (new, mirrors `requireAdmin.ts`): 401 if no
|
||||||
|
user; allow if `role === 'admin'` or `capabilities.has(cap)`; else 403.
|
||||||
|
- **`requireBotAccess`** helper: allow if admin or `bots.all` or botId ∈ access list;
|
||||||
|
else 403. Mounted on the player router's existing `/:botId` choke-point
|
||||||
|
(`src/web/api/player.ts`) and on each `:id` route in `src/web/api/bot.ts`
|
||||||
|
(start/stop/edit/delete/avatar/profile).
|
||||||
|
- **Route → capability mapping:**
|
||||||
|
- `/api/player/:botId/*` playback actions → `player.control` (+ `requireBotAccess`)
|
||||||
|
- `/api/player/:botId/*` queue actions → `player.queue` (+ `requireBotAccess`)
|
||||||
|
- `/api/bot` create, `/api/bot/:id` edit/delete, `/api/bot/:id/start|stop|avatar|profile`, `/api/bot/settings` → `bot.manage` (+ `requireBotAccess` for the `:id` ones)
|
||||||
|
- `/api/auth/*` (cookie/QR/SMS) → `platform.auth`
|
||||||
|
- `/api/music/quality` POST → `quality`
|
||||||
|
- **`GET /api/bot`** filters its result to the caller's allowed bots for members
|
||||||
|
(admins see all). This is what "hides" disallowed bots in the UI.
|
||||||
|
|
||||||
|
## Management API (admin-only, added to the existing users router)
|
||||||
|
|
||||||
|
- `GET /api/users/:id/permissions` → `{ capabilities: string[], bots: 'all' | string[] }`
|
||||||
|
- `PUT /api/users/:id/permissions` → body `{ capabilities, bots }`; validates tokens
|
||||||
|
against the known set and botIds against existing bots; writes audit
|
||||||
|
`user.permissions_changed`.
|
||||||
|
- `GET /api/session/me` is extended to include the **current** user's
|
||||||
|
`{ capabilities, bots }` so the frontend can gate UI. (admins report effectively-all.)
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- `useSession` extends `User` with `capabilities` + bot scope and exposes
|
||||||
|
`can(cap)` and `canControlBot(botId)` helpers.
|
||||||
|
- **Navbar bot selector** filters `store.bots` to controllable bots (others hidden);
|
||||||
|
`activeBot` fallback and `fetchBots` default only ever land on an allowed bot.
|
||||||
|
- **Player / Settings** hide controls and whole sections a member lacks: platform
|
||||||
|
login, audio quality, and bot create/edit/delete are hidden without the matching
|
||||||
|
capability; playback/queue buttons hidden without `player.control` / `player.queue`.
|
||||||
|
- **Admin permission editor:** in the Settings → User Management list, each member row
|
||||||
|
gets a "权限" editor — capability checkboxes + a bot allow-list with an "全部机器人"
|
||||||
|
toggle. Saving calls `PUT /api/users/:id/permissions`.
|
||||||
|
|
||||||
|
## Defaults & migration
|
||||||
|
|
||||||
|
- New tables created idempotently in `initTables`.
|
||||||
|
- **One-time backfill** (guarded so it runs once): every existing `member` gets all
|
||||||
|
five capabilities + `bots.all`. Admins are skipped (they bypass). This preserves
|
||||||
|
current behavior for existing members on upgrade.
|
||||||
|
- **New member default** (`POST /api/users` with role member): capabilities
|
||||||
|
`{ player.control, player.queue }` + `bots.all` (basic tier).
|
||||||
|
- Pre-existing accounts default to `role = 'admin'` per the current schema — those are
|
||||||
|
super-users and unaffected.
|
||||||
|
|
||||||
|
## Testing (TDD)
|
||||||
|
|
||||||
|
- `PermissionStore` unit tests (set/get capabilities + bot access; `'all'` vs list vs
|
||||||
|
empty; `pruneBot`).
|
||||||
|
- `requirePermission` / `requireBotAccess` middleware tests (admin bypass; has/lacks
|
||||||
|
cap → 200/403; bot in/out of allow-list; `bots.all`).
|
||||||
|
- API tests: member without cap → 403; with cap → 200; bot not allowed → 403/hidden;
|
||||||
|
`GET /api/bot` filtered for members, full for admin; `PUT .../permissions` validates
|
||||||
|
+ audits.
|
||||||
|
- Migration test: existing members backfilled to full + `bots.all`; new member gets
|
||||||
|
basic tier.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No per-bot×per-capability matrix, no custom role templates (YAGNI).
|
||||||
|
- Guest mode, dedicated-link UX, auto-pause, and the refresh bug (#1–#4) are separate.
|
||||||
|
- No change to the admin/member role concept itself; this layers capabilities under
|
||||||
|
the existing `member` role.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Auto-pause on empty channel — design
|
||||||
|
|
||||||
|
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
|
||||||
|
**Date:** 2026-05-30
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
|
||||||
|
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
|
||||||
|
and resume when someone returns.
|
||||||
|
|
||||||
|
## Decisions (from brainstorm)
|
||||||
|
|
||||||
|
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
|
||||||
|
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
|
||||||
|
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
|
||||||
|
enter/leave/move events; keep the existing 30s idle poll as a fallback.
|
||||||
|
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
|
||||||
|
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
|
||||||
|
emptiness signal but act independently (pause immediately; disconnect after N minutes).
|
||||||
|
|
||||||
|
## Current state (verified)
|
||||||
|
|
||||||
|
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
|
||||||
|
bot*; callers compute "others" as `length - 1`. No persistent roster.
|
||||||
|
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
|
||||||
|
*logs* `clientEnter` and does not re-emit leave/moved.
|
||||||
|
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
|
||||||
|
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
|
||||||
|
idle-disconnect after `idleTimeoutMinutes`.
|
||||||
|
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
|
||||||
|
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
|
||||||
|
auto-vs-user-pause distinction today.
|
||||||
|
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Occupancy signal (shared)
|
||||||
|
Extract the idle poller's count into one method on `BotInstance`:
|
||||||
|
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
|
||||||
|
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
|
||||||
|
auto-pause logic below. It is called by:
|
||||||
|
1. the existing 30s poll (fallback), and
|
||||||
|
2. new TS event handlers.
|
||||||
|
|
||||||
|
### Event subscription
|
||||||
|
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
|
||||||
|
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
|
||||||
|
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
|
||||||
|
the poll remains as a safety net.
|
||||||
|
|
||||||
|
### Auto-pause logic (inside `checkChannelOccupancy`)
|
||||||
|
Add a private `autoPaused = false` flag to `BotInstance`.
|
||||||
|
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
|
||||||
|
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
|
||||||
|
scheduled as today.)
|
||||||
|
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
|
||||||
|
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
|
||||||
|
|
||||||
|
### `autoPaused` bookkeeping (so user pauses are respected)
|
||||||
|
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
|
||||||
|
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
|
||||||
|
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
|
||||||
|
|
||||||
|
### Config wiring
|
||||||
|
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
|
||||||
|
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
|
||||||
|
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
|
||||||
|
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
|
||||||
|
can be as simple as updating the stored config reference / a field the check reads.
|
||||||
|
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
|
||||||
|
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
|
||||||
|
it on save.
|
||||||
|
|
||||||
|
## Components / files
|
||||||
|
|
||||||
|
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
|
||||||
|
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
|
||||||
|
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
|
||||||
|
connect/disconnect; `updateAutoPause(enabled)`.
|
||||||
|
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
|
||||||
|
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
|
||||||
|
- `src/data/config.ts` — field already exists (no change beyond confirming default).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
|
||||||
|
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
|
||||||
|
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
|
||||||
|
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
|
||||||
|
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
|
||||||
|
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
|
||||||
|
persists, propagates).
|
||||||
|
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
|
||||||
|
- Reaction relies on events the bot can already see (same-channel members are always in view);
|
||||||
|
no extra channel subscription needed.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Dedicated-link bot scoping (+ refresh fix) — design
|
||||||
|
|
||||||
|
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
|
||||||
|
**Date:** 2026-05-30
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
|
||||||
|
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
|
||||||
|
|
||||||
|
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
|
||||||
|
|
||||||
|
## Decision (from brainstorm: Q2 = URL-carried scope)
|
||||||
|
|
||||||
|
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Scope state (store)
|
||||||
|
Add to the player store:
|
||||||
|
- `scopedBotId: string | null` — the bot the UI is locked to.
|
||||||
|
- getter `isScoped` = `scopedBotId !== null`.
|
||||||
|
- action `setScope(id)` / `clearScope()`.
|
||||||
|
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
|
||||||
|
|
||||||
|
### URL as the durable source of truth
|
||||||
|
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
|
||||||
|
- **Router `beforeEach` guard** (the heart of it):
|
||||||
|
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
|
||||||
|
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
|
||||||
|
- Else → no scope; continue.
|
||||||
|
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
|
||||||
|
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
|
||||||
|
|
||||||
|
### Exit
|
||||||
|
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
|
||||||
|
|
||||||
|
### Navbar (the lock UI)
|
||||||
|
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
|
||||||
|
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
|
||||||
|
- When not scoped: unchanged (full selector over `controllableBots`).
|
||||||
|
|
||||||
|
### Active-bot coherence
|
||||||
|
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
|
||||||
|
|
||||||
|
## Components / files
|
||||||
|
|
||||||
|
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
|
||||||
|
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
|
||||||
|
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
|
||||||
|
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
|
||||||
|
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
|
||||||
|
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
|
||||||
|
- `clearScope` resets state.
|
||||||
|
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
|
||||||
|
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
|
||||||
Generated
+218
@@ -14,8 +14,10 @@
|
|||||||
"@koa/router": "^15.4.0",
|
"@koa/router": "^15.4.0",
|
||||||
"@sansenjian/qq-music-api": "^2.2.10",
|
"@sansenjian/qq-music-api": "^2.2.10",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"koa": "^3.2.0",
|
"koa": "^3.2.0",
|
||||||
@@ -29,10 +31,14 @@
|
|||||||
"yt-dlp-wrap": "^2.3.12"
|
"yt-dlp-wrap": "^2.3.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
@@ -667,6 +673,29 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
@@ -1152,6 +1181,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
@@ -1194,6 +1230,23 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -1240,6 +1293,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.0",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
@@ -1285,6 +1345,30 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -1501,6 +1585,13 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/asn1": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
@@ -1608,6 +1699,12 @@
|
|||||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||||
"license": "Unlicense"
|
"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": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.8.0",
|
"version": "12.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
||||||
@@ -2011,6 +2108,16 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -2076,6 +2183,25 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
@@ -2085,6 +2211,13 @@
|
|||||||
"node": ">=6.6.0"
|
"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": {
|
"node_modules/cookies": {
|
||||||
"version": "0.9.1",
|
"version": "0.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
|
||||||
@@ -2265,6 +2398,17 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dijkstrajs": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
@@ -2596,6 +2740,13 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -2744,6 +2895,24 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -5671,6 +5840,55 @@
|
|||||||
"url": "https://github.com/sponsors/Borewit"
|
"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": {
|
"node_modules/tar": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
"@koa/router": "^15.4.0",
|
"@koa/router": "^15.4.0",
|
||||||
"@sansenjian/qq-music-api": "^2.2.10",
|
"@sansenjian/qq-music-api": "^2.2.10",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"koa": "^3.2.0",
|
"koa": "^3.2.0",
|
||||||
@@ -34,10 +36,14 @@
|
|||||||
"yt-dlp-wrap": "^2.3.12"
|
"yt-dlp-wrap": "^2.3.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
|
|||||||
Executable
+161
@@ -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
@@ -1,321 +1,294 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
title TSMusicBot Setup
|
title TSMusicBot Setup
|
||||||
|
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
:: TSMusicBot Setup Script (Robust Edition)
|
:: TSMusicBot Setup Script (Windows)
|
||||||
:: - Auto-detect China network, switch to npmmirror
|
:: - Auto-detect China network, switch to npmmirror
|
||||||
:: - Strict error checking at every step
|
:: - Download native binaries from CDN (避开 GitHub)
|
||||||
:: - Detailed logging to setup.log
|
:: - 自动修复 PowerShell 环境变量
|
||||||
:: - Skip already-completed steps on re-run
|
:: ============================================================
|
||||||
:: ============================================================
|
|
||||||
|
set "SCRIPT_VERSION=2.1"
|
||||||
set "SCRIPT_VERSION=2.0"
|
set "MIN_NODE_MAJOR=20"
|
||||||
set "MIN_NODE_MAJOR=20"
|
set "LOG_FILE=%~dp0..\setup.log"
|
||||||
set "LOG_FILE=%~dp0..\setup.log"
|
set "FAILED=0"
|
||||||
set "FAILED=0"
|
|
||||||
|
:: Resolve project root (one level up from scripts/)
|
||||||
:: Resolve project root (one level up from scripts/)
|
cd /d "%~dp0.." || (
|
||||||
cd /d "%~dp0.." || (
|
echo [FATAL] Cannot change to project directory.
|
||||||
echo [FATAL] Cannot change to project directory.
|
pause
|
||||||
pause
|
exit /b 1
|
||||||
exit /b 1
|
)
|
||||||
)
|
|
||||||
|
set "PROJECT_ROOT=%cd%"
|
||||||
set "PROJECT_ROOT=%cd%"
|
|
||||||
|
:: ---- Initialize log ----
|
||||||
:: ---- Initialize log ----
|
echo. > "%LOG_FILE%"
|
||||||
echo. > "%LOG_FILE%"
|
call :log "============================================"
|
||||||
call :log "============================================"
|
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
|
||||||
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
|
call :log " Started: %date% %time%"
|
||||||
call :log " Started: %date% %time%"
|
call :log " Project root: %PROJECT_ROOT%"
|
||||||
call :log " Project root: %PROJECT_ROOT%"
|
call :log "============================================"
|
||||||
call :log "============================================"
|
|
||||||
|
echo ============================================
|
||||||
echo ============================================
|
echo TSMusicBot - First-Time Setup (Windows)
|
||||||
echo TSMusicBot - First-Time Setup (Windows)
|
echo Version %SCRIPT_VERSION%
|
||||||
echo Version %SCRIPT_VERSION%
|
echo ============================================
|
||||||
echo ============================================
|
echo.
|
||||||
echo.
|
echo Log file: %LOG_FILE%
|
||||||
echo Log file: %LOG_FILE%
|
echo.
|
||||||
echo.
|
|
||||||
|
:: ============================================================
|
||||||
:: ============================================================
|
:: Step 1: Check Node.js
|
||||||
:: Step 1: Check Node.js
|
:: ============================================================
|
||||||
:: ============================================================
|
call :step "1/7" "Checking Node.js"
|
||||||
call :step "1/6" "Checking Node.js"
|
|
||||||
|
where node >nul 2>&1
|
||||||
where node >nul 2>&1
|
if not errorlevel 1 goto :check_node_version
|
||||||
if errorlevel 1 (
|
|
||||||
call :error "Node.js not found in PATH."
|
call :error "Node.js not found in PATH."
|
||||||
echo.
|
echo.
|
||||||
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from one of:
|
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
|
||||||
echo - https://nodejs.org/ ^(official^)
|
echo https://nodejs.org/ (official)
|
||||||
echo - https://nodejs.cn/ ^(China mirror, recommended for CN users^)
|
echo https://nodejs.cn/ (China mirror, recommended)
|
||||||
echo.
|
echo.
|
||||||
echo After installation:
|
pause
|
||||||
echo 1. Close this window completely
|
exit /b 1
|
||||||
echo 2. Open a NEW Command Prompt
|
|
||||||
echo 3. Run scripts\setup.bat again
|
:check_node_version
|
||||||
echo.
|
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
|
||||||
pause
|
for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
|
||||||
exit /b 1
|
|
||||||
)
|
call :log "Node.js version: %NODE_VER%"
|
||||||
|
echo [OK] Node.js found: %NODE_VER%
|
||||||
:: Check Node version >= 20
|
|
||||||
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do set "NODE_RAW=%%a"
|
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
|
||||||
for /f "tokens=1 delims=v." %%a in ('node --version 2^>nul') do (
|
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
|
||||||
for /f "tokens=1 delims=." %%b in ("%%a") do set "NODE_MAJOR=%%b"
|
pause
|
||||||
)
|
exit /b 1
|
||||||
|
)
|
||||||
:: Robust version parse
|
echo.
|
||||||
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"
|
:: Step 2: Check npm
|
||||||
|
:: ============================================================
|
||||||
call :log "Node.js version: %NODE_VER%"
|
call :step "2/7" "Checking npm"
|
||||||
echo [OK] Node.js found: %NODE_VER%
|
|
||||||
|
where npm >nul 2>&1
|
||||||
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
|
if errorlevel 1 (
|
||||||
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
|
call :error "npm not found."
|
||||||
echo Please upgrade Node.js to version %MIN_NODE_MAJOR% LTS or newer.
|
pause
|
||||||
pause
|
exit /b 1
|
||||||
exit /b 1
|
)
|
||||||
)
|
|
||||||
echo.
|
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%
|
||||||
:: Step 2: Check npm
|
echo.
|
||||||
:: ============================================================
|
|
||||||
call :step "2/6" "Checking npm"
|
:: ============================================================
|
||||||
|
:: Step 3: Detect network and configure mirror
|
||||||
where npm >nul 2>&1
|
:: ============================================================
|
||||||
if errorlevel 1 (
|
call :step "3/7" "Checking network"
|
||||||
call :error "npm not found. This is unusual since Node.js is installed."
|
|
||||||
echo Please reinstall Node.js to fix this.
|
set "USE_MIRROR=0"
|
||||||
pause
|
set "MIRROR_REGISTRY=https://registry.npmjs.org"
|
||||||
exit /b 1
|
|
||||||
)
|
echo Testing connection to npm registry...
|
||||||
|
call :log "Testing npm registry connectivity..."
|
||||||
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
|
|
||||||
call :log "npm version: %NPM_VER%"
|
ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
|
||||||
echo [OK] npm found: %NPM_VER%
|
if errorlevel 1 (
|
||||||
echo.
|
echo [WARN] Cannot reach npm registry quickly, using China mirror.
|
||||||
|
call :log "npm registry unreachable via ping"
|
||||||
:: ============================================================
|
set "USE_MIRROR=1"
|
||||||
:: Step 3: Detect network and configure mirror
|
) else (
|
||||||
:: ============================================================
|
echo [OK] npm registry reachable.
|
||||||
call :step "3/6" "Checking network"
|
call :log "npm registry reachable"
|
||||||
|
)
|
||||||
set "USE_MIRROR=0"
|
|
||||||
|
if "%USE_MIRROR%"=="1" (
|
||||||
:: Try reaching npm registry with a short timeout
|
echo.
|
||||||
echo Testing connection to registry.npmjs.org...
|
echo [INFO] Using China mirror (npmmirror.com)
|
||||||
call :log "Testing npm registry connectivity..."
|
call :log "Switching to npmmirror.com"
|
||||||
|
set "MIRROR_REGISTRY=https://registry.npmmirror.com"
|
||||||
:: Use curl if available (more reliable than ping for HTTPS)
|
set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
|
||||||
where curl >nul 2>&1
|
) else (
|
||||||
if not errorlevel 1 (
|
set "CDN_MIRROR="
|
||||||
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"
|
echo.
|
||||||
del "%TEMP%\npmtest.txt" >nul 2>&1
|
|
||||||
if "!HTTP_CODE!"=="200" (
|
:: ============================================================
|
||||||
echo [OK] npm registry reachable.
|
:: Step 4: Install backend dependencies (跳过二进制)
|
||||||
call :log "npm registry HTTP 200 OK"
|
:: ============================================================
|
||||||
) else (
|
call :step "4/7" "Installing backend dependencies"
|
||||||
echo [WARN] npm registry slow or unreachable ^(code: !HTTP_CODE!^).
|
|
||||||
call :log "npm registry returned: !HTTP_CODE!"
|
if exist "node_modules\.package-lock.json" (
|
||||||
set "USE_MIRROR=1"
|
echo Found existing node_modules. Checking integrity...
|
||||||
)
|
)
|
||||||
) else (
|
|
||||||
:: Fallback to ping
|
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
|
||||||
ping -n 1 -w 3000 registry.npmjs.org >nul 2>&1
|
echo.
|
||||||
if errorlevel 1 (
|
|
||||||
echo [WARN] Cannot reach npm registry quickly.
|
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
|
||||||
set "USE_MIRROR=1"
|
if errorlevel 1 (
|
||||||
) else (
|
call :error "Backend npm install failed."
|
||||||
echo [OK] npm registry reachable.
|
echo Check the log: %LOG_FILE%
|
||||||
)
|
pause
|
||||||
)
|
exit /b 1
|
||||||
|
)
|
||||||
if "%USE_MIRROR%"=="1" (
|
echo [OK] Backend dependencies installed.
|
||||||
echo.
|
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
|
:: Step 4b: Download native binaries from CDN
|
||||||
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 :step "4b/7" "Downloading native binaries"
|
||||||
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
|
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
|
||||||
call npm config set sharp_binary_host https://registry.npmmirror.com/-/binary/sharp >>"%LOG_FILE%" 2>&1
|
if errorlevel 1 (
|
||||||
call npm config set sharp_libvips_binary_host https://registry.npmmirror.com/-/binary/sharp-libvips >>"%LOG_FILE%" 2>&1
|
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
|
||||||
call npm config set FFMPEG_BINARIES_URL https://registry.npmmirror.com/-/binary/ffmpeg-static >>"%LOG_FILE%" 2>&1
|
) else (
|
||||||
call npm config set @discordjs:registry https://registry.npmmirror.com >>"%LOG_FILE%" 2>&1
|
echo [OK] Native binaries installed.
|
||||||
echo [OK] Mirror configured.
|
)
|
||||||
)
|
echo.
|
||||||
echo.
|
|
||||||
|
:: ============================================================
|
||||||
:: ============================================================
|
:: Step 5: Install frontend dependencies
|
||||||
:: Step 4: Install backend dependencies
|
:: ============================================================
|
||||||
:: ============================================================
|
call :step "5/7" "Installing frontend dependencies"
|
||||||
call :step "4/6" "Installing backend dependencies"
|
|
||||||
|
if not exist "web\package.json" (
|
||||||
if exist "node_modules\.package-lock.json" (
|
call :error "web\package.json not found."
|
||||||
echo Found existing node_modules. Checking integrity...
|
pause
|
||||||
call :log "Existing node_modules detected, running npm install to verify"
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
echo Running: npm install ^(this can take 5-15 minutes on slow networks^)
|
echo Running: npm install (in web/)
|
||||||
echo Press Ctrl+C to abort.
|
echo.
|
||||||
echo.
|
|
||||||
|
pushd web >nul
|
||||||
call npm install >>"%LOG_FILE%" 2>&1
|
call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
|
||||||
if errorlevel 1 (
|
set "WEB_INSTALL_RESULT=!errorlevel!"
|
||||||
call :error "Backend npm install failed."
|
popd >nul
|
||||||
echo.
|
|
||||||
echo Common causes:
|
if !WEB_INSTALL_RESULT! neq 0 (
|
||||||
echo - Network timeout ^(retry with VPN or check %LOG_FILE%^)
|
call :error "Frontend npm install failed."
|
||||||
echo - Native module compile failure ^(missing Python/VS Build Tools^)
|
pause
|
||||||
echo - Disk space full
|
exit /b 1
|
||||||
echo.
|
)
|
||||||
echo Try manually:
|
echo [OK] Frontend dependencies installed.
|
||||||
echo cd /d "%PROJECT_ROOT%"
|
echo.
|
||||||
echo npm install --verbose
|
|
||||||
echo.
|
:: ============================================================
|
||||||
pause
|
:: Step 6: Build project
|
||||||
exit /b 1
|
:: ============================================================
|
||||||
)
|
call :step "6/7" "Building project"
|
||||||
echo [OK] Backend dependencies installed.
|
|
||||||
echo.
|
echo Running: npm run build
|
||||||
|
echo.
|
||||||
:: ============================================================
|
|
||||||
:: Step 5: Install frontend dependencies
|
call npm run build >>"%LOG_FILE%" 2>&1
|
||||||
:: ============================================================
|
if errorlevel 1 (
|
||||||
call :step "5/6" "Installing frontend dependencies"
|
call :error "Build failed. Check: %LOG_FILE%"
|
||||||
|
pause
|
||||||
if not exist "web\package.json" (
|
exit /b 1
|
||||||
call :error "web\package.json not found. Repository may be incomplete."
|
)
|
||||||
echo Please re-clone the repository:
|
echo [OK] Build succeeded.
|
||||||
echo git clone https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
|
echo.
|
||||||
pause
|
|
||||||
exit /b 1
|
:: ============================================================
|
||||||
)
|
:: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
|
||||||
|
:: ============================================================
|
||||||
echo Running: npm install ^(in web/ directory^)
|
call :step "7/7" "Checking PowerShell PATH"
|
||||||
echo.
|
|
||||||
|
where powershell >nul 2>&1
|
||||||
pushd web >nul
|
if errorlevel 1 (
|
||||||
call npm install >>"%LOG_FILE%" 2>&1
|
echo [WARN] PowerShell not found in PATH.
|
||||||
set "WEB_INSTALL_RESULT=!errorlevel!"
|
echo Attempting to fix...
|
||||||
popd >nul
|
set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
|
||||||
|
if exist "!POWERSHELL_PATH!\powershell.exe" (
|
||||||
if !WEB_INSTALL_RESULT! neq 0 (
|
:: 为用户添加永久 PATH 环境变量
|
||||||
call :error "Frontend npm install failed ^(exit code !WEB_INSTALL_RESULT!^)."
|
echo [INFO] Adding PowerShell to user PATH...
|
||||||
echo.
|
call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
|
||||||
echo Try manually:
|
echo [OK] PowerShell added to PATH. Please restart your terminal.
|
||||||
echo cd /d "%PROJECT_ROOT%\web"
|
) else (
|
||||||
echo npm install --verbose
|
echo [WARN] Could not find powershell.exe on this system.
|
||||||
echo.
|
echo If you encounter playback issues with some NetEase songs,
|
||||||
pause
|
echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
|
||||||
exit /b 1
|
echo before running scripts\start.bat
|
||||||
)
|
)
|
||||||
echo [OK] Frontend dependencies installed.
|
) else (
|
||||||
echo.
|
echo [OK] PowerShell found in PATH.
|
||||||
|
)
|
||||||
:: ============================================================
|
echo.
|
||||||
:: Step 6: Build project (backend + frontend)
|
|
||||||
:: ============================================================
|
:: ============================================================
|
||||||
call :step "6/6" "Building project"
|
:: Verify build outputs
|
||||||
|
:: ============================================================
|
||||||
echo Running: npm run build
|
echo Verifying build outputs...
|
||||||
echo.
|
set "BUILD_OK=1"
|
||||||
|
|
||||||
call npm run build >>"%LOG_FILE%" 2>&1
|
if not exist "dist" (
|
||||||
if errorlevel 1 (
|
call :error "dist/ directory missing after build."
|
||||||
call :error "Build failed."
|
set "BUILD_OK=0"
|
||||||
echo.
|
)
|
||||||
echo Check the log file for details: %LOG_FILE%
|
if not exist "web\dist" (
|
||||||
echo.
|
call :error "web\dist/ directory missing after build."
|
||||||
echo Try manually:
|
set "BUILD_OK=0"
|
||||||
echo cd /d "%PROJECT_ROOT%"
|
)
|
||||||
echo npm run build
|
|
||||||
echo.
|
if "!BUILD_OK!"=="0" (
|
||||||
pause
|
echo Build completed but expected output is missing.
|
||||||
exit /b 1
|
pause
|
||||||
)
|
exit /b 1
|
||||||
echo [OK] Build succeeded.
|
)
|
||||||
echo.
|
echo [OK] Build outputs verified.
|
||||||
|
echo.
|
||||||
:: ============================================================
|
|
||||||
:: Verify build outputs
|
if not exist "config.json" (
|
||||||
:: ============================================================
|
echo [INFO] config.json will be auto-generated on first launch.
|
||||||
echo Verifying build outputs...
|
) else (
|
||||||
set "BUILD_OK=1"
|
echo [OK] config.json already exists.
|
||||||
|
)
|
||||||
if not exist "dist" (
|
echo.
|
||||||
call :error "dist/ directory missing after build."
|
|
||||||
set "BUILD_OK=0"
|
:: ============================================================
|
||||||
)
|
:: Done
|
||||||
if not exist "web\dist" (
|
:: ============================================================
|
||||||
call :error "web\dist/ directory missing after build."
|
call :log "Setup completed successfully at %date% %time%"
|
||||||
set "BUILD_OK=0"
|
|
||||||
)
|
echo ============================================
|
||||||
|
echo Setup Complete!
|
||||||
if "!BUILD_OK!"=="0" (
|
echo ============================================
|
||||||
echo.
|
echo.
|
||||||
echo Build completed but expected output is missing.
|
echo Next steps:
|
||||||
echo Check %LOG_FILE% for details.
|
echo 1. Run: scripts\start.bat
|
||||||
pause
|
echo 2. Open: http://localhost:3000
|
||||||
exit /b 1
|
echo.
|
||||||
)
|
echo Setup log: %LOG_FILE%
|
||||||
echo [OK] Build outputs verified.
|
echo.
|
||||||
echo.
|
pause
|
||||||
|
exit /b 0
|
||||||
:: ============================================================
|
|
||||||
:: Optional: config.json hint
|
:: ============================================================
|
||||||
:: ============================================================
|
:: Subroutines
|
||||||
if not exist "config.json" (
|
:: ============================================================
|
||||||
echo [INFO] config.json will be auto-generated on first launch.
|
:step
|
||||||
) else (
|
echo ---- Step %~1: %~2 ----
|
||||||
echo [OK] config.json already exists.
|
call :log ""
|
||||||
)
|
call :log "---- Step %~1: %~2 ----"
|
||||||
echo.
|
goto :eof
|
||||||
|
|
||||||
:: ============================================================
|
:error
|
||||||
:: Done
|
echo.
|
||||||
:: ============================================================
|
echo [ERROR] %~1
|
||||||
call :log "Setup completed successfully at %date% %time%"
|
call :log "[ERROR] %~1"
|
||||||
|
goto :eof
|
||||||
echo ============================================
|
|
||||||
echo Setup Complete!
|
:log
|
||||||
echo ============================================
|
echo [%time%] %~1 >> "%LOG_FILE%"
|
||||||
echo.
|
goto :eof
|
||||||
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
|
|
||||||
Executable
+145
@@ -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
@@ -1,47 +1,44 @@
|
|||||||
@echo off
|
@echo off
|
||||||
title TSMusicBot
|
title TSMusicBot
|
||||||
echo Starting TSMusicBot...
|
echo Starting TSMusicBot...
|
||||||
echo.
|
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
|
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir } from "./player.js";
|
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
|
||||||
|
|
||||||
function getHeadersArg(args: string[]): string {
|
function getHeadersArg(args: string[]): string {
|
||||||
const idx = args.indexOf("-headers");
|
const idx = args.indexOf("-headers");
|
||||||
@@ -45,6 +45,14 @@ describe("buildFfmpegArgs", () => {
|
|||||||
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
|
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => {
|
||||||
|
const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0);
|
||||||
|
const idx = args.indexOf("-reconnect_at_eof");
|
||||||
|
expect(idx).toBeGreaterThan(-1);
|
||||||
|
expect(args[idx + 1]).toBe("1");
|
||||||
|
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
|
||||||
|
});
|
||||||
|
|
||||||
it("inserts -ss before -i when seekSeconds > 0", () => {
|
it("inserts -ss before -i when seekSeconds > 0", () => {
|
||||||
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
|
||||||
const ssIdx = args.indexOf("-ss");
|
const ssIdx = args.indexOf("-ss");
|
||||||
@@ -62,6 +70,7 @@ describe("buildFfmpegArgs", () => {
|
|||||||
it("omits HTTP-only flags when input is a local file path", () => {
|
it("omits HTTP-only flags when input is a local file path", () => {
|
||||||
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
|
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
|
||||||
expect(args).not.toContain("-reconnect");
|
expect(args).not.toContain("-reconnect");
|
||||||
|
expect(args).not.toContain("-reconnect_at_eof");
|
||||||
expect(args).not.toContain("-reconnect_on_network_error");
|
expect(args).not.toContain("-reconnect_on_network_error");
|
||||||
expect(args).not.toContain("-reconnect_on_http_error");
|
expect(args).not.toContain("-reconnect_on_http_error");
|
||||||
expect(args).not.toContain("-headers");
|
expect(args).not.toContain("-headers");
|
||||||
@@ -80,6 +89,37 @@ describe("buildFfmpegArgs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("volumeToFactor (#84 smooth volume curve)", () => {
|
||||||
|
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
|
||||||
|
expect(volumeToFactor(0)).toBe(0);
|
||||||
|
expect(volumeToFactor(100)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps out-of-range input", () => {
|
||||||
|
expect(volumeToFactor(-20)).toBe(0);
|
||||||
|
expect(volumeToFactor(150)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is strictly monotonic across the whole range (no dead zone)", () => {
|
||||||
|
for (let v = 0; v < 100; v++) {
|
||||||
|
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the old flat 80-99 dead zone", () => {
|
||||||
|
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
|
||||||
|
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
|
||||||
|
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the low range gentle", () => {
|
||||||
|
expect(volumeToFactor(50)).toBeLessThan(0.12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("shouldUsePowerShellDownload", () => {
|
describe("shouldUsePowerShellDownload", () => {
|
||||||
const jdymusicUrl =
|
const jdymusicUrl =
|
||||||
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
|
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
|
||||||
@@ -133,3 +173,30 @@ describe("cleanupTempDir", () => {
|
|||||||
expect(() => cleanupTempDir(dir)).not.toThrow();
|
expect(() => cleanupTempDir(dir)).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
|
||||||
|
const MAX_EMPTY = 250; // ~5s near-end threshold
|
||||||
|
const MAX_STALL = 3000; // ~60s far-from-end watchdog
|
||||||
|
|
||||||
|
it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => {
|
||||||
|
expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||||
|
expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => {
|
||||||
|
// This is the core regression: a brief underrun mid-song must not end the track.
|
||||||
|
expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => {
|
||||||
|
// The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence.
|
||||||
|
expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||||
|
expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never ends before any threshold", () => {
|
||||||
|
expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+124
-6
@@ -91,6 +91,11 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
args.push(
|
args.push(
|
||||||
"-reconnect", "1",
|
"-reconnect", "1",
|
||||||
|
// Long B站 streams sit on a CDN whose session/token can close the
|
||||||
|
// connection mid-file (premature EOF). Without this, FFmpeg treats that
|
||||||
|
// EOF as end-of-input and stops ~partway through (see #89); with it, it
|
||||||
|
// re-issues a Range request from the current offset to finish the stream.
|
||||||
|
"-reconnect_at_eof", "1",
|
||||||
"-reconnect_streamed", "1",
|
"-reconnect_streamed", "1",
|
||||||
"-reconnect_delay_max", "30",
|
"-reconnect_delay_max", "30",
|
||||||
"-reconnect_on_network_error", "1",
|
"-reconnect_on_network_error", "1",
|
||||||
@@ -103,6 +108,43 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to end the current track when FFmpeg is still alive but has
|
||||||
|
* produced no decodable audio for `emptyAttempts` consecutive frame ticks.
|
||||||
|
*
|
||||||
|
* - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF.
|
||||||
|
* - Far from the end we wait much longer (`maxStallAttempts`) before giving up,
|
||||||
|
* so a transient buffer underrun on a healthy stream does NOT cause a false
|
||||||
|
* skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session
|
||||||
|
* expired mid-playback, #89) still recovers by advancing instead of going
|
||||||
|
* permanently silent.
|
||||||
|
*/
|
||||||
|
export function shouldEndOnStall(
|
||||||
|
emptyAttempts: number,
|
||||||
|
isNearEnd: boolean,
|
||||||
|
maxEmptyAttempts: number,
|
||||||
|
maxStallAttempts: number,
|
||||||
|
): boolean {
|
||||||
|
if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true;
|
||||||
|
if (emptyAttempts >= maxStallAttempts) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
|
||||||
|
*
|
||||||
|
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
|
||||||
|
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
|
||||||
|
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
|
||||||
|
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
|
||||||
|
* the low end gentle but ramps smoothly toward full loudness near the top, so the
|
||||||
|
* slider feels proportional with no dead zone and no discontinuity at 100.
|
||||||
|
*/
|
||||||
|
export function volumeToFactor(volume: number): number {
|
||||||
|
const x = Math.max(0, Math.min(100, volume)) / 100;
|
||||||
|
return 0.2 * x + 0.8 * Math.pow(x, 8);
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerEvents {
|
export interface PlayerEvents {
|
||||||
frame: (opusFrame: Buffer) => void;
|
frame: (opusFrame: Buffer) => void;
|
||||||
trackEnd: () => void;
|
trackEnd: () => void;
|
||||||
@@ -136,6 +178,14 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||||
private downloader: ChildProcess | null = null;
|
private downloader: ChildProcess | null = null;
|
||||||
private currentTempDir: string | null = null;
|
private currentTempDir: string | null = null;
|
||||||
|
private emptyFrameAttempts = 0;
|
||||||
|
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
|
||||||
|
// Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio
|
||||||
|
// for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream
|
||||||
|
// as dead and advance instead of staying silent forever. Set high so a normal
|
||||||
|
// transient underrun never trips it.
|
||||||
|
private static readonly MAX_STALL_ATTEMPTS = 3000;
|
||||||
|
private currentSongDuration = 0; // 当前歌曲总时长(秒)
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -143,7 +193,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
play(url: string, seekSeconds = 0): void {
|
play(url: string, seekSeconds = 0, songDuration = 0): void {
|
||||||
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
||||||
@@ -154,6 +204,8 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.healthyFrames = 0;
|
this.healthyFrames = 0;
|
||||||
this.ffmpegPaused = false;
|
this.ffmpegPaused = false;
|
||||||
this.spawnFailed = false;
|
this.spawnFailed = false;
|
||||||
|
this.emptyFrameAttempts = 0;
|
||||||
|
this.currentSongDuration = songDuration;
|
||||||
|
|
||||||
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
||||||
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
||||||
@@ -183,7 +235,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
if (this.sessionId !== currentSessionId) {
|
if (this.sessionId !== currentSessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||||
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
|
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
|
||||||
this.ffmpeg.stdout.pause();
|
this.ffmpeg.stdout.pause();
|
||||||
@@ -279,8 +331,12 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.emit("error", err);
|
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.state = "playing";
|
||||||
this.startFrameLoop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
|
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
|
||||||
@@ -329,6 +385,9 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.emit("error", err);
|
this.emit("error", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Now that ffmpeg is producing PCM, run the frame loop.
|
||||||
|
this.startFrameLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
@@ -413,6 +472,60 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
if (this.state === "playing") this.sendNextFrame();
|
if (this.state === "playing") this.sendNextFrame();
|
||||||
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
||||||
|
|
||||||
|
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
|
||||||
|
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
|
||||||
|
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
|
||||||
|
const elapsed = this.getElapsed();
|
||||||
|
const isNearEnd = this.currentSongDuration > 0
|
||||||
|
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
|
||||||
|
: true; // 未知时长时保守处理
|
||||||
|
|
||||||
|
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||||
|
this.emptyFrameAttempts++;
|
||||||
|
|
||||||
|
// End the track when FFmpeg has gone silent: quickly if we're near the
|
||||||
|
// end (normal EOF), or after a much longer stall window if we're not
|
||||||
|
// (a dead/expired stream — #89 — so playback recovers instead of going
|
||||||
|
// permanently silent).
|
||||||
|
if (
|
||||||
|
shouldEndOnStall(
|
||||||
|
this.emptyFrameAttempts,
|
||||||
|
isNearEnd,
|
||||||
|
AudioPlayer.MAX_EMPTY_ATTEMPTS,
|
||||||
|
AudioPlayer.MAX_STALL_ATTEMPTS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.logger.info({
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
emptyAttempts: this.emptyFrameAttempts,
|
||||||
|
bufferSize: this.pcmBuffer.length,
|
||||||
|
elapsed: Math.round(elapsed),
|
||||||
|
duration: this.currentSongDuration,
|
||||||
|
remaining: Math.round(this.currentSongDuration - elapsed),
|
||||||
|
nearEnd: isNearEnd,
|
||||||
|
}, "FFmpeg stopped outputting data, ending track");
|
||||||
|
this.frameLoopRunning = false;
|
||||||
|
if (this.state !== "idle") {
|
||||||
|
this.state = "idle";
|
||||||
|
// 清理FFmpeg进程
|
||||||
|
if (this.ffmpeg) {
|
||||||
|
const procToKill = this.ffmpeg;
|
||||||
|
const pidToKill = procToKill.pid;
|
||||||
|
this.ffmpeg = null;
|
||||||
|
if (pidToKill) {
|
||||||
|
this.forceCleanup(procToKill, pidToKill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
this.emit("trackEnd");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 成功获取数据或FFmpeg已结束,重置计数器
|
||||||
|
this.emptyFrameAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||||
this.frameLoopRunning = false;
|
this.frameLoopRunning = false;
|
||||||
if (this.state !== "idle") {
|
if (this.state !== "idle") {
|
||||||
@@ -454,8 +567,9 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyVolume(pcm: Buffer): Buffer {
|
private applyVolume(pcm: Buffer): Buffer {
|
||||||
if (this.volume === 100) return Buffer.from(pcm);
|
const factor = volumeToFactor(this.volume);
|
||||||
const factor = (this.volume / 100) * 0.2;
|
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
|
||||||
|
if (factor >= 1) return Buffer.from(pcm);
|
||||||
const out = Buffer.alloc(pcm.length);
|
const out = Buffer.alloc(pcm.length);
|
||||||
for (let i = 0; i < pcm.length; i += 2) {
|
for (let i = 0; i < pcm.length; i += 2) {
|
||||||
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
let sample = Math.round(pcm.readInt16LE(i) * factor);
|
||||||
@@ -465,7 +579,11 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
|
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"; }
|
pause(): void { if (this.state === "playing") this.state = "paused"; }
|
||||||
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
|
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
|
||||||
resetFailures(): void { this.consecutiveFailures = 0; }
|
resetFailures(): void { this.consecutiveFailures = 0; }
|
||||||
|
|||||||
@@ -484,4 +484,84 @@ describe("PlayQueue", () => {
|
|||||||
expect(promoted?.id).toBe("x");
|
expect(promoted?.id).toBe("x");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
|
||||||
|
// songs repeated often while others were starved. It should behave like a
|
||||||
|
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
|
||||||
|
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
|
||||||
|
describe("random-loop shuffle bag (issue #70)", () => {
|
||||||
|
it("plays every song exactly once per cycle before repeating", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 12;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
const cycle1 = [queue.current()!.id];
|
||||||
|
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
|
||||||
|
const cycle2: string[] = [];
|
||||||
|
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
|
||||||
|
|
||||||
|
// Each cycle is a full permutation of all N songs — zero repeats within
|
||||||
|
// a cycle, and both cycles cover the same complete set.
|
||||||
|
expect(new Set(cycle1).size).toBe(N);
|
||||||
|
expect(new Set(cycle2).size).toBe(N);
|
||||||
|
expect(new Set(cycle1)).toEqual(new Set(cycle2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 6;
|
||||||
|
const CYCLES = 20;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
counts.set(queue.current()!.id, 1);
|
||||||
|
for (let i = 0; i < CYCLES * N - 1; i++) {
|
||||||
|
const id = queue.next()!.id;
|
||||||
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle bag => each song plays exactly CYCLES times. True random
|
||||||
|
// would skew heavily.
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
expect(counts.get(`s${i}`)).toBe(CYCLES);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replay the same song across a cycle boundary", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
const N = 5;
|
||||||
|
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
|
||||||
|
queue.play();
|
||||||
|
|
||||||
|
// Walk to the last song of cycle 1, then cross into cycle 2.
|
||||||
|
for (let i = 0; i < N - 1; i++) queue.next();
|
||||||
|
const lastOfCycle1 = queue.current()!.id;
|
||||||
|
const firstOfCycle2 = queue.next()!.id;
|
||||||
|
expect(firstOfCycle2).not.toBe(lastOfCycle1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a song added mid-cycle within the current cycle", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.play(); // A
|
||||||
|
queue.next(); // B — both originals now played this cycle
|
||||||
|
queue.add(makeSong("C")); // added mid-cycle, still unplayed
|
||||||
|
// C is the only unplayed song, so it must come next (not a reshuffle).
|
||||||
|
expect(queue.next()?.id).toBe("C");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps looping forever with multiple songs (never returns null)", () => {
|
||||||
|
queue.setMode(PlayMode.RandomLoop);
|
||||||
|
queue.add(makeSong("A"));
|
||||||
|
queue.add(makeSong("B"));
|
||||||
|
queue.add(makeSong("C"));
|
||||||
|
queue.play();
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
expect(queue.next()).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+49
-17
@@ -22,6 +22,7 @@ export class PlayQueue {
|
|||||||
private mode: PlayMode = PlayMode.Sequential;
|
private mode: PlayMode = PlayMode.Sequential;
|
||||||
private playedIndices = new Set<number>();
|
private playedIndices = new Set<number>();
|
||||||
private history: number[] = [];
|
private history: number[] = [];
|
||||||
|
private forwardStack: number[] = [];
|
||||||
private static readonly HISTORY_LIMIT = 50;
|
private static readonly HISTORY_LIMIT = 50;
|
||||||
|
|
||||||
private pushHistory(idx: number): void {
|
private pushHistory(idx: number): void {
|
||||||
@@ -100,12 +101,14 @@ export class PlayQueue {
|
|||||||
this.currentIndex = -1;
|
this.currentIndex = -1;
|
||||||
this.playedIndices.clear();
|
this.playedIndices.clear();
|
||||||
this.history = [];
|
this.history = [];
|
||||||
|
this.forwardStack = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): QueuedSong | null {
|
play(): QueuedSong | null {
|
||||||
if (this.songs.length === 0) return null;
|
if (this.songs.length === 0) return null;
|
||||||
this.playedIndices.clear();
|
this.playedIndices.clear();
|
||||||
this.history = [];
|
this.history = [];
|
||||||
|
this.forwardStack = [];
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
this.playedIndices.add(0);
|
this.playedIndices.add(0);
|
||||||
return this.songs[0];
|
return this.songs[0];
|
||||||
@@ -118,6 +121,7 @@ export class PlayQueue {
|
|||||||
// shuffle from this point. History tracking is independent and
|
// shuffle from this point. History tracking is independent and
|
||||||
// unaffected by this clear.
|
// unaffected by this clear.
|
||||||
this.playedIndices.clear();
|
this.playedIndices.clear();
|
||||||
|
this.forwardStack = [];
|
||||||
this.currentIndex = index;
|
this.currentIndex = index;
|
||||||
this.playedIndices.add(index);
|
this.playedIndices.add(index);
|
||||||
return this.songs[index];
|
return this.songs[index];
|
||||||
@@ -139,12 +143,48 @@ export class PlayQueue {
|
|||||||
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
|
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
|
||||||
return this.songs[this.currentIndex];
|
return this.songs[this.currentIndex];
|
||||||
}
|
}
|
||||||
case PlayMode.Random: {
|
case PlayMode.Random:
|
||||||
|
case PlayMode.RandomLoop: {
|
||||||
|
// 优先回到前进栈记录的位置(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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle bag: pick uniformly from the songs not yet played this
|
||||||
|
// cycle, so every song plays once before any repeats (NetEase/QQ
|
||||||
|
// style). Songs added mid-cycle aren't in playedIndices, so they're
|
||||||
|
// naturally eligible within the current cycle.
|
||||||
const unplayed: number[] = [];
|
const unplayed: number[] = [];
|
||||||
for (let i = 0; i < this.songs.length; i++) {
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||||
}
|
}
|
||||||
if (unplayed.length === 0) return null;
|
|
||||||
|
if (unplayed.length === 0) {
|
||||||
|
// Cycle complete.
|
||||||
|
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
|
||||||
|
// 随机循环:reshuffle and keep going forever.
|
||||||
|
if (this.songs.length === 1) {
|
||||||
|
this.pushHistory(this.currentIndex);
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.playedIndices = new Set([0]);
|
||||||
|
return this.songs[0];
|
||||||
|
}
|
||||||
|
// Start a fresh cycle: every song is eligible again, but exclude
|
||||||
|
// the song that just played from THIS pick only, so it doesn't
|
||||||
|
// repeat back-to-back across the boundary. It stays eligible for
|
||||||
|
// the rest of the new cycle, so every song still plays exactly once.
|
||||||
|
this.playedIndices = new Set();
|
||||||
|
for (let i = 0; i < this.songs.length; i++) {
|
||||||
|
if (i !== this.currentIndex) unplayed.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nextIndex =
|
const nextIndex =
|
||||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||||
this.pushHistory(this.currentIndex);
|
this.pushHistory(this.currentIndex);
|
||||||
@@ -152,33 +192,24 @@ export class PlayQueue {
|
|||||||
this.playedIndices.add(nextIndex);
|
this.playedIndices.add(nextIndex);
|
||||||
return this.songs[nextIndex];
|
return this.songs[nextIndex];
|
||||||
}
|
}
|
||||||
case PlayMode.RandomLoop: {
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prev(): QueuedSong | null {
|
prev(): QueuedSong | null {
|
||||||
if (this.songs.length === 0) return 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
|
// Preferred: pop from the back-stack so prev means "the song I
|
||||||
// actually played before this one," not "the previous array slot."
|
// actually played before this one," not "the previous array slot."
|
||||||
while (this.history.length > 0) {
|
while (this.history.length > 0) {
|
||||||
const idx = this.history.pop()!;
|
const idx = this.history.pop()!;
|
||||||
if (idx >= 0 && idx < this.songs.length) {
|
if (idx >= 0 && idx < this.songs.length) {
|
||||||
this.currentIndex = idx;
|
this.currentIndex = idx;
|
||||||
this.playedIndices.add(idx);
|
this.playedIndices = new Set([...this.history, this.currentIndex]);
|
||||||
return this.songs[idx];
|
return this.songs[idx];
|
||||||
}
|
}
|
||||||
// Stale entry (song removed) — keep popping.
|
// Stale entry (song removed) — keep popping.
|
||||||
@@ -227,6 +258,7 @@ export class PlayQueue {
|
|||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
this.playedIndices.clear();
|
this.playedIndices.clear();
|
||||||
this.history = [];
|
this.history = [];
|
||||||
|
this.forwardStack = [];
|
||||||
if (this.currentIndex >= 0) {
|
if (this.currentIndex >= 0) {
|
||||||
this.playedIndices.add(this.currentIndex);
|
this.playedIndices.add(this.currentIndex);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
|
describe("decideOccupancyAction", () => {
|
||||||
|
it("pauses when empty while playing and enabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
|
||||||
|
});
|
||||||
|
it("does not pause when the feature is disabled", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when idle (nothing playing)", () => {
|
||||||
|
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does not pause when already paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resumes when re-populated and we auto-paused", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
|
||||||
|
});
|
||||||
|
it("does NOT resume a user-paused track on re-population", () => {
|
||||||
|
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("does nothing when re-populated and already playing", () => {
|
||||||
|
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
|
||||||
|
});
|
||||||
|
it("resume is independent of the enabled flag (we already auto-paused)", () => {
|
||||||
|
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type PlayerStateName = "idle" | "playing" | "paused";
|
||||||
|
export type OccupancyAction = "pause" | "resume" | "none";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what auto-pause should do given channel occupancy.
|
||||||
|
* - empty (userCount <= 0): pause iff enabled and currently playing.
|
||||||
|
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
|
||||||
|
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
|
||||||
|
*/
|
||||||
|
export function decideOccupancyAction(
|
||||||
|
playerState: PlayerStateName,
|
||||||
|
autoPaused: boolean,
|
||||||
|
enabled: boolean,
|
||||||
|
userCount: number,
|
||||||
|
): OccupancyAction {
|
||||||
|
const empty = userCount <= 0;
|
||||||
|
if (empty) {
|
||||||
|
if (enabled && playerState === "playing") return "pause";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
if (autoPaused && playerState === "paused") return "resume";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
+146
-29
@@ -16,6 +16,8 @@ import type { Logger } from "../logger.js";
|
|||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
import { BotProfileManager } from "./profile.js";
|
import { BotProfileManager } from "./profile.js";
|
||||||
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
|
import { decideOccupancyAction } from "./auto-pause.js";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,6 +30,7 @@ export interface BotInstanceOptions {
|
|||||||
database: BotDatabase;
|
database: BotDatabase;
|
||||||
config: BotConfig;
|
config: BotConfig;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
avatarStore: AvatarStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotStatus {
|
export interface BotStatus {
|
||||||
@@ -57,14 +60,17 @@ export class BotInstance extends EventEmitter {
|
|||||||
private database: BotDatabase;
|
private database: BotDatabase;
|
||||||
private config: BotConfig;
|
private config: BotConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
private avatarStore: AvatarStore;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private disconnectEmitted = false;
|
private disconnectEmitted = false;
|
||||||
private voteSkipUsers = new Set<string>();
|
private voteSkipUsers = new Set<string>();
|
||||||
private isAdvancing = false;
|
private isAdvancing = false;
|
||||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private channelUserCount = 0;
|
private channelUserCount = 0;
|
||||||
|
private autoPaused = false;
|
||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
private isFmMode = false;
|
private isFmMode = false;
|
||||||
|
private fmProvider: MusicProvider | null = null;
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -77,6 +83,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.database = options.database;
|
this.database = options.database;
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
this.logger = options.logger.child({ botId: this.id });
|
this.logger = options.logger.child({ botId: this.id });
|
||||||
|
this.avatarStore = options.avatarStore;
|
||||||
|
|
||||||
this.tsClient = new TS3Client(options.tsOptions, this.logger);
|
this.tsClient = new TS3Client(options.tsOptions, this.logger);
|
||||||
this.player = new AudioPlayer(this.logger);
|
this.player = new AudioPlayer(this.logger);
|
||||||
@@ -90,6 +97,17 @@ export class BotInstance extends EventEmitter {
|
|||||||
options.tsOptions.nickname,
|
options.tsOptions.nickname,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Best-effort: a corrupted/locked avatar file must not block bot startup.
|
||||||
|
try {
|
||||||
|
const relPath = this.database.getCustomAvatarPath(this.id);
|
||||||
|
if (relPath) {
|
||||||
|
const buf = this.avatarStore.read(relPath);
|
||||||
|
if (buf) this.profileManager.setCustomAvatar(buf);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err }, "Failed to load custom avatar — skipping");
|
||||||
|
}
|
||||||
|
|
||||||
this.setupPlayerEvents();
|
this.setupPlayerEvents();
|
||||||
this.setupTsEvents();
|
this.setupTsEvents();
|
||||||
}
|
}
|
||||||
@@ -128,6 +146,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
// A lifecycle change must not leave a stale auto-resume armed.
|
||||||
|
this.autoPaused = false;
|
||||||
// Only emit externally once per lifecycle so clients don't see a
|
// Only emit externally once per lifecycle so clients don't see a
|
||||||
// duplicate "disconnected" after an explicit disconnect() call.
|
// duplicate "disconnected" after an explicit disconnect() call.
|
||||||
if (this.disconnectEmitted) return;
|
if (this.disconnectEmitted) return;
|
||||||
@@ -136,8 +156,26 @@ export class BotInstance extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.tsClient.on("connected", () => {
|
this.tsClient.on("connected", () => {
|
||||||
|
// Fresh connection — clear any stale auto-pause flag from a prior session.
|
||||||
|
this.autoPaused = false;
|
||||||
this._startIdlePoller();
|
this._startIdlePoller();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// React near-instantly to channel membership changes. The 30s idle
|
||||||
|
// poller remains the fallback if any of these events are missed.
|
||||||
|
this.tsClient.on("clientEnter", () => void this.refreshOccupancy());
|
||||||
|
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
|
||||||
|
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshOccupancy(): Promise<void> {
|
||||||
|
if (!this.connected) return;
|
||||||
|
try {
|
||||||
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
|
this.handleOccupancy(clients.length - 1);
|
||||||
|
} catch {
|
||||||
|
// ignore — the 30s poll is the fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
@@ -172,6 +210,16 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (minutes === 0) this._cancelIdleTimer();
|
if (minutes === 0) this._cancelIdleTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 外部更新 autoPauseOnEmpty(由 API 保存时调用) */
|
||||||
|
updateAutoPause(enabled: boolean): void {
|
||||||
|
this.config.autoPauseOnEmpty = enabled;
|
||||||
|
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _startIdlePoller(): void {
|
private _startIdlePoller(): void {
|
||||||
// 每 30 秒检查一次频道人数
|
// 每 30 秒检查一次频道人数
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@@ -179,17 +227,35 @@ export class BotInstance extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const clients = await this.tsClient.getClientsInChannel();
|
const clients = await this.tsClient.getClientsInChannel();
|
||||||
const userCount = clients.length - 1; // 排除 bot 自身
|
const userCount = clients.length - 1; // 排除 bot 自身
|
||||||
if (userCount <= 0) {
|
this.handleOccupancy(userCount);
|
||||||
this._scheduleIdleCheck();
|
|
||||||
} else {
|
|
||||||
this._cancelIdleTimer();
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
setTimeout(poll, 30_000);
|
setTimeout(poll, 30_000);
|
||||||
};
|
};
|
||||||
setTimeout(poll, 30_000);
|
setTimeout(poll, 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleOccupancy(userCount: number): void {
|
||||||
|
// idle-disconnect (unchanged behavior)
|
||||||
|
if (userCount <= 0) this._scheduleIdleCheck();
|
||||||
|
else this._cancelIdleTimer();
|
||||||
|
// auto-pause
|
||||||
|
const action = decideOccupancyAction(
|
||||||
|
this.player.getState(),
|
||||||
|
this.autoPaused,
|
||||||
|
this.config.autoPauseOnEmpty,
|
||||||
|
userCount,
|
||||||
|
);
|
||||||
|
if (action === "pause") {
|
||||||
|
this.player.pause();
|
||||||
|
this.autoPaused = true;
|
||||||
|
this.emit("stateChange");
|
||||||
|
} else if (action === "resume") {
|
||||||
|
this.player.resume();
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.emit("stateChange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _scheduleIdleCheck(): void {
|
private _scheduleIdleCheck(): void {
|
||||||
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||||
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||||
@@ -304,7 +370,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
case "album":
|
case "album":
|
||||||
return this.cmdAlbum(cmd);
|
return this.cmdAlbum(cmd);
|
||||||
case "fm":
|
case "fm":
|
||||||
return this.cmdFm();
|
return this.cmdFm(cmd);
|
||||||
case "artist":
|
case "artist":
|
||||||
return this.cmdArtist(cmd);
|
return this.cmdArtist(cmd);
|
||||||
case "vote":
|
case "vote":
|
||||||
@@ -328,6 +394,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private disableFmMode(): void {
|
||||||
|
this.isFmMode = false;
|
||||||
|
this.fmProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
private getProvider(flags: Set<string>): MusicProvider {
|
private getProvider(flags: Set<string>): MusicProvider {
|
||||||
if (flags.has("b")) return this.bilibiliProvider;
|
if (flags.has("b")) return this.bilibiliProvider;
|
||||||
if (flags.has("q")) return this.qqProvider;
|
if (flags.has("q")) return this.qqProvider;
|
||||||
@@ -365,7 +436,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
song.url = url;
|
song.url = url;
|
||||||
this.player.play(url);
|
this.player.play(url, 0, song.duration);
|
||||||
|
// Fresh playback (re)start — clear auto-pause so a later occupancy
|
||||||
|
// change won't try to "resume" a track the user already restarted.
|
||||||
|
this.autoPaused = false;
|
||||||
this.database.addPlayHistory({
|
this.database.addPlayHistory({
|
||||||
botId: this.id,
|
botId: this.id,
|
||||||
songId: song.id,
|
songId: song.id,
|
||||||
@@ -375,10 +449,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
platform: song.platform,
|
platform: song.platform,
|
||||||
coverUrl: song.coverUrl,
|
coverUrl: song.coverUrl,
|
||||||
});
|
});
|
||||||
// Update bot presence (fire-and-forget — never blocks playback)
|
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
|
||||||
this.profileManager.onSongChange(song).catch((err) => {
|
await this.syncProfileToSong(song);
|
||||||
this.logger.warn({ err }, "Profile update failed after song change");
|
|
||||||
});
|
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -387,6 +459,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncProfileToSong(song: QueuedSong | null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.profileManager.onSongChange(song);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err }, "Profile update failed after song change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!cmd.args) return "Usage: !play <song name or URL>";
|
if (!cmd.args) return "Usage: !play <song name or URL>";
|
||||||
const provider = this.getProvider(cmd.flags);
|
const provider = this.getProvider(cmd.flags);
|
||||||
@@ -396,7 +476,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
const song = result.songs[0];
|
const song = result.songs[0];
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
this.queue.play();
|
this.queue.play();
|
||||||
|
|
||||||
@@ -468,20 +548,25 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private cmdPause(): string {
|
private cmdPause(): string {
|
||||||
this.player.pause();
|
this.player.pause();
|
||||||
|
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
|
||||||
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Paused";
|
return "Paused";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdResume(): string {
|
private cmdResume(): string {
|
||||||
this.player.resume();
|
this.player.resume();
|
||||||
|
// User-initiated resume — drop any auto-pause flag.
|
||||||
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Resumed";
|
return "Resumed";
|
||||||
}
|
}
|
||||||
|
|
||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.autoPaused = false;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
this.logger.warn({ err }, "Profile restore failed on stop");
|
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||||
});
|
});
|
||||||
@@ -539,7 +624,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdClear(): string {
|
private cmdClear(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
this.profileManager.onSongChange(null).catch((err) => {
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
this.logger.warn({ err }, "Profile restore failed on clear");
|
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||||
});
|
});
|
||||||
@@ -612,7 +697,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
if (songs.length === 0) return "Playlist is empty or not found";
|
if (songs.length === 0) return "Playlist is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -623,13 +708,31 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
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 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";
|
if (songs.length === 0) return "Album is empty or not found";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -639,26 +742,38 @@ export class BotInstance extends EventEmitter {
|
|||||||
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdFm(): Promise<string> {
|
private async cmdFm(cmd: ParsedCommand): Promise<string> {
|
||||||
if (!this.neteaseProvider.getPersonalFm) {
|
return this.startFm(this.getProvider(cmd.flags));
|
||||||
return "Personal FM is only available for NetEase Cloud Music";
|
}
|
||||||
|
|
||||||
|
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
|
||||||
|
// Match the !fm chat-command guard: refuse before mutating the queue when
|
||||||
|
// offline, so the web /fm route can't wipe the queue + flip into FM mode
|
||||||
|
// while nothing can actually play.
|
||||||
|
if (!this.connected) {
|
||||||
|
return "Bot is not connected to TeamSpeak";
|
||||||
}
|
}
|
||||||
const songs = await this.neteaseProvider.getPersonalFm();
|
if (!provider.getPersonalFm) {
|
||||||
|
return `Personal FM is not available for ${provider.platform}`;
|
||||||
|
}
|
||||||
|
const songs = await provider.getPersonalFm();
|
||||||
if (songs.length === 0)
|
if (songs.length === 0)
|
||||||
return "No FM songs available (need to login first)";
|
return "No FM songs available (need to login first)";
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: "netease" });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
this.queue.setMode(PlayMode.Random);
|
this.queue.setMode(PlayMode.Random);
|
||||||
this.isFmMode = true;
|
this.isFmMode = true;
|
||||||
|
this.fmProvider = provider;
|
||||||
this.player.resetFailures();
|
this.player.resetFailures();
|
||||||
|
|
||||||
const first = this.queue.play();
|
const first = this.queue.play();
|
||||||
if (first) await this.resolveAndPlay(first);
|
if (first) await this.resolveAndPlay(first);
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
const label = provider.platform === "qq" ? "QQ Radar FM" : "Personal FM";
|
||||||
|
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
|
||||||
@@ -679,7 +794,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.isFmMode = false;
|
this.disableFmMode();
|
||||||
for (const song of filtered) {
|
for (const song of filtered) {
|
||||||
this.queue.add({ ...song, platform: provider.platform });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
@@ -693,14 +808,15 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refillFm(): Promise<void> {
|
private async refillFm(): Promise<void> {
|
||||||
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
|
const provider = this.fmProvider;
|
||||||
|
if (!this.isFmMode || !provider?.getPersonalFm) return;
|
||||||
try {
|
try {
|
||||||
const songs = await this.neteaseProvider.getPersonalFm();
|
const songs = await provider.getPersonalFm();
|
||||||
if (songs.length === 0) return;
|
if (songs.length === 0) return;
|
||||||
for (const song of songs) {
|
for (const song of songs) {
|
||||||
this.queue.add({ ...song, platform: "netease" });
|
this.queue.add({ ...song, platform: provider.platform });
|
||||||
}
|
}
|
||||||
this.logger.debug({ count: songs.length }, "FM queue refilled");
|
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({ err }, "Failed to refill FM queue");
|
this.logger.error({ err }, "Failed to refill FM queue");
|
||||||
}
|
}
|
||||||
@@ -763,6 +879,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
`${p}stop — Stop and clear queue`,
|
`${p}stop — Stop and clear queue`,
|
||||||
`${p}vol <0-100> — Set volume`,
|
`${p}vol <0-100> — Set volume`,
|
||||||
`${p}queue — Show queue`,
|
`${p}queue — Show queue`,
|
||||||
|
`${p}remove <pos> — Remove song at position (see ${p}queue)`,
|
||||||
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
||||||
`${p}playlist <name or id> — Load playlist by name or ID`,
|
`${p}playlist <name or id> — Load playlist by name or ID`,
|
||||||
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
|
`${p}playlist -q <name or id> — Load playlist from QQ Music`,
|
||||||
|
|||||||
+15
-3
@@ -11,6 +11,8 @@ import type { BotConfig } from "../data/config.js";
|
|||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
import type { ServerProtocol } from "../ts-protocol/client.js";
|
import type { ServerProtocol } from "../ts-protocol/client.js";
|
||||||
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
|
import type { PermissionStore } from "../data/permissions.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
|
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
|
||||||
@@ -74,6 +76,8 @@ export class BotManager extends EventEmitter {
|
|||||||
private database: BotDatabase;
|
private database: BotDatabase;
|
||||||
private config: BotConfig;
|
private config: BotConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
private avatarStore: AvatarStore;
|
||||||
|
private permissions: PermissionStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
neteaseProvider: MusicProvider,
|
neteaseProvider: MusicProvider,
|
||||||
@@ -81,7 +85,9 @@ export class BotManager extends EventEmitter {
|
|||||||
bilibiliProvider: MusicProvider,
|
bilibiliProvider: MusicProvider,
|
||||||
database: BotDatabase,
|
database: BotDatabase,
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
logger: Logger
|
logger: Logger,
|
||||||
|
avatarStore: AvatarStore,
|
||||||
|
permissions: PermissionStore
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.neteaseProvider = neteaseProvider;
|
this.neteaseProvider = neteaseProvider;
|
||||||
@@ -91,6 +97,8 @@ export class BotManager extends EventEmitter {
|
|||||||
this.database = database;
|
this.database = database;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
|
this.avatarStore = avatarStore;
|
||||||
|
this.permissions = permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBot(params: CreateBotParams): Promise<BotInstance> {
|
async createBot(params: CreateBotParams): Promise<BotInstance> {
|
||||||
@@ -117,6 +125,7 @@ export class BotManager extends EventEmitter {
|
|||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
|
avatarStore: this.avatarStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(id, bot);
|
this.bots.set(id, bot);
|
||||||
@@ -147,6 +156,7 @@ export class BotManager extends EventEmitter {
|
|||||||
this.bots.delete(id);
|
this.bots.delete(id);
|
||||||
}
|
}
|
||||||
this.database.deleteBotInstance(id);
|
this.database.deleteBotInstance(id);
|
||||||
|
this.permissions.pruneBot(id);
|
||||||
this.emit("botInstanceRemoved", id);
|
this.emit("botInstanceRemoved", id);
|
||||||
this.logger.info({ botId: id }, "Bot instance removed");
|
this.logger.info({ botId: id }, "Bot instance removed");
|
||||||
}
|
}
|
||||||
@@ -229,10 +239,11 @@ export class BotManager extends EventEmitter {
|
|||||||
neteaseProvider: this.neteaseProvider,
|
neteaseProvider: this.neteaseProvider,
|
||||||
qqProvider: this.qqProvider,
|
qqProvider: this.qqProvider,
|
||||||
bilibiliProvider: this.bilibiliProvider,
|
bilibiliProvider: this.bilibiliProvider,
|
||||||
youtubeProvider: this.youtubeProvider,
|
youtubeProvider: this.youtubeProvider,
|
||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
|
avatarStore: this.avatarStore,
|
||||||
});
|
});
|
||||||
this.bots.set(id, bot);
|
this.bots.set(id, bot);
|
||||||
this.emit("botInstance", bot);
|
this.emit("botInstance", bot);
|
||||||
@@ -279,10 +290,11 @@ export class BotManager extends EventEmitter {
|
|||||||
neteaseProvider: this.neteaseProvider,
|
neteaseProvider: this.neteaseProvider,
|
||||||
qqProvider: this.qqProvider,
|
qqProvider: this.qqProvider,
|
||||||
bilibiliProvider: this.bilibiliProvider,
|
bilibiliProvider: this.bilibiliProvider,
|
||||||
youtubeProvider: this.youtubeProvider,
|
youtubeProvider: this.youtubeProvider,
|
||||||
database: this.database,
|
database: this.database,
|
||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
|
avatarStore: this.avatarStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(saved.id, bot);
|
this.bots.set(saved.id, bot);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { BotProfileManager } from "./profile.js";
|
||||||
|
import type { TS3Client } from "../ts-protocol/client.js";
|
||||||
|
import type { QueuedSong } from "../audio/queue.js";
|
||||||
|
|
||||||
|
function makeMockTs(): TS3Client & {
|
||||||
|
uploadCalls: Buffer[];
|
||||||
|
clearCalls: number;
|
||||||
|
} {
|
||||||
|
const calls: Buffer[] = [];
|
||||||
|
let clears = 0;
|
||||||
|
const ts: any = {
|
||||||
|
uploadCalls: calls,
|
||||||
|
get clearCalls() { return clears; },
|
||||||
|
getHost: () => "127.0.0.1",
|
||||||
|
getHttpQuery: () => null,
|
||||||
|
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||||
|
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of stream) chunks.push(c as Buffer);
|
||||||
|
calls.push(Buffer.concat(chunks));
|
||||||
|
}),
|
||||||
|
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
|
||||||
|
if (/client_flag_avatar=$/.test(cmd)) clears++;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||||
|
|
||||||
|
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
|
||||||
|
const cfgOff = { ...cfgOn, avatarEnabled: false };
|
||||||
|
|
||||||
|
const fakeSong: QueuedSong = {
|
||||||
|
id: "1",
|
||||||
|
name: "X",
|
||||||
|
artist: "Y",
|
||||||
|
album: "Z",
|
||||||
|
platform: "netease",
|
||||||
|
url: "u",
|
||||||
|
coverUrl: "c",
|
||||||
|
duration: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
describe("BotProfileManager custom avatar precedence", () => {
|
||||||
|
let ts: ReturnType<typeof makeMockTs>;
|
||||||
|
beforeEach(() => { ts = makeMockTs(); });
|
||||||
|
|
||||||
|
it("setCustomAvatar uploads immediately on a fresh idle bot (sync on)", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([1, 2, 3]));
|
||||||
|
await flush();
|
||||||
|
expect(ts.uploadCalls.length).toBe(1);
|
||||||
|
expect(ts.uploadCalls[0].equals(Buffer.from([1, 2, 3]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCustomAvatar uploads immediately when sync is off (always idle)", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([7]));
|
||||||
|
await flush();
|
||||||
|
expect(ts.uploadCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCustomAvatar while playing + sync on does NOT push (cover wins)", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
// Simulate the bot playing a song. We can't actually run updateAvatar's
|
||||||
|
// full HTTP fetch path, but onSongChange records currentSong before
|
||||||
|
// updateAvatar runs, which is enough for this assertion.
|
||||||
|
void pm.onSongChange(fakeSong);
|
||||||
|
await flush();
|
||||||
|
const uploadsBefore = ts.uploadCalls.length;
|
||||||
|
pm.setCustomAvatar(Buffer.from([42]));
|
||||||
|
await flush();
|
||||||
|
expect(ts.uploadCalls.length).toBe(uploadsBefore); // no new upload
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCustomAvatar while playing + sync off DOES push (sync-off is idle)", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||||
|
void pm.onSongChange(fakeSong);
|
||||||
|
await flush();
|
||||||
|
const uploadsBefore = ts.uploadCalls.length;
|
||||||
|
pm.setCustomAvatar(Buffer.from([42]));
|
||||||
|
await flush();
|
||||||
|
expect(ts.uploadCalls.length).toBe(uploadsBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCustomAvatar(null) while idle clears the TS3 avatar", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([1]));
|
||||||
|
await flush();
|
||||||
|
const clearsBefore = ts.clearCalls;
|
||||||
|
pm.setCustomAvatar(null);
|
||||||
|
await flush();
|
||||||
|
expect(ts.clearCalls).toBe(clearsBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on stop with custom avatar set + sync on, restores custom (does not clear)", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([1, 2, 3, 4]));
|
||||||
|
await flush();
|
||||||
|
const clearsBefore = ts.clearCalls;
|
||||||
|
await pm.onSongChange(null);
|
||||||
|
expect(ts.uploadCalls.at(-1)?.equals(Buffer.from([1, 2, 3, 4]))).toBe(true);
|
||||||
|
expect(ts.clearCalls).toBe(clearsBefore); // no extra clear
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on stop with no custom avatar, falls back to clear", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
await pm.onSongChange(null);
|
||||||
|
expect(ts.clearCalls).toBe(1);
|
||||||
|
expect(ts.uploadCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on connect with custom avatar set + sync ON, applies custom (spec matrix row 1)", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([5, 5]));
|
||||||
|
await flush();
|
||||||
|
ts.uploadCalls.length = 0; // reset
|
||||||
|
pm.onConnect();
|
||||||
|
await flush();
|
||||||
|
expect(ts.uploadCalls.length).toBe(1);
|
||||||
|
expect(ts.uploadCalls[0].equals(Buffer.from([5, 5]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on connect with custom avatar set + sync OFF, applies custom", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([9, 9]));
|
||||||
|
await flush();
|
||||||
|
ts.uploadCalls.length = 0;
|
||||||
|
pm.onConnect();
|
||||||
|
await flush();
|
||||||
|
expect(ts.uploadCalls.length).toBe(1);
|
||||||
|
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on connect with no custom avatar, does not touch avatar", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||||
|
pm.onConnect();
|
||||||
|
await flush();
|
||||||
|
expect(ts.uploadCalls.length).toBe(0);
|
||||||
|
expect(ts.clearCalls).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-1
@@ -25,6 +25,13 @@ export class BotProfileManager {
|
|||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private config: ProfileConfig;
|
private config: ProfileConfig;
|
||||||
private defaultNickname: string;
|
private defaultNickname: string;
|
||||||
|
private customAvatar: Buffer | null = null;
|
||||||
|
/**
|
||||||
|
* Tracks the last song handed to onSongChange. null means stopped/idle.
|
||||||
|
* Used by setCustomAvatar to decide whether the new buffer should be
|
||||||
|
* pushed immediately (idle) or wait for the next stop event (playing).
|
||||||
|
*/
|
||||||
|
private currentSong: QueuedSong | null = null;
|
||||||
|
|
||||||
/** Per-feature permission-denied flags. Reset on reconnect. */
|
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||||
private permDenied = {
|
private permDenied = {
|
||||||
@@ -58,6 +65,26 @@ export class BotProfileManager {
|
|||||||
|
|
||||||
// --- Public API ---
|
// --- Public API ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set/clear the persistent idle avatar. Pass null to remove.
|
||||||
|
*
|
||||||
|
* If the bot is currently in an idle state (no song playing OR
|
||||||
|
* avatarEnabled is off), the new buffer is pushed to TS3 right away;
|
||||||
|
* otherwise the cover-art sync is in charge until the next stop event,
|
||||||
|
* at which point clearAvatar restores from this.customAvatar.
|
||||||
|
*/
|
||||||
|
setCustomAvatar(buffer: Buffer | null): void {
|
||||||
|
this.customAvatar = buffer;
|
||||||
|
const idle = this.currentSong === null || !this.config.avatarEnabled;
|
||||||
|
if (!idle) return;
|
||||||
|
const gen = ++this.generation;
|
||||||
|
if (buffer && buffer.length > 0) {
|
||||||
|
void this.applyIdleAvatar(gen);
|
||||||
|
} else {
|
||||||
|
void this.clearAvatar(gen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when a new song starts playing (song != null) or playback
|
* Called when a new song starts playing (song != null) or playback
|
||||||
* stops (song == null).
|
* stops (song == null).
|
||||||
@@ -71,6 +98,7 @@ export class BotProfileManager {
|
|||||||
*/
|
*/
|
||||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||||
const gen = ++this.generation;
|
const gen = ++this.generation;
|
||||||
|
this.currentSong = song;
|
||||||
|
|
||||||
// 1. Avatar first — file transfer uses its own response tracker and
|
// 1. Avatar first — file transfer uses its own response tracker and
|
||||||
// must run before sendCommandNoWait calls whose orphaned responses
|
// must run before sendCommandNoWait calls whose orphaned responses
|
||||||
@@ -91,6 +119,7 @@ export class BotProfileManager {
|
|||||||
/** Reset permission-denied flags and bump generation on new connection. */
|
/** Reset permission-denied flags and bump generation on new connection. */
|
||||||
onConnect(): void {
|
onConnect(): void {
|
||||||
this.generation++;
|
this.generation++;
|
||||||
|
this.currentSong = null;
|
||||||
this.permDenied = {
|
this.permDenied = {
|
||||||
avatar: false,
|
avatar: false,
|
||||||
description: false,
|
description: false,
|
||||||
@@ -99,6 +128,12 @@ export class BotProfileManager {
|
|||||||
channelDesc: false,
|
channelDesc: false,
|
||||||
nowPlayingMsg: false,
|
nowPlayingMsg: false,
|
||||||
};
|
};
|
||||||
|
// No song is playing on a fresh connect, so the matrix says the
|
||||||
|
// custom avatar should be visible regardless of avatarEnabled.
|
||||||
|
if (this.customAvatar) {
|
||||||
|
const gen = this.generation;
|
||||||
|
void this.applyIdleAvatar(gen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfig(): ProfileConfig {
|
getConfig(): ProfileConfig {
|
||||||
@@ -171,6 +206,10 @@ export class BotProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async clearAvatar(gen: number): Promise<void> {
|
private async clearAvatar(gen: number): Promise<void> {
|
||||||
|
if (this.customAvatar && this.customAvatar.length > 0) {
|
||||||
|
await this.applyIdleAvatar(gen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.withTimeout(
|
await this.withTimeout(
|
||||||
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
||||||
@@ -179,7 +218,6 @@ export class BotProfileManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// File may not exist or transfer timed out — that's fine
|
// File may not exist or transfer timed out — that's fine
|
||||||
}
|
}
|
||||||
// Bail if a newer song started while we were deleting
|
|
||||||
if (this.generation !== gen) return;
|
if (this.generation !== gen) return;
|
||||||
try {
|
try {
|
||||||
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
||||||
@@ -188,6 +226,18 @@ export class BotProfileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyIdleAvatar(gen: number): Promise<void> {
|
||||||
|
if (!this.customAvatar || this.customAvatar.length === 0) return;
|
||||||
|
if (this.permDenied.avatar) return;
|
||||||
|
try {
|
||||||
|
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
|
||||||
|
if (this.generation !== gen) return;
|
||||||
|
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
|
||||||
|
} catch (err) {
|
||||||
|
this.handleFeatureError("avatar", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export type AuditAction =
|
||||||
|
| "admin.first_created"
|
||||||
|
| "user.created"
|
||||||
|
| "user.deleted"
|
||||||
|
| "user.password_reset"
|
||||||
|
| "user.password_changed"
|
||||||
|
| "user.role_changed"
|
||||||
|
| "user.permissions_changed";
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
timestamp: number;
|
||||||
|
actorId: string | null;
|
||||||
|
actorUsername: string | null;
|
||||||
|
targetUserId: string | null;
|
||||||
|
targetUsername: string | null;
|
||||||
|
action: AuditAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditRecordInput {
|
||||||
|
actorId: string | null;
|
||||||
|
actorUsername: string | null;
|
||||||
|
targetUserId: string | null;
|
||||||
|
targetUsername: string | null;
|
||||||
|
action: AuditAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditStore {
|
||||||
|
record(input: AuditRecordInput): void;
|
||||||
|
list(limit: number, offset: number): AuditEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuditStore(db: Database.Database): AuditStore {
|
||||||
|
const insertStmt = db.prepare(
|
||||||
|
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
const listStmt = db.prepare(
|
||||||
|
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
record(input) {
|
||||||
|
insertStmt.run(
|
||||||
|
Date.now(),
|
||||||
|
input.actorId,
|
||||||
|
input.actorUsername,
|
||||||
|
input.targetUserId,
|
||||||
|
input.targetUsername,
|
||||||
|
input.action
|
||||||
|
);
|
||||||
|
},
|
||||||
|
list(limit, offset) {
|
||||||
|
return listStmt.all(limit, offset) as AuditEntry[];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createAvatarStore } from "./avatars.js";
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAvatarStore", () => {
|
||||||
|
it("write returns a relative path under the store dir", () => {
|
||||||
|
const store = createAvatarStore(dir);
|
||||||
|
const buf = Buffer.from("fake-png");
|
||||||
|
const rel = store.write("bot-1", "image/png", buf);
|
||||||
|
expect(rel).toBe("bot-1.png");
|
||||||
|
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write picks correct extension for jpeg / webp", () => {
|
||||||
|
const store = createAvatarStore(dir);
|
||||||
|
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
|
||||||
|
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write rejects unsupported MIME types", () => {
|
||||||
|
const store = createAvatarStore(dir);
|
||||||
|
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(/unsupported/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read returns the bytes for an existing file", () => {
|
||||||
|
const store = createAvatarStore(dir);
|
||||||
|
store.write("bot-1", "image/png", Buffer.from("hello"));
|
||||||
|
const buf = store.read("bot-1.png");
|
||||||
|
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read returns null when path is missing", () => {
|
||||||
|
const store = createAvatarStore(dir);
|
||||||
|
expect(store.read("missing.png")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove deletes the file (idempotent)", () => {
|
||||||
|
const store = createAvatarStore(dir);
|
||||||
|
store.write("bot-1", "image/png", Buffer.from("x"));
|
||||||
|
store.remove("bot-1.png");
|
||||||
|
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
|
||||||
|
expect(() => store.remove("bot-1.png")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write replaces any existing file for the same botId regardless of old extension", () => {
|
||||||
|
const store = createAvatarStore(dir);
|
||||||
|
store.write("bot-1", "image/png", Buffer.from("old"));
|
||||||
|
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
|
||||||
|
expect(rel).toBe("bot-1.jpg");
|
||||||
|
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
|
||||||
|
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/webp": "webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AvatarStore {
|
||||||
|
/** Returns the relative path written (e.g. "bot-1.png"). */
|
||||||
|
write(botId: string, mime: string, buffer: Buffer): string;
|
||||||
|
read(relPath: string): Buffer | null;
|
||||||
|
remove(relPath: string): void;
|
||||||
|
getDir(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAvatarStore(dir: string): AvatarStore {
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
return {
|
||||||
|
write(botId, mime, buffer) {
|
||||||
|
const ext = MIME_TO_EXT[mime];
|
||||||
|
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
|
||||||
|
}
|
||||||
|
const rel = `${botId}.${ext}`;
|
||||||
|
writeFileSync(join(dir, rel), buffer);
|
||||||
|
return rel;
|
||||||
|
},
|
||||||
|
read(relPath) {
|
||||||
|
const full = join(dir, relPath);
|
||||||
|
if (!existsSync(full)) return null;
|
||||||
|
return readFileSync(full);
|
||||||
|
},
|
||||||
|
remove(relPath) {
|
||||||
|
rmSync(join(dir, relPath), { force: true });
|
||||||
|
},
|
||||||
|
getDir() {
|
||||||
|
return dir;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+55
-2
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { getDefaultConfig, loadConfig, saveConfig } from "./config.js";
|
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
|
||||||
|
|
||||||
describe("config", () => {
|
describe("config", () => {
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
@@ -51,4 +51,57 @@ describe("config", () => {
|
|||||||
expect(loaded.commandPrefix).toBe("!");
|
expect(loaded.commandPrefix).toBe("!");
|
||||||
expect(loaded.autoPauseOnEmpty).toBe(true);
|
expect(loaded.autoPauseOnEmpty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- #86: config.json must live under (and be created in) the persisted data dir ---
|
||||||
|
|
||||||
|
it("first run writes config.json into the data dir and reads it back", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const dataDir = join(root, "data");
|
||||||
|
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
|
||||||
|
|
||||||
|
// Boot sequence: load (missing -> defaults) then save.
|
||||||
|
const config = loadConfig(configPath);
|
||||||
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
expect(existsSync(configPath)).toBe(true);
|
||||||
|
// A subsequent hand-edited file under the SAME persisted path is honored.
|
||||||
|
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
|
||||||
|
expect(loadConfig(configPath).webPort).toBe(9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates a legacy root config into the data dir, preserving values", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const legacyPath = join(root, "config.json");
|
||||||
|
const newPath = join(root, "data", "config.json");
|
||||||
|
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
|
||||||
|
|
||||||
|
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
||||||
|
|
||||||
|
expect(migrated).toBe(true);
|
||||||
|
expect(existsSync(newPath)).toBe(true);
|
||||||
|
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
|
||||||
|
const loaded = loadConfig(newPath);
|
||||||
|
expect(loaded.webPort).toBe(4242);
|
||||||
|
expect(loaded.publicUrl).toBe("http://x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT overwrite an existing data-dir config during migration", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const legacyPath = join(root, "config.json");
|
||||||
|
const newPath = join(root, "data", "config.json");
|
||||||
|
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
|
||||||
|
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
|
||||||
|
|
||||||
|
const migrated = migrateLegacyConfig(legacyPath, newPath);
|
||||||
|
|
||||||
|
expect(migrated).toBe(false); // new location wins, untouched
|
||||||
|
expect(loadConfig(newPath).webPort).toBe(2222);
|
||||||
|
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migration is a no-op when there is no legacy config", () => {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
|
||||||
|
expect(migrated).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+33
-1
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
export interface BotConfig {
|
export interface BotConfig {
|
||||||
@@ -58,3 +58,35 @@ export function saveConfig(path: string, config: BotConfig): void {
|
|||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
|
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration for the config location fix (#86).
|
||||||
|
*
|
||||||
|
* Older versions wrote config.json to the app/repo ROOT, which is NOT inside the
|
||||||
|
* persisted data directory (the Docker volume is mounted at data/). That meant the
|
||||||
|
* file never landed in the volume on first run and a manually-placed data/config.json
|
||||||
|
* was ignored. config.json now lives under the data dir alongside the DB/cookies/logs.
|
||||||
|
*
|
||||||
|
* If a legacy root-level config exists and the new data-dir config does not yet exist,
|
||||||
|
* move it so existing local installs keep their customized settings. Best-effort:
|
||||||
|
* any failure is swallowed and loadConfig falls back to defaults.
|
||||||
|
*
|
||||||
|
* @returns true if a legacy config was migrated, false otherwise.
|
||||||
|
*/
|
||||||
|
export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean {
|
||||||
|
try {
|
||||||
|
if (legacyPath === newPath) return false;
|
||||||
|
if (existsSync(newPath)) return false; // new location already populated — leave it
|
||||||
|
if (!existsSync(legacyPath)) return false; // nothing to migrate
|
||||||
|
mkdirSync(dirname(newPath), { recursive: true });
|
||||||
|
copyFileSync(legacyPath, newPath); // copy first (works across filesystems)
|
||||||
|
try {
|
||||||
|
rmSync(legacyPath);
|
||||||
|
} catch {
|
||||||
|
/* leave the legacy file if it can't be removed; the new one wins */
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,30 @@ describe("database", () => {
|
|||||||
expect(names).toContain("bot_instances");
|
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", () => {
|
it("records and retrieves play history", () => {
|
||||||
botDb.addPlayHistory({
|
botDb.addPlayHistory({
|
||||||
botId: "bot1",
|
botId: "bot1",
|
||||||
@@ -98,4 +122,26 @@ describe("database", () => {
|
|||||||
expect(botDb.getBotInstances()).toHaveLength(0);
|
expect(botDb.getBotInstances()).toHaveLength(0);
|
||||||
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
|
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists and clears customAvatarPath on a bot instance", () => {
|
||||||
|
const inst = {
|
||||||
|
id: "bot-1",
|
||||||
|
name: "B",
|
||||||
|
serverAddress: "x",
|
||||||
|
serverPort: 9987,
|
||||||
|
nickname: "n",
|
||||||
|
defaultChannel: "",
|
||||||
|
channelPassword: "",
|
||||||
|
autoStart: false,
|
||||||
|
serverProtocol: "",
|
||||||
|
ts6ApiKey: "",
|
||||||
|
serverPassword: "",
|
||||||
|
};
|
||||||
|
botDb.saveBotInstance(inst);
|
||||||
|
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
||||||
|
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
|
||||||
|
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
|
||||||
|
botDb.setCustomAvatarPath("bot-1", null);
|
||||||
|
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
|
||||||
|
|
||||||
export interface PlayHistoryEntry {
|
export interface PlayHistoryEntry {
|
||||||
botId: string;
|
botId: string;
|
||||||
@@ -51,6 +52,17 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
|||||||
nowPlayingMsgEnabled: true,
|
nowPlayingMsgEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface FavoritePlaylist {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
platform: string;
|
||||||
|
playlistId: string;
|
||||||
|
name: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BotDatabase {
|
export interface BotDatabase {
|
||||||
db: Database.Database;
|
db: Database.Database;
|
||||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||||
@@ -60,6 +72,12 @@ export interface BotDatabase {
|
|||||||
deleteBotInstance(id: string): boolean;
|
deleteBotInstance(id: string): boolean;
|
||||||
getProfileConfig(botId: string): ProfileConfig;
|
getProfileConfig(botId: string): ProfileConfig;
|
||||||
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||||
|
getCustomAvatarPath(botId: string): string | null;
|
||||||
|
setCustomAvatarPath(botId: string, path: string | null): void;
|
||||||
|
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
|
||||||
|
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
|
||||||
|
getFavorites(userId: string): FavoritePlaylist[];
|
||||||
|
isFavorited(userId: string, playlistId: string, platform: string): boolean;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +110,15 @@ function migrateSchema(db: Database.Database): void {
|
|||||||
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
function initTables(db: Database.Database): void {
|
||||||
@@ -122,14 +149,100 @@ function initTables(db: Database.Database): void {
|
|||||||
serverPassword TEXT NOT NULL DEFAULT '',
|
serverPassword TEXT NOT NULL DEFAULT '',
|
||||||
identity TEXT
|
identity TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
passwordHash TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
expiresAt INTEGER NOT NULL,
|
||||||
|
lastSeenAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_audit (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
actorId TEXT,
|
||||||
|
actorUsername TEXT,
|
||||||
|
targetUserId TEXT,
|
||||||
|
targetUsername TEXT,
|
||||||
|
action TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_playlists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
playlistId TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
coverUrl TEXT NOT NULL DEFAULT '',
|
||||||
|
songCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(userId, platform, playlistId)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (userId, permission),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS user_bot_access (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
botId TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (userId, botId),
|
||||||
|
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time backfill: existing `member` users created before the
|
||||||
|
* account-permissions feature are granted full access (all 5 capabilities +
|
||||||
|
* the `bots.all` marker), exactly once per database. Admins are skipped (they
|
||||||
|
* bypass permission checks). New members created after this runs are not
|
||||||
|
* affected — they get the basic tier via POST /api/users. A marker row in
|
||||||
|
* `schema_meta` makes this idempotent.
|
||||||
|
*/
|
||||||
|
export function backfillMemberPermissions(db: Database.Database): void {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
|
||||||
|
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
|
||||||
|
if (done) return;
|
||||||
|
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
|
||||||
|
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||||
|
const tokens = [...CAPABILITIES, BOTS_ALL];
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const m of members) {
|
||||||
|
for (const t of tokens) insCap.run(m.id, t);
|
||||||
|
}
|
||||||
|
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length));
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
export function createDatabase(dbPath: string): BotDatabase {
|
export function createDatabase(dbPath: string): BotDatabase {
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma("journal_mode = WAL");
|
db.pragma("journal_mode = WAL");
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
initTables(db);
|
initTables(db);
|
||||||
migrateSchema(db);
|
migrateSchema(db);
|
||||||
|
backfillMemberPermissions(db);
|
||||||
|
|
||||||
const insertHistory = db.prepare(`
|
const insertHistory = db.prepare(`
|
||||||
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
||||||
@@ -179,6 +292,27 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
|
||||||
|
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
|
||||||
|
|
||||||
|
const insertFavorite = db.prepare(`
|
||||||
|
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
|
||||||
|
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteFavorite = db.prepare(`
|
||||||
|
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectFavorites = db.prepare(`
|
||||||
|
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
|
||||||
|
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const checkFavorited = db.prepare(`
|
||||||
|
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
|
||||||
|
`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|
||||||
@@ -242,6 +376,32 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getCustomAvatarPath(botId) {
|
||||||
|
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
|
||||||
|
return row?.custom_avatar_path ?? null;
|
||||||
|
},
|
||||||
|
setCustomAvatarPath(botId, path) {
|
||||||
|
updateCustomAvatar.run(path, botId);
|
||||||
|
},
|
||||||
|
|
||||||
|
addFavorite(userId, playlist) {
|
||||||
|
insertFavorite.run({ userId, ...playlist });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFavorite(userId, playlistId, platform) {
|
||||||
|
const result = deleteFavorite.run(userId, playlistId, platform);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
getFavorites(userId) {
|
||||||
|
return selectFavorites.all(userId) as FavoritePlaylist[];
|
||||||
|
},
|
||||||
|
|
||||||
|
isFavorited(userId, playlistId, platform) {
|
||||||
|
const row = checkFavorited.get(userId, playlistId, platform);
|
||||||
|
return row !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js";
|
||||||
|
import { createPermissionStore, CAPABILITIES } from "./permissions.js";
|
||||||
|
|
||||||
|
describe("backfillMemberPermissions", () => {
|
||||||
|
let dbFile: string;
|
||||||
|
let db: BotDatabase;
|
||||||
|
function fresh() {
|
||||||
|
dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||||
|
db = createDatabase(dbFile);
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
for (const s of ["", "-wal", "-shm"]) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dbFile + s, { force: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants existing members full access + bots.all, skips admins, once", () => {
|
||||||
|
fresh();
|
||||||
|
// simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows
|
||||||
|
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
|
||||||
|
const now = Date.now();
|
||||||
|
const ins = db.db.prepare(
|
||||||
|
"INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)"
|
||||||
|
);
|
||||||
|
ins.run("m1", "mem", "x", now, now, "member");
|
||||||
|
ins.run("a1", "adm", "x", now, now, "admin");
|
||||||
|
|
||||||
|
backfillMemberPermissions(db.db);
|
||||||
|
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort());
|
||||||
|
expect(store.getBotAccess("m1")).toBe("all");
|
||||||
|
expect(store.getCapabilities("a1")).toEqual([]);
|
||||||
|
expect(store.getBotAccess("a1")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — running again does not change or re-grant", () => {
|
||||||
|
fresh();
|
||||||
|
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
|
||||||
|
const now = Date.now();
|
||||||
|
db.db
|
||||||
|
.prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)")
|
||||||
|
.run("m1", "mem", "x", now, now, "member");
|
||||||
|
backfillMemberPermissions(db.db);
|
||||||
|
// member restricted afterwards
|
||||||
|
createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] });
|
||||||
|
// second run must NOT re-grant (marker present)
|
||||||
|
backfillMemberPermissions(db.db);
|
||||||
|
expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { createDatabase, type BotDatabase } from "./database.js";
|
||||||
|
import { createPermissionStore } from "./permissions.js";
|
||||||
|
import { CAPABILITIES, BASIC_TIER_CAPABILITIES, resolvePermissionContext } from "./permissions.js";
|
||||||
|
|
||||||
|
describe("PermissionStore", () => {
|
||||||
|
let dbFile: string;
|
||||||
|
let db: BotDatabase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||||
|
db = createDatabase(dbFile);
|
||||||
|
db.db.prepare(
|
||||||
|
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
|
||||||
|
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
try { fs.rmSync(dbFile, { force: true }); } catch {}
|
||||||
|
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
|
||||||
|
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the five capability tokens and a basic tier", () => {
|
||||||
|
expect(CAPABILITIES).toEqual([
|
||||||
|
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
|
||||||
|
]);
|
||||||
|
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to no capabilities and no bots", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
expect(store.getCapabilities("u1")).toEqual([]);
|
||||||
|
expect(store.getBotAccess("u1")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips capabilities and a specific bot list", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
|
||||||
|
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
|
||||||
|
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the all-bots flag as 'all'", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
|
||||||
|
expect(store.getBotAccess("u1")).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPermissions replaces prior capabilities and bots", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
|
||||||
|
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
|
||||||
|
expect(store.getCapabilities("u1")).toEqual(["quality"]);
|
||||||
|
expect(store.getBotAccess("u1")).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown capability tokens", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
|
||||||
|
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pruneBot removes a bot from every user's allow-list", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
|
||||||
|
store.pruneBot("botA");
|
||||||
|
expect(store.getBotAccess("u1")).toEqual(["botB"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolvePermissionContext", () => {
|
||||||
|
it("admin gets all capabilities and all bots regardless of stored rows", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
const ctx = resolvePermissionContext("admin", "u1", store);
|
||||||
|
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
|
||||||
|
expect(ctx.bots).toBe("all");
|
||||||
|
});
|
||||||
|
it("member reflects stored capabilities + bot access", () => {
|
||||||
|
const store = createPermissionStore(db.db);
|
||||||
|
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
|
||||||
|
const ctx = resolvePermissionContext("member", "u1", store);
|
||||||
|
expect([...ctx.capabilities]).toEqual(["player.control"]);
|
||||||
|
expect(ctx.bots).toEqual(new Set(["b1"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export const CAPABILITIES = [
|
||||||
|
"player.control",
|
||||||
|
"player.queue",
|
||||||
|
"bot.manage",
|
||||||
|
"platform.auth",
|
||||||
|
"quality",
|
||||||
|
] as const;
|
||||||
|
export type Capability = (typeof CAPABILITIES)[number];
|
||||||
|
|
||||||
|
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
|
||||||
|
export const BOTS_ALL = "bots.all";
|
||||||
|
|
||||||
|
/** Capabilities granted to a newly-created member by default. */
|
||||||
|
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
|
||||||
|
|
||||||
|
export function isCapability(x: string): x is Capability {
|
||||||
|
return (CAPABILITIES as readonly string[]).includes(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BotAccess = "all" | string[];
|
||||||
|
|
||||||
|
export interface PermissionStore {
|
||||||
|
getCapabilities(userId: string): Capability[];
|
||||||
|
getBotAccess(userId: string): BotAccess;
|
||||||
|
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
|
||||||
|
pruneBot(botId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPermissionStore(db: Database.Database): PermissionStore {
|
||||||
|
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
|
||||||
|
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
|
||||||
|
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
|
||||||
|
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
|
||||||
|
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
|
||||||
|
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
|
||||||
|
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCapabilities(userId) {
|
||||||
|
return (selCaps.all(userId) as { permission: string }[])
|
||||||
|
.map((r) => r.permission)
|
||||||
|
.filter((p): p is Capability => isCapability(p));
|
||||||
|
},
|
||||||
|
getBotAccess(userId) {
|
||||||
|
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
|
||||||
|
if (all) return "all";
|
||||||
|
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
|
||||||
|
},
|
||||||
|
setPermissions(userId, input) {
|
||||||
|
const caps = input.capabilities.filter(isCapability);
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
delCaps.run(userId);
|
||||||
|
delBots.run(userId);
|
||||||
|
for (const c of caps) insCap.run(userId, c);
|
||||||
|
if (input.bots === "all") {
|
||||||
|
insCap.run(userId, BOTS_ALL);
|
||||||
|
} else {
|
||||||
|
for (const b of input.bots) insBot.run(userId, b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
},
|
||||||
|
pruneBot(botId) {
|
||||||
|
pruneBotStmt.run(botId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionContext {
|
||||||
|
capabilities: Set<string>;
|
||||||
|
bots: "all" | Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePermissionContext(
|
||||||
|
role: "admin" | "member",
|
||||||
|
userId: string,
|
||||||
|
store: PermissionStore
|
||||||
|
): PermissionContext {
|
||||||
|
if (role === "admin") {
|
||||||
|
return { capabilities: new Set(CAPABILITIES), bots: "all" };
|
||||||
|
}
|
||||||
|
const access = store.getBotAccess(userId);
|
||||||
|
return {
|
||||||
|
capabilities: new Set(store.getCapabilities(userId)),
|
||||||
|
bots: access === "all" ? "all" : new Set(access),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+19
-3
@@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadConfig, saveConfig } from "./data/config.js";
|
import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js";
|
||||||
import { createDatabase } from "./data/database.js";
|
import { createDatabase } from "./data/database.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { createApiServerManager } from "./music/api-server.js";
|
import { createApiServerManager } from "./music/api-server.js";
|
||||||
@@ -8,19 +8,29 @@ import { NeteaseProvider } from "./music/netease.js";
|
|||||||
import { QQMusicProvider } from "./music/qq.js";
|
import { QQMusicProvider } from "./music/qq.js";
|
||||||
import { BiliBiliProvider } from "./music/bilibili.js";
|
import { BiliBiliProvider } from "./music/bilibili.js";
|
||||||
import { createCookieStore } from "./music/auth.js";
|
import { createCookieStore } from "./music/auth.js";
|
||||||
|
import { createAvatarStore } from "./data/avatars.js";
|
||||||
|
import { createPermissionStore } from "./data/permissions.js";
|
||||||
import { BotManager } from "./bot/manager.js";
|
import { BotManager } from "./bot/manager.js";
|
||||||
import { createWebServer } from "./web/server.js";
|
import { createWebServer } from "./web/server.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||||
const DATA_DIR = path.join(ROOT_DIR, "data");
|
const DATA_DIR = path.join(ROOT_DIR, "data");
|
||||||
const CONFIG_PATH = path.join(ROOT_DIR, "config.json");
|
// config.json lives under the persisted data dir (the Docker volume) alongside the
|
||||||
|
// DB/cookies/logs, so it survives container restarts and manual edits take effect
|
||||||
|
// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once.
|
||||||
|
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
|
||||||
|
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json");
|
||||||
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
|
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
|
||||||
const LOG_DIR = path.join(DATA_DIR, "logs");
|
const LOG_DIR = path.join(DATA_DIR, "logs");
|
||||||
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
||||||
|
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
||||||
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
// Migrate a pre-#86 root-level config.json into the data dir so existing
|
||||||
|
// installs keep their settings; no-op if already migrated or none exists.
|
||||||
|
migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH);
|
||||||
const config = loadConfig(CONFIG_PATH);
|
const config = loadConfig(CONFIG_PATH);
|
||||||
saveConfig(CONFIG_PATH, config);
|
saveConfig(CONFIG_PATH, config);
|
||||||
|
|
||||||
@@ -46,6 +56,7 @@ async function main() {
|
|||||||
const bilibiliProvider = new BiliBiliProvider();
|
const bilibiliProvider = new BiliBiliProvider();
|
||||||
|
|
||||||
const cookieStore = createCookieStore(COOKIE_DIR);
|
const cookieStore = createCookieStore(COOKIE_DIR);
|
||||||
|
const avatarStore = createAvatarStore(AVATAR_DIR);
|
||||||
const neteaseCookie = cookieStore.load("netease");
|
const neteaseCookie = cookieStore.load("netease");
|
||||||
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
|
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
|
||||||
const qqCookie = cookieStore.load("qq");
|
const qqCookie = cookieStore.load("qq");
|
||||||
@@ -53,13 +64,17 @@ async function main() {
|
|||||||
const bilibiliCookie = cookieStore.load("bilibili");
|
const bilibiliCookie = cookieStore.load("bilibili");
|
||||||
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
|
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
|
||||||
|
|
||||||
|
const permissions = createPermissionStore(db.db);
|
||||||
|
|
||||||
const botManager = new BotManager(
|
const botManager = new BotManager(
|
||||||
neteaseProvider,
|
neteaseProvider,
|
||||||
qqProvider,
|
qqProvider,
|
||||||
bilibiliProvider,
|
bilibiliProvider,
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
logger
|
logger,
|
||||||
|
avatarStore,
|
||||||
|
permissions
|
||||||
);
|
);
|
||||||
await botManager.loadSavedBots();
|
await botManager.loadSavedBots();
|
||||||
|
|
||||||
@@ -70,6 +85,7 @@ async function main() {
|
|||||||
qqProvider,
|
qqProvider,
|
||||||
bilibiliProvider,
|
bilibiliProvider,
|
||||||
database: db,
|
database: db,
|
||||||
|
avatarStore,
|
||||||
config,
|
config,
|
||||||
configPath: CONFIG_PATH,
|
configPath: CONFIG_PATH,
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
+65
-6
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import axios, { type AxiosInstance } from "axios";
|
import axios, { type AxiosInstance } from "axios";
|
||||||
import type {
|
import type {
|
||||||
MusicProvider,
|
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",
|
"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 {
|
export class BiliBiliProvider implements MusicProvider {
|
||||||
readonly platform = "bilibili" as const;
|
readonly platform = "bilibili" as const;
|
||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
@@ -24,6 +35,8 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
private cidCache = new Map<string, number>();
|
private cidCache = new Map<string, number>();
|
||||||
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
|
private buvidCookie = ""; // anonymous session cookie (buvid3) for anti-412
|
||||||
private buvidInitialized = false;
|
private buvidInitialized = false;
|
||||||
|
private wbiMixinKey = "";
|
||||||
|
private wbiKeyFetchedAt = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
@@ -62,6 +75,50 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
return combined ? { Cookie: combined } : {};
|
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 {
|
setQuality(quality: string): void {
|
||||||
this.quality = quality;
|
this.quality = quality;
|
||||||
}
|
}
|
||||||
@@ -91,12 +148,14 @@ export class BiliBiliProvider implements MusicProvider {
|
|||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
await this.ensureBuvidCookie();
|
await this.ensureBuvidCookie();
|
||||||
const res = await this.api.get("/x/web-interface/search/type", {
|
await this.ensureWbiKeys();
|
||||||
params: {
|
const signed = this.signWbi({
|
||||||
search_type: "video",
|
search_type: "video",
|
||||||
keyword: query,
|
keyword: query,
|
||||||
page_size: limit,
|
page_size: limit,
|
||||||
},
|
});
|
||||||
|
const res = await this.api.get("/x/web-interface/wbi/search/type", {
|
||||||
|
params: signed,
|
||||||
headers: this.cookieHeaders,
|
headers: this.cookieHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ export class NeteaseProvider implements MusicProvider {
|
|||||||
params: {
|
params: {
|
||||||
keywords: query,
|
keywords: query,
|
||||||
type: 1000,
|
type: 1000,
|
||||||
limit: 5,
|
limit: 10,
|
||||||
...this.cookieParams,
|
...this.cookieParams,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.api.get("/cloudsearch", {
|
this.api.get("/cloudsearch", {
|
||||||
params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
|
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -1,7 +1,31 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { mapQqAlbums } from "./qq.js";
|
import { mapQqAlbums, mapQqSongs } from "./qq.js";
|
||||||
|
|
||||||
describe("QQ adapter", () => {
|
describe("QQ adapter", () => {
|
||||||
|
it("mapQqSongs maps QQMusicApi-style song entries", () => {
|
||||||
|
const out = mapQqSongs([
|
||||||
|
{
|
||||||
|
mid: "001abc",
|
||||||
|
name: "Radar Song",
|
||||||
|
singer: [{ name: "Singer A" }, { name: "Singer B" }],
|
||||||
|
album: { name: "Album A", mid: "alb001" },
|
||||||
|
interval: 243,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
id: "001abc",
|
||||||
|
name: "Radar Song",
|
||||||
|
artist: "Singer A / Singer B",
|
||||||
|
album: "Album A",
|
||||||
|
duration: 243,
|
||||||
|
coverUrl: "https://y.gtimg.cn/music/photo_new/T002R300x300M000alb001.jpg",
|
||||||
|
platform: "qq",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
it("mapQqAlbums maps albumMID-style raw entries", () => {
|
||||||
const raw = [
|
const raw = [
|
||||||
{
|
{
|
||||||
|
|||||||
+225
-56
@@ -12,14 +12,25 @@ import type {
|
|||||||
} from "./provider.js";
|
} from "./provider.js";
|
||||||
import { parseLyrics } from "./netease.js";
|
import { parseLyrics } from "./netease.js";
|
||||||
|
|
||||||
// Direct QQ Music API client — bypasses the local API server for search
|
// Primary search client: u.y.qq.com/cgi-bin/musicu.fcg (JSON sub-request
|
||||||
// because @sansenjian/qq-music-api still uses the broken c.y.qq.com endpoint.
|
// batch). Was broken ca. 2026-05 due to two upstream API changes:
|
||||||
const qqDirectApi = axios.create({
|
// 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",
|
baseURL: "https://u.y.qq.com",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: { referer: "https://y.qq.com" },
|
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).
|
// Direct client for c.y.qq.com endpoints (collected playlists / favorites).
|
||||||
// The bundled qq-music-api wrapper doesn't expose these endpoints.
|
// The bundled qq-music-api wrapper doesn't expose these endpoints.
|
||||||
const qqFavApi = axios.create({
|
const qqFavApi = axios.create({
|
||||||
@@ -28,6 +39,24 @@ const qqFavApi = axios.create({
|
|||||||
headers: { referer: "https://y.qq.com/" },
|
headers: { referer: "https://y.qq.com/" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function mapQqSongs(raw: any[] | null | undefined): Song[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((s) => {
|
||||||
|
const albumMid = s.album?.mid ?? s.album?.pmid ?? s.albummid ?? s.albumMid ?? "";
|
||||||
|
return {
|
||||||
|
id: String(s.mid ?? s.songmid ?? s.songMID ?? s.id ?? s.songid ?? s.songId ?? ""),
|
||||||
|
name: s.title ?? s.name ?? s.songname ?? "",
|
||||||
|
artist: (s.singer ?? s.singers ?? []).map((a: any) => a.name ?? a.title ?? "").filter(Boolean).join(" / "),
|
||||||
|
album: s.album?.name ?? s.album?.title ?? s.albumname ?? "",
|
||||||
|
duration: s.interval ?? Math.round((s.duration ?? 0) / 1000),
|
||||||
|
coverUrl: albumMid
|
||||||
|
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
||||||
|
: "",
|
||||||
|
platform: "qq" as const,
|
||||||
|
};
|
||||||
|
}).filter((s) => s.id);
|
||||||
|
}
|
||||||
|
|
||||||
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw.map((a) => {
|
return raw.map((a) => {
|
||||||
@@ -61,6 +90,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
private api: AxiosInstance;
|
private api: AxiosInstance;
|
||||||
private cookie = "";
|
private cookie = "";
|
||||||
private quality = "exhigh";
|
private quality = "exhigh";
|
||||||
|
private radarPage = 1;
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
this.api = axios.create({
|
this.api = axios.create({
|
||||||
@@ -81,38 +111,137 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
return this.cookie ? { cookie: this.cookie } : {};
|
return this.cookie ? { cookie: this.cookie } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get directCookieHeaders(): Record<string, string> {
|
||||||
|
return this.cookie ? { Cookie: this.cookie } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMusicuPayload(module: string, method: string, param: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const uinMatch = /(?:^|; )(?:uin|qqmusic_uin)=o?0?(\d+)/.exec(this.cookie);
|
||||||
|
const pSkeyMatch = /(?:^|; )p_skey=([^;]+)/.exec(this.cookie);
|
||||||
|
return {
|
||||||
|
comm: {
|
||||||
|
ct: 24,
|
||||||
|
cv: 4747474,
|
||||||
|
platform: "yqq.json",
|
||||||
|
uin: uinMatch ? uinMatch[1] : "0",
|
||||||
|
g_tk: pSkeyMatch ? computeGtk(pSkeyMatch[1]) : 5381,
|
||||||
|
format: "json",
|
||||||
|
inCharset: "utf-8",
|
||||||
|
outCharset: "utf-8",
|
||||||
|
notice: 0,
|
||||||
|
need_new_code: 1,
|
||||||
|
},
|
||||||
|
req_0: { module, method, param },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||||
const reqData = JSON.stringify({
|
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
|
||||||
req_0: {
|
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
|
||||||
module: "music.search.SearchCgiService",
|
// (removed searchid, num_per_page >= 10, corrected search_type values).
|
||||||
method: "DoSearchForQQMusicDesktop",
|
const primary = await this.searchViaMusicuFcg(query, limit);
|
||||||
param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
|
if (primary) return primary;
|
||||||
},
|
|
||||||
req_album: {
|
|
||||||
module: "music.search.SearchCgiService",
|
|
||||||
method: "DoSearchForQQMusicDesktop",
|
|
||||||
param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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 ?? [];
|
|
||||||
|
|
||||||
const songs: Song[] = list.map((s: any) => ({
|
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
|
||||||
id: String(s.mid ?? s.id),
|
// no playlist support). Kept as redundancy.
|
||||||
name: s.title ?? s.name ?? "",
|
return this.searchViaClientSearchCp(query, limit);
|
||||||
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 ?? [];
|
/** 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 = mapQqSongs(songList);
|
||||||
|
|
||||||
|
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 = mapQqSongs(songList);
|
||||||
|
|
||||||
|
const albumList: any[] =
|
||||||
|
albumRes.status === "fulfilled"
|
||||||
|
? (albumRes.value.data?.data?.album?.list ?? [])
|
||||||
|
: [];
|
||||||
const albums = mapQqAlbums(albumList);
|
const albums = mapQqAlbums(albumList);
|
||||||
|
|
||||||
return { songs, playlists: [], albums };
|
return { songs, playlists: [], albums };
|
||||||
@@ -233,19 +362,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
});
|
});
|
||||||
const cdlist = res.data?.response?.cdlist ?? [];
|
const cdlist = res.data?.response?.cdlist ?? [];
|
||||||
if (cdlist.length === 0) return [];
|
if (cdlist.length === 0) return [];
|
||||||
return (cdlist[0].songlist ?? []).map((s: any) => ({
|
return mapQqSongs(cdlist[0].songlist ?? []);
|
||||||
id: String(s.mid ?? s.songmid ?? s.songid),
|
|
||||||
name: s.songname ?? s.name ?? "",
|
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
|
||||||
album: s.albumname ?? "",
|
|
||||||
duration: s.interval ?? 0,
|
|
||||||
coverUrl: s.album?.mid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
|
||||||
: s.albummid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
|
||||||
: "",
|
|
||||||
platform: "qq",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
async getPlaylistDetail(playlistId: string): Promise<PlaylistDetail | null> {
|
||||||
@@ -280,17 +397,7 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
const res = await this.api.get("/getAlbumInfo", {
|
const res = await this.api.get("/getAlbumInfo", {
|
||||||
params: { albummid: albumId, ...this.cookieParams },
|
params: { albummid: albumId, ...this.cookieParams },
|
||||||
});
|
});
|
||||||
return (res.data?.response?.data?.list ?? []).map((s: any) => ({
|
return mapQqSongs(res.data?.response?.data?.list ?? []);
|
||||||
id: String(s.songmid ?? s.songid),
|
|
||||||
name: s.songname ?? "",
|
|
||||||
artist: (s.singer ?? []).map((a: any) => a.name).join(" / "),
|
|
||||||
album: s.albumname ?? "",
|
|
||||||
duration: s.interval ?? 0,
|
|
||||||
coverUrl: s.albummid
|
|
||||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
|
||||||
: "",
|
|
||||||
platform: "qq",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||||
@@ -355,6 +462,9 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
|
|
||||||
setCookie(cookie: string): void {
|
setCookie(cookie: string): void {
|
||||||
this.cookie = cookie;
|
this.cookie = cookie;
|
||||||
|
// Reset radar pagination so a re-login (different account) starts from the
|
||||||
|
// first page rather than inheriting the previous account's cursor.
|
||||||
|
this.radarPage = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCookie(): string {
|
getCookie(): string {
|
||||||
@@ -416,6 +526,65 @@ export class QQMusicProvider implements MusicProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPersonalFm(): Promise<Song[]> {
|
||||||
|
const radarSongs = await this.getRadarRecommendSongs();
|
||||||
|
if (radarSongs.length > 0) return radarSongs;
|
||||||
|
return this.getGuessRecommendSongs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRadarRecommendSongs(): Promise<Song[]> {
|
||||||
|
try {
|
||||||
|
const page = this.radarPage;
|
||||||
|
const res = await qqMusicuApi.post(
|
||||||
|
"/cgi-bin/musicu.fcg",
|
||||||
|
this.buildMusicuPayload(
|
||||||
|
"music.recommend.TrackRelationServer",
|
||||||
|
"GetRadarSong",
|
||||||
|
{
|
||||||
|
Page: page,
|
||||||
|
ReqType: 0,
|
||||||
|
FavSongs: [],
|
||||||
|
EntranceSongs: [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
||||||
|
);
|
||||||
|
const tracks = (res.data?.req_0?.data?.VecSongs ?? [])
|
||||||
|
.map((item: any) => item?.Track)
|
||||||
|
.filter(Boolean);
|
||||||
|
const songs = mapQqSongs(tracks);
|
||||||
|
if (songs.length > 0) {
|
||||||
|
this.radarPage = page + 1;
|
||||||
|
}
|
||||||
|
return songs;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getGuessRecommendSongs(): Promise<Song[]> {
|
||||||
|
try {
|
||||||
|
const res = await qqMusicuApi.post(
|
||||||
|
"/cgi-bin/musicu.fcg",
|
||||||
|
this.buildMusicuPayload(
|
||||||
|
"music.radioProxy.MbTrackRadioSvr",
|
||||||
|
"get_radio_track",
|
||||||
|
{
|
||||||
|
id: 99,
|
||||||
|
num: 5,
|
||||||
|
from: 0,
|
||||||
|
scene: 0,
|
||||||
|
song_ids: [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ headers: { referer: "https://y.qq.com/", ...this.directCookieHeaders } }
|
||||||
|
);
|
||||||
|
return mapQqSongs(res.data?.req_0?.data?.Tracks ?? []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getUserPlaylists(): Promise<Playlist[]> {
|
async getUserPlaylists(): Promise<Playlist[]> {
|
||||||
if (!this.cookie) return [];
|
if (!this.cookie) return [];
|
||||||
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
const uinMatch = /(?:^|; )uin=o?0?(\d+)/.exec(this.cookie);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type Identity,
|
type Identity,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type ClientInfo,
|
type ClientInfo,
|
||||||
|
type ClientLeftViewEvent,
|
||||||
|
type ClientMovedEvent,
|
||||||
type FileUploadInfo,
|
type FileUploadInfo,
|
||||||
} from "@honeybbq/teamspeak-client";
|
} from "@honeybbq/teamspeak-client";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
@@ -221,6 +223,20 @@ export class TS3Client extends EventEmitter {
|
|||||||
{ nickname: info.nickname, id: info.id },
|
{ nickname: info.nickname, id: info.id },
|
||||||
"Client entered"
|
"Client entered"
|
||||||
);
|
);
|
||||||
|
this.emit("clientEnter", info);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
|
||||||
|
this.logger.debug({ id: ev.id }, "Client left");
|
||||||
|
this.emit("clientLeave", ev);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("clientMoved", (ev: ClientMovedEvent) => {
|
||||||
|
this.logger.debug(
|
||||||
|
{ id: ev.id, targetChannelID: ev.targetChannelID.toString() },
|
||||||
|
"Client moved"
|
||||||
|
);
|
||||||
|
this.emit("clientMoved", ev);
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore } from "../../data/sessions.js";
|
||||||
|
import { createAuditStore } from "../../data/audit.js";
|
||||||
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
|
import { createAuditRouter } from "./audit.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
describe("audit router", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let app: express.Express;
|
||||||
|
let cookie: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
const users = createUserStore(botDb.db);
|
||||||
|
const sessions = createSessionStore(botDb.db);
|
||||||
|
const audit = createAuditStore(botDb.db);
|
||||||
|
const permissions = createPermissionStore(botDb.db);
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
audit.record({
|
||||||
|
actorId: alice.id, actorUsername: "alice",
|
||||||
|
targetUserId: "x", targetUsername: "x",
|
||||||
|
action: "user.created",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use("/api", createRequireAuth(sessions, permissions));
|
||||||
|
app.use("/api/audit", createAuditRouter(audit));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => botDb.close());
|
||||||
|
|
||||||
|
it("requires auth", async () => {
|
||||||
|
const res = await request(app).get("/api/audit");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns entries newest-first", async () => {
|
||||||
|
const res = await request(app).get("/api/audit").set("Cookie", cookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.entries).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors limit query param", async () => {
|
||||||
|
const res = await request(app).get("/api/audit?limit=1").set("Cookie", cookie);
|
||||||
|
expect(res.body.entries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { AuditStore } from "../../data/audit.js";
|
||||||
|
|
||||||
|
export function createAuditRouter(audit: AuditStore): Router {
|
||||||
|
const router = Router();
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const limit = clampInt(req.query.limit, 1, 500, 100);
|
||||||
|
const offset = clampInt(req.query.offset, 0, 100_000, 0);
|
||||||
|
res.json({ entries: audit.list(limit, offset) });
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(v: unknown, min: number, max: number, def: number): number {
|
||||||
|
const n = typeof v === "string" ? parseInt(v, 10) : NaN;
|
||||||
|
if (!Number.isFinite(n)) return def;
|
||||||
|
return Math.min(Math.max(n, min), max);
|
||||||
|
}
|
||||||
+5
-4
@@ -3,6 +3,7 @@ import type { MusicProvider } from "../../music/provider.js";
|
|||||||
import { YouTubeProvider } from "../../music/youtube.js";
|
import { YouTubeProvider } from "../../music/youtube.js";
|
||||||
import type { CookieStore } from "../../music/auth.js";
|
import type { CookieStore } from "../../music/auth.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
|
import { requirePermission } from "../middleware/requirePermission.js";
|
||||||
|
|
||||||
export function createAuthRouter(
|
export function createAuthRouter(
|
||||||
neteaseProvider: MusicProvider,
|
neteaseProvider: MusicProvider,
|
||||||
@@ -35,7 +36,7 @@ export function createAuthRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/qrcode", async (req, res) => {
|
router.post("/qrcode", requirePermission("platform.auth"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { platform } = req.body;
|
const { platform } = req.body;
|
||||||
const provider = getProvider(platform);
|
const provider = getProvider(platform);
|
||||||
@@ -77,7 +78,7 @@ export function createAuthRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/sms/send", async (req, res) => {
|
router.post("/sms/send", requirePermission("platform.auth"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { phone } = req.body;
|
const { phone } = req.body;
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
@@ -97,7 +98,7 @@ export function createAuthRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/sms/verify", async (req, res) => {
|
router.post("/sms/verify", requirePermission("platform.auth"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { phone, code } = req.body;
|
const { phone, code } = req.body;
|
||||||
if (!phone || !code) {
|
if (!phone || !code) {
|
||||||
@@ -118,7 +119,7 @@ export function createAuthRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/cookie", (req, res) => {
|
router.post("/cookie", requirePermission("platform.auth"), (req, res) => {
|
||||||
const { platform, cookie } = req.body;
|
const { platform, cookie } = req.body;
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
res.status(400).json({ error: "cookie is required" });
|
res.status(400).json({ error: "cookie is required" });
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createBotRouter } from "./bot.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
|
// Fake bot whose getStatus() exposes its id, matching the real status shape.
|
||||||
|
function makeFakeBot(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
getStatus: () => ({ id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBotManager() {
|
||||||
|
const b1 = makeFakeBot("b1");
|
||||||
|
const b2 = makeFakeBot("b2");
|
||||||
|
return {
|
||||||
|
getBot: (id: string) => (id === "b1" ? b1 : id === "b2" ? b2 : undefined),
|
||||||
|
getAllBots: () => [b1, b2],
|
||||||
|
getBotConfig: () => undefined,
|
||||||
|
createBot: async () => b1,
|
||||||
|
updateBot: () => {},
|
||||||
|
removeBot: async () => {},
|
||||||
|
startBot: async () => {},
|
||||||
|
stopBot: () => {},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(user: any) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||||
|
app.use(
|
||||||
|
"/api/bot",
|
||||||
|
createBotRouter(
|
||||||
|
makeBotManager(),
|
||||||
|
{ idleTimeoutMinutes: 0 } as any,
|
||||||
|
"/tmp/config.json",
|
||||||
|
logger,
|
||||||
|
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
|
||||||
|
{ read: () => null, write: () => "x", remove: () => {} } as any,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = (bots: "all" | string[]) => ({
|
||||||
|
id: "u1",
|
||||||
|
username: "alice",
|
||||||
|
role: "member" as const,
|
||||||
|
capabilities: new Set<string>(),
|
||||||
|
bots: bots === "all" ? ("all" as const) : new Set(bots),
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = {
|
||||||
|
id: "a",
|
||||||
|
username: "admin",
|
||||||
|
role: "admin" as const,
|
||||||
|
capabilities: new Set<string>(),
|
||||||
|
bots: "all" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("GET /api/bot bot-list filtering", () => {
|
||||||
|
it("member with bots:Set([b1]) sees only b1", async () => {
|
||||||
|
const app = makeApp(member(["b1"]));
|
||||||
|
const res = await request(app).get("/api/bot");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ids = (res.body.bots as { id: string }[]).map((b) => b.id);
|
||||||
|
expect(ids).toEqual(["b1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin sees both b1 and b2", async () => {
|
||||||
|
const app = makeApp(admin);
|
||||||
|
const res = await request(app).get("/api/bot");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
|
||||||
|
expect(ids).toEqual(["b1", "b2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member with bots:'all' sees both b1 and b2", async () => {
|
||||||
|
const app = makeApp(member("all"));
|
||||||
|
const res = await request(app).get("/api/bot");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ids = (res.body.bots as { id: string }[]).map((b) => b.id).sort();
|
||||||
|
expect(ids).toEqual(["b1", "b2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore } from "../../data/sessions.js";
|
||||||
|
import { createAvatarStore } from "../../data/avatars.js";
|
||||||
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
|
import { createBotRouter } from "./bot.js";
|
||||||
|
import { getDefaultConfig, type BotConfig } from "../../data/config.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
|
|
||||||
|
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
|
||||||
|
function makeFakeBot() {
|
||||||
|
return {
|
||||||
|
idleTimeoutCalls: [] as number[],
|
||||||
|
autoPauseCalls: [] as boolean[],
|
||||||
|
updateIdleTimeout(minutes: number) {
|
||||||
|
this.idleTimeoutCalls.push(minutes);
|
||||||
|
},
|
||||||
|
updateAutoPause(enabled: boolean) {
|
||||||
|
this.autoPauseCalls.push(enabled);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bot router /settings", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let app: express.Express;
|
||||||
|
let cookie: string;
|
||||||
|
let config: BotConfig;
|
||||||
|
let configPath: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
let fakeBots: ReturnType<typeof makeFakeBot>[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
const users = createUserStore(botDb.db);
|
||||||
|
const sessions = createSessionStore(botDb.db);
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||||
|
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-"));
|
||||||
|
configPath = join(tmpDir, "config.json");
|
||||||
|
config = { ...getDefaultConfig(), idleTimeoutMinutes: 15, autoPauseOnEmpty: true };
|
||||||
|
|
||||||
|
fakeBots = [makeFakeBot(), makeFakeBot()];
|
||||||
|
const fakeManager = {
|
||||||
|
getAllBots: () => fakeBots,
|
||||||
|
} as unknown as BotManager;
|
||||||
|
const avatarStore = createAvatarStore(tmpDir);
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
|
||||||
|
app.use(
|
||||||
|
"/api/bot",
|
||||||
|
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
botDb.close();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires auth", async () => {
|
||||||
|
const res = await request(app).get("/api/bot/settings");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /settings includes autoPauseOnEmpty reflecting config", async () => {
|
||||||
|
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.idleTimeoutMinutes).toBe(15);
|
||||||
|
expect(res.body.autoPauseOnEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// in-memory config mutated
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(false);
|
||||||
|
|
||||||
|
// propagated to every live bot
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow-up GET reflects the new value
|
||||||
|
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(followUp.body.autoPauseOnEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings still handles idleTimeoutMinutes (no regression)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 42 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(42);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([42]);
|
||||||
|
}
|
||||||
|
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
|
||||||
|
expect(followUp.body.idleTimeoutMinutes).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings handles both fields together", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 7, autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(7);
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(false);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([7]);
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings with only autoPauseOnEmpty does not touch idleTimeout bots", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ autoPauseOnEmpty: false });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.idleTimeoutCalls).toEqual([]);
|
||||||
|
expect(bot.autoPauseCalls).toEqual([false]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings ignores non-boolean autoPauseOnEmpty without 400", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot/settings")
|
||||||
|
.set("Cookie", cookie)
|
||||||
|
.send({ idleTimeoutMinutes: 5, autoPauseOnEmpty: "yes" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// idleTimeout still applied
|
||||||
|
expect(config.idleTimeoutMinutes).toBe(5);
|
||||||
|
// autoPause left at its prior value, not propagated
|
||||||
|
expect(config.autoPauseOnEmpty).toBe(true);
|
||||||
|
for (const bot of fakeBots) {
|
||||||
|
expect(bot.autoPauseCalls).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+126
-32
@@ -3,21 +3,69 @@ import type { BotManager } from "../../bot/manager.js";
|
|||||||
import type { BotConfig } from "../../data/config.js";
|
import type { BotConfig } from "../../data/config.js";
|
||||||
import { saveConfig } from "../../data/config.js";
|
import { saveConfig } from "../../data/config.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
|
import type { AvatarStore } from "../../data/avatars.js";
|
||||||
|
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||||
|
|
||||||
export function createBotRouter(
|
export function createBotRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
configPath: string,
|
configPath: string,
|
||||||
logger: Logger
|
logger: Logger,
|
||||||
|
botDb: BotDatabase,
|
||||||
|
avatarStore: AvatarStore,
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", (_req, res) => {
|
router.get("/", (req, res) => {
|
||||||
const bots = botManager.getAllBots().map((b) => b.getStatus());
|
const all = botManager.getAllBots().map((b) => b.getStatus());
|
||||||
|
const u = req.user!;
|
||||||
|
const bots =
|
||||||
|
u.role === "admin" || u.bots === "all"
|
||||||
|
? all
|
||||||
|
: all.filter((b) => u.bots instanceof Set && u.bots.has(b.id));
|
||||||
res.json({ bots });
|
res.json({ bots });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:id", (req, res) => {
|
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||||
|
// NOTE: must be registered before "/:id" so it isn't shadowed by the param route.
|
||||||
|
router.get("/settings", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
|
||||||
|
// behavior is a bot.manage operation, consistent with PR #80's permission model)
|
||||||
|
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
|
||||||
|
const { idleTimeoutMinutes, autoPauseOnEmpty } = req.body;
|
||||||
|
|
||||||
|
const hasIdle = idleTimeoutMinutes !== undefined;
|
||||||
|
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
|
||||||
|
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAutoPause = typeof autoPauseOnEmpty === "boolean";
|
||||||
|
|
||||||
|
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||||
|
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||||
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
// 通知所有 bot 实例更新
|
||||||
|
for (const bot of botManager.getAllBots()) {
|
||||||
|
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
|
||||||
|
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
|
||||||
|
autoPauseOnEmpty: config.autoPauseOnEmpty,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id", requireBotAccess("id"), (req, res) => {
|
||||||
const bot = botManager.getBot(req.params.id);
|
const bot = botManager.getBot(req.params.id);
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
res.status(404).json({ error: "Bot not found" });
|
res.status(404).json({ error: "Bot not found" });
|
||||||
@@ -27,16 +75,83 @@ export function createBotRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get saved config for a bot
|
// Get saved config for a bot
|
||||||
router.get("/:id/config", (req, res) => {
|
router.get("/:id/config", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||||
const saved = botManager.getBotConfig(req.params.id);
|
const saved = botManager.getBotConfig(req.params.id);
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
res.status(404).json({ error: "Bot config not found" });
|
res.status(404).json({ error: "Bot config not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json(saved);
|
// Never expose the TS identity / API key to the client; the edit form only
|
||||||
|
// consumes channel/server passwords.
|
||||||
|
const { ts6ApiKey: _ts6ApiKey, identity: _identity, ...safe } = saved as unknown as Record<string, unknown>;
|
||||||
|
res.json(safe);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/", async (req, res) => {
|
router.get("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||||
|
const path = botDb.getCustomAvatarPath(req.params.id);
|
||||||
|
if (!path) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buf = avatarStore.read(path);
|
||||||
|
if (!buf) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ext = path.split(".").pop() ?? "";
|
||||||
|
const mime = ext === "png"
|
||||||
|
? "image/png"
|
||||||
|
: ext === "webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "image/jpeg";
|
||||||
|
res.set("Content-Type", mime);
|
||||||
|
res.set("Cache-Control", "no-cache");
|
||||||
|
res.send(buf);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||||
|
const exists =
|
||||||
|
botManager.getBot(req.params.id) ||
|
||||||
|
botDb.getBotInstances().some((b) => b.id === req.params.id);
|
||||||
|
if (!exists) {
|
||||||
|
res.status(404).json({ error: "Bot not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { dataUrl } = req.body as { dataUrl?: string };
|
||||||
|
if (typeof dataUrl !== "string") {
|
||||||
|
res.status(400).json({ error: "dataUrl required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m = /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/.exec(dataUrl);
|
||||||
|
if (!m) {
|
||||||
|
res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mime = m[1] as string;
|
||||||
|
const buf = Buffer.from(m[2] ?? "", "base64");
|
||||||
|
if (buf.length === 0) {
|
||||||
|
res.status(400).json({ error: "empty image" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buf.length > 200 * 1024) {
|
||||||
|
res.status(413).json({ error: "avatar exceeds 200KB limit" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rel = avatarStore.write(req.params.id, mime, buf);
|
||||||
|
botDb.setCustomAvatarPath(req.params.id, rel);
|
||||||
|
botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(buf);
|
||||||
|
res.json({ path: rel });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id/avatar", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||||
|
const path = botDb.getCustomAvatarPath(req.params.id);
|
||||||
|
if (path) avatarStore.remove(path);
|
||||||
|
botDb.setCustomAvatarPath(req.params.id, null);
|
||||||
|
botManager.getBot(req.params.id)?.getProfileManager().setCustomAvatar(null);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", requirePermission("bot.manage"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
@@ -72,7 +187,7 @@ export function createBotRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update bot config (must be stopped first to apply connection changes)
|
// Update bot config (must be stopped first to apply connection changes)
|
||||||
router.put("/:id", async (req, res) => {
|
router.put("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = botManager.getBot(req.params.id);
|
const bot = botManager.getBot(req.params.id);
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
@@ -91,7 +206,7 @@ export function createBotRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/:id", async (req, res) => {
|
router.delete("/:id", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await botManager.removeBot(req.params.id);
|
await botManager.removeBot(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -100,7 +215,7 @@ export function createBotRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:id/start", async (req, res) => {
|
router.post("/:id/start", requirePermission("bot.manage"), requireBotAccess("id"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await botManager.startBot(req.params.id);
|
await botManager.startBot(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -109,7 +224,7 @@ export function createBotRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:id/stop", (req, res) => {
|
router.post("/:id/stop", requirePermission("bot.manage"), requireBotAccess("id"), (req, res) => {
|
||||||
try {
|
try {
|
||||||
botManager.stopBot(req.params.id);
|
botManager.stopBot(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -117,27 +232,6 @@ export function createBotRouter(
|
|||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/bot/settings — 读取全局 bot 行为设置
|
|
||||||
router.get("/settings", (_req, res) => {
|
|
||||||
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/bot/settings — 保存全局 bot 行为设置
|
|
||||||
router.post("/settings", (req, res) => {
|
|
||||||
const { idleTimeoutMinutes } = req.body;
|
|
||||||
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
|
|
||||||
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
config.idleTimeoutMinutes = idleTimeoutMinutes;
|
|
||||||
saveConfig(configPath, config);
|
|
||||||
// 通知所有 bot 实例更新定时器
|
|
||||||
for (const bot of botManager.getAllBots()) {
|
|
||||||
bot.updateIdleTimeout(idleTimeoutMinutes);
|
|
||||||
}
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
|
||||||
|
export function createFavoritesRouter(database: BotDatabase, logger: Logger): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/favorites — 获取当前用户的所有收藏
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const favorites = database.getFavorites(userId);
|
||||||
|
res.json({ favorites });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/favorites — 添加收藏
|
||||||
|
router.post("/", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const { platform, playlistId, name, coverUrl, songCount } = req.body ?? {};
|
||||||
|
if (!platform || !playlistId || !name) {
|
||||||
|
res.status(400).json({ error: "platform, playlistId, name are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
database.addFavorite(userId, {
|
||||||
|
platform,
|
||||||
|
playlistId,
|
||||||
|
name,
|
||||||
|
coverUrl: coverUrl ?? "",
|
||||||
|
songCount: songCount ?? 0,
|
||||||
|
});
|
||||||
|
logger.info({ userId, platform, playlistId, name }, "Playlist favorited");
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { code?: string };
|
||||||
|
if (e?.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||||
|
res.status(409).json({ error: "already favorited" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error({ err }, "Failed to add favorite");
|
||||||
|
res.status(500).json({ error: "internal error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/favorites/:id — 取消收藏(只允许删除自己的)
|
||||||
|
router.delete("/:id", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const favId = parseInt(req.params.id, 10);
|
||||||
|
if (isNaN(favId)) {
|
||||||
|
res.status(400).json({ error: "invalid id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const favorites = database.getFavorites(userId);
|
||||||
|
const fav = favorites.find((f) => f.id === favId);
|
||||||
|
if (!fav) {
|
||||||
|
res.status(404).json({ error: "favorite not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
database.removeFavorite(userId, fav.playlistId, fav.platform);
|
||||||
|
logger.info({ userId, playlistId: fav.playlistId, platform: fav.platform }, "Playlist unfavorited");
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/favorites/check?platform=netease&playlistId=xxx — 检查是否已收藏
|
||||||
|
router.get("/check", (req, res) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const { platform, playlistId } = req.query;
|
||||||
|
if (typeof platform !== "string" || typeof playlistId !== "string") {
|
||||||
|
res.status(400).json({ error: "platform and playlistId required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const favorited = database.isFavorited(userId, playlistId, platform);
|
||||||
|
res.json({ favorited });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|||||||
import type { MusicProvider } from "../../music/provider.js";
|
import type { MusicProvider } from "../../music/provider.js";
|
||||||
import { YouTubeProvider } from "../../music/youtube.js";
|
import { YouTubeProvider } from "../../music/youtube.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
|
import { requirePermission } from "../middleware/requirePermission.js";
|
||||||
|
|
||||||
export function createMusicRouter(
|
export function createMusicRouter(
|
||||||
neteaseProvider: MusicProvider,
|
neteaseProvider: MusicProvider,
|
||||||
@@ -217,7 +218,7 @@ export function createMusicRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set quality
|
// Set quality
|
||||||
router.post("/quality", (req, res) => {
|
router.post("/quality", requirePermission("quality"), (req, res) => {
|
||||||
const { quality, platform } = req.body;
|
const { quality, platform } = req.body;
|
||||||
if (!quality) {
|
if (!quality) {
|
||||||
res.status(400).json({ error: "quality is required" });
|
res.status(400).json({ error: "quality is required" });
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createPlayerRouter } from "./player.js";
|
||||||
|
import { createBotRouter } from "./bot.js";
|
||||||
|
import { createAuthRouter } from "./auth.js";
|
||||||
|
import { createMusicRouter } from "./music.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
|
||||||
|
// --- minimal stubs --------------------------------------------------------
|
||||||
|
|
||||||
|
const ALLOWED_BOT = "bot-allowed";
|
||||||
|
|
||||||
|
// A fake bot whose methods all no-op / return benign values so the real
|
||||||
|
// handlers run to completion without 500ing. We only assert that the
|
||||||
|
// permission/bot-access gate let the request THROUGH (status !== 403).
|
||||||
|
function makeFakeBot(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
executeCommand: async () => "ok",
|
||||||
|
getStatus: () => ({ id }),
|
||||||
|
getQueue: () => [],
|
||||||
|
getProfileManager: () => ({ getConfig: () => ({}), updateConfig: () => {}, setCustomAvatar: () => {} }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBotManager() {
|
||||||
|
const bot = makeFakeBot(ALLOWED_BOT);
|
||||||
|
return {
|
||||||
|
getBot: (id: string) => (id === ALLOWED_BOT ? bot : undefined),
|
||||||
|
getAllBots: () => [bot],
|
||||||
|
getBotConfig: () => undefined,
|
||||||
|
createBot: async () => bot,
|
||||||
|
updateBot: () => {},
|
||||||
|
removeBot: async () => {},
|
||||||
|
startBot: async () => {},
|
||||||
|
stopBot: () => {},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProvider() {
|
||||||
|
return {
|
||||||
|
platform: "netease",
|
||||||
|
getQuality: () => "high",
|
||||||
|
setQuality: () => {},
|
||||||
|
getAuthStatus: async () => ({ loggedIn: false }),
|
||||||
|
getQrCode: async () => ({ key: "k", url: "u" }),
|
||||||
|
getCookie: () => "c",
|
||||||
|
setCookie: () => {},
|
||||||
|
search: async () => ({ songs: [], albums: [], playlists: [] }),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one app mounting all four real routers, with req.user injected by a
|
||||||
|
// middleware placed BEFORE the routers (mimicking what requireAuth does).
|
||||||
|
function makeApp(user: any) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||||
|
|
||||||
|
const botManager = makeBotManager();
|
||||||
|
const provider = makeProvider();
|
||||||
|
|
||||||
|
app.use("/api/player", createPlayerRouter(botManager, logger));
|
||||||
|
app.use(
|
||||||
|
"/api/bot",
|
||||||
|
createBotRouter(
|
||||||
|
botManager,
|
||||||
|
{ idleTimeoutMinutes: 0 } as any,
|
||||||
|
"/tmp/config.json",
|
||||||
|
logger,
|
||||||
|
{ getBotInstances: () => [], getCustomAvatarPath: () => null, setCustomAvatarPath: () => {} } as any,
|
||||||
|
{ read: () => null, write: () => "x", remove: () => {} } as any,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.use("/api/auth", createAuthRouter(provider, provider, provider, logger));
|
||||||
|
app.use("/api/music", createMusicRouter(provider, provider, provider, logger));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = (caps: string[], bots: "all" | string[]) => ({
|
||||||
|
id: "u1",
|
||||||
|
username: "alice",
|
||||||
|
role: "member" as const,
|
||||||
|
capabilities: new Set(caps),
|
||||||
|
bots: bots === "all" ? ("all" as const) : new Set(bots),
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = {
|
||||||
|
id: "a",
|
||||||
|
username: "admin",
|
||||||
|
role: "admin" as const,
|
||||||
|
capabilities: new Set<string>(),
|
||||||
|
bots: "all" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("permission enforcement on action routes", () => {
|
||||||
|
describe("player.control", () => {
|
||||||
|
it("403 for member WITHOUT player.control", async () => {
|
||||||
|
const app = makeApp(member([], [ALLOWED_BOT]));
|
||||||
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NOT 403 for member WITH player.control + bot in allow-list", async () => {
|
||||||
|
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
|
||||||
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403 for member WITH player.control but bot NOT in allow-list", async () => {
|
||||||
|
const app = makeApp(member(["player.control"], ["other-bot"]));
|
||||||
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/pause`);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("player.queue", () => {
|
||||||
|
it("403 for member WITHOUT player.queue", async () => {
|
||||||
|
const app = makeApp(member(["player.control"], [ALLOWED_BOT]));
|
||||||
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NOT 403 for member WITH player.queue", async () => {
|
||||||
|
const app = makeApp(member(["player.queue"], [ALLOWED_BOT]));
|
||||||
|
const res = await request(app).post(`/api/player/${ALLOWED_BOT}/clear`);
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bot.manage", () => {
|
||||||
|
it("403 for member WITHOUT bot.manage on POST /api/bot", async () => {
|
||||||
|
const app = makeApp(member([], "all"));
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot")
|
||||||
|
.send({ name: "n", serverAddress: "s", nickname: "nick" });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NOT 403 for member WITH bot.manage on POST /api/bot", async () => {
|
||||||
|
const app = makeApp(member(["bot.manage"], "all"));
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/bot")
|
||||||
|
.send({ name: "n", serverAddress: "s", nickname: "nick" });
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403 for member WITH bot.manage but bot NOT in allow-list on POST /api/bot/:id/start", async () => {
|
||||||
|
const app = makeApp(member(["bot.manage"], ["other-bot"]));
|
||||||
|
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NOT 403 for member WITH bot.manage + bot in allow-list on POST /api/bot/:id/start", async () => {
|
||||||
|
const app = makeApp(member(["bot.manage"], [ALLOWED_BOT]));
|
||||||
|
const res = await request(app).post(`/api/bot/${ALLOWED_BOT}/start`);
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("platform.auth", () => {
|
||||||
|
it("403 for member WITHOUT platform.auth on POST /api/auth/cookie", async () => {
|
||||||
|
const app = makeApp(member([], "all"));
|
||||||
|
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NOT 403 for member WITH platform.auth on POST /api/auth/cookie", async () => {
|
||||||
|
const app = makeApp(member(["platform.auth"], "all"));
|
||||||
|
const res = await request(app).post("/api/auth/cookie").send({ cookie: "c" });
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quality", () => {
|
||||||
|
it("403 for member WITHOUT quality on POST /api/music/quality", async () => {
|
||||||
|
const app = makeApp(member([], "all"));
|
||||||
|
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NOT 403 for member WITH quality on POST /api/music/quality", async () => {
|
||||||
|
const app = makeApp(member(["quality"], "all"));
|
||||||
|
const res = await request(app).post("/api/music/quality").send({ quality: "high" });
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("read-only routes stay open", () => {
|
||||||
|
it("GET /api/auth/status not gated", async () => {
|
||||||
|
const app = makeApp(member([], "all"));
|
||||||
|
const res = await request(app).get("/api/auth/status");
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/music/quality not gated", async () => {
|
||||||
|
const app = makeApp(member([], "all"));
|
||||||
|
const res = await request(app).get("/api/music/quality");
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/bot not gated", async () => {
|
||||||
|
const app = makeApp(member([], "all"));
|
||||||
|
const res = await request(app).get("/api/bot");
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin bypasses every gate", () => {
|
||||||
|
let app: express.Express;
|
||||||
|
beforeEach(() => { app = makeApp(admin); });
|
||||||
|
|
||||||
|
it("player.control", async () => {
|
||||||
|
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
|
||||||
|
});
|
||||||
|
it("player.queue", async () => {
|
||||||
|
expect((await request(app).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
|
||||||
|
});
|
||||||
|
it("bot.manage POST /api/bot", async () => {
|
||||||
|
const res = await request(app).post("/api/bot").send({ name: "n", serverAddress: "s", nickname: "nick" });
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
it("bot.manage POST /api/bot/:id/start", async () => {
|
||||||
|
expect((await request(app).post(`/api/bot/${ALLOWED_BOT}/start`)).status).not.toBe(403);
|
||||||
|
});
|
||||||
|
it("platform.auth POST /api/auth/cookie", async () => {
|
||||||
|
expect((await request(app).post("/api/auth/cookie").send({ cookie: "c" })).status).not.toBe(403);
|
||||||
|
});
|
||||||
|
it("quality POST /api/music/quality", async () => {
|
||||||
|
expect((await request(app).post("/api/music/quality").send({ quality: "high" })).status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-21
@@ -4,6 +4,7 @@ import type { BotDatabase } from "../../data/database.js";
|
|||||||
import type { MusicProvider } from "../../music/provider.js";
|
import type { MusicProvider } from "../../music/provider.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import { parseCommand } from "../../bot/commands.js";
|
import { parseCommand } from "../../bot/commands.js";
|
||||||
|
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
||||||
|
|
||||||
export function createPlayerRouter(
|
export function createPlayerRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
@@ -15,6 +16,13 @@ export function createPlayerRouter(
|
|||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Access check runs BEFORE the existence/resolver check so a member who is
|
||||||
|
// not allowed a bot always gets a uniform 403 — whether or not the bot
|
||||||
|
// exists — instead of a 404 that would leak which bot IDs are real.
|
||||||
|
// requireBotAccess only needs req.params.botId and req.user (set by the
|
||||||
|
// global requireAuth mounted earlier), so it works before the resolver.
|
||||||
|
router.use("/:botId", requireBotAccess("botId"));
|
||||||
|
|
||||||
router.use("/:botId", (req, res, next) => {
|
router.use("/:botId", (req, res, next) => {
|
||||||
const bot = botManager.getBot(req.params.botId);
|
const bot = botManager.getBot(req.params.botId);
|
||||||
if (!bot) {
|
if (!bot) {
|
||||||
@@ -33,7 +41,7 @@ export function createPlayerRouter(
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post("/:botId/play", async (req, res) => {
|
router.post("/:botId/play", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { query, platform } = req.body;
|
const { query, platform } = req.body;
|
||||||
@@ -53,7 +61,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/add", async (req, res) => {
|
router.post("/:botId/add", requirePermission("player.queue"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { query, platform } = req.body;
|
const { query, platform } = req.body;
|
||||||
@@ -80,14 +88,36 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post("/:botId/pause", simpleCommand("!pause"));
|
router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause"));
|
||||||
router.post("/:botId/resume", simpleCommand("!resume"));
|
router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume"));
|
||||||
router.post("/:botId/next", simpleCommand("!next"));
|
router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next"));
|
||||||
router.post("/:botId/prev", simpleCommand("!prev"));
|
router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev"));
|
||||||
router.post("/:botId/stop", simpleCommand("!stop"));
|
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
|
||||||
router.post("/:botId/clear", simpleCommand("!clear"));
|
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear"));
|
||||||
|
|
||||||
router.post("/:botId/volume", async (req, res) => {
|
router.post("/:botId/fm", requirePermission("player.control"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const { platform } = req.body;
|
||||||
|
const provider = bot.getProviderFor(
|
||||||
|
platform === "bilibili" || platform === "qq" || platform === "youtube"
|
||||||
|
? platform
|
||||||
|
: "netease"
|
||||||
|
);
|
||||||
|
const message = await bot.startFm(provider);
|
||||||
|
res.json({
|
||||||
|
ok:
|
||||||
|
!message.startsWith("No FM songs") &&
|
||||||
|
!message.includes("not available") &&
|
||||||
|
!message.includes("not connected"),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { volume } = req.body;
|
const { volume } = req.body;
|
||||||
@@ -115,7 +145,7 @@ export function createPlayerRouter(
|
|||||||
|
|
||||||
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
||||||
|
|
||||||
router.post("/:botId/mode", async (req, res) => {
|
router.post("/:botId/mode", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { mode } = req.body;
|
const { mode } = req.body;
|
||||||
@@ -140,7 +170,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Seek to position
|
// Seek to position
|
||||||
router.post("/:botId/seek", async (req, res) => {
|
router.post("/:botId/seek", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { position } = req.body; // seconds
|
const { position } = req.body; // seconds
|
||||||
@@ -164,7 +194,7 @@ export function createPlayerRouter(
|
|||||||
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
|
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/:botId/queue/:index", async (req, res) => {
|
router.delete("/:botId/queue/:index", requirePermission("player.queue"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
|
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
|
||||||
@@ -176,7 +206,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Jump to a specific index in the queue (without clearing it)
|
// Jump to a specific index in the queue (without clearing it)
|
||||||
router.post("/:botId/play-at", async (req, res) => {
|
router.post("/:botId/play-at", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { index } = req.body;
|
const { index } = req.body;
|
||||||
@@ -210,7 +240,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/playlist", async (req, res) => {
|
router.post("/:botId/playlist", requirePermission("player.queue"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { playlistId, platform } = req.body;
|
const { playlistId, platform } = req.body;
|
||||||
@@ -227,7 +257,7 @@ export function createPlayerRouter(
|
|||||||
|
|
||||||
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
||||||
// Respects current play mode (random = pick random first song)
|
// Respects current play mode (random = pick random first song)
|
||||||
router.post("/:botId/play-playlist", async (req, res) => {
|
router.post("/:botId/play-playlist", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { playlistId, platform } = req.body;
|
const { playlistId, platform } = req.body;
|
||||||
@@ -314,7 +344,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
||||||
router.post("/:botId/play-album", async (req, res) => {
|
router.post("/:botId/play-album", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { albumId, platform } = req.body;
|
const { albumId, platform } = req.body;
|
||||||
@@ -386,7 +416,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Play a single song by ID — resolves URL on demand
|
// Play a single song by ID — resolves URL on demand
|
||||||
router.post("/:botId/play-song", async (req, res) => {
|
router.post("/:botId/play-song", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { song } = req.body;
|
const { song } = req.body;
|
||||||
@@ -414,7 +444,7 @@ export function createPlayerRouter(
|
|||||||
|
|
||||||
// Insert a single song to play right after the current one.
|
// Insert a single song to play right after the current one.
|
||||||
// If nothing is playing, behaves like /play-song (start immediately).
|
// If nothing is playing, behaves like /play-song (start immediately).
|
||||||
router.post("/:botId/play-next-song", async (req, res) => {
|
router.post("/:botId/play-next-song", requirePermission("player.control"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { song } = req.body;
|
const { song } = req.body;
|
||||||
@@ -452,7 +482,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/add-song", async (req, res) => {
|
router.post("/:botId/add-song", requirePermission("player.queue"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { song } = req.body;
|
const { song } = req.body;
|
||||||
@@ -480,7 +510,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add a song to queue by ID — metadata only
|
// Add a song to queue by ID — metadata only
|
||||||
router.post("/:botId/add-by-id", async (req, res) => {
|
router.post("/:botId/add-by-id", requirePermission("player.queue"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { songId, platform } = req.body;
|
const { songId, platform } = req.body;
|
||||||
@@ -518,7 +548,7 @@ export function createPlayerRouter(
|
|||||||
res.json(bot.getProfileManager().getConfig());
|
res.json(bot.getProfileManager().getConfig());
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/:botId/profile", (req, res) => {
|
router.put("/:botId/profile", requirePermission("bot.manage"), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const pm = bot.getProfileManager();
|
const pm = bot.getProfileManager();
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||||
|
import { createAuditStore } from "../../data/audit.js";
|
||||||
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
|
import { createSessionRouter } from "./session.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
const audit = createAuditStore(botDb.db);
|
||||||
|
const permissions = createPermissionStore(botDb.db);
|
||||||
|
app.use("/api/session", createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCookie(res: request.Response): string {
|
||||||
|
const header = res.headers["set-cookie"];
|
||||||
|
const arr = Array.isArray(header) ? header : header ? [header] : [];
|
||||||
|
const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||||
|
if (!found) throw new Error("no session cookie set");
|
||||||
|
return found.split(";")[0]; // "tsmb_session=xxxx"
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session router", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let users: UserStore;
|
||||||
|
let sessions: SessionStore;
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
users = createUserStore(botDb.db);
|
||||||
|
sessions = createSessionStore(botDb.db);
|
||||||
|
app = makeApp(botDb, users, sessions);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => botDb.close());
|
||||||
|
|
||||||
|
it("GET /needs-setup returns true on an empty db", async () => {
|
||||||
|
const res = await request(app).get("/api/session/needs-setup");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ needsSetup: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => {
|
||||||
|
const setupRes = await request(app)
|
||||||
|
.post("/api/session/setup")
|
||||||
|
.send({ username: "alice", password: "hunter2-hunter2" });
|
||||||
|
expect(setupRes.status).toBe(200);
|
||||||
|
expect(setupRes.body.username).toBe("alice");
|
||||||
|
extractCookie(setupRes);
|
||||||
|
|
||||||
|
const needs = await request(app).get("/api/session/needs-setup");
|
||||||
|
expect(needs.body).toEqual({ needsSetup: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /setup returns 409 once a user already exists", async () => {
|
||||||
|
await users.createUser("admin", "pw-admin-pw", "admin");
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/session/setup")
|
||||||
|
.send({ username: "alice", password: "pw" });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body).toEqual({ error: "already initialized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /login returns 401 with constant-time delay on bad credentials", async () => {
|
||||||
|
await users.createUser("alice", "correct-pw-pw", "admin");
|
||||||
|
const start = Date.now();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "wrong" });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "invalid credentials" });
|
||||||
|
expect(Date.now() - start).toBeGreaterThanOrEqual(200);
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
it("POST /login sets a session cookie on success", async () => {
|
||||||
|
await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "pw-alice" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.username).toBe("alice");
|
||||||
|
extractCookie(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /me returns the current user when cookie is present, 401 otherwise", async () => {
|
||||||
|
await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "pw-alice" });
|
||||||
|
const cookie = extractCookie(loginRes);
|
||||||
|
|
||||||
|
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||||
|
expect(me.status).toBe(200);
|
||||||
|
expect(me.body.username).toBe("alice");
|
||||||
|
// alice is the first user (an admin), so /me exposes all capabilities and full bot access.
|
||||||
|
expect(Array.isArray(me.body.capabilities)).toBe(true);
|
||||||
|
expect(me.body.capabilities).toEqual(
|
||||||
|
expect.arrayContaining(["player.control", "player.queue", "bot.manage", "platform.auth", "quality"])
|
||||||
|
);
|
||||||
|
expect(me.body.bots).toBe("all");
|
||||||
|
|
||||||
|
const anon = await request(app).get("/api/session/me");
|
||||||
|
expect(anon.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /logout deletes the session and clears the cookie", async () => {
|
||||||
|
await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
const loginRes = await request(app)
|
||||||
|
.post("/api/session/login")
|
||||||
|
.send({ username: "alice", password: "pw-alice" });
|
||||||
|
const cookie = extractCookie(loginRes);
|
||||||
|
|
||||||
|
const logout = await request(app).post("/api/session/logout").set("Cookie", cookie);
|
||||||
|
expect(logout.status).toBe(204);
|
||||||
|
|
||||||
|
const me = await request(app).get("/api/session/me").set("Cookie", cookie);
|
||||||
|
expect(me.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /change-password requires old password and invalidates other sessions", async () => {
|
||||||
|
const u = await users.createUser("alice", "old-pw-pw", "admin");
|
||||||
|
const cookieA = extractCookie(
|
||||||
|
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
|
||||||
|
);
|
||||||
|
const cookieB = extractCookie(
|
||||||
|
await request(app).post("/api/session/login").send({ username: "alice", password: "old-pw-pw" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrongOld = await request(app)
|
||||||
|
.post("/api/session/change-password")
|
||||||
|
.set("Cookie", cookieA)
|
||||||
|
.send({ oldPassword: "WRONG", newPassword: "newpassword" });
|
||||||
|
expect(wrongOld.status).toBe(401);
|
||||||
|
|
||||||
|
const ok = await request(app)
|
||||||
|
.post("/api/session/change-password")
|
||||||
|
.set("Cookie", cookieA)
|
||||||
|
.send({ oldPassword: "old-pw-pw", newPassword: "newpassword" });
|
||||||
|
expect(ok.status).toBe(204);
|
||||||
|
|
||||||
|
const meA = await request(app).get("/api/session/me").set("Cookie", cookieA);
|
||||||
|
expect(meA.status).toBe(200);
|
||||||
|
|
||||||
|
const meB = await request(app).get("/api/session/me").set("Cookie", cookieB);
|
||||||
|
expect(meB.status).toBe(401);
|
||||||
|
|
||||||
|
expect(u.id).toBe(meA.body.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
import type { UserStore } from "../../data/users.js";
|
||||||
|
import type { SessionStore } from "../../data/sessions.js";
|
||||||
|
import type { AuditStore } from "../../data/audit.js";
|
||||||
|
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||||
|
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||||
|
import { SESSION_COOKIE_NAME, validateSessionFromHeaders, extractSessionToken } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
const FAILED_LOGIN_DELAY_MS = 250;
|
||||||
|
|
||||||
|
function setSessionCookie(res: Response, token: string): void {
|
||||||
|
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: res.req.secure,
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie(res: Response): void {
|
||||||
|
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUsername(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPassword(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && v.length >= 8 && v.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenFromCookie(cookieHeader: string | undefined): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||||
|
if (!match) return null;
|
||||||
|
return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionRouter(
|
||||||
|
users: UserStore,
|
||||||
|
sessions: SessionStore,
|
||||||
|
audit: AuditStore,
|
||||||
|
logger: Logger,
|
||||||
|
permissions: PermissionStore
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const requireAuthInline = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||||
|
if (!result) {
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.status(401).json({ error: "unauthenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.user = { id: result.userId, username: result.username, role: result.role };
|
||||||
|
const token = extractSessionToken(req.headers.cookie);
|
||||||
|
if (token) setSessionCookie(res, token);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get("/needs-setup", (_req, res) => {
|
||||||
|
res.json({ needsSetup: users.countUsers() === 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/setup", async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
if (users.countUsers() !== 0) {
|
||||||
|
res.status(409).json({ error: "already initialized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidUsername(username) || !isValidPassword(password)) {
|
||||||
|
res.status(400).json({ error: "invalid username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const user = await users.createFirstUser(username, password);
|
||||||
|
if (!user) {
|
||||||
|
res.status(409).json({ error: "already initialized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { token } = sessions.createSession(user.id);
|
||||||
|
setSessionCookie(res, token);
|
||||||
|
try {
|
||||||
|
audit.record({
|
||||||
|
actorId: user.id, actorUsername: user.username,
|
||||||
|
targetUserId: user.id, targetUsername: user.username,
|
||||||
|
action: "admin.first_created",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
logger.info({ userId: user.id, username }, "First admin created");
|
||||||
|
res.json({ id: user.id, username: user.username, role: user.role });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "setup failed");
|
||||||
|
res.status(500).json({ error: "internal" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/login", async (req, res) => {
|
||||||
|
const { username, password } = req.body ?? {};
|
||||||
|
if (typeof username !== "string" || typeof password !== "string") {
|
||||||
|
res.status(400).json({ error: "invalid request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = users.findByUsername(username);
|
||||||
|
const ok = user ? await users.verifyPassword(password, user.passwordHash) : false;
|
||||||
|
if (!user || !ok) {
|
||||||
|
await delay(FAILED_LOGIN_DELAY_MS);
|
||||||
|
res.status(401).json({ error: "invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { token } = sessions.createSession(user.id);
|
||||||
|
setSessionCookie(res, token);
|
||||||
|
res.json({ id: user.id, username: user.username, role: user.role });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/logout", (req, res) => {
|
||||||
|
const token = parseTokenFromCookie(req.headers.cookie);
|
||||||
|
if (token) {
|
||||||
|
sessions.deleteSession(token);
|
||||||
|
}
|
||||||
|
clearSessionCookie(res);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/me", requireAuthInline, (req, res) => {
|
||||||
|
const user = req.user!;
|
||||||
|
const ctx = resolvePermissionContext(user.role, user.id, permissions);
|
||||||
|
res.json({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
capabilities: [...ctx.capabilities],
|
||||||
|
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/change-password", requireAuthInline, async (req, res) => {
|
||||||
|
const { oldPassword, newPassword } = req.body ?? {};
|
||||||
|
if (typeof oldPassword !== "string") {
|
||||||
|
res.status(400).json({ error: "invalid request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u = users.findById(req.user!.id);
|
||||||
|
if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) {
|
||||||
|
await delay(FAILED_LOGIN_DELAY_MS);
|
||||||
|
res.status(401).json({ error: "invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidPassword(newPassword)) {
|
||||||
|
res.status(400).json({ error: "invalid request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await users.changePassword(u.id, newPassword);
|
||||||
|
const currentToken = parseTokenFromCookie(req.headers.cookie);
|
||||||
|
sessions.deleteAllForUser(u.id, currentToken ?? undefined);
|
||||||
|
try {
|
||||||
|
audit.record({
|
||||||
|
actorId: u.id, actorUsername: u.username,
|
||||||
|
targetUserId: u.id, targetUsername: u.username,
|
||||||
|
action: "user.password_changed",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "user.password_changed" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import pino from "pino";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore, type UserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
|
||||||
|
import { createAuditStore, type AuditStore } from "../../data/audit.js";
|
||||||
|
import { createPermissionStore, type PermissionStore } from "../../data/permissions.js";
|
||||||
|
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||||
|
import { createUsersRouter } from "./users.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
function makeApp(botDb: BotDatabase, users: UserStore, sessions: SessionStore) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
const permissions = createPermissionStore(botDb.db);
|
||||||
|
const requireAuth = createRequireAuth(sessions, permissions);
|
||||||
|
const audit = createAuditStore(botDb.db);
|
||||||
|
app.use("/api", requireAuth);
|
||||||
|
app.use("/api/users", createUsersRouter(users, sessions, audit, pino({ level: "silent" }), permissions));
|
||||||
|
return { app, permissions, audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("users router", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let users: UserStore;
|
||||||
|
let sessions: SessionStore;
|
||||||
|
let app: express.Express;
|
||||||
|
let permissions: PermissionStore;
|
||||||
|
let audit: AuditStore;
|
||||||
|
let aliceId: string;
|
||||||
|
let aliceCookie: string;
|
||||||
|
let bobId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
users = createUserStore(botDb.db);
|
||||||
|
sessions = createSessionStore(botDb.db);
|
||||||
|
({ app, permissions, audit } = makeApp(botDb, users, sessions));
|
||||||
|
const alice = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
aliceId = alice.id;
|
||||||
|
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
|
||||||
|
const bob = await users.createUser("bob", "pw-bob-bob", "member");
|
||||||
|
bobId = bob.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => botDb.close());
|
||||||
|
|
||||||
|
it("requires auth for all routes", async () => {
|
||||||
|
expect((await request(app).get("/api/users")).status).toBe(401);
|
||||||
|
expect((await request(app).post("/api/users").send({ username: "x", password: "yyyyyyyy" })).status).toBe(401);
|
||||||
|
expect((await request(app).delete(`/api/users/${bobId}`)).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET / lists users with id+username+createdAt, no password hash", async () => {
|
||||||
|
const res = await request(app).get("/api/users").set("Cookie", aliceCookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.users).toHaveLength(2);
|
||||||
|
for (const u of res.body.users) {
|
||||||
|
expect(u).toHaveProperty("id");
|
||||||
|
expect(u).toHaveProperty("username");
|
||||||
|
expect(u).toHaveProperty("createdAt");
|
||||||
|
expect(u).not.toHaveProperty("passwordHash");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST / creates a user", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "charlie", password: "charlie-pw" });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.username).toBe("charlie");
|
||||||
|
expect(users.countUsers()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST / returns 409 on duplicate username", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "BOB", password: "another-pw" });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST / returns 400 on invalid input", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "x", password: "short" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /:id removes the user and their sessions", async () => {
|
||||||
|
const bobToken = sessions.createSession(bobId).token;
|
||||||
|
const res = await request(app).delete(`/api/users/${bobId}`).set("Cookie", aliceCookie);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(users.countUsers()).toBe(1);
|
||||||
|
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /:id of self returns 400", async () => {
|
||||||
|
const res = await request(app).delete(`/api/users/${aliceId}`).set("Cookie", aliceCookie);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toEqual({ error: "cannot delete self" });
|
||||||
|
expect(users.countUsers()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /:id of nonexistent returns 404", async () => {
|
||||||
|
const res = await request(app).delete(`/api/users/not-a-real-id`).set("Cookie", aliceCookie);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /:id/reset-password updates the hash and invalidates target's sessions", async () => {
|
||||||
|
const bobToken = sessions.createSession(bobId).token;
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/users/${bobId}/reset-password`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ newPassword: "bob-new-pw" });
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
||||||
|
const bob = users.findByUsername("bob");
|
||||||
|
expect(await users.verifyPassword("bob-new-pw", bob!.passwordHash)).toBe(true);
|
||||||
|
expect(await users.verifyPassword("pw-bob-bob", bob!.passwordHash)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /:id/reset-password 404 on unknown user", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/users/not-a-real-id/reset-password`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ newPassword: "anything-here" });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /:id/reset-password 400 on short password", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/users/${bobId}/reset-password`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ newPassword: "short" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 201 even if audit insert fails (POST /api/users)", async () => {
|
||||||
|
// Build a broken audit store that throws on record()
|
||||||
|
const brokenAudit = {
|
||||||
|
record: () => { throw new Error("simulated disk-full"); },
|
||||||
|
list: () => [],
|
||||||
|
};
|
||||||
|
// Reassemble app with the broken audit
|
||||||
|
const localApp = express();
|
||||||
|
localApp.use(express.json());
|
||||||
|
localApp.use(cookieParser());
|
||||||
|
localApp.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db)));
|
||||||
|
localApp.use(
|
||||||
|
"/api/users",
|
||||||
|
createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" }), createPermissionStore(botDb.db))
|
||||||
|
);
|
||||||
|
const res = await request(localApp)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "charlie", password: "charlie-pw" });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(users.countUsers()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /:id/reset-password on self preserves the actor's current session", async () => {
|
||||||
|
// Alice resets her OWN password
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/users/${aliceId}/reset-password`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ newPassword: "alice-new-pw" });
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
|
// Alice's CURRENT session should still work
|
||||||
|
// (we'd need a protected endpoint to verify; use GET /api/users which is already mounted)
|
||||||
|
const followUp = await request(app).get("/api/users").set("Cookie", aliceCookie);
|
||||||
|
expect(followUp.status).toBe(200);
|
||||||
|
|
||||||
|
// The password hash IS updated (sanity check)
|
||||||
|
const alice = users.findById(aliceId);
|
||||||
|
expect(await users.verifyPassword("alice-new-pw", alice!.passwordHash)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /:id/reset-password on another user does NOT preserve any of target's sessions", async () => {
|
||||||
|
const bobToken = sessions.createSession(bobId).token;
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/users/${bobId}/reset-password`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ newPassword: "bob-new-pw" });
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
// Bob's session should be dead
|
||||||
|
expect(sessions.validateAndTouch(bobToken)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST / defaults new user to role=member when role omitted", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "carol", password: "pw-carol-pw" });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.role).toBe("member");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST / accepts role=admin", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "carol", password: "pw-carol-pw", role: "admin" });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.role).toBe("admin");
|
||||||
|
expect(users.countAdmins()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /:id/role can change role between admin and member", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/users/${bobId}/role`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ role: "admin" });
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(users.findById(bobId)!.role).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /:id/role blocks demoting the last admin", async () => {
|
||||||
|
// alice is the only admin. Demoting her would leave 0 admins. Block.
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/users/${aliceId}/role`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ role: "member" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toEqual({ error: "cannot demote last admin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /:id/role allows demoting an admin when other admins exist", async () => {
|
||||||
|
// Promote bob first
|
||||||
|
users.setRole(bobId, "admin");
|
||||||
|
// Now both are admins. Demoting alice should work.
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/users/${aliceId}/role`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ role: "member" });
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /:id/role 400 on invalid role", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/users/${bobId}/role`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ role: "superuser" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /:id/role 404 on unknown user", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch(`/api/users/not-a-real-id/role`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ role: "admin" });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /:id/permissions returns empty arrays for a fresh member", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/users/${bobId}/permissions`)
|
||||||
|
.set("Cookie", aliceCookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ capabilities: [], bots: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /:id/permissions 404 on unknown user", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/users/not-a-real-id/permissions`)
|
||||||
|
.set("Cookie", aliceCookie);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /:id/permissions sets permissions, persists, and audits", async () => {
|
||||||
|
const before = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
|
||||||
|
expect(before).toHaveLength(0);
|
||||||
|
|
||||||
|
const put = await request(app)
|
||||||
|
.put(`/api/users/${bobId}/permissions`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ capabilities: ["player.control"], bots: "all" });
|
||||||
|
expect(put.status).toBe(200);
|
||||||
|
|
||||||
|
const get = await request(app)
|
||||||
|
.get(`/api/users/${bobId}/permissions`)
|
||||||
|
.set("Cookie", aliceCookie);
|
||||||
|
expect(get.status).toBe(200);
|
||||||
|
expect(get.body).toEqual({ capabilities: ["player.control"], bots: "all" });
|
||||||
|
|
||||||
|
const rows = audit.list(100, 0).filter((e) => e.action === "user.permissions_changed");
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].actorId).toBe(aliceId);
|
||||||
|
expect(rows[0].targetUserId).toBe(bobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /:id/permissions drops unknown capability tokens", async () => {
|
||||||
|
const put = await request(app)
|
||||||
|
.put(`/api/users/${bobId}/permissions`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ capabilities: ["player.control", "bogus"], bots: [] });
|
||||||
|
expect(put.status).toBe(200);
|
||||||
|
expect(permissions.getCapabilities(bobId)).toEqual(["player.control"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /:id/permissions 404 on unknown user", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/users/not-a-real-id/permissions`)
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ capabilities: ["player.control"], bots: "all" });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST / seeds the basic tier for a new member", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "dave", password: "dave-pw-pw" });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const perms = await request(app)
|
||||||
|
.get(`/api/users/${res.body.id}/permissions`)
|
||||||
|
.set("Cookie", aliceCookie);
|
||||||
|
expect(perms.status).toBe(200);
|
||||||
|
expect(perms.body).toEqual({
|
||||||
|
capabilities: ["player.control", "player.queue"],
|
||||||
|
bots: "all",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST / does NOT seed permissions for a new admin", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/users")
|
||||||
|
.set("Cookie", aliceCookie)
|
||||||
|
.send({ username: "erin", password: "erin-pw-pw", role: "admin" });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const perms = await request(app)
|
||||||
|
.get(`/api/users/${res.body.id}/permissions`)
|
||||||
|
.set("Cookie", aliceCookie);
|
||||||
|
expect(perms.status).toBe(200);
|
||||||
|
expect(perms.body).toEqual({ capabilities: [], bots: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { Logger } from "../../logger.js";
|
||||||
|
import type { UserStore } from "../../data/users.js";
|
||||||
|
import { UsernameTakenError } from "../../data/users.js";
|
||||||
|
import type { SessionStore } from "../../data/sessions.js";
|
||||||
|
import type { AuditStore } from "../../data/audit.js";
|
||||||
|
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
|
||||||
|
import { extractSessionToken } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
function isValidUsername(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPassword(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && v.length >= 8 && v.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUsersRouter(
|
||||||
|
users: UserStore,
|
||||||
|
sessions: SessionStore,
|
||||||
|
audit: AuditStore,
|
||||||
|
logger: Logger,
|
||||||
|
permissions: PermissionStore
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", (_req, res) => {
|
||||||
|
res.json({ users: users.listUsers() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
const { username, password, role: roleInput } = req.body ?? {};
|
||||||
|
if (!isValidUsername(username) || !isValidPassword(password)) {
|
||||||
|
res.status(400).json({ error: "invalid username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
|
||||||
|
try {
|
||||||
|
const u = await users.createUser(username, password, role);
|
||||||
|
if (u.role === "member") {
|
||||||
|
permissions.setPermissions(u.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
audit.record({
|
||||||
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
|
targetUserId: u.id, targetUsername: u.username,
|
||||||
|
action: "user.created",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
|
||||||
|
res.status(201).json({ id: u.id, username: u.username, role: u.role });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UsernameTakenError) {
|
||||||
|
res.status(409).json({ error: "username taken" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error({ err }, "createUser failed");
|
||||||
|
res.status(500).json({ error: "internal" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id", (req, res) => {
|
||||||
|
const targetId = req.params.id;
|
||||||
|
// Snapshot target's username BEFORE deletion for audit
|
||||||
|
const target = users.findById(targetId);
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetId === req.user!.id) {
|
||||||
|
res.status(400).json({ error: "cannot delete self" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = users.deleteUserIfNotLastAdmin(targetId);
|
||||||
|
if (result === "not_found") {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result === "would_orphan") {
|
||||||
|
res.status(400).json({ error: "cannot delete last admin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
|
||||||
|
sessions.deleteAllForUser(targetId);
|
||||||
|
try {
|
||||||
|
audit.record({
|
||||||
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
|
targetUserId: target.id, targetUsername: target.username,
|
||||||
|
action: "user.deleted",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "user.deleted" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted");
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:id/reset-password", async (req, res) => {
|
||||||
|
const { newPassword } = req.body ?? {};
|
||||||
|
if (!isValidPassword(newPassword)) {
|
||||||
|
res.status(400).json({ error: "invalid password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetId = req.params.id;
|
||||||
|
const target = users.findById(targetId);
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await users.changePassword(targetId, newPassword);
|
||||||
|
// Invalidate all sessions for the target user (except current actor's if it's the same user)
|
||||||
|
const exceptToken = targetId === req.user!.id
|
||||||
|
? (extractSessionToken(req.headers.cookie) ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
sessions.deleteAllForUser(targetId, exceptToken);
|
||||||
|
try {
|
||||||
|
audit.record({
|
||||||
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
|
targetUserId: target.id, targetUsername: target.username,
|
||||||
|
action: "user.password_reset",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "user.password_reset" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset");
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:id/role", (req, res) => {
|
||||||
|
const targetId = req.params.id;
|
||||||
|
const { role: newRole } = req.body ?? {};
|
||||||
|
if (newRole !== "admin" && newRole !== "member") {
|
||||||
|
res.status(400).json({ error: "invalid role" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Snapshot the target's old role and username for audit (BEFORE the atomic update,
|
||||||
|
// so we record what actually changed; if the user is gone we'll skip audit).
|
||||||
|
const targetBefore = users.findById(targetId);
|
||||||
|
if (!targetBefore) {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = users.setRoleIfNotLastAdmin(targetId, newRole);
|
||||||
|
if (result === "not_found") {
|
||||||
|
res.status(404).json({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result === "would_orphan") {
|
||||||
|
res.status(400).json({ error: "cannot demote last admin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only audit when the role actually changed
|
||||||
|
if (targetBefore.role !== newRole) {
|
||||||
|
try {
|
||||||
|
audit.record({
|
||||||
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
|
targetUserId: targetBefore.id, targetUsername: targetBefore.username,
|
||||||
|
action: "user.role_changed",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id/permissions", (req, res) => {
|
||||||
|
const user = users.findById(req.params.id);
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: "not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
capabilities: permissions.getCapabilities(user.id),
|
||||||
|
bots: permissions.getBotAccess(user.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/:id/permissions", (req, res) => {
|
||||||
|
const user = users.findById(req.params.id);
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: "not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const caps: string[] = Array.isArray(body.capabilities)
|
||||||
|
? body.capabilities.filter(isCapability)
|
||||||
|
: [];
|
||||||
|
const bots: "all" | string[] =
|
||||||
|
body.bots === "all" ? "all" : Array.isArray(body.bots) ? body.bots.map(String) : [];
|
||||||
|
permissions.setPermissions(user.id, { capabilities: caps, bots });
|
||||||
|
try {
|
||||||
|
audit.record({
|
||||||
|
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||||
|
targetUserId: user.id, targetUsername: user.username,
|
||||||
|
action: "user.permissions_changed",
|
||||||
|
});
|
||||||
|
} catch (auditErr) {
|
||||||
|
logger.warn({ err: auditErr, action: "user.permissions_changed" }, "audit insert failed");
|
||||||
|
}
|
||||||
|
logger.info({ actorId: req.user!.id, targetUserId: user.id }, "User permissions changed");
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { csrfOriginCheck } from "./csrf.js";
|
||||||
|
|
||||||
|
describe("csrfOriginCheck middleware", () => {
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(csrfOriginCheck);
|
||||||
|
app.get("/", (_req, res) => res.json({ ok: true }));
|
||||||
|
app.post("/", (_req, res) => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => {
|
||||||
|
const res = await request(app).get("/");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects POST without Origin or Referer", async () => {
|
||||||
|
const res = await request(app).post("/");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body).toEqual({ error: "bad origin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts POST when Origin host matches request host", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/")
|
||||||
|
.set("Host", "example.com")
|
||||||
|
.set("Origin", "https://example.com");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects POST when Origin host does not match request host", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/")
|
||||||
|
.set("Host", "example.com")
|
||||||
|
.set("Origin", "https://evil.com");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts POST when Referer host matches and Origin is absent", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/")
|
||||||
|
.set("Host", "example.com")
|
||||||
|
.set("Referer", "https://example.com/some/path");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects POST when Referer host does not match", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/")
|
||||||
|
.set("Host", "example.com")
|
||||||
|
.set("Referer", "https://evil.com/some/path");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Documents the server side of the QR-login outage: a `no-referrer` document
|
||||||
|
// policy makes the browser send the literal `Origin: null` on same-origin
|
||||||
|
// POSTs, which this guard cannot parse a host from and therefore rejects.
|
||||||
|
// The fix lives in the frontend (referrer policy -> same-origin); this test
|
||||||
|
// pins the gate behavior so the interaction stays understood. See
|
||||||
|
// src/web/referrer-policy.test.ts.
|
||||||
|
it('rejects POST with the literal Origin: "null" (no-referrer downgrade)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/")
|
||||||
|
.set("Host", "example.com")
|
||||||
|
.set("Origin", "null");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body).toEqual({ error: "bad origin" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore } from "../../data/sessions.js";
|
||||||
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
|
import { createRequireAuth } from "./requireAuth.js";
|
||||||
|
import { requireAdmin } from "./requireAdmin.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
describe("requireAdmin middleware", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let app: express.Express;
|
||||||
|
let adminCookie: string;
|
||||||
|
let memberCookie: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
const users = createUserStore(botDb.db);
|
||||||
|
const sessions = createSessionStore(botDb.db);
|
||||||
|
const permissions = createPermissionStore(botDb.db);
|
||||||
|
const admin = await users.createUser("admin", "pw-admin-pw", "admin");
|
||||||
|
const member = await users.createUser("member", "pw-member-pw", "member");
|
||||||
|
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
|
||||||
|
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
|
||||||
|
app = express();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(createRequireAuth(sessions, permissions));
|
||||||
|
app.use(requireAdmin);
|
||||||
|
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => botDb.close());
|
||||||
|
|
||||||
|
it("rejects unauthenticated requests with 401", async () => {
|
||||||
|
const res = await request(app).get("/admin-only");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects member with 403", async () => {
|
||||||
|
const res = await request(app).get("/admin-only").set("Cookie", memberCookie);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body).toEqual({ error: "forbidden" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows admin", async () => {
|
||||||
|
const res = await request(app).get("/admin-only").set("Cookie", adminCookie);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import request from "supertest";
|
||||||
|
import { createDatabase, type BotDatabase } from "../../data/database.js";
|
||||||
|
import { createUserStore } from "../../data/users.js";
|
||||||
|
import { createSessionStore } from "../../data/sessions.js";
|
||||||
|
import { createPermissionStore } from "../../data/permissions.js";
|
||||||
|
import { createRequireAuth } from "./requireAuth.js";
|
||||||
|
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
describe("requireAuth middleware", () => {
|
||||||
|
let botDb: BotDatabase;
|
||||||
|
let app: express.Express;
|
||||||
|
let validToken: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
botDb = createDatabase(":memory:");
|
||||||
|
const users = createUserStore(botDb.db);
|
||||||
|
const sessions = createSessionStore(botDb.db);
|
||||||
|
const permissions = createPermissionStore(botDb.db);
|
||||||
|
const u = await users.createUser("alice", "pw-alice", "admin");
|
||||||
|
validToken = sessions.createSession(u.id).token;
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(createRequireAuth(sessions, permissions));
|
||||||
|
app.get("/protected", (req, res) => {
|
||||||
|
res.json({ ok: true, user: (req as any).user });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
botDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests without a session cookie", async () => {
|
||||||
|
const res = await request(app).get("/protected");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: "unauthenticated" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests with an unknown session cookie", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/protected")
|
||||||
|
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows requests with a valid session cookie and attaches req.user", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/protected")
|
||||||
|
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.user.username).toBe("alice");
|
||||||
|
expect(res.body.user.role).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls the cookie max-age forward on successful auth", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/protected")
|
||||||
|
.set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const setCookieHeaders = res.headers["set-cookie"];
|
||||||
|
const arr = Array.isArray(setCookieHeaders) ? setCookieHeaders : setCookieHeaders ? [setCookieHeaders] : [];
|
||||||
|
const refreshed = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`));
|
||||||
|
expect(refreshed).toBeDefined();
|
||||||
|
expect(refreshed!).toMatch(/Max-Age=\d+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||||
|
import type { SessionStore } from "../../data/sessions.js";
|
||||||
|
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||||
|
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
|
||||||
|
import {
|
||||||
|
validateSessionFromHeaders,
|
||||||
|
extractSessionToken,
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
} from "../auth/validateSession.js";
|
||||||
|
|
||||||
|
declare module "express-serve-static-core" {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
capabilities?: Set<string>;
|
||||||
|
bots?: "all" | Set<string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
|
||||||
|
return function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||||
|
if (!result) {
|
||||||
|
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||||
|
res.status(401).json({ error: "unauthenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
|
||||||
|
req.user = {
|
||||||
|
id: result.userId,
|
||||||
|
username: result.username,
|
||||||
|
role: result.role,
|
||||||
|
capabilities: ctx.capabilities,
|
||||||
|
bots: ctx.bots,
|
||||||
|
};
|
||||||
|
const token = extractSessionToken(req.headers.cookie);
|
||||||
|
if (token) {
|
||||||
|
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: req.secure,
|
||||||
|
path: "/",
|
||||||
|
maxAge: SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { requirePermission, requireBotAccess } from "./requirePermission.js";
|
||||||
|
|
||||||
|
function appWith(user: any) {
|
||||||
|
const app = express();
|
||||||
|
app.use((req, _res, next) => { (req as any).user = user; next(); });
|
||||||
|
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
|
||||||
|
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = (caps: string[], bots: "all" | string[]) => ({
|
||||||
|
id: "u1", username: "a", role: "member",
|
||||||
|
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
|
||||||
|
});
|
||||||
|
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
|
||||||
|
|
||||||
|
describe("requirePermission", () => {
|
||||||
|
it("401 when unauthenticated", async () => {
|
||||||
|
const app = express();
|
||||||
|
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
|
||||||
|
expect((await request(app).post("/cap")).status).toBe(401);
|
||||||
|
});
|
||||||
|
it("403 when member lacks the capability", async () => {
|
||||||
|
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
|
||||||
|
});
|
||||||
|
it("200 when member has the capability", async () => {
|
||||||
|
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
|
||||||
|
});
|
||||||
|
it("200 for admin regardless of capabilities", async () => {
|
||||||
|
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireBotAccess", () => {
|
||||||
|
it("200 when bots = all", async () => {
|
||||||
|
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
|
||||||
|
});
|
||||||
|
it("200 when botId in allow-list", async () => {
|
||||||
|
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
|
||||||
|
});
|
||||||
|
it("403 when botId not in allow-list", async () => {
|
||||||
|
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
|
||||||
|
});
|
||||||
|
it("200 for admin", async () => {
|
||||||
|
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
|
||||||
|
});
|
||||||
|
it("401 when unauthenticated", async () => {
|
||||||
|
const app = express();
|
||||||
|
app.post("/bot/:botId", requireBotAccess("botId"), (_r, res) => res.json({ ok: true }));
|
||||||
|
expect((await request(app).post("/bot/b1")).status).toBe(401);
|
||||||
|
});
|
||||||
|
it("403 when the route param is absent", async () => {
|
||||||
|
const app = express();
|
||||||
|
app.use((req, _res, next) => { (req as any).user = member([], ["b1"]); next(); });
|
||||||
|
app.post("/bot/:botId", requireBotAccess("nope"), (_r, res) => res.json({ ok: true }));
|
||||||
|
expect((await request(app).post("/bot/b1")).status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||||
|
|
||||||
|
// Generic over the route-param shape (`P`) so Express can keep inferring
|
||||||
|
// `req.params` from the route string (e.g. `/:id` → `{ id: string }`) when
|
||||||
|
// these are passed as a per-route middleware argument. Pinning the default
|
||||||
|
// `ParamsDictionary` here would otherwise force the broad
|
||||||
|
// `string | string[]` param overload on every route they guard.
|
||||||
|
export function requirePermission<P = Record<string, string>>(capability: string): RequestHandler<P> {
|
||||||
|
return (req: Request<P>, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||||
|
if (req.user.role === "admin" || req.user.capabilities?.has(capability)) { next(); return; }
|
||||||
|
res.status(403).json({ error: "forbidden" });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireBotAccess<P = Record<string, string>>(paramName = "botId"): RequestHandler<P> {
|
||||||
|
return (req: Request<P>, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
|
||||||
|
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
|
||||||
|
const botId = (req.params as Record<string, string | undefined>)[paramName];
|
||||||
|
if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; }
|
||||||
|
res.status(403).json({ error: "forbidden" });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression guard for the QR-login / cookie-save outage (and in fact every
|
||||||
|
* mutating WebUI action). On 2026-05-27 the WebUI-auth feature added the
|
||||||
|
* same-origin CSRF gate `app.use("/api", csrfOriginCheck)` in server.ts, and
|
||||||
|
* the same day a `<meta name="referrer" content="no-referrer">` was added to
|
||||||
|
* web/index.html so cross-origin CDN cover thumbnails would load.
|
||||||
|
*
|
||||||
|
* Those two changes conflict: per the WHATWG Fetch "Append a request Origin
|
||||||
|
* header" algorithm, the `no-referrer` policy sets the Origin header to the
|
||||||
|
* literal string "null" on same-origin non-GET requests. csrfOriginCheck then
|
||||||
|
* fails to parse a host (`new URL("null")` throws) and returns 403 "bad
|
||||||
|
* origin", so POST /api/auth/qrcode (and every other POST/PUT/DELETE under
|
||||||
|
* /api/* except /api/session/*) never reaches its handler.
|
||||||
|
*
|
||||||
|
* `same-origin` is the correct policy: it keeps the real Origin on same-origin
|
||||||
|
* requests (CSRF passes) while still sending no Referer cross-origin (CDN
|
||||||
|
* thumbnails keep loading). Never switch this back to `no-referrer`.
|
||||||
|
*/
|
||||||
|
describe("frontend referrer policy (CSRF / Origin-header regression)", () => {
|
||||||
|
const indexHtmlPath = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../web/index.html"
|
||||||
|
);
|
||||||
|
const html = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||||
|
|
||||||
|
const referrerMeta = html.match(
|
||||||
|
/<meta\s+name=["']referrer["']\s+content=["']([^"']+)["']\s*\/?>/i
|
||||||
|
);
|
||||||
|
|
||||||
|
it("declares a referrer policy meta tag", () => {
|
||||||
|
expect(referrerMeta).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses same-origin (NOT no-referrer, which sends Origin: null and 403s every POST)", () => {
|
||||||
|
expect(referrerMeta?.[1]).toBe("same-origin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not contain no-referrer anywhere in the document head", () => {
|
||||||
|
expect(html).not.toMatch(/content=["']no-referrer["']/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
+115
-7
@@ -1,6 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import type { BotManager } from "../bot/manager.js";
|
import type { BotManager } from "../bot/manager.js";
|
||||||
import type { MusicProvider } from "../music/provider.js";
|
import type { MusicProvider } from "../music/provider.js";
|
||||||
@@ -8,11 +9,27 @@ import type { BotDatabase } from "../data/database.js";
|
|||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig } from "../data/config.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { CookieStore } from "../music/auth.js";
|
import type { CookieStore } from "../music/auth.js";
|
||||||
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
import { createBotRouter } from "./api/bot.js";
|
import { createBotRouter } from "./api/bot.js";
|
||||||
import { createMusicRouter } from "./api/music.js";
|
import { createMusicRouter } from "./api/music.js";
|
||||||
import { createPlayerRouter } from "./api/player.js";
|
import { createPlayerRouter } from "./api/player.js";
|
||||||
import { createAuthRouter } from "./api/auth.js";
|
import { createAuthRouter } from "./api/auth.js";
|
||||||
|
import { createSessionRouter } from "./api/session.js";
|
||||||
|
import { createUsersRouter } from "./api/users.js";
|
||||||
|
import { createAuditStore } from "../data/audit.js";
|
||||||
|
import { createAuditRouter } from "./api/audit.js";
|
||||||
|
import { createFavoritesRouter } from "./api/favorites.js";
|
||||||
import { setupWebSocket } from "./websocket.js";
|
import { setupWebSocket } from "./websocket.js";
|
||||||
|
import { createUserStore } from "../data/users.js";
|
||||||
|
import { createSessionStore } from "../data/sessions.js";
|
||||||
|
import { createPermissionStore } from "../data/permissions.js";
|
||||||
|
import { createRequireAuth } from "./middleware/requireAuth.js";
|
||||||
|
import { requireAdmin } from "./middleware/requireAdmin.js";
|
||||||
|
import { csrfOriginCheck } from "./middleware/csrf.js";
|
||||||
|
import { createRateLimit } from "./middleware/rateLimit.js";
|
||||||
|
import { validateSessionFromHeaders } from "./auth/validateSession.js";
|
||||||
|
|
||||||
|
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
export interface WebServerOptions {
|
export interface WebServerOptions {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -25,6 +42,7 @@ export interface WebServerOptions {
|
|||||||
configPath: string;
|
configPath: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
cookieStore?: CookieStore;
|
cookieStore?: CookieStore;
|
||||||
|
avatarStore: AvatarStore;
|
||||||
staticDir?: string;
|
staticDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,20 +57,62 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
const logger = options.logger.child({ component: "web" });
|
const logger = options.logger.child({ component: "web" });
|
||||||
|
|
||||||
if (options.config.trustProxy) {
|
if (options.config.trustProxy) {
|
||||||
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
|
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(express.json());
|
// Security headers: prevent the WebUI from being embedded in a third-party
|
||||||
|
// iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent
|
||||||
|
// of X-Frame-Options; both are set for compatibility across browsers.
|
||||||
|
app.use((_req, res, next) => {
|
||||||
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
|
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.json({ limit: "400kb" }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
const users = createUserStore(options.database.db);
|
||||||
|
const sessions = createSessionStore(options.database.db);
|
||||||
|
const audit = createAuditStore(options.database.db);
|
||||||
|
const permissions = createPermissionStore(options.database.db);
|
||||||
|
|
||||||
|
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok", version: "0.1.0" });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/config/public-url", (_req, res) => {
|
app.get("/api/config/public-url", (_req, res) => {
|
||||||
const raw = (options.config.publicUrl ?? "").trim();
|
const raw = (options.config.publicUrl ?? "").trim();
|
||||||
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Anti-DoS: throttle expensive (bcrypt) auth endpoints.
|
||||||
|
// 5 req per minute per IP for /login (capacity 5, refill 5/60 = ~0.083/sec).
|
||||||
|
// 3 req per minute per IP for /setup (more limited; first-run is rare).
|
||||||
|
const loginLimit = createRateLimit({ capacity: 5, refillPerSec: 5 / 60 });
|
||||||
|
const setupLimit = createRateLimit({ capacity: 3, refillPerSec: 3 / 60 });
|
||||||
|
app.use("/api/session/login", loginLimit);
|
||||||
|
app.use("/api/session/setup", setupLimit);
|
||||||
|
|
||||||
|
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions));
|
||||||
|
|
||||||
|
// ─── Gates for everything else under /api ───────────────────────────────
|
||||||
|
const requireAuth = createRequireAuth(sessions, permissions);
|
||||||
|
app.use("/api", csrfOriginCheck);
|
||||||
|
app.use("/api", requireAuth);
|
||||||
|
|
||||||
|
// ─── Protected routes ───────────────────────────────────────────────────
|
||||||
app.use(
|
app.use(
|
||||||
"/api/bot",
|
"/api/bot",
|
||||||
createBotRouter(options.botManager, options.config, options.configPath, logger)
|
createBotRouter(
|
||||||
|
options.botManager,
|
||||||
|
options.config,
|
||||||
|
options.configPath,
|
||||||
|
logger,
|
||||||
|
options.database,
|
||||||
|
options.avatarStore,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
app.use(
|
app.use(
|
||||||
"/api/music",
|
"/api/music",
|
||||||
@@ -66,11 +126,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
"/api/auth",
|
"/api/auth",
|
||||||
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore)
|
||||||
);
|
);
|
||||||
|
app.use("/api/favorites", createFavoritesRouter(options.database, logger));
|
||||||
|
|
||||||
app.get("/api/health", (_req, res) => {
|
// admin-only routes
|
||||||
res.json({ status: "ok", version: "0.1.0" });
|
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
||||||
});
|
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
|
||||||
|
|
||||||
|
// ─── Static SPA (public) ────────────────────────────────────────────────
|
||||||
if (options.staticDir) {
|
if (options.staticDir) {
|
||||||
app.use(express.static(options.staticDir));
|
app.use(express.static(options.staticDir));
|
||||||
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
|
app.get(/^(?!\/api|\/ws)/, (_req, res) => {
|
||||||
@@ -82,22 +144,68 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
logger.error({ err }, "HTTP server error");
|
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) => {
|
wss.on("error", (err) => {
|
||||||
logger.error({ err }, "WebSocket server error");
|
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);
|
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
||||||
|
|
||||||
|
// ─── Session cleanup interval ──────────────────────────────────────────
|
||||||
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
server.listen(options.port, () => {
|
server.listen(options.port, () => {
|
||||||
logger.info({ port: options.port }, "Web server started");
|
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();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
if (cleanupTimer) {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
cleanupTimer = null;
|
||||||
|
}
|
||||||
cleanupWs();
|
cleanupWs();
|
||||||
wss.close();
|
wss.close();
|
||||||
server.close();
|
server.close();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
title TSMusicBot
|
||||||
|
|
||||||
|
:: Check node
|
||||||
|
where node >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Node.js not found. Run scripts\setup.bat first.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting TSMusicBot...
|
||||||
|
echo WebUI: http://localhost:3000
|
||||||
|
echo Press Ctrl+C to stop.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
node dist\index.js
|
||||||
|
|
||||||
|
pause
|
||||||
@@ -3,6 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
|
||||||
|
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
|
||||||
|
does exactly that: full Referer for our own requests, none for cross-origin
|
||||||
|
ones — so cover thumbnails (<img> AND CSS background-image) still load.
|
||||||
|
Do NOT switch this back to "no-referrer": per the WHATWG Fetch spec
|
||||||
|
("Append a request Origin header") no-referrer downgrades the Origin header
|
||||||
|
to the literal string "null" on same-origin non-GET requests. The /api/*
|
||||||
|
CSRF guard (src/web/middleware/csrf.ts) then can't parse a host from it and
|
||||||
|
responds 403 "bad origin", silently breaking EVERY POST/PUT/DELETE — QR
|
||||||
|
login, cookie save, playback controls, bot management, user admin, etc.
|
||||||
|
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
|
||||||
|
<meta name="referrer" content="same-origin">
|
||||||
<title>TSMusicBot</title>
|
<title>TSMusicBot</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|||||||
+14
-2
@@ -140,12 +140,24 @@ function cycleMobileMode() {
|
|||||||
playerStore.setMode(nextMode);
|
playerStore.setMode(nextMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
playerStore.loadTheme();
|
playerStore.loadTheme();
|
||||||
connect();
|
connect();
|
||||||
playerStore.fetchBots();
|
// Hydrate favorites once per session so deep-links / hard refreshes onto
|
||||||
|
// Search or Playlist render hearts correctly without first visiting Home.
|
||||||
|
// (fire-and-forget; fetchFavorites swallows the 401 when not yet logged in.)
|
||||||
|
playerStore.fetchFavorites();
|
||||||
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
||||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
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(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div class="avatar-upload">
|
||||||
|
<div class="preview" :class="{ empty: !previewUrl }">
|
||||||
|
<img v-if="previewUrl" :src="previewUrl" alt="avatar" />
|
||||||
|
<Icon v-else icon="mdi:account-circle-outline" />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFile"
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn-sm" @click="fileInput?.click()">
|
||||||
|
{{ previewUrl ? '更换' : '上传' }}
|
||||||
|
</button>
|
||||||
|
<button v-if="previewUrl" type="button" class="btn-sm btn-danger" @click="clear">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="hint error">{{ error }}</p>
|
||||||
|
<p v-else class="hint">PNG / JPG / WebP,≤200 KB</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: string | null }>();
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>();
|
||||||
|
|
||||||
|
const previewUrl = ref<string | null>(props.modelValue);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => { previewUrl.value = v; });
|
||||||
|
|
||||||
|
function onFile(ev: Event) {
|
||||||
|
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||||||
|
error.value = '仅支持 PNG / JPG / WebP';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 200 * 1024) {
|
||||||
|
error.value = `图片 ${(file.size / 1024).toFixed(0)} KB 超过 200 KB 上限`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = null;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
previewUrl.value = dataUrl;
|
||||||
|
emit('update:modelValue', dataUrl);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
previewUrl.value = null;
|
||||||
|
emit('update:modelValue', null);
|
||||||
|
if (fileInput.value) fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.avatar-upload { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 80px; height: 80px; border-radius: 50%;
|
||||||
|
background: var(--bg-card); display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
&.empty :deep(svg) { font-size: 48px; opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
&:hover { background: var(--color-primary); color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
&:hover { background: #f44336; color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
|
||||||
|
.hint.error { color: #f44336; opacity: 1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<AvatarUpload v-model="avatarDataUrl" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch, nextTick } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import AvatarUpload from './AvatarUpload.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ botId: string }>();
|
||||||
|
const avatarDataUrl = ref<string | null>(null);
|
||||||
|
// Stays true until the watcher queued by the load-time assignment has run,
|
||||||
|
// so the initial null → loaded-data-url transition does not fire a redundant
|
||||||
|
// PUT echoing the just-fetched bytes back to the server.
|
||||||
|
let initializing = true;
|
||||||
|
|
||||||
|
async function loadCurrent() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/bot/${props.botId}/avatar`, { responseType: 'blob' });
|
||||||
|
const blob = res.data as Blob;
|
||||||
|
avatarDataUrl.value = await blobToDataUrl(blob);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.response?.status !== 404) {
|
||||||
|
console.warn('failed to load avatar', err);
|
||||||
|
}
|
||||||
|
avatarDataUrl.value = null;
|
||||||
|
} finally {
|
||||||
|
// Wait for the watcher's flush queue to drain (it'll see initializing=true
|
||||||
|
// and bail), then release for real user-driven changes.
|
||||||
|
await nextTick();
|
||||||
|
initializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(avatarDataUrl, async (newVal, oldVal) => {
|
||||||
|
if (initializing) return;
|
||||||
|
if (newVal === oldVal) return;
|
||||||
|
try {
|
||||||
|
if (newVal && newVal.startsWith('data:')) {
|
||||||
|
await axios.put(`/api/bot/${props.botId}/avatar`, { dataUrl: newVal });
|
||||||
|
} else if (newVal === null) {
|
||||||
|
await axios.delete(`/api/bot/${props.botId}/avatar`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('avatar update failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(loadCurrent);
|
||||||
|
</script>
|
||||||
@@ -10,8 +10,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<!-- Bot selector (always shown when at least one bot exists) -->
|
<!-- Scoped (dedicated link): static label locked to the one bot, no switching -->
|
||||||
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
<div v-if="store.isScoped" class="bot-selector scoped" ref="selectorRef">
|
||||||
|
<div class="bot-selector-btn static">
|
||||||
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
|
<span class="bot-selector-name">{{ activeBot?.name ?? '专属机器人' }}</span>
|
||||||
|
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
|
||||||
|
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
|
||||||
|
<span class="scope-badge">专属模式</span>
|
||||||
|
</div>
|
||||||
|
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal: full selector with switching (shown when at least one
|
||||||
|
controllable bot exists — scope ∩ permission via displayedBots) -->
|
||||||
|
<div v-else-if="displayedBots.length > 0" class="bot-selector" ref="selectorRef">
|
||||||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
||||||
@@ -22,7 +35,7 @@
|
|||||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||||
<div class="bot-dropdown-header">机器人</div>
|
<div class="bot-dropdown-header">机器人</div>
|
||||||
<div
|
<div
|
||||||
v-for="bot in store.bots"
|
v-for="bot in displayedBots"
|
||||||
:key="bot.id"
|
:key="bot.id"
|
||||||
class="bot-card"
|
class="bot-card"
|
||||||
:class="{ active: bot.id === store.activeBotId }"
|
:class="{ active: bot.id === store.activeBotId }"
|
||||||
@@ -88,6 +101,16 @@
|
|||||||
<RouterLink to="/settings" class="settings-btn">
|
<RouterLink to="/settings" class="settings-btn">
|
||||||
<Icon icon="mdi:cog" />
|
<Icon icon="mdi:cog" />
|
||||||
</RouterLink>
|
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -114,11 +137,34 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
|
const session = useSession();
|
||||||
|
const { canControlBot } = session;
|
||||||
|
const navRouter = useRouter();
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await session.logout();
|
||||||
|
navRouter.replace({ name: 'login' });
|
||||||
|
}
|
||||||
|
// Belt-and-suspenders: the backend already scopes store.bots to the allowed
|
||||||
|
// set for members, but filtering here keeps the UI correct if an admin (who
|
||||||
|
// sees all bots) is constrained, or if the list ever isn't pre-filtered.
|
||||||
|
const controllableBots = computed(() => store.bots.filter((b) => canControlBot(b.id)));
|
||||||
const activeBot = computed(() => store.activeBot);
|
const activeBot = computed(() => store.activeBot);
|
||||||
|
// The bots shown in the selector are the INTERSECTION of the permission
|
||||||
|
// allow-list (controllableBots) and the dedicated-link scope: while scoped the
|
||||||
|
// selector is locked to the single scoped bot, otherwise the full controllable
|
||||||
|
// list is shown and switching is allowed.
|
||||||
|
const displayedBots = computed(() =>
|
||||||
|
store.isScoped
|
||||||
|
? controllableBots.value.filter((b) => b.id === store.scopedBotId)
|
||||||
|
: controllableBots.value,
|
||||||
|
);
|
||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
const selectorRef = ref<HTMLElement | null>(null);
|
const selectorRef = ref<HTMLElement | null>(null);
|
||||||
const togglingBots = ref<Record<string, boolean>>({});
|
const togglingBots = ref<Record<string, boolean>>({});
|
||||||
@@ -137,6 +183,14 @@ function selectBot(id: string) {
|
|||||||
dropdownOpen.value = false;
|
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 {
|
function resolveBaseUrl(): string {
|
||||||
const base = publicBaseUrl.value;
|
const base = publicBaseUrl.value;
|
||||||
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||||||
@@ -351,6 +405,60 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scoped (dedicated-link) selector: locked, non-interactive label + exit */
|
||||||
|
.bot-selector.scoped {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector-btn.static {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-primary-15);
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-exit-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bot-state-mini {
|
.bot-state-mini {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
&.playing { color: var(--color-online); }
|
&.playing { color: var(--color-online); }
|
||||||
@@ -643,4 +751,22 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-left: 12px;
|
||||||
|
color: var(--text-secondary); font-size: 13px;
|
||||||
|
}
|
||||||
|
.nav-user-logout {
|
||||||
|
height: 28px; width: 28px; display: grid; place-items: center;
|
||||||
|
border: 0; background: transparent; color: var(--text-secondary); cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
&:hover { background: var(--bg-secondary); color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-role {
|
||||||
|
font-size: 11px; padding: 2px 6px; border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
|
||||||
|
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
|
||||||
</style>
|
</style>
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
<Queue :open="showQueue" @close="showQueue = false" />
|
<Queue :open="showQueue" @close="showQueue = false" />
|
||||||
|
|
||||||
<div class="player-bar frosted-glass">
|
<div class="player-bar frosted-glass">
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar (read-only display; seek interaction gated on player.control) -->
|
||||||
<div
|
<div
|
||||||
class="progress-bar-container"
|
class="progress-bar-container"
|
||||||
|
:class="{ 'no-seek': !canControl }"
|
||||||
ref="progressBarRef"
|
ref="progressBarRef"
|
||||||
@click="onProgressClick"
|
@click="onProgressClick"
|
||||||
@mousemove="onProgressHover"
|
@mousemove="onProgressHover"
|
||||||
@@ -27,42 +28,48 @@
|
|||||||
<div class="player-left" @click="toggleLyrics">
|
<div class="player-left" @click="toggleLyrics">
|
||||||
<CoverArt :url="currentSong.coverUrl" :size="40" />
|
<CoverArt :url="currentSong.coverUrl" :size="40" />
|
||||||
<div class="song-info">
|
<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">
|
<div class="song-artist">
|
||||||
<span v-if="showBotBadge" class="bot-badge">{{ activeBot?.name }}</span>
|
<span v-if="showBotBadge" class="bot-badge">{{ activeBot?.name }}</span>
|
||||||
{{ currentSong.artist }}
|
<span class="artist-name" :title="currentSong.artist">{{ currentSong.artist }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-center">
|
<div class="player-center">
|
||||||
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
|
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
|
||||||
<button class="control-btn" @click="store.prev()">
|
<!-- Transport controls require player.control -->
|
||||||
<Icon icon="mdi:skip-previous" />
|
<template v-if="canControl">
|
||||||
</button>
|
<button class="control-btn" @click="store.prev()">
|
||||||
<button class="play-btn" @click="togglePlay">
|
<Icon icon="mdi:skip-previous" />
|
||||||
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
|
</button>
|
||||||
</button>
|
<button class="play-btn" @click="togglePlay">
|
||||||
<button class="control-btn" @click="store.next()">
|
<Icon :icon="store.isPlaying ? 'mdi:pause' : 'mdi:play'" />
|
||||||
<Icon icon="mdi:skip-next" />
|
</button>
|
||||||
</button>
|
<button class="control-btn" @click="store.next()">
|
||||||
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
|
<Icon icon="mdi:skip-next" />
|
||||||
<Icon :icon="modeIcon" />
|
</button>
|
||||||
<span class="mode-label">{{ modeLabel }}</span>
|
<button class="control-btn mode-btn" @click="cycleMode" :title="modeLabel">
|
||||||
</button>
|
<Icon :icon="modeIcon" />
|
||||||
|
<span class="mode-label">{{ modeLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
|
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-right">
|
<div class="player-right">
|
||||||
<Icon icon="mdi:volume-high" class="volume-icon" />
|
<!-- Volume requires player.control -->
|
||||||
<input
|
<template v-if="canControl">
|
||||||
type="range"
|
<Icon icon="mdi:volume-high" class="volume-icon" />
|
||||||
min="0"
|
<input
|
||||||
max="100"
|
type="range"
|
||||||
:value="activeBot?.volume ?? 75"
|
min="0"
|
||||||
@change="onVolumeChange"
|
max="100"
|
||||||
class="volume-slider"
|
:value="activeBot?.volume ?? 75"
|
||||||
/>
|
@change="onVolumeChange"
|
||||||
|
class="volume-slider"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue">
|
<button class="control-btn" :class="{ active: showQueue }" @click="showQueue = !showQueue">
|
||||||
<Icon icon="mdi:playlist-music" />
|
<Icon icon="mdi:playlist-music" />
|
||||||
</button>
|
</button>
|
||||||
@@ -79,6 +86,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue';
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import { useSession } from '../composables/useSession.js';
|
||||||
import CoverArt from './CoverArt.vue';
|
import CoverArt from './CoverArt.vue';
|
||||||
import Queue from './Queue.vue';
|
import Queue from './Queue.vue';
|
||||||
|
|
||||||
@@ -86,6 +94,9 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const showQueue = ref(false);
|
const showQueue = ref(false);
|
||||||
|
|
||||||
|
const { can } = useSession();
|
||||||
|
const canControl = computed(() => can('player.control'));
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const activeBot = computed(() => store.activeBot);
|
const activeBot = computed(() => store.activeBot);
|
||||||
const currentSong = computed(() => store.currentSong);
|
const currentSong = computed(() => store.currentSong);
|
||||||
@@ -133,6 +144,7 @@ function updateProgress() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onProgressClick(e: MouseEvent) {
|
async function onProgressClick(e: MouseEvent) {
|
||||||
|
if (!canControl.value) return; // seek requires player.control
|
||||||
const bar = progressBarRef.value;
|
const bar = progressBarRef.value;
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
const rect = bar.getBoundingClientRect();
|
const rect = bar.getBoundingClientRect();
|
||||||
@@ -237,6 +249,14 @@ function cycleMode() {
|
|||||||
.progress-bar-bg { height: 4px; }
|
.progress-bar-bg { height: 4px; }
|
||||||
.progress-bar-thumb { opacity: 1; transform: scale(1); }
|
.progress-bar-thumb { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.no-seek {
|
||||||
|
cursor: default;
|
||||||
|
&:hover {
|
||||||
|
.progress-bar-bg { height: 2px; }
|
||||||
|
.progress-bar-thumb { opacity: 0; transform: scale(0); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-bg {
|
.progress-bar-bg {
|
||||||
@@ -310,6 +330,8 @@ function cycleMode() {
|
|||||||
|
|
||||||
.song-info {
|
.song-info {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-name {
|
.song-name {
|
||||||
@@ -326,6 +348,16 @@ function cycleMode() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bot-badge {
|
.bot-badge {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
<div class="queue-header">
|
<div class="queue-header">
|
||||||
<h3 class="queue-title">播放队列</h3>
|
<h3 class="queue-title">播放队列</h3>
|
||||||
<span class="queue-count">{{ botQueue.length }} 首</span>
|
<span class="queue-count">{{ botQueue.length }} 首</span>
|
||||||
<button
|
<button
|
||||||
v-if="botQueue.length > 0"
|
v-if="botQueue.length > 0 && can('player.control')"
|
||||||
class="clear-btn"
|
class="clear-btn"
|
||||||
@click="clearAndStop"
|
@click="clearAndStop"
|
||||||
title="清空队列并停止播放"
|
title="清空队列并停止播放"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:stop-circle-outline" />
|
<Icon icon="mdi:stop-circle-outline" />
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="queue-song-name">{{ song.name }}</div>
|
<div class="queue-song-name">{{ song.name }}</div>
|
||||||
<div class="queue-song-artist">{{ song.artist }}</div>
|
<div class="queue-song-artist">{{ song.artist }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="remove-btn" @click="removeSong(i)" title="移除">
|
<button v-if="can('player.queue')" class="remove-btn" @click="removeSong(i)" title="移除">
|
||||||
<Icon icon="mdi:close" />
|
<Icon icon="mdi:close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,6 +46,7 @@ import { watch, computed } from 'vue';
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import { useSession } from '../composables/useSession.js';
|
||||||
import CoverArt from './CoverArt.vue';
|
import CoverArt from './CoverArt.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -57,6 +58,7 @@ defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
|
const { can } = useSession();
|
||||||
const botQueue = computed(() => store.queue);
|
const botQueue = computed(() => store.queue);
|
||||||
|
|
||||||
// Fetch queue when panel opens
|
// Fetch queue when panel opens
|
||||||
@@ -65,6 +67,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function playAtIndex(index: number) {
|
async function playAtIndex(index: number) {
|
||||||
|
if (!can('player.control')) return;
|
||||||
await store.playAtIndex(index);
|
await store.playAtIndex(index);
|
||||||
await store.fetchQueue();
|
await store.fetchQueue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { ref, computed, readonly } from "vue";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: 'admin' | 'member';
|
||||||
|
capabilities?: string[];
|
||||||
|
bots?: "all" | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = ref<User | null>(null);
|
||||||
|
const needsSetup = ref<boolean | null>(null); // null = unknown / not fetched yet
|
||||||
|
const ready = ref(false);
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
function ensurePollStarted() {
|
||||||
|
if (pollTimer !== null) return;
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (currentUser.value !== null) {
|
||||||
|
// Best-effort refresh; ignore errors (network blips etc.)
|
||||||
|
refreshMe().catch(() => {});
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNeedsSetup(): Promise<void> {
|
||||||
|
const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" });
|
||||||
|
if (res.ok) {
|
||||||
|
const body = await res.json();
|
||||||
|
needsSetup.value = Boolean(body.needsSetup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMe(): Promise<void> {
|
||||||
|
const res = await fetch("/api/session/me", { credentials: "same-origin" });
|
||||||
|
if (res.status === 200) {
|
||||||
|
currentUser.value = (await res.json()) as User;
|
||||||
|
} else {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await refreshNeedsSetup();
|
||||||
|
if (needsSetup.value) {
|
||||||
|
currentUser.value = null;
|
||||||
|
} else {
|
||||||
|
await refreshMe();
|
||||||
|
}
|
||||||
|
ready.value = true;
|
||||||
|
ensurePollStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username: string, password: string): Promise<void> {
|
||||||
|
const res = await fetch("/api/session/login", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `login failed (${res.status})`);
|
||||||
|
}
|
||||||
|
currentUser.value = (await res.json()) as User;
|
||||||
|
// Login response omits capabilities/bots; fetch the authoritative ones from /me.
|
||||||
|
await refreshMe();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(username: string, password: string): Promise<void> {
|
||||||
|
const res = await fetch("/api/session/setup", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `setup failed (${res.status})`);
|
||||||
|
}
|
||||||
|
currentUser.value = (await res.json()) as User;
|
||||||
|
needsSetup.value = false;
|
||||||
|
// Setup response omits capabilities/bots; fetch the authoritative ones from /me.
|
||||||
|
await refreshMe();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
stopPoll();
|
||||||
|
await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" });
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function can(cap: string): boolean {
|
||||||
|
const u = currentUser.value;
|
||||||
|
return !!u && (u.role === "admin" || (u.capabilities ?? []).includes(cap));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canControlBot(botId: string): boolean {
|
||||||
|
const u = currentUser.value;
|
||||||
|
if (!u) return false;
|
||||||
|
if (u.role === "admin" || u.bots === "all") return true;
|
||||||
|
return Array.isArray(u.bots) && u.bots.includes(botId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return {
|
||||||
|
currentUser: readonly(currentUser),
|
||||||
|
needsSetup: readonly(needsSetup),
|
||||||
|
isAuthenticated: computed(() => currentUser.value !== null),
|
||||||
|
isAdmin: computed(() => currentUser.value?.role === 'admin'),
|
||||||
|
ready: readonly(ready),
|
||||||
|
refresh,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setup,
|
||||||
|
can,
|
||||||
|
canControlBot,
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-1
@@ -2,10 +2,19 @@ import { createApp } from 'vue';
|
|||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router/index.js';
|
import router from './router/index.js';
|
||||||
|
import { installApiClient } from './api/http.js';
|
||||||
import './styles/global.scss';
|
import './styles/global.scss';
|
||||||
import './styles/mobile.scss';
|
import './styles/mobile.scss';
|
||||||
|
|
||||||
|
installApiClient();
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount('#app');
|
// Wait for the initial navigation (and the beforeEach guard that reads ?bot)
|
||||||
|
// to fully resolve before mounting, so the reactive route query is populated
|
||||||
|
// when App.onMounted runs and the dedicated-bot scope locks the right bot.
|
||||||
|
// .catch keeps parity with the old unconditional mount: if the initial
|
||||||
|
// navigation errors (e.g. a transient network failure in the auth guard),
|
||||||
|
// still render the shell rather than leaving a blank page.
|
||||||
|
router.isReady().catch(() => {}).then(() => app.mount('#app'));
|
||||||
+58
-41
@@ -1,23 +1,13 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{ path: '/', name: 'home', component: () => import('../views/Home.vue') },
|
||||||
path: '/',
|
{ path: '/search', name: 'search', component: () => import('../views/Search.vue') },
|
||||||
name: 'home',
|
{ path: '/library', name: 'library', component: () => import('../views/Library.vue') },
|
||||||
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',
|
path: '/playlist/:id',
|
||||||
name: 'playlist',
|
name: 'playlist',
|
||||||
@@ -30,33 +20,60 @@ const router = createRouter({
|
|||||||
component: () => import('../views/Playlist.vue'),
|
component: () => import('../views/Playlist.vue'),
|
||||||
meta: { kind: 'album' },
|
meta: { kind: 'album' },
|
||||||
},
|
},
|
||||||
{
|
{ path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') },
|
||||||
path: '/lyrics',
|
{ path: '/history', name: 'history', component: () => import('../views/History.vue') },
|
||||||
name: 'lyrics',
|
{ path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') },
|
||||||
component: () => import('../views/Lyrics.vue'),
|
{ path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') },
|
||||||
},
|
{ path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') },
|
||||||
{
|
|
||||||
path: '/history',
|
// Auth views
|
||||||
name: 'history',
|
{ path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||||
component: () => import('../views/History.vue'),
|
{ path: '/first-run', name: 'first-run', component: () => import('../views/FirstRunSetup.vue'), meta: { public: true } },
|
||||||
},
|
|
||||||
{
|
|
||||||
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'),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
export default router;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { resolveScopedBot } from './scope.js';
|
||||||
|
|
||||||
export interface Song {
|
export interface Song {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +35,17 @@ export interface PlaylistItem {
|
|||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FavoritePlaylist {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
platform: string;
|
||||||
|
playlistId: string;
|
||||||
|
name: string;
|
||||||
|
coverUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TimingState {
|
interface TimingState {
|
||||||
serverElapsed: number;
|
serverElapsed: number;
|
||||||
serverSyncTime: number;
|
serverSyncTime: number;
|
||||||
@@ -50,6 +62,9 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
bots: [] as BotStatus[],
|
bots: [] as BotStatus[],
|
||||||
activeBotId: null as string | null,
|
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 */
|
/** Per-bot queues keyed by botId */
|
||||||
queues: {} as Record<string, Song[]>,
|
queues: {} as Record<string, Song[]>,
|
||||||
/** Per-bot timing state keyed by botId */
|
/** Per-bot timing state keyed by botId */
|
||||||
@@ -64,6 +79,9 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
authStatus: { netease: false, qq: false },
|
authStatus: { netease: false, qq: false },
|
||||||
lastFetchTime: 0,
|
lastFetchTime: 0,
|
||||||
|
|
||||||
|
// Favorited playlists (fetched from server, isolated per WebUI user)
|
||||||
|
favoritedPlaylists: [] as FavoritePlaylist[],
|
||||||
|
|
||||||
// Transient notification for surfacing failures (e.g., "song not playable")
|
// Transient notification for surfacing failures (e.g., "song not playable")
|
||||||
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
||||||
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
||||||
@@ -73,6 +91,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
activeBot(): BotStatus | null {
|
activeBot(): BotStatus | null {
|
||||||
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? 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 {
|
currentSong(): Song | null {
|
||||||
return this.activeBot?.currentSong ?? null;
|
return this.activeBot?.currentSong ?? null;
|
||||||
},
|
},
|
||||||
@@ -125,6 +147,8 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setActiveBotId(id: string) {
|
setActiveBotId(id: string) {
|
||||||
|
// While scoped to a dedicated link, switching bots is blocked.
|
||||||
|
if (this.scopedBotId !== null && id !== this.scopedBotId) return;
|
||||||
this.activeBotId = id;
|
this.activeBotId = id;
|
||||||
// Fetch queue for newly active bot if we don't have it yet
|
// Fetch queue for newly active bot if we don't have it yet
|
||||||
if (!this.queues[id]) {
|
if (!this.queues[id]) {
|
||||||
@@ -132,6 +156,32 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Lock the UI to a single bot (dedicated link). Sets scope first so the
|
||||||
|
* setActiveBotId guard does not block the switch to the scoped bot. */
|
||||||
|
setScope(id: string) {
|
||||||
|
this.scopedBotId = id;
|
||||||
|
this.activeBotId = id;
|
||||||
|
// Lazily fetch this bot's queue, mirroring setActiveBotId.
|
||||||
|
if (!this.queues[id]) {
|
||||||
|
this.fetchQueue();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearScope() {
|
||||||
|
this.scopedBotId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reconcile the scope with the desired id from the URL (?bot). A stale or
|
||||||
|
* forbidden id resolves to null and clears the scope rather than locking. */
|
||||||
|
applyScopeFromQuery(requestedId: string | null) {
|
||||||
|
const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id));
|
||||||
|
if (r) {
|
||||||
|
this.setScope(r);
|
||||||
|
} else if (requestedId) {
|
||||||
|
this.clearScope();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
updateBotStatus(botId: string, status: BotStatus) {
|
updateBotStatus(botId: string, status: BotStatus) {
|
||||||
const prev = this.bots.find((b) => b.id === botId);
|
const prev = this.bots.find((b) => b.id === botId);
|
||||||
const prevSongId = prev?.currentSong?.id;
|
const prevSongId = prev?.currentSong?.id;
|
||||||
@@ -166,6 +216,11 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.bots = this.bots.filter((b) => b.id !== botId);
|
this.bots = this.bots.filter((b) => b.id !== botId);
|
||||||
delete this.queues[botId];
|
delete this.queues[botId];
|
||||||
delete this.timings[botId];
|
delete this.timings[botId];
|
||||||
|
// If the bot we were locked to is gone, drop the scope so the UI does not
|
||||||
|
// stay 'locked' onto a phantom (activeBot would silently fall back to bots[0]).
|
||||||
|
if (this.scopedBotId === botId) {
|
||||||
|
this.clearScope();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setQueue(botId: string, queue: Song[]) {
|
setQueue(botId: string, queue: Song[]) {
|
||||||
@@ -397,6 +452,60 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
if (bot) bot.playMode = mode;
|
if (bot) bot.playMode = mode;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async startFm(platform: Source = 'netease') {
|
||||||
|
if (!this.activeBotId) return;
|
||||||
|
const res = await axios.post(`/api/player/${this.activeBotId}/fm`, { platform });
|
||||||
|
if (res.data?.message) {
|
||||||
|
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||||
|
}
|
||||||
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
|
this._syncAfterAction();
|
||||||
|
this.fetchQueue();
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchFavorites() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/favorites');
|
||||||
|
this.favoritedPlaylists = res.data.favorites ?? [];
|
||||||
|
} catch {
|
||||||
|
// not critical
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addFavorite(playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }) {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/favorites', playlist);
|
||||||
|
await this.fetchFavorites();
|
||||||
|
this.notify('已收藏', 'info');
|
||||||
|
} catch (err: any) {
|
||||||
|
// 409 = already favorited (e.g. stale heart); just resync so the UI converges.
|
||||||
|
if (err?.response?.status === 409) {
|
||||||
|
await this.fetchFavorites();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.notify('收藏失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFavorite(id: number) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/favorites/${id}`);
|
||||||
|
await this.fetchFavorites();
|
||||||
|
this.notify('已取消收藏', 'info');
|
||||||
|
} catch (err: any) {
|
||||||
|
// 404 = already gone; resync. Otherwise report failure.
|
||||||
|
if (err?.response?.status === 404) {
|
||||||
|
await this.fetchFavorites();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.notify('取消收藏失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isFavorited(playlistId: string, platform: string): boolean {
|
||||||
|
return this.favoritedPlaylists.some((f) => f.playlistId === playlistId && f.platform === platform);
|
||||||
|
},
|
||||||
|
|
||||||
async fetchHomeData() {
|
async fetchHomeData() {
|
||||||
// Always check auth status first — if it changed since the cached
|
// Always check auth status first — if it changed since the cached
|
||||||
// fetch (e.g., user logged in/out as a different account), the
|
// fetch (e.g., user logged in/out as a different account), the
|
||||||
@@ -414,6 +523,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.authStatus.netease = newAuth.netease;
|
this.authStatus.netease = newAuth.netease;
|
||||||
this.authStatus.qq = newAuth.qq;
|
this.authStatus.qq = newAuth.qq;
|
||||||
|
|
||||||
|
// Favorites are user-local and cheap; always refresh them, even on a
|
||||||
|
// home-data cache hit, so hearts stay correct across tabs/sessions.
|
||||||
|
this.fetchFavorites();
|
||||||
|
|
||||||
// Cache hit only if auth is unchanged AND within TTL.
|
// Cache hit only if auth is unchanged AND within TTL.
|
||||||
if (
|
if (
|
||||||
!authChanged &&
|
!authChanged &&
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
const bot = store.bots.find((b) => b.id === botId);
|
const bot = store.bots.find((b) => b.id === botId);
|
||||||
if (bot) {
|
if (bot) {
|
||||||
store.setActiveBotId(botId);
|
store.setScope(botId);
|
||||||
router.replace('/');
|
router.replace({ path: '/', query: { bot: botId } });
|
||||||
} else {
|
} else {
|
||||||
notFound.value = true;
|
notFound.value = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
|
<form class="auth-card" @submit.prevent="submit">
|
||||||
|
<h1>首次使用</h1>
|
||||||
|
<p class="auth-hint">创建管理员账号。该账号将拥有 WebUI 的全部权限。</p>
|
||||||
|
<label>
|
||||||
|
<span>用户名</span>
|
||||||
|
<input v-model="username" type="text" autocomplete="username" autofocus required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>密码 (≥8 位)</span>
|
||||||
|
<input v-model="password" type="password" autocomplete="new-password" minlength="8" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>再次输入密码</span>
|
||||||
|
<input v-model="confirm" type="password" autocomplete="new-password" minlength="8" required />
|
||||||
|
</label>
|
||||||
|
<p v-if="error" class="auth-error">{{ error }}</p>
|
||||||
|
<button type="submit" :disabled="loading">{{ loading ? '创建中…' : '创建管理员' }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
|
||||||
|
const username = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const confirm = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const session = useSession();
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = '';
|
||||||
|
if (password.value !== confirm.value) {
|
||||||
|
error.value = '两次输入的密码不一致';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await session.setup(username.value, password.value);
|
||||||
|
router.replace('/');
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.auth-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-primary); }
|
||||||
|
.auth-card {
|
||||||
|
width: 360px; padding: 32px; background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 12px;
|
||||||
|
box-shadow: var(--shadow-dropdown);
|
||||||
|
}
|
||||||
|
.auth-card h1 { margin: 0; font-size: 20px; color: var(--text-primary); }
|
||||||
|
.auth-hint { margin: 0 0 4px; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.auth-card label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.auth-card input {
|
||||||
|
height: 36px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.auth-card button {
|
||||||
|
height: 38px; border-radius: var(--radius-sm); border: 0;
|
||||||
|
background: var(--color-primary); color: #fff; font-weight: 500; cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-card button:disabled { opacity: 0.6; cursor: progress; }
|
||||||
|
.auth-error { color: #e26a6a; font-size: 13px; margin: 0; }
|
||||||
|
</style>
|
||||||
+42
-16
@@ -21,7 +21,7 @@
|
|||||||
<!-- 私人FM -->
|
<!-- 私人FM -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">私人FM</h2>
|
<h2 class="section-title">私人FM</h2>
|
||||||
<div class="fm-card hover-scale" @click="playFm">
|
<div class="fm-card hover-scale" @click="playFm('netease')">
|
||||||
<div class="fm-icon-wrapper">
|
<div class="fm-icon-wrapper">
|
||||||
<Icon icon="mdi:radio" class="fm-icon" />
|
<Icon icon="mdi:radio" class="fm-icon" />
|
||||||
</div>
|
</div>
|
||||||
@@ -31,6 +31,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||||
|
<div class="fm-icon-wrapper qq">
|
||||||
|
<Icon icon="mdi:radar" class="fm-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="fm-info">
|
||||||
|
<div class="fm-title">QQ音乐雷达</div>
|
||||||
|
<div class="fm-desc">猜你喜欢 / 雷达推荐歌曲流</div>
|
||||||
|
</div>
|
||||||
|
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 每日推荐 -->
|
<!-- 每日推荐 -->
|
||||||
@@ -72,6 +82,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 我的收藏 -->
|
||||||
|
<section class="section" v-if="store.favoritedPlaylists.length > 0">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
|
||||||
|
我的收藏
|
||||||
|
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
|
||||||
|
</h2>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<RouterLink
|
||||||
|
v-for="fav in store.favoritedPlaylists"
|
||||||
|
:key="fav.id"
|
||||||
|
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
|
||||||
|
class="playlist-card hover-scale"
|
||||||
|
>
|
||||||
|
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="playlist-name">{{ fav.name }}</div>
|
||||||
|
<div class="playlist-count">{{ fav.songCount }} 首</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 我的歌单 -->
|
<!-- 我的歌单 -->
|
||||||
<section class="section" v-if="userAvailable.length > 0">
|
<section class="section" v-if="userAvailable.length > 0">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
@@ -125,9 +156,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import { usePlayerStore, type Source } from '../stores/player.js';
|
||||||
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
|
||||||
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
|
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
|
||||||
import CoverArt from '../components/CoverArt.vue';
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
import SourceTabs from '../components/SourceTabs.vue';
|
import SourceTabs from '../components/SourceTabs.vue';
|
||||||
@@ -180,19 +211,8 @@ const visibleUserPlaylists = computed(() =>
|
|||||||
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
|
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
|
||||||
);
|
);
|
||||||
|
|
||||||
async function playFm() {
|
async function playFm(platform: Source) {
|
||||||
try {
|
await store.startFm(platform);
|
||||||
const res = await axios.get('/api/music/personal/fm');
|
|
||||||
const songs: Song[] = res.data.songs;
|
|
||||||
if (songs.length > 0) {
|
|
||||||
await store.play(songs[0].name, songs[0].platform);
|
|
||||||
for (let i = 1; i < songs.length; i++) {
|
|
||||||
await store.addToQueue(songs[i].name, songs[i].platform);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -318,6 +338,7 @@ onMounted(() => {
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast);
|
transition: background var(--transition-fast);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
@@ -333,6 +354,10 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.qq {
|
||||||
|
background: linear-gradient(135deg, var(--brand-qq), #17a2b8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fm-icon {
|
.fm-icon {
|
||||||
@@ -379,6 +404,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.daily-card {
|
.daily-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-name {
|
.daily-name {
|
||||||
|
|||||||
@@ -2,6 +2,27 @@
|
|||||||
<div class="library-page">
|
<div class="library-page">
|
||||||
<h1 class="page-title">音乐库</h1>
|
<h1 class="page-title">音乐库</h1>
|
||||||
|
|
||||||
|
<!-- 我的收藏 -->
|
||||||
|
<section class="section" v-if="store.favoritedPlaylists.length > 0">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<Icon icon="mdi:heart" style="color: var(--color-primary)" />
|
||||||
|
我的收藏
|
||||||
|
<span class="section-count">{{ store.favoritedPlaylists.length }}</span>
|
||||||
|
</h2>
|
||||||
|
<div class="playlist-grid">
|
||||||
|
<RouterLink
|
||||||
|
v-for="fav in store.favoritedPlaylists"
|
||||||
|
:key="fav.id"
|
||||||
|
:to="`/playlist/${fav.playlistId}?platform=${fav.platform}`"
|
||||||
|
class="playlist-card hover-scale"
|
||||||
|
>
|
||||||
|
<CoverArt :url="fav.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
|
<div class="playlist-name">{{ fav.name }}</div>
|
||||||
|
<div class="playlist-count">{{ fav.songCount }} 首</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 我的歌单 -->
|
<!-- 我的歌单 -->
|
||||||
<section class="section" v-if="userAvailable.length > 0">
|
<section class="section" v-if="userAvailable.length > 0">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
@@ -51,6 +72,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -16,10 +16,21 @@
|
|||||||
<div class="playlist-stats">
|
<div class="playlist-stats">
|
||||||
{{ songs.length }} 首歌曲
|
{{ songs.length }} 首歌曲
|
||||||
</div>
|
</div>
|
||||||
<button class="play-all-btn" @click="playAll">
|
<div class="playlist-actions">
|
||||||
<Icon icon="mdi:play" />
|
<button class="play-all-btn" @click="playAll">
|
||||||
播放全部
|
<Icon icon="mdi:play" />
|
||||||
</button>
|
播放全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="kind === 'playlist'"
|
||||||
|
class="fav-btn"
|
||||||
|
:class="{ favorited }"
|
||||||
|
@click="toggleFavorite"
|
||||||
|
>
|
||||||
|
<Icon :icon="favorited ? 'mdi:heart' : 'mdi:heart-outline'" />
|
||||||
|
{{ favorited ? '已收藏' : '收藏' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,6 +80,7 @@ const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
|
|||||||
const playlist = ref<PlaylistDetail | null>(null);
|
const playlist = ref<PlaylistDetail | null>(null);
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const favorited = ref(false);
|
||||||
|
|
||||||
async function playAll() {
|
async function playAll() {
|
||||||
const id = route.params.id as string;
|
const id = route.params.id as string;
|
||||||
@@ -126,8 +138,35 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
songs.value = songList;
|
songs.value = songList;
|
||||||
|
|
||||||
|
// Check favorite status after songs are resolved
|
||||||
|
if (kind === 'playlist') {
|
||||||
|
favorited.value = store.isFavorited(id, platform);
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function toggleFavorite() {
|
||||||
|
const id = route.params.id as string;
|
||||||
|
const platform = (route.query.platform as string) || 'netease';
|
||||||
|
if (favorited.value) {
|
||||||
|
const fav = store.favoritedPlaylists.find((f) => f.playlistId === id && f.platform === platform);
|
||||||
|
if (fav) {
|
||||||
|
await store.removeFavorite(fav.id);
|
||||||
|
favorited.value = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await store.addFavorite({
|
||||||
|
platform,
|
||||||
|
playlistId: id,
|
||||||
|
name: playlist.value?.name ?? '未知歌单',
|
||||||
|
coverUrl: playlist.value?.coverUrl ?? '',
|
||||||
|
songCount: songs.value.length,
|
||||||
|
});
|
||||||
|
favorited.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -193,6 +232,43 @@ onMounted(async () => {
|
|||||||
&:active { transform: scale(0.96); }
|
&:active { transform: scale(0.96); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.favorited {
|
||||||
|
color: #e74c3c;
|
||||||
|
border-color: #e74c3c;
|
||||||
|
background: rgba(231, 76, 60, 0.08);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(231, 76, 60, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.song-list {
|
.song-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+281
-23
@@ -20,42 +20,96 @@
|
|||||||
|
|
||||||
<div v-if="loading" class="loading">搜索中...</div>
|
<div v-if="loading" class="loading">搜索中...</div>
|
||||||
|
|
||||||
<template v-else-if="songs.length || albums.length || playlists.length">
|
<template v-else-if="allSongs.length || allAlbums.length || allPlaylists.length">
|
||||||
<section v-if="albums.length" class="result-section">
|
<div class="source-bar">
|
||||||
<h2 class="section-title">专辑</h2>
|
<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 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">
|
<div class="card-grid">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="al in albums"
|
v-for="al in filteredAlbums"
|
||||||
:key="`${al.platform}-${al.id}`"
|
:key="`${al.platform}-${al.id}`"
|
||||||
:to="`/album/${al.id}?platform=${al.platform}`"
|
:to="`/album/${al.id}?platform=${al.platform}`"
|
||||||
class="card hover-scale"
|
class="card hover-scale"
|
||||||
>
|
>
|
||||||
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
<div class="card-name">{{ al.name }}</div>
|
<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>
|
<div class="card-sub">{{ al.artist }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="playlists.length" class="result-section">
|
<section v-if="activeTab === 'playlists' && filteredPlaylists.length" class="result-section">
|
||||||
<h2 class="section-title">歌单</h2>
|
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<router-link
|
<router-link
|
||||||
v-for="pl in playlists"
|
v-for="pl in filteredPlaylists"
|
||||||
:key="`${pl.platform}-${pl.id}`"
|
:key="`${pl.platform}-${pl.id}`"
|
||||||
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
|
||||||
class="card hover-scale"
|
class="card hover-scale"
|
||||||
>
|
>
|
||||||
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||||
<div class="card-name">{{ pl.name }}</div>
|
<button
|
||||||
|
class="fav-badge"
|
||||||
|
:class="{ favorited: isFav(pl) }"
|
||||||
|
@click.prevent.stop="toggleFavPlaylist(pl)"
|
||||||
|
>
|
||||||
|
<Icon :icon="isFav(pl) ? 'mdi:heart' : 'mdi:heart-outline'" />
|
||||||
|
</button>
|
||||||
|
<div class="card-name">
|
||||||
|
{{ pl.name }}
|
||||||
|
<span class="platform-badge" :class="badgeClass(pl.platform)">{{ badgeLabel(pl.platform) }}</span>
|
||||||
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="songs.length" class="result-section">
|
<section v-if="activeTab === 'songs' && filteredSongs.length" class="result-section">
|
||||||
<h2 class="section-title">单曲</h2>
|
|
||||||
<SongCard
|
<SongCard
|
||||||
v-for="(song, i) in songs"
|
v-for="(song, i) in filteredSongs"
|
||||||
:key="`${song.platform}-${song.id}`"
|
:key="`${song.platform}-${song.id}`"
|
||||||
:song="song"
|
:song="song"
|
||||||
:index="i + 1"
|
:index="i + 1"
|
||||||
@@ -72,8 +126,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
@@ -83,34 +137,106 @@ import CoverArt from '../components/CoverArt.vue';
|
|||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const route = useRoute();
|
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) || '');
|
const query = ref((route.query.q as string) || '');
|
||||||
|
const activeTab = ref<'songs' | 'albums' | 'playlists'>('songs');
|
||||||
|
const selectedSource = ref<'netease' | 'qq' | 'bilibili'>(loadSource());
|
||||||
|
|
||||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
|
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; }
|
interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
|
||||||
|
|
||||||
const songs = ref<Song[]>([]);
|
const allSongs = ref<Song[]>([]);
|
||||||
const albums = ref<Album[]>([]);
|
const allAlbums = ref<Album[]>([]);
|
||||||
const playlists = ref<Playlist[]>([]);
|
const allPlaylists = ref<Playlist[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const searched = 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function isFav(pl: { id: string; platform: string }): boolean {
|
||||||
|
return store.isFavorited(pl.id, pl.platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFavPlaylist(pl: { id: string; platform: string; name: string; coverUrl: string; songCount?: number }) {
|
||||||
|
if (isFav(pl)) {
|
||||||
|
const fav = store.favoritedPlaylists.find((f) => f.playlistId === pl.id && f.platform === pl.platform);
|
||||||
|
if (fav) await store.removeFavorite(fav.id);
|
||||||
|
} else {
|
||||||
|
await store.addFavorite({
|
||||||
|
platform: pl.platform,
|
||||||
|
playlistId: pl.id,
|
||||||
|
name: pl.name,
|
||||||
|
coverUrl: pl.coverUrl,
|
||||||
|
songCount: pl.songCount ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
if (!query.value.trim()) return;
|
if (!query.value.trim()) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
searched.value = true;
|
searched.value = true;
|
||||||
|
activeTab.value = 'songs';
|
||||||
|
router.replace({ query: { q: query.value } });
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
|
||||||
songs.value = res.data.songs ?? [];
|
allSongs.value = res.data.songs ?? [];
|
||||||
albums.value = res.data.albums ?? [];
|
allAlbums.value = res.data.albums ?? [];
|
||||||
playlists.value = res.data.playlists ?? [];
|
allPlaylists.value = res.data.playlists ?? [];
|
||||||
} catch {
|
} catch {
|
||||||
songs.value = []; albums.value = []; playlists.value = [];
|
allSongs.value = []; allAlbums.value = []; allPlaylists.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
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(() => {
|
onMounted(() => {
|
||||||
if (query.value) doSearch();
|
if (query.value) doSearch();
|
||||||
});
|
});
|
||||||
@@ -180,16 +306,83 @@ onMounted(() => {
|
|||||||
gap: 2px;
|
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 {
|
.result-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
.section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
|
|
||||||
}
|
}
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px 28px;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -198,4 +391,69 @@ onMounted(() => {
|
|||||||
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
|
.card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
|
||||||
.card-sub { font-size: 12px; opacity: 0.6; }
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.card:hover & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.favorited {
|
||||||
|
color: #e74c3c;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #e74c3c;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+688
-8
@@ -21,8 +21,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bot Management -->
|
<!-- Account: own password change -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
|
<h2 class="section-title">账户</h2>
|
||||||
|
<div class="account-info-card">
|
||||||
|
<div class="account-row">
|
||||||
|
<span class="account-label">用户名</span>
|
||||||
|
<span class="account-value">{{ session.currentUser.value?.username ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="account-row">
|
||||||
|
<span class="account-label">角色</span>
|
||||||
|
<span class="account-value">
|
||||||
|
<span class="user-role-badge" :class="`role-${session.currentUser.value?.role}`">
|
||||||
|
{{ session.currentUser.value?.role === 'admin' ? '管理员' : '成员' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="change-pw-form" @submit.prevent="onChangeOwnPassword">
|
||||||
|
<input v-model="ownPw.old" type="password" autocomplete="current-password" class="input" placeholder="当前密码" required />
|
||||||
|
<input v-model="ownPw.new" type="password" autocomplete="new-password" minlength="8" class="input" placeholder="新密码 (≥8 位)" required />
|
||||||
|
<input v-model="ownPw.confirm" type="password" autocomplete="new-password" minlength="8" class="input" placeholder="再次输入新密码" required />
|
||||||
|
<button class="btn-sm btn-primary" type="submit" :disabled="changingOwnPw">
|
||||||
|
{{ changingOwnPw ? '更新中…' : '修改密码' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
||||||
|
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
||||||
|
<section v-if="can('bot.manage')" class="settings-section">
|
||||||
<h2 class="section-title">机器人管理</h2>
|
<h2 class="section-title">机器人管理</h2>
|
||||||
<div class="bot-list">
|
<div class="bot-list">
|
||||||
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
|
<div v-for="bot in store.bots" :key="bot.id" class="bot-item">
|
||||||
@@ -80,6 +109,10 @@
|
|||||||
<label>服务器密码(可选)</label>
|
<label>服务器密码(可选)</label>
|
||||||
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
<input v-model="editForm.serverPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>自定义头像</label>
|
||||||
|
<CustomAvatarRow :bot-id="editingBot" />
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-secondary" @click="editingBot = null">取消</button>
|
<button class="btn-secondary" @click="editingBot = null">取消</button>
|
||||||
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
|
<button class="btn-primary" @click="saveEditBot">保存(需重启机器人生效)</button>
|
||||||
@@ -116,12 +149,16 @@
|
|||||||
<label>服务器密码(可选)</label>
|
<label>服务器密码(可选)</label>
|
||||||
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
<input v-model="newBotServerPassword" class="input" type="password" placeholder="服务器有密码时填写" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>自定义头像(可选)</label>
|
||||||
|
<AvatarUpload v-model="newBotAvatar" />
|
||||||
|
</div>
|
||||||
<button class="btn-primary" @click="createBot">创建</button>
|
<button class="btn-primary" @click="createBot">创建</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Music Account - QR Code Login -->
|
<!-- Music Account - QR Code Login (platform auth) requires platform.auth -->
|
||||||
<section class="settings-section">
|
<section v-if="can('platform.auth')" class="settings-section">
|
||||||
<h2 class="section-title">音乐账号</h2>
|
<h2 class="section-title">音乐账号</h2>
|
||||||
|
|
||||||
<!-- NetEase -->
|
<!-- NetEase -->
|
||||||
@@ -334,8 +371,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Audio Quality -->
|
<!-- Audio Quality requires quality -->
|
||||||
<section class="settings-section">
|
<section v-if="can('quality')" class="settings-section">
|
||||||
<h2 class="section-title">音质设置</h2>
|
<h2 class="section-title">音质设置</h2>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-label">
|
<div class="setting-label">
|
||||||
@@ -373,7 +410,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Idle Timeout -->
|
<!-- Idle Timeout -->
|
||||||
<section class="settings-section">
|
<section v-if="can('bot.manage')" class="settings-section">
|
||||||
<h2 class="section-title">行为设置</h2>
|
<h2 class="section-title">行为设置</h2>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-label">
|
<div class="setting-label">
|
||||||
@@ -396,10 +433,22 @@
|
|||||||
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="profile-toggle behavior-toggle">
|
||||||
|
<div class="profile-toggle-text">
|
||||||
|
<div class="profile-toggle-label">频道无人时自动暂停播放</div>
|
||||||
|
<div class="profile-toggle-hint">机器人所在频道没有其他人时自动暂停,有人加入后可继续播放</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="autoPauseOnEmpty"
|
||||||
|
type="checkbox"
|
||||||
|
class="profile-toggle-switch"
|
||||||
|
@change="saveAutoPause"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bot Profile (TeamSpeak Behavior) -->
|
<!-- Bot Profile (TeamSpeak Behavior) -->
|
||||||
<section class="settings-section">
|
<section v-if="can('bot.manage')" class="settings-section">
|
||||||
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
|
<h2 class="section-title">机器人 Profile(TeamSpeak 行为)</h2>
|
||||||
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
|
<p class="profile-section-hint">控制 bot 在 TeamSpeak 上自动同步歌曲信息的方式。⚠️ 标记的项会触发频道里所有人的提示音。</p>
|
||||||
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
|
<div v-if="store.bots.length === 0" class="empty-hint">还没有机器人,先在上面创建一个。</div>
|
||||||
@@ -439,10 +488,169 @@
|
|||||||
@change="updateProfile(bot.id, t.key, ($event.target as HTMLInputElement).checked)"
|
@change="updateProfile(bot.id, t.key, ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div v-if="profileConfigs[bot.id]" class="profile-toggle profile-toggle-static">
|
||||||
|
<div class="profile-toggle-text">
|
||||||
|
<div class="profile-toggle-label">自定义头像</div>
|
||||||
|
<div class="profile-toggle-hint">无论封面同步是否开启,停播时都会回到这张图</div>
|
||||||
|
</div>
|
||||||
|
<CustomAvatarRow :bot-id="bot.id" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- User Management -->
|
||||||
|
<section v-if="session.isAdmin.value" class="settings-section">
|
||||||
|
<h2 class="section-title">用户管理</h2>
|
||||||
|
<div class="user-list">
|
||||||
|
<div v-for="u in userList" :key="u.id" class="user-row-wrap">
|
||||||
|
<div class="user-item">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">
|
||||||
|
{{ u.username }}
|
||||||
|
<span class="user-role-badge" :class="`role-${u.role}`">
|
||||||
|
{{ u.role === 'admin' ? '管理员' : '成员' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="session.currentUser.value && u.id === session.currentUser.value.id" class="user-self-badge">本人</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-created">创建于 {{ formatDate(u.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-actions">
|
||||||
|
<span v-if="u.role === 'admin'" class="perm-admin-label">全部权限(管理员)</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-sm"
|
||||||
|
:class="{ 'btn-primary': permEditingId === u.id }"
|
||||||
|
@click="onTogglePermEditor(u)"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:shield-key" /> 权限
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm" @click="openResetPassword(u)">
|
||||||
|
<Icon icon="mdi:lock-reset" /> 重置密码
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-sm"
|
||||||
|
:disabled="changingRoleId === u.id || isLastAdmin(u)"
|
||||||
|
:title="isLastAdmin(u) ? '不能降级唯一的管理员' : (u.role === 'admin' ? '降级为成员' : '提升为管理员')"
|
||||||
|
@click="onToggleRole(u)"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:account-cog" />
|
||||||
|
{{ u.role === 'admin' ? '降为成员' : '提升管理员' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-sm btn-delete"
|
||||||
|
:disabled="!!(session.currentUser.value && u.id === session.currentUser.value.id) || isLastAdmin(u)"
|
||||||
|
:title="session.currentUser.value && u.id === session.currentUser.value.id ? '不能删除自己' : (isLastAdmin(u) ? '不能删除唯一的管理员' : '')"
|
||||||
|
@click="onDeleteUser(u)"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:delete" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inline permission editor (members only) -->
|
||||||
|
<div v-if="permEditingId === u.id" class="perm-editor">
|
||||||
|
<div v-if="permLoading" class="user-empty">加载权限中…</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="perm-group">
|
||||||
|
<div class="perm-group-title">能力</div>
|
||||||
|
<div class="perm-checks">
|
||||||
|
<label v-for="cap in CAPABILITIES" :key="cap.token" class="perm-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="permDraft.capabilities.includes(cap.token)"
|
||||||
|
@change="toggleCapability(cap.token, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
{{ cap.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="perm-group">
|
||||||
|
<div class="perm-group-title">机器人</div>
|
||||||
|
<label class="perm-check">
|
||||||
|
<input type="checkbox" v-model="permDraft.botsAll" />
|
||||||
|
全部机器人
|
||||||
|
</label>
|
||||||
|
<div v-if="!permDraft.botsAll" class="perm-checks perm-bots">
|
||||||
|
<label v-for="bot in store.bots" :key="bot.id" class="perm-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="permDraft.selectedBotIds.includes(bot.id)"
|
||||||
|
@change="toggleBotSelection(bot.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
{{ bot.name }}
|
||||||
|
</label>
|
||||||
|
<span v-if="store.bots.length === 0" class="user-empty">还没有机器人。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="permError" class="user-error">{{ permError }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-sm" @click="permEditingId = null">取消</button>
|
||||||
|
<button class="btn-sm btn-primary" :disabled="permSaving" @click="onSavePermissions(u)">
|
||||||
|
{{ permSaving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="userList.length === 0 && !userLoadError" class="user-empty">加载中…</div>
|
||||||
|
<div v-if="userLoadError" class="user-error">{{ userLoadError }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="user-add-form" @submit.prevent="onCreateUser">
|
||||||
|
<input v-model="newUser.username" class="input" placeholder="新用户名 (3-32 字符)" required />
|
||||||
|
<input v-model="newUser.password" type="password" class="input" placeholder="密码 (≥8 位)" minlength="8" required />
|
||||||
|
<select v-model="newUser.role" class="input user-role-select">
|
||||||
|
<option value="member">成员</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-sm btn-primary" type="submit" :disabled="creatingUser">
|
||||||
|
{{ creatingUser ? '创建中…' : '添加用户' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="userMutationError" class="user-error">{{ userMutationError }}</p>
|
||||||
|
|
||||||
|
<!-- Reset password modal -->
|
||||||
|
<div v-if="resetTarget" class="edit-modal-overlay" @click.self="resetTarget = null">
|
||||||
|
<div class="edit-modal">
|
||||||
|
<h3 class="modal-title">重置 {{ resetTarget.username }} 的密码</h3>
|
||||||
|
<p class="modal-hint">该用户的所有会话将被强制下线。</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>新密码 (≥8 位)</label>
|
||||||
|
<input v-model="resetPassword" type="password" class="input" minlength="8" />
|
||||||
|
</div>
|
||||||
|
<p v-if="resetError" class="user-error">{{ resetError }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-sm" @click="resetTarget = null">取消</button>
|
||||||
|
<button class="btn-sm btn-primary" :disabled="resettingPw" @click="onConfirmReset">
|
||||||
|
{{ resettingPw ? '保存中…' : '确认重置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Audit Log -->
|
||||||
|
<section v-if="session.isAdmin.value" class="settings-section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
操作审计
|
||||||
|
<button class="audit-refresh-btn" @click="loadAudit" :disabled="auditLoading" title="刷新">
|
||||||
|
<Icon icon="mdi:refresh" :class="{ spinning: auditLoading }" />
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div v-if="auditLoadError" class="user-error">{{ auditLoadError }}</div>
|
||||||
|
<div v-else-if="auditEntries.length === 0 && !auditLoading" class="user-empty">暂无操作记录</div>
|
||||||
|
<div v-else class="audit-list">
|
||||||
|
<div v-for="e in auditEntries" :key="e.id" class="audit-row">
|
||||||
|
<div class="audit-time">{{ formatDateTime(e.timestamp) }}</div>
|
||||||
|
<div class="audit-actor">{{ e.actorUsername ?? '—' }}</div>
|
||||||
|
<div class="audit-action" :class="auditActionClass(e.action)">{{ describeAction(e) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -450,8 +658,11 @@
|
|||||||
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import AvatarUpload from '../components/AvatarUpload.vue';
|
||||||
|
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
|
|
||||||
@@ -475,6 +686,7 @@ const newBotPort = ref(9987);
|
|||||||
const newBotNickname = ref('MusicBot');
|
const newBotNickname = ref('MusicBot');
|
||||||
const newBotChannel = ref('');
|
const newBotChannel = ref('');
|
||||||
const newBotServerPassword = ref('');
|
const newBotServerPassword = ref('');
|
||||||
|
const newBotAvatar = ref<string | null>(null);
|
||||||
|
|
||||||
// Edit bot
|
// Edit bot
|
||||||
const editingBot = ref<string | null>(null);
|
const editingBot = ref<string | null>(null);
|
||||||
@@ -635,7 +847,7 @@ async function pollQrStatus(platform: string) {
|
|||||||
async function createBot() {
|
async function createBot() {
|
||||||
if (!newBotName.value || !newBotServer.value) return;
|
if (!newBotName.value || !newBotServer.value) return;
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/bot', {
|
const res = await axios.post('/api/bot', {
|
||||||
name: newBotName.value,
|
name: newBotName.value,
|
||||||
serverAddress: newBotServer.value,
|
serverAddress: newBotServer.value,
|
||||||
serverPort: newBotPort.value || 9987,
|
serverPort: newBotPort.value || 9987,
|
||||||
@@ -644,12 +856,20 @@ async function createBot() {
|
|||||||
serverPassword: newBotServerPassword.value || undefined,
|
serverPassword: newBotServerPassword.value || undefined,
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
});
|
});
|
||||||
|
if (newBotAvatar.value && res.data?.id) {
|
||||||
|
try {
|
||||||
|
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('failed to set avatar on new bot', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
newBotName.value = '';
|
newBotName.value = '';
|
||||||
newBotServer.value = '';
|
newBotServer.value = '';
|
||||||
newBotPort.value = 9987;
|
newBotPort.value = 9987;
|
||||||
newBotNickname.value = 'MusicBot';
|
newBotNickname.value = 'MusicBot';
|
||||||
newBotChannel.value = '';
|
newBotChannel.value = '';
|
||||||
newBotServerPassword.value = '';
|
newBotServerPassword.value = '';
|
||||||
|
newBotAvatar.value = null;
|
||||||
await store.fetchBots();
|
await store.fetchBots();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore
|
// Ignore
|
||||||
@@ -735,11 +955,13 @@ async function savePrefix() {
|
|||||||
|
|
||||||
// Idle timeout
|
// Idle timeout
|
||||||
const idleTimeout = ref(0);
|
const idleTimeout = ref(0);
|
||||||
|
const autoPauseOnEmpty = ref(true);
|
||||||
|
|
||||||
async function loadIdleTimeout() {
|
async function loadIdleTimeout() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get('/api/bot/settings');
|
const res = await axios.get('/api/bot/settings');
|
||||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||||
|
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? true;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,6 +971,12 @@ async function saveIdleTimeout() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAutoPause() {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/bot/settings', { autoPauseOnEmpty: autoPauseOnEmpty.value });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bot Profile config ---
|
// --- Bot Profile config ---
|
||||||
interface ProfileConfig {
|
interface ProfileConfig {
|
||||||
avatarEnabled: boolean;
|
avatarEnabled: boolean;
|
||||||
@@ -815,11 +1043,329 @@ async function updateProfile(botId: string, key: keyof ProfileConfig, value: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- User Management ---
|
||||||
|
const session = useSession();
|
||||||
|
const { can } = session;
|
||||||
|
|
||||||
|
// --- Own password change (available to all authenticated users) ---
|
||||||
|
const ownPw = reactive({ old: '', new: '', confirm: '' });
|
||||||
|
const ownPwError = ref('');
|
||||||
|
const ownPwSuccess = ref('');
|
||||||
|
const changingOwnPw = ref(false);
|
||||||
|
|
||||||
|
async function onChangeOwnPassword() {
|
||||||
|
ownPwError.value = '';
|
||||||
|
ownPwSuccess.value = '';
|
||||||
|
if (ownPw.new !== ownPw.confirm) {
|
||||||
|
ownPwError.value = '两次输入的新密码不一致';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ownPw.new.length < 8) {
|
||||||
|
ownPwError.value = '新密码至少 8 位';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changingOwnPw.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/session/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ oldPassword: ownPw.old, newPassword: ownPw.new }),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const b = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
ownPw.old = '';
|
||||||
|
ownPw.new = '';
|
||||||
|
ownPw.confirm = '';
|
||||||
|
ownPwSuccess.value = '密码已更新';
|
||||||
|
// The server kills other sessions but keeps the current one. No reload needed.
|
||||||
|
} catch (e) {
|
||||||
|
ownPwError.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
changingOwnPw.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListEntry { id: string; username: string; createdAt: number; role: 'admin' | 'member' }
|
||||||
|
const userList = ref<UserListEntry[]>([]);
|
||||||
|
const userLoadError = ref('');
|
||||||
|
const userMutationError = ref('');
|
||||||
|
const newUser = reactive({ username: '', password: '', role: 'member' as 'admin' | 'member' });
|
||||||
|
const creatingUser = ref(false);
|
||||||
|
const resetTarget = ref<UserListEntry | null>(null);
|
||||||
|
const resetPassword = ref('');
|
||||||
|
const resetError = ref('');
|
||||||
|
const resettingPw = ref(false);
|
||||||
|
const changingRoleId = ref<string | null>(null);
|
||||||
|
|
||||||
|
function isLastAdmin(u: UserListEntry): boolean {
|
||||||
|
if (u.role !== 'admin') return false;
|
||||||
|
const adminCount = userList.value.filter((x) => x.role === 'admin').length;
|
||||||
|
return adminCount <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleRole(u: UserListEntry) {
|
||||||
|
const newRole = u.role === 'admin' ? 'member' : 'admin';
|
||||||
|
if (!confirm(`确认将 ${u.username} 切换为${newRole === 'admin' ? '管理员' : '成员'}?`)) return;
|
||||||
|
userMutationError.value = '';
|
||||||
|
changingRoleId.value = u.id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${u.id}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ role: newRole }),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const b = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
userMutationError.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
changingRoleId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
userLoadError.value = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
userList.value = body.users ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
userLoadError.value = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateUser() {
|
||||||
|
userMutationError.value = '';
|
||||||
|
creatingUser.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: newUser.username, password: newUser.password, role: newUser.role }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const b = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
newUser.username = '';
|
||||||
|
newUser.password = '';
|
||||||
|
newUser.role = 'member';
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
userMutationError.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
creatingUser.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteUser(u: UserListEntry) {
|
||||||
|
if (!confirm(`确认删除用户 ${u.username}?`)) return;
|
||||||
|
userMutationError.value = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${u.id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const b = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
userMutationError.value = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetPassword(u: UserListEntry) {
|
||||||
|
resetTarget.value = u;
|
||||||
|
resetPassword.value = '';
|
||||||
|
resetError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirmReset() {
|
||||||
|
if (!resetTarget.value) return;
|
||||||
|
if (resetPassword.value.length < 8) {
|
||||||
|
resetError.value = '密码至少 8 位';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resettingPw.value = true;
|
||||||
|
resetError.value = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${resetTarget.value.id}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ newPassword: resetPassword.value }),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const b = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
resetTarget.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
resetError.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
resettingPw.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-user permission editor (members only) ---
|
||||||
|
const CAPABILITIES: { token: string; label: string }[] = [
|
||||||
|
{ token: 'player.control', label: '播放控制' },
|
||||||
|
{ token: 'player.queue', label: '队列管理' },
|
||||||
|
{ token: 'bot.manage', label: '机器人管理' },
|
||||||
|
{ token: 'platform.auth', label: '平台登录凭据' },
|
||||||
|
{ token: 'quality', label: '音质设置' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const permEditingId = ref<string | null>(null);
|
||||||
|
const permLoading = ref(false);
|
||||||
|
const permSaving = ref(false);
|
||||||
|
const permError = ref('');
|
||||||
|
const permDraft = reactive<{ capabilities: string[]; botsAll: boolean; selectedBotIds: string[] }>({
|
||||||
|
capabilities: [],
|
||||||
|
botsAll: true,
|
||||||
|
selectedBotIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onTogglePermEditor(u: UserListEntry) {
|
||||||
|
if (permEditingId.value === u.id) {
|
||||||
|
permEditingId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
permEditingId.value = u.id;
|
||||||
|
permError.value = '';
|
||||||
|
permLoading.value = true;
|
||||||
|
permDraft.capabilities = [];
|
||||||
|
permDraft.botsAll = true;
|
||||||
|
permDraft.selectedBotIds = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${u.id}/permissions`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
permDraft.capabilities = Array.isArray(body.capabilities) ? [...body.capabilities] : [];
|
||||||
|
if (body.bots === 'all') {
|
||||||
|
permDraft.botsAll = true;
|
||||||
|
permDraft.selectedBotIds = [];
|
||||||
|
} else {
|
||||||
|
permDraft.botsAll = false;
|
||||||
|
permDraft.selectedBotIds = Array.isArray(body.bots) ? [...body.bots] : [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
permError.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
permLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCapability(token: string, checked: boolean) {
|
||||||
|
const has = permDraft.capabilities.includes(token);
|
||||||
|
if (checked && !has) permDraft.capabilities.push(token);
|
||||||
|
else if (!checked && has) permDraft.capabilities = permDraft.capabilities.filter((t) => t !== token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBotSelection(id: string, checked: boolean) {
|
||||||
|
const has = permDraft.selectedBotIds.includes(id);
|
||||||
|
if (checked && !has) permDraft.selectedBotIds.push(id);
|
||||||
|
else if (!checked && has) permDraft.selectedBotIds = permDraft.selectedBotIds.filter((b) => b !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSavePermissions(u: UserListEntry) {
|
||||||
|
permSaving.value = true;
|
||||||
|
permError.value = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${u.id}/permissions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
capabilities: [...permDraft.capabilities],
|
||||||
|
bots: permDraft.botsAll ? 'all' : [...permDraft.selectedBotIds],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const b = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
permEditingId.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
permError.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
permSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audit Log ---
|
||||||
|
interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
timestamp: number;
|
||||||
|
actorId: string | null;
|
||||||
|
actorUsername: string | null;
|
||||||
|
targetUserId: string | null;
|
||||||
|
targetUsername: string | null;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditEntries = ref<AuditEntry[]>([]);
|
||||||
|
const auditLoadError = ref('');
|
||||||
|
const auditLoading = ref(false);
|
||||||
|
|
||||||
|
async function loadAudit() {
|
||||||
|
auditLoadError.value = '';
|
||||||
|
auditLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/audit?limit=100');
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
auditEntries.value = body.entries ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
auditLoadError.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
auditLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeAction(e: AuditEntry): string {
|
||||||
|
const target = e.targetUsername ?? e.targetUserId ?? '—';
|
||||||
|
switch (e.action) {
|
||||||
|
case 'admin.first_created': return `创建首位管理员 ${target}`;
|
||||||
|
case 'user.created': return `创建用户 ${target}`;
|
||||||
|
case 'user.deleted': return `删除用户 ${target}`;
|
||||||
|
case 'user.password_reset': return `重置 ${target} 的密码`;
|
||||||
|
case 'user.password_changed': return `修改自己的密码`;
|
||||||
|
case 'user.role_changed': return `变更 ${target} 的角色`;
|
||||||
|
case 'user.permissions_changed': return `权限变更 → ${target}`;
|
||||||
|
default: return `${e.action} → ${target}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditActionClass(action: string): string {
|
||||||
|
if (action === 'user.deleted') return 'audit-action-danger';
|
||||||
|
if (action === 'user.password_reset' || action === 'user.password_changed') return 'audit-action-warn';
|
||||||
|
return 'audit-action-ok';
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.fetchBots(); // Refresh bot status on page visit
|
store.fetchBots(); // Refresh bot status on page visit
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
loadQuality();
|
loadQuality();
|
||||||
loadIdleTimeout();
|
loadIdleTimeout();
|
||||||
|
if (session.isAdmin.value) {
|
||||||
|
loadUsers();
|
||||||
|
loadAudit();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -1408,6 +1954,17 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-toggle-static {
|
||||||
|
cursor: default;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone toggle inside 行为设置 (not part of a bordered list)
|
||||||
|
.behavior-toggle {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.profile-bot-header {
|
.profile-bot-header {
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
@@ -1436,4 +1993,127 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- User Management ---
|
||||||
|
.user-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.user-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.user-info { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.user-name { font-weight: 500; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.user-self-badge {
|
||||||
|
font-size: 11px; padding: 2px 6px; border-radius: 4px;
|
||||||
|
background: var(--color-primary); color: #fff;
|
||||||
|
}
|
||||||
|
.user-created { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.user-actions { display: flex; gap: 8px; }
|
||||||
|
.user-add-form {
|
||||||
|
display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.user-add-form .input { flex: 1; min-width: 140px; }
|
||||||
|
.user-empty, .user-error { font-size: 12px; color: var(--text-secondary); padding: 8px 0; }
|
||||||
|
.user-error { color: #e26a6a; }
|
||||||
|
.modal-hint { color: var(--text-secondary); font-size: 12px; margin: 0 0 8px; }
|
||||||
|
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
|
||||||
|
|
||||||
|
.audit-refresh-btn {
|
||||||
|
margin-left: 10px;
|
||||||
|
border: 0; background: transparent;
|
||||||
|
color: var(--text-secondary); cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: progress; }
|
||||||
|
}
|
||||||
|
.spinning { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.audit-list {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.audit-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 170px 120px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 13px;
|
||||||
|
&:last-child { border-bottom: 0; }
|
||||||
|
}
|
||||||
|
.audit-time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.audit-actor {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.audit-action { color: var(--text-primary); }
|
||||||
|
.audit-action-ok { color: var(--text-primary); }
|
||||||
|
.audit-action-warn { color: #d3a44b; }
|
||||||
|
.audit-action-danger { color: #e26a6a; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.audit-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role-badge {
|
||||||
|
font-size: 11px; padding: 2px 6px; border-radius: 4px; margin-left: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.role-admin { background: rgba(99, 145, 226, 0.18); color: #6391e2; }
|
||||||
|
.role-member { background: rgba(150, 150, 150, 0.18); color: var(--text-secondary); }
|
||||||
|
.user-role-select { flex: 0 0 110px; }
|
||||||
|
|
||||||
|
.user-row-wrap { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.perm-admin-label { font-size: 12px; color: var(--text-secondary); align-self: center; }
|
||||||
|
.perm-editor {
|
||||||
|
margin-top: -2px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.perm-group { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.perm-group-title { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||||
|
.perm-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
||||||
|
.perm-bots { padding-left: 16px; }
|
||||||
|
.perm-check {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 13px; color: var(--text-secondary); cursor: pointer;
|
||||||
|
}
|
||||||
|
.perm-check input { cursor: pointer; }
|
||||||
|
|
||||||
|
// --- Account section (own password change) ---
|
||||||
|
.account-info-card {
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.account-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.account-label { color: var(--text-secondary); }
|
||||||
|
.account-value { color: var(--text-primary); font-weight: 500; }
|
||||||
|
.change-pw-form {
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.change-pw-form .input { width: 100%; }
|
||||||
|
.change-pw-form button { align-self: flex-start; }
|
||||||
|
.user-success { color: #4caf7a; font-size: 13px; margin: 4px 0 0; }
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user