mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-03 21:42:50 +08:00
Merge pull request #173 from senlinjun/feat/restapi
This commit is contained in:
commit
bdb33df89d
16 files changed
+1288
-7
No files matched your search
@@ -462,6 +462,52 @@ sudo systemctl start tsmusicbot
|
||||
|
||||
聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。
|
||||
|
||||
## REST API(API Key)
|
||||
|
||||
除浏览器 session 登录外,REST API 还支持用 **API Key** 调用,便于脚本、Home Assistant 等外部集成。
|
||||
|
||||
### 创建 Key
|
||||
|
||||
登录 WebUI → 设置页 → 「API 密钥」→ 输入名称 → 生成。明文**只在创建时显示一次**(形如 `tsmb_xxxxx…`),之后只能看到前缀;可随时在设置页吊销。Key 的权限与所属账户一致:管理员拥有全部权限,成员只能操作被授权的机器人、使用被授予的能力(播放控制 / 队列管理等)。每位用户最多创建 20 个 Key。
|
||||
|
||||
### 调用方式
|
||||
|
||||
两种请求头任选其一:
|
||||
|
||||
```
|
||||
Authorization: Bearer tsmb_xxxxxxxxxxxx
|
||||
X-API-Key: tsmb_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
> 修改类请求(POST/PUT/DELETE)无需 CSRF Origin 头;WebSocket 推送(`/ws`)暂不支持 API Key,仅限浏览器 session。
|
||||
|
||||
### 常用端点示例
|
||||
|
||||
```bash
|
||||
# 机器人列表(拿到 botId)
|
||||
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:3000/api/bot
|
||||
|
||||
# 当前队列 + 播放状态
|
||||
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:3000/api/player/<botId>/queue
|
||||
|
||||
# 点歌(搜索文本 + 平台:netease/qq/bilibili/youtube/kugou/jellyfin/local)
|
||||
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||
-d '{"query":"周杰伦 晴天","platform":"netease"}' \
|
||||
http://127.0.0.1:3000/api/player/<botId>/play
|
||||
|
||||
# 搜索歌曲(拿 song id / song 对象)
|
||||
curl -H "Authorization: Bearer $KEY" \
|
||||
"http://127.0.0.1:3000/api/music/search?q=晴天&platform=netease"
|
||||
|
||||
# 播放控制
|
||||
curl -X POST -H "X-API-Key: $KEY" http://127.0.0.1:3000/api/player/<botId>/pause
|
||||
curl -X POST -H "X-API-Key: $KEY" http://127.0.0.1:3000/api/player/<botId>/next
|
||||
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
|
||||
-d '{"volume":50}' http://127.0.0.1:3000/api/player/<botId>/volume
|
||||
```
|
||||
|
||||
全部端点、参数与返回值见 **[docs/API.md](docs/API.md)**。认证失败返回 `401 {"error":"invalid api key"}`,越权返回 `403`。
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
# REST API 参考
|
||||
|
||||
本文档列出机器人对外提供的全部 REST API 端点、参数与返回。所有端点均支持两种认证方式(见下),除单独标注「仅浏览器 session」的端点外。
|
||||
|
||||
## 通用约定
|
||||
|
||||
### 认证
|
||||
|
||||
```
|
||||
Authorization: Bearer tsmb_xxxxxxxxxxxx
|
||||
# 或
|
||||
X-API-Key: tsmb_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Key 在 WebUI 设置页创建,权限与所属账户一致。浏览器 session(cookie)也可调用全部端点,两者行为相同。
|
||||
|
||||
### 错误格式
|
||||
|
||||
所有错误返回统一为 JSON `{ "error": "..." }`:
|
||||
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 400 | 参数缺失或格式错误 |
|
||||
| 401 | 未认证 / API Key 无效(`invalid api key`) |
|
||||
| 403 | 无权限(能力不足、机器人未授权、API Key 试图管理 Key 等) |
|
||||
| 404 | 资源不存在 |
|
||||
| 409 | 冲突(如收藏已存在、Key 数量达上限) |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 权限模型
|
||||
|
||||
| 标注 | 含义 |
|
||||
|------|------|
|
||||
| 公开 | 无需认证 |
|
||||
| 已认证 | 任意登录用户 / 有效 API Key |
|
||||
| 非游客 | API Key 用户恒满足(guest session 除外) |
|
||||
| `player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality` | 需要账户持有对应能力;管理员恒通过 |
|
||||
| 机器人访问 | 成员只能操作被授予的机器人(账户权限中的 bot 范围),管理员不限 |
|
||||
| 管理员 | 仅 `role=admin` |
|
||||
|
||||
### 平台(platform)取值
|
||||
|
||||
`netease` / `qq` / `bilibili` / `youtube` / `kugou` / `jellyfin` / `local` / `spotify`
|
||||
|
||||
省略 `platform` 时使用设置页配置的默认音源;已禁用的音源返回 `400 音源未启用`。
|
||||
|
||||
### 点歌归属
|
||||
|
||||
`/play`、`/add`、`/play-*`、`/add-*` 等入队端点会把 `requestedBy` 记为 Key 所属账户的用户名,队列与播放历史中可见。
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
```ts
|
||||
// 歌曲(搜索结果 / 队列元素)
|
||||
interface Song {
|
||||
id: string; // 平台内歌曲 id
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number; // 秒
|
||||
coverUrl: string;
|
||||
platform: Platform;
|
||||
vip?: boolean; // VIP/版权受限(仅试听)
|
||||
}
|
||||
|
||||
// 队列中的歌曲(Song + 归属;url 仅播放时内部解析,不出现在响应里)
|
||||
interface QueuedSong extends Omit<Song, "vip"> {
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
interface Album { id: string; name: string; artist: string; coverUrl: string; songCount: number; platform: Platform }
|
||||
interface Playlist { id: string; name: string; coverUrl: string; songCount: number; platform: Platform }
|
||||
|
||||
// 机器人实时状态
|
||||
interface BotStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
playing: boolean;
|
||||
paused: boolean;
|
||||
currentSong: QueuedSong | null;
|
||||
queueSize: number;
|
||||
volume: number; // 0-100
|
||||
playMode: "seq" | "loop" | "random" | "rloop";
|
||||
elapsed: number; // 当前曲目已播秒数
|
||||
effectiveDuration?: number; // 当前曲实际播放时长(试听片段=试听秒数)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 公开端点(无需认证)
|
||||
|
||||
### GET /api/health
|
||||
|
||||
```json
|
||||
{ "status": "ok", "version": "0.1.0" }
|
||||
```
|
||||
|
||||
### GET /api/config/public-url
|
||||
|
||||
```json
|
||||
{ "publicUrl": "https://bot.example.com" } // 未配置时为 null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 机器人管理 /api/bot
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/bot` | 已认证 | 机器人列表(成员只返回被授权的) |
|
||||
| GET | `/api/bot/settings` | 非游客 | 全局行为设置 |
|
||||
| POST | `/api/bot/settings` | `bot.manage` | 保存全局设置(部分合并) |
|
||||
| POST | `/api/bot` | `bot.manage` | 创建机器人 |
|
||||
| GET | `/api/bot/:id` | 机器人访问 | 单个机器人状态 |
|
||||
| PUT | `/api/bot/:id` | `bot.manage` + 机器人访问 | 更新连接配置 |
|
||||
| DELETE | `/api/bot/:id` | `bot.manage` + 机器人访问 | 删除机器人 |
|
||||
| POST | `/api/bot/:id/start` | `bot.manage` + 机器人访问 | 连接服务器 |
|
||||
| POST | `/api/bot/:id/stop` | `bot.manage` + 机器人访问 | 断开连接 |
|
||||
| GET | `/api/bot/:id/config` | `bot.manage` + 机器人访问 | 保存的连接配置(不含 identity/TS6 key) |
|
||||
| GET / PUT / DELETE | `/api/bot/:id/avatar` | `bot.manage` + 机器人访问 | 自定义头像 |
|
||||
|
||||
### GET /api/bot
|
||||
|
||||
```json
|
||||
{ "bots": [ { "id": "…", "name": "客厅bot", "connected": true, "playing": true, "paused": false,
|
||||
"currentSong": { "…": "QueuedSong" }, "queueSize": 3, "volume": 75,
|
||||
"playMode": "seq", "elapsed": 42.5, "effectiveDuration": 269 } ] }
|
||||
```
|
||||
|
||||
### POST /api/bot
|
||||
|
||||
```json
|
||||
// 请求体(name、serverAddress、nickname 必填;serverPort 默认 9987)
|
||||
{ "name": "客厅bot", "serverAddress": "ts.example.com", "serverPort": 9987,
|
||||
"nickname": "♪ 音乐机器人", "defaultChannel": "音乐频道", "channelId": "12",
|
||||
"channelPassword": "", "serverPassword": "", "autoStart": true }
|
||||
// 201 返回 BotStatus
|
||||
```
|
||||
|
||||
### PUT /api/bot/:id
|
||||
|
||||
请求体字段同上(全部可选),返回 `{ "success": true }`。连接相关修改需重启机器人(`stop` 后 `start`)生效。
|
||||
|
||||
### POST /api/bot/settings(部分合并,未传的字段不变)
|
||||
|
||||
```json
|
||||
{
|
||||
"idleTimeoutMinutes": 30, // 空闲自动断开,0=不启用
|
||||
"autoPauseOnEmpty": true, // 频道无人自动暂停
|
||||
"localAudioEnabled": true, // 本地音频
|
||||
"voiceDucking": { "enabled": true, "volumePercent": 20 },
|
||||
"savedQueuesEnabled": true,
|
||||
"playKeepsQueue": false, // !play 是否保留队列
|
||||
"adminGroups": [6],
|
||||
"enabledProviders": ["netease","qq","bilibili","youtube","kugou"],
|
||||
"defaultPlatform": "netease", // null/"" 清除
|
||||
"guestMode": { "enabled": false, "bots": "all", "permissions": { "…": true } },
|
||||
"spotify": { "enabled": false, "clientId": "…", "clientSecret": "…", "backend": "auto", "bitrate": 160, "deviceName": "…" },
|
||||
"jellyfin": { "serverUrl": "…", "authMode": "userpass", "username": "…", "password": "…" }
|
||||
}
|
||||
// 返回:与 GET /settings 相同结构(spotify.clientSecret / jellyfin.password 永不回传,仅 hasClientSecret / hasPassword 布尔)
|
||||
```
|
||||
|
||||
### PUT /api/bot/:id/avatar
|
||||
|
||||
请求体 `{ "dataUrl": "data:image/png;base64,…" }`(png/jpeg/webp,≤200KB),返回 `{ "path": "avatars/xx.png" }`。
|
||||
|
||||
---
|
||||
|
||||
## 播放控制 /api/player/:botId
|
||||
|
||||
以下所有端点都要求机器人访问权限;标注能力的管理类操作还需对应能力。`{ "message": "…" }` 为命令执行回执文本(与聊天命令回执一致),失败时 message 中带原因或返回 4xx/5xx。
|
||||
|
||||
### 播放入口
|
||||
|
||||
| 方法 | 路径 | 能力 | 请求体 | 返回 |
|
||||
|------|------|------|--------|------|
|
||||
| POST | `/play` | `player.control` | `{ query, platform? }`(搜索文本) | `{ message }` |
|
||||
| POST | `/add` | `player.queue` | `{ query, platform? }` | `{ message }` |
|
||||
| POST | `/play-song` | `player.control` | `{ song }`(Song 对象,清空队列播放) | `{ ok, message }` |
|
||||
| POST | `/play-now-song` | `player.control` | `{ song }`(插入当前曲后立即播放,保留队列) | `{ ok, message }` |
|
||||
| POST | `/play-next-song` | `player.control` | `{ song }`(插播下一首;空闲时直接播放) | `{ ok, message }` |
|
||||
| POST | `/add-song` | `player.queue` | `{ song }`(入队;空闲时立即播放) | `{ message }` |
|
||||
| POST | `/add-by-id` | `player.queue` | `{ songId, platform? }` | `{ message }` |
|
||||
| POST | `/play-playlist` | `player.control` | `{ playlistId, platform? }`(清队列载入歌单) | `{ ok, message }` |
|
||||
| POST | `/play-album` | `player.control` | `{ albumId, platform? }`(清队列载入专辑) | `{ ok, message }` |
|
||||
| POST | `/playlist` | `player.queue` | `{ playlistId, platform? }`(追加整个歌单) | `{ message }` |
|
||||
| POST | `/fm` | `player.control` | `{ platform? }`(私人 FM 模式) | `{ ok, message }` |
|
||||
|
||||
`/play` 与 `/add` 接受搜索文本,内部按 `platform` 调对应音源搜索并播放/入队第一个结果;`/play-song` 系列接受 `/api/music` 返回的完整 Song 对象。B站多P视频的 Song `id` 形如 `BVxxxx?p=2`(见 `/api/music/bilibili/parts`),传对应分P的 id 即播放该分P。
|
||||
|
||||
`/fm` 的平台为网易时,若调用者账户已绑定个人网易账号(见 `/api/me/music`),FM 曲目按**个人账号**的口味推荐;未绑定则使用机器人共享登录。
|
||||
|
||||
### 播放器控制
|
||||
|
||||
| 方法 | 路径 | 能力 | 请求体 | 返回 |
|
||||
|------|------|------|--------|------|
|
||||
| POST | `/pause` | `player.control` | — | `{ message }` |
|
||||
| POST | `/resume` | `player.control` | — | `{ message }` |
|
||||
| POST | `/next` | `player.control` | — | `{ message }` |
|
||||
| POST | `/prev` | `player.control` | — | `{ message }` |
|
||||
| POST | `/stop` | `player.control` | — | `{ message }` |
|
||||
| POST | `/clear` | `player.queue` | — | `{ message }` |
|
||||
| POST | `/volume` | `player.control` | `{ volume: 0-100 }` | `{ message }` |
|
||||
| POST | `/mode` | `player.control` | `{ mode: "seq"|"loop"|"random"|"rloop" }` | `{ message }` |
|
||||
| POST | `/seek` | `player.control` | `{ position: 秒 }` | `{ message, seekOffset }` |
|
||||
| POST | `/play-at` | `player.control` | `{ index: 队列下标 }` | `{ message }`,越界 400 |
|
||||
|
||||
### 状态与队列
|
||||
|
||||
| 方法 | 路径 | 返回 |
|
||||
|------|------|------|
|
||||
| GET | `/queue` | `{ queue: QueuedSong[], status: BotStatus }` |
|
||||
| GET | `/elapsed` | `{ elapsed: 42.5 }` |
|
||||
| DELETE | `/queue/:index` | `{ message }`(移除指定下标,能力 `player.queue`) |
|
||||
| GET | `/history?limit=50` | `{ history: [{ id, name, artist, album, coverUrl, platform, playedAt, requestedBy }] }` |
|
||||
| GET | `/profile` | ProfileConfig |
|
||||
| PUT | `/profile` | ProfileConfig(能力 `bot.manage`) |
|
||||
|
||||
ProfileConfig:`{ avatarEnabled, descriptionEnabled, nicknameEnabled, awayStatusEnabled, channelDescEnabled, nowPlayingMsgEnabled }`(机器人头像/昵称/频道描述等自动更新开关)。
|
||||
|
||||
---
|
||||
|
||||
## 音乐数据 /api/music
|
||||
|
||||
除特别标注外均为「已认证」;`platform` 为可选 query 参数。
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/search` | `q`(必填)、`platform`、`limit`(默认 20)、`offset`(默认 0) | `{ songs, albums, playlists }` |
|
||||
| GET | `/search/all` | `q`(必填)、`limit` | 各音源合并的 `{ songs, albums, playlists }`(不含 spotify) |
|
||||
| GET | `/song/:id` | `platform` | Song 对象,无则 404 |
|
||||
| GET | `/album/:id` | `platform` | `{ songs: Song[] }` |
|
||||
| GET | `/playlist/:id` | `platform` | `{ songs: Song[] }` |
|
||||
| GET | `/playlist/:id/detail` | `platform` | `{ playlist: { id, name, description, coverUrl, songCount } }`(音源不支持时 501) |
|
||||
| GET | `/lyrics/:id` | `platform` | `{ lyrics }` |
|
||||
| GET | `/recommend/playlists` | `platform` | `{ playlists }` |
|
||||
| GET | `/recommend/songs` | `platform` | `{ songs }`(每日推荐;非游客) |
|
||||
| GET | `/personal/fm` | `platform` | `{ songs }`(私人 FM;非游客) |
|
||||
| GET | `/user/playlists` | `platform` | `{ playlists }`(当前登录音源账号的歌单;非游客) |
|
||||
| GET | `/bilibili/popular` | `limit`(默认 20) | `{ songs }` |
|
||||
| GET | `/bilibili/parts` | `bvid`(BV 号或视频链接) | `{ bvid, title, coverUrl, artist, parts }`(无此视频 404) |
|
||||
| GET | `/providers` | — | `{ enabled: Platform[], default: Platform }` |
|
||||
| GET | `/quality` | — | `{ netease, qq, bilibili, local, kugou, spotify, jellyfin }` |
|
||||
| POST | `/quality` | `{ quality, platform? }`(能力 `quality`;省略 platform 时对所有音源生效) | `{ success, quality }` |
|
||||
|
||||
### Jellyfin 音乐库
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/jellyfin/latest-albums` | `limit`(默认 12) | `{ albums }` |
|
||||
| GET | `/jellyfin/most-played` | `limit`(默认 12) | `{ songs }` |
|
||||
| GET | `/jellyfin/favorites` | `limit`(默认 100) | `{ songs }`(非游客) |
|
||||
| GET | `/jellyfin/genres` | `limit`(默认 30) | `{ genres: [{ id, name }] }` |
|
||||
| GET | `/jellyfin/genre/:id/songs` | `limit`(默认 100) | `{ songs }` |
|
||||
|
||||
### 本地音频上传
|
||||
|
||||
`POST /api/music/local/upload` — 能力 `player.queue`。请求体为**原始音频文件**(audio/* 或 video/*,≤500MB,非 multipart;文件名放 `x-filename` 请求头)。返回 `{ song }`;本地音频关闭时 403。
|
||||
|
||||
```bash
|
||||
curl -X POST -H "X-API-Key: $KEY" -H "x-filename: theme.mp3" \
|
||||
--data-binary @theme.mp3 http://127.0.0.1:3000/api/music/local/upload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 收藏 /api/favorites(非游客,仅本人数据)
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/` | — | `{ favorites: [{ id, platform, playlistId, name, coverUrl, songCount, createdAt }] }` |
|
||||
| POST | `/` | `{ platform, playlistId, name, coverUrl?, songCount? }` | `{ success: true }`;已收藏 409 |
|
||||
| GET | `/check` | `platform`、`playlistId` | `{ favorited: bool }` |
|
||||
| DELETE | `/:id` | 收藏记录 id | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
## 保存的队列 /api/saved-queues(非游客;需在设置页开启「保存队列」)
|
||||
|
||||
所有权:私有为本人,`shared: true` 保存到共享桶;列表返回本人的+共享的;他人私有队列 404。
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/` | — | `{ queues: [{ id, ownerId, name, songCount, createdAt, updatedAt }] }` |
|
||||
| POST | `/` | `{ botId, name, shared? }`(快照该 bot 当前队列,同名覆盖) | `{ queue }`;队列空 400 |
|
||||
| POST | `/:id/load` | `{ botId, mode?: "replace"(默认)|"append" }` | `{ ok, loaded, mode }` |
|
||||
| DELETE | `/:id` | — | `{ ok: true }` |
|
||||
|
||||
---
|
||||
|
||||
## 平台账号 /api/auth
|
||||
|
||||
| 方法 | 路径 | 权限 | 参数 | 返回 |
|
||||
|------|------|------|------|------|
|
||||
| GET | `/status` | 非游客 | `platform` | `{ platform, loggedIn, nickname?, avatarUrl? }` |
|
||||
| POST | `/qrcode` | `platform.auth` | `{ platform }`(netease/qq/bilibili/kugou) | `{ qrUrl, qrImg?(base64 data URL), key }` |
|
||||
| GET | `/qrcode/status` | 非游客 | `key`、`platform` | `{ status: "waiting"|"scanned"|"confirmed"|"expired" }`;confirmed 自动持久化登录态 |
|
||||
| POST | `/jellyfin/test` | `platform.auth` | `{ serverUrl?, authMode?, username?, password?, apiKey?, userId? }`(空字段回退已存配置) | `{ ok, serverName?, version?, error? }` |
|
||||
| POST | `/sms/send` | `platform.auth` | `{ phone }`(网易手机号登录) | `{ success }` |
|
||||
| POST | `/sms/verify` | `platform.auth` | `{ phone, code }` | `{ success }` |
|
||||
| POST | `/cookie` | `platform.auth` | `{ platform, cookie }`(不支持 youtube/jellyfin) | `{ success: true }` |
|
||||
|
||||
## Spotify /api/spotify(配置 Spotify OAuth 后挂载)
|
||||
|
||||
| 方法 | 路径 | 权限 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/login` | `platform.auth` | `{ url }`(accounts.spotify.com 授权页,浏览器打开) |
|
||||
| GET | `/callback` | — | OAuth 回调,重定向回 WebUI(浏览器流程,脚本无需调用) |
|
||||
| GET | `/status` | 非游客 | `{ authorized, backend, deviceName, binaryAvailable }` |
|
||||
|
||||
---
|
||||
|
||||
## 个人音乐账号 /api/me/music(非游客,仅本人数据)
|
||||
|
||||
绑定**自己的**网易账号,让 `POST /api/player/:botId/fm` 按个人口味推荐;cookie 只存服务端,任何接口都不会回传。与 `/api/auth` 的机器人共享登录互不影响。
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/netease/status` | — | `{ linked, loggedIn, nickname?, avatarUrl? }` |
|
||||
| POST | `/netease/qrcode` | — | `{ qrUrl, qrImg?(base64 data URL), key }`(个人绑定专用二维码) |
|
||||
| GET | `/netease/qrcode/status` | `key` | `{ status: "waiting"|"scanned"|"confirmed"|"expired" }`;confirmed 后自动绑定到当前账户 |
|
||||
| DELETE | `/netease` | — | `{ ok: true }`(解除绑定) |
|
||||
|
||||
---
|
||||
|
||||
## API 密钥管理 /api/keys(仅浏览器 session)
|
||||
|
||||
API Key **不能**调用这些端点(403)——泄露的 Key 无法自我复制;游客 session 也被拒绝。浏览器登录后调用。
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/` | `?all=1`(管理员可看全部,含 username) | `{ keys: [{ id, userId, username?, name, keyPrefix, createdAt, lastUsedAt }] }` |
|
||||
| POST | `/` | `{ name: "1-64字符" }` | `201 { key: {...}, rawKey: "tsmb_…" }`(明文仅此一次);达上限 409 |
|
||||
| DELETE | `/:id` | — | `{ success: true }`(仅本人;管理员可删任意) |
|
||||
|
||||
---
|
||||
|
||||
## 用户管理 /api/users(管理员)
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/` | — | `{ users: [{ id, username, createdAt, role }] }` |
|
||||
| POST | `/` | `{ username, password(≥8位), role: "admin"|"member" }` | `201 { id, username, role }`;重名 409 |
|
||||
| DELETE | `/:id` | — | `204`(级联删除其 session 与 API Key) |
|
||||
| POST | `/:id/reset-password` | `{ newPassword }` | `204`(该用户的 session 与 API Key 全部失效) |
|
||||
| PATCH | `/:id/role` | `{ role: "admin"|"member" }` | `204`(不能降级最后一个管理员) |
|
||||
| GET | `/:id/permissions` | — | `{ capabilities: string[], bots: "all" | string[] }` |
|
||||
| PUT | `/:id/permissions` | `{ capabilities, bots: "all"|string[] }` | `{ success: true }` |
|
||||
|
||||
## 操作审计 /api/audit(管理员)
|
||||
|
||||
| 方法 | 路径 | 参数 | 返回 |
|
||||
|------|------|------|------|
|
||||
| GET | `/` | `limit`(1-500,默认 100)、`offset`(默认 0) | `{ entries: [{ id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action }] }` |
|
||||
|
||||
action 取值:`admin.first_created`、`user.created`、`user.deleted`、`user.password_reset`、`user.password_changed`、`user.role_changed`、`user.permissions_changed`、`api_key.created`、`api_key.deleted`。
|
||||
|
||||
## 会话 /api/session(仅浏览器,API Key 不可用)
|
||||
|
||||
会话登录本身无法用 API Key 完成:`GET /needs-setup`、`POST /setup`、`POST /login`、`POST /guest`、`POST /logout`、`GET /me`、`POST /change-password` 均基于 cookie。`/login` 有每 IP 每分钟 5 次、`/setup` 3 次的限流。
|
||||
@@ -0,0 +1,124 @@
|
||||
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 {
|
||||
createApiKeyStore,
|
||||
type ApiKeyStore,
|
||||
MAX_API_KEYS_PER_USER,
|
||||
API_KEY_TOUCH_INTERVAL_MS,
|
||||
} from "./api-keys.js";
|
||||
|
||||
function sha256(key: string) {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
describe("ApiKeyStore", () => {
|
||||
let botDb: BotDatabase;
|
||||
let users: UserStore;
|
||||
let keys: ApiKeyStore;
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
users = createUserStore(botDb.db);
|
||||
keys = createApiKeyStore(botDb.db);
|
||||
const u = await users.createUser("alice", "pw-alice", "admin");
|
||||
userId = u.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("create returns a tsmb_-prefixed raw key whose sha256 is stored, never the plaintext", () => {
|
||||
const created = keys.create(userId, "ci");
|
||||
expect(created).not.toBeNull();
|
||||
expect(created!.rawKey).toMatch(/^tsmb_[A-Za-z0-9_-]{40,}$/);
|
||||
const row = botDb.db.prepare("SELECT keyHash, keyPrefix FROM api_keys").get() as {
|
||||
keyHash: string;
|
||||
keyPrefix: string;
|
||||
};
|
||||
expect(row.keyHash).toBe(sha256(created!.rawKey));
|
||||
expect(row.keyHash).not.toContain(created!.rawKey);
|
||||
expect(created!.key.keyPrefix).toBe(created!.rawKey.slice(0, 12));
|
||||
});
|
||||
|
||||
it("validateAndTouch resolves the owner user for a fresh key", () => {
|
||||
const { rawKey } = keys.create(userId, "ci")!;
|
||||
const result = keys.validateAndTouch(rawKey);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.userId).toBe(userId);
|
||||
expect(result!.username).toBe("alice");
|
||||
expect(result!.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("validateAndTouch returns null for an unknown or empty key", () => {
|
||||
keys.create(userId, "ci");
|
||||
expect(keys.validateAndTouch("tsmb_not-a-real-key")).toBeNull();
|
||||
expect(keys.validateAndTouch("")).toBeNull();
|
||||
});
|
||||
|
||||
it("delete removes the key so it no longer validates", () => {
|
||||
const { key, rawKey } = keys.create(userId, "ci")!;
|
||||
expect(keys.delete(key.id, userId)).toBe(true);
|
||||
expect(keys.validateAndTouch(rawKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("delete with userId refuses to remove another user's key", async () => {
|
||||
const { key } = keys.create(userId, "ci")!;
|
||||
const other = await users.createUser("bob", "pw-bob", "member");
|
||||
expect(keys.delete(key.id, other.id)).toBe(false);
|
||||
expect(keys.delete(key.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("keys of a deleted user stop validating", async () => {
|
||||
const { rawKey } = keys.create(userId, "ci")!;
|
||||
users.deleteUser(userId);
|
||||
expect(keys.validateAndTouch(rawKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces the per-user key cap", () => {
|
||||
for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) {
|
||||
expect(keys.create(userId, `key-${i}`)).not.toBeNull();
|
||||
}
|
||||
expect(keys.create(userId, "one-too-many")).toBeNull();
|
||||
expect(keys.listForUser(userId)).toHaveLength(MAX_API_KEYS_PER_USER);
|
||||
});
|
||||
|
||||
it("touches lastUsedAt at most once per interval", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { rawKey } = keys.create(userId, "ci")!;
|
||||
keys.validateAndTouch(rawKey);
|
||||
const first = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + 30_000);
|
||||
keys.validateAndTouch(rawKey);
|
||||
const second = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
|
||||
expect(second).toBe(first);
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + API_KEY_TOUCH_INTERVAL_MS + 1000);
|
||||
keys.validateAndTouch(rawKey);
|
||||
const third = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
|
||||
expect(third).toBeGreaterThan(first);
|
||||
});
|
||||
|
||||
it("deleteAllForUser clears every key of that user", async () => {
|
||||
keys.create(userId, "a");
|
||||
keys.create(userId, "b");
|
||||
const other = await users.createUser("bob", "pw-bob", "member");
|
||||
keys.create(other.id, "c");
|
||||
keys.deleteAllForUser(userId);
|
||||
expect(keys.listForUser(userId)).toHaveLength(0);
|
||||
expect(keys.listForUser(other.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("listAll exposes usernames for admin views", async () => {
|
||||
keys.create(userId, "ci");
|
||||
const other = await users.createUser("bob", "pw-bob", "member");
|
||||
keys.create(other.id, "deploy");
|
||||
const all = keys.listAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.map((k) => k.username).sort()).toEqual(["alice", "bob"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export const MAX_API_KEYS_PER_USER = 20;
|
||||
export const API_KEY_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
/** Visible prefix stored for list views, e.g. "tsmb_a1b2c3d4". */
|
||||
export const API_KEY_PREFIX_LENGTH = 12;
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
}
|
||||
|
||||
export interface ApiKeyWithUser extends ApiKeyRow {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyValidation {
|
||||
keyId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
role: "admin" | "member";
|
||||
}
|
||||
|
||||
export interface CreatedApiKey {
|
||||
key: ApiKeyRow;
|
||||
/** Plaintext key — returned exactly once, at creation time. */
|
||||
rawKey: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyStore {
|
||||
/** Returns null when the per-user key cap is reached. */
|
||||
create(userId: string, name: string): CreatedApiKey | null;
|
||||
listForUser(userId: string): ApiKeyRow[];
|
||||
listAll(): ApiKeyWithUser[];
|
||||
/** With userId, only deletes a key owned by that user. */
|
||||
delete(id: string, userId?: string): boolean;
|
||||
deleteAllForUser(userId: string): void;
|
||||
validateAndTouch(rawKey: string): ApiKeyValidation | null;
|
||||
}
|
||||
|
||||
function hashKey(rawKey: string): string {
|
||||
return createHash("sha256").update(rawKey).digest("hex");
|
||||
}
|
||||
|
||||
export function createApiKeyStore(db: Database.Database): ApiKeyStore {
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO api_keys (id, userId, name, keyHash, keyPrefix, createdAt, lastUsedAt) VALUES (?, ?, ?, ?, ?, ?, NULL)"
|
||||
);
|
||||
const selectForUserStmt = db.prepare(
|
||||
"SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE userId = ? ORDER BY createdAt DESC"
|
||||
);
|
||||
const selectAllStmt = db.prepare(
|
||||
`SELECT k.id, k.userId, k.name, k.keyPrefix, k.createdAt, k.lastUsedAt, u.username
|
||||
FROM api_keys k INNER JOIN users u ON u.id = k.userId
|
||||
ORDER BY k.createdAt DESC`
|
||||
);
|
||||
const selectByIdStmt = db.prepare(
|
||||
"SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE id = ?"
|
||||
);
|
||||
const deleteStmt = db.prepare("DELETE FROM api_keys WHERE id = ?");
|
||||
const deleteAllForUserStmt = db.prepare("DELETE FROM api_keys WHERE userId = ?");
|
||||
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE userId = ?");
|
||||
const validateStmt = db.prepare(
|
||||
`SELECT k.id, k.userId, k.lastUsedAt, u.username, u.role
|
||||
FROM api_keys k INNER JOIN users u ON u.id = k.userId
|
||||
WHERE k.keyHash = ?`
|
||||
);
|
||||
const touchStmt = db.prepare("UPDATE api_keys SET lastUsedAt = ? WHERE id = ?");
|
||||
|
||||
return {
|
||||
create(userId, name) {
|
||||
const count = (countForUserStmt.get(userId) as { n: number }).n;
|
||||
if (count >= MAX_API_KEYS_PER_USER) {
|
||||
return null;
|
||||
}
|
||||
const rawKey = `tsmb_${randomBytes(32).toString("base64url")}`;
|
||||
const row: ApiKeyRow = {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
name,
|
||||
keyPrefix: rawKey.slice(0, API_KEY_PREFIX_LENGTH),
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
insertStmt.run(row.id, row.userId, row.name, hashKey(rawKey), row.keyPrefix, row.createdAt);
|
||||
return { key: row, rawKey };
|
||||
},
|
||||
|
||||
listForUser(userId) {
|
||||
return selectForUserStmt.all(userId) as ApiKeyRow[];
|
||||
},
|
||||
|
||||
listAll() {
|
||||
return selectAllStmt.all() as ApiKeyWithUser[];
|
||||
},
|
||||
|
||||
delete(id, userId) {
|
||||
const row = selectByIdStmt.get(id) as ApiKeyRow | undefined;
|
||||
if (!row) return false;
|
||||
if (userId !== undefined && row.userId !== userId) return false;
|
||||
deleteStmt.run(id);
|
||||
return true;
|
||||
},
|
||||
|
||||
deleteAllForUser(userId) {
|
||||
deleteAllForUserStmt.run(userId);
|
||||
},
|
||||
|
||||
validateAndTouch(rawKey) {
|
||||
if (!rawKey) return null;
|
||||
const row = validateStmt.get(hashKey(rawKey)) as
|
||||
| { id: string; userId: string; lastUsedAt: number | null; username: string; role: string }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
// The reserved guest principal must never authenticate via API keys;
|
||||
// guest access is session-only by design.
|
||||
if (row.role !== "admin" && row.role !== "member") return null;
|
||||
const now = Date.now();
|
||||
if (row.lastUsedAt === null || now - row.lastUsedAt > API_KEY_TOUCH_INTERVAL_MS) {
|
||||
touchStmt.run(now, row.id);
|
||||
}
|
||||
return { keyId: row.id, userId: row.userId, username: row.username, role: row.role };
|
||||
},
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -7,7 +7,9 @@ export type AuditAction =
|
||||
| "user.password_reset"
|
||||
| "user.password_changed"
|
||||
| "user.role_changed"
|
||||
| "user.permissions_changed";
|
||||
| "user.permissions_changed"
|
||||
| "api_key.created"
|
||||
| "api_key.deleted";
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
|
||||
@@ -272,6 +272,18 @@ function initTables(db: Database.Database): void {
|
||||
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 api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
keyHash TEXT NOT NULL UNIQUE,
|
||||
keyPrefix TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
lastUsedAt INTEGER,
|
||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_userId ON api_keys(userId);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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, type SessionStore } from "../../data/sessions.js";
|
||||
import { createAuditStore } from "../../data/audit.js";
|
||||
import { createApiKeyStore, MAX_API_KEYS_PER_USER, type ApiKeyStore } from "../../data/api-keys.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { createApiKeysRouter } from "./api-keys.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
|
||||
describe("api-keys router", () => {
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let sessions: SessionStore;
|
||||
let apiKeys: ApiKeyStore;
|
||||
let adminId: string;
|
||||
let memberId: string;
|
||||
let adminToken: string;
|
||||
let memberToken: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
sessions = createSessionStore(botDb.db);
|
||||
const audit = createAuditStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
apiKeys = createApiKeyStore(botDb.db);
|
||||
const admin = await users.createUser("alice", "pw-alice", "admin");
|
||||
const member = await users.createUser("bob", "pw-bob", "member");
|
||||
adminId = admin.id;
|
||||
memberId = member.id;
|
||||
adminToken = sessions.createSession(adminId).token;
|
||||
memberToken = sessions.createSession(memberId).token;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
createRequireAuth(sessions, permissions, () => ({
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {} as any,
|
||||
}), apiKeys)
|
||||
);
|
||||
app.use("/api/keys", createApiKeysRouter(apiKeys, audit, { info: () => {}, warn: () => {}, error: () => {}, child: () => ({}) } as any));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
const authed = (token: string) => {
|
||||
const cookie = `${SESSION_COOKIE_NAME}=${token}`;
|
||||
return {
|
||||
get: (url: string) => request(app).get(url).set("Cookie", cookie),
|
||||
post: (url: string) => request(app).post(url).set("Cookie", cookie),
|
||||
delete: (url: string) => request(app).delete(url).set("Cookie", cookie),
|
||||
};
|
||||
};
|
||||
const asAdmin = () => authed(adminToken);
|
||||
const asMember = () => authed(memberToken);
|
||||
|
||||
it("lists only the caller's own keys", async () => {
|
||||
apiKeys.create(adminId, "mine");
|
||||
apiKeys.create(memberId, "theirs");
|
||||
const res = await asAdmin().get("/api/keys");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.keys).toHaveLength(1);
|
||||
expect(res.body.keys[0].name).toBe("mine");
|
||||
expect(res.body.keys[0].rawKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a key and returns the plaintext exactly once", async () => {
|
||||
const res = await asAdmin().post("/api/keys").send({ name: "ci" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.rawKey).toMatch(/^tsmb_/);
|
||||
expect(apiKeys.validateAndTouch(res.body.rawKey)?.userId).toBe(adminId);
|
||||
// The list view never exposes the plaintext again.
|
||||
const list = await asAdmin().get("/api/keys");
|
||||
expect(JSON.stringify(list.body)).not.toContain(res.body.rawKey);
|
||||
});
|
||||
|
||||
it("rejects creation without a valid name", async () => {
|
||||
expect((await asAdmin().post("/api/keys").send({})).status).toBe(400);
|
||||
expect((await asAdmin().post("/api/keys").send({ name: "" })).status).toBe(400);
|
||||
expect((await asAdmin().post("/api/keys").send({ name: "x".repeat(65) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects creation beyond the per-user cap with 409", async () => {
|
||||
for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) {
|
||||
apiKeys.create(memberId, `k${i}`);
|
||||
}
|
||||
const res = await asMember().post("/api/keys").send({ name: "overflow" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("deletes own key and it stops validating", async () => {
|
||||
const { key } = apiKeys.create(memberId, "ci")!;
|
||||
const res = await asMember().delete(`/api/keys/${key.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a member cannot delete another user's key", async () => {
|
||||
const { key } = apiKeys.create(adminId, "admin-key")!;
|
||||
const res = await asMember().delete(`/api/keys/${key.id}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(apiKeys.listForUser(adminId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an admin can delete another user's key", async () => {
|
||||
const { key } = apiKeys.create(memberId, "member-key")!;
|
||||
const res = await asAdmin().delete(`/api/keys/${key.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("admin can list all keys with ?all=1, members cannot", async () => {
|
||||
apiKeys.create(adminId, "a");
|
||||
apiKeys.create(memberId, "b");
|
||||
const adminAll = await asAdmin().get("/api/keys?all=1");
|
||||
expect(adminAll.body.keys).toHaveLength(2);
|
||||
expect(adminAll.body.keys.map((k: any) => k.username).sort()).toEqual(["alice", "bob"]);
|
||||
const memberAll = await asMember().get("/api/keys?all=1");
|
||||
expect(memberAll.body.keys).toHaveLength(1);
|
||||
expect(memberAll.body.keys[0].name).toBe("b");
|
||||
});
|
||||
|
||||
it("a request authenticated by an API key cannot manage keys", async () => {
|
||||
const { rawKey } = apiKeys.create(adminId, "self-mgmt")!;
|
||||
const res = await request(app)
|
||||
.post("/api/keys")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send({ name: "proliferate" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
expect((await request(app).get("/api/keys")).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Router } from "express";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { ApiKeyStore } from "../../data/api-keys.js";
|
||||
import { MAX_API_KEYS_PER_USER } from "../../data/api-keys.js";
|
||||
import type { AuditStore } from "../../data/audit.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
|
||||
/**
|
||||
* API-key management (list / create / revoke), mounted at /api/keys.
|
||||
* Only interactive sessions may manage keys: a leaked key must never be able
|
||||
* to mint its own replacements.
|
||||
*/
|
||||
export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, logger: Logger): Router {
|
||||
const router = Router();
|
||||
|
||||
const rejectApiKeyAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (req.authMethod === "api-key") {
|
||||
res.status(403).json({ error: "API keys cannot manage API keys — log in to the WebUI" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
router.use(rejectApiKeyAuth);
|
||||
|
||||
// GET /api/keys — the caller's keys; admins may pass ?all=1 for every user's.
|
||||
router.get("/", (req, res) => {
|
||||
const user = req.user!;
|
||||
if (req.query.all === "1" && user.role === "admin") {
|
||||
res.json({ keys: apiKeys.listAll() });
|
||||
return;
|
||||
}
|
||||
res.json({ keys: apiKeys.listForUser(user.id) });
|
||||
});
|
||||
|
||||
// POST /api/keys — create a key; the plaintext is returned exactly once.
|
||||
router.post("/", (req, res) => {
|
||||
const user = req.user!;
|
||||
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
|
||||
if (!name || name.length > 64) {
|
||||
res.status(400).json({ error: "name is required (1-64 characters)" });
|
||||
return;
|
||||
}
|
||||
const created = apiKeys.create(user.id, name);
|
||||
if (!created) {
|
||||
res.status(409).json({ error: `每个用户最多创建 ${MAX_API_KEYS_PER_USER} 个 API Key` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
audit.record({
|
||||
actorId: user.id,
|
||||
actorUsername: user.username,
|
||||
targetUserId: user.id,
|
||||
targetUsername: user.username,
|
||||
action: "api_key.created",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "api_key.created" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ userId: user.id, keyId: created.key.id }, "API key created");
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
// DELETE /api/keys/:id — revoke; members only their own, admins any.
|
||||
router.delete("/:id", (req, res) => {
|
||||
const user = req.user!;
|
||||
const ok =
|
||||
user.role === "admin"
|
||||
? apiKeys.delete(req.params.id)
|
||||
: apiKeys.delete(req.params.id, user.id);
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: "API key not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
audit.record({
|
||||
actorId: user.id,
|
||||
actorUsername: user.username,
|
||||
targetUserId: user.id,
|
||||
targetUsername: user.username,
|
||||
action: "api_key.deleted",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "api_key.deleted" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ userId: user.id, keyId: req.params.id }, "API key deleted");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js";
|
||||
import type { UserStore } from "../../data/users.js";
|
||||
import { UsernameTakenError, GUEST_USER_ID } from "../../data/users.js";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import type { ApiKeyStore } from "../../data/api-keys.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";
|
||||
@@ -20,7 +21,8 @@ export function createUsersRouter(
|
||||
sessions: SessionStore,
|
||||
audit: AuditStore,
|
||||
logger: Logger,
|
||||
permissions: PermissionStore
|
||||
permissions: PermissionStore,
|
||||
apiKeys?: ApiKeyStore
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -85,6 +87,7 @@ export function createUsersRouter(
|
||||
}
|
||||
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
|
||||
sessions.deleteAllForUser(targetId);
|
||||
apiKeys?.deleteAllForUser(targetId);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
@@ -117,6 +120,9 @@ export function createUsersRouter(
|
||||
? (extractSessionToken(req.headers.cookie) ?? undefined)
|
||||
: undefined;
|
||||
sessions.deleteAllForUser(targetId, exceptToken);
|
||||
// A password reset must also kill the target's API keys — they are
|
||||
// long-lived credentials that otherwise survive credential rotation.
|
||||
apiKeys?.deleteAllForUser(targetId);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Request } from "express";
|
||||
import { SESSION_COOKIE_NAME } from "./validateSession.js";
|
||||
|
||||
/**
|
||||
* Extract a raw API key from the `X-API-Key` header or an
|
||||
* `Authorization: Bearer <key>` header. Returns null when neither is present.
|
||||
*/
|
||||
export function extractApiKey(req: Request): string | null {
|
||||
const header = req.headers["x-api-key"];
|
||||
if (typeof header === "string" && header.trim()) {
|
||||
return header.trim();
|
||||
}
|
||||
const auth = req.headers.authorization;
|
||||
if (typeof auth === "string") {
|
||||
const match = /^bearer\s+(.+)$/i.exec(auth);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
if (key) return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasApiKeyCredential(req: Request): boolean {
|
||||
return extractApiKey(req) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* API-key clients (no session cookie) skip the origin check entirely. Requests
|
||||
* that ALSO carry the session cookie must NOT rely on this — an attacker page
|
||||
* can set arbitrary headers while the victim's cookie rides along ambiently,
|
||||
* so the cookie keeps the request under the origin check.
|
||||
*/
|
||||
export function isApiKeyOnlyRequest(req: Request): boolean {
|
||||
return hasApiKeyCredential(req) && !req.headers.cookie?.includes(`${SESSION_COOKIE_NAME}=`);
|
||||
}
|
||||
@@ -70,4 +70,28 @@ describe("csrfOriginCheck middleware", () => {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "bad origin" });
|
||||
});
|
||||
|
||||
// API-key clients authenticate via a header the browser never attaches
|
||||
// automatically, so CSRF cannot abuse them — the origin check is skipped.
|
||||
it("allows POST with an X-API-Key header and no session cookie", async () => {
|
||||
const res = await request(app).post("/").set("X-API-Key", "tsmb_abc");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows POST with an Authorization: Bearer key and no session cookie", async () => {
|
||||
const res = await request(app).post("/").set("Authorization", "Bearer tsmb_abc");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("does NOT skip the origin check when a session cookie rides along with an API key", async () => {
|
||||
// An attacker page can set arbitrary headers while the victim's cookie is
|
||||
// attached ambiently — the cookie keeps the request under the gate.
|
||||
const res = await request(app)
|
||||
.post("/")
|
||||
.set("Host", "example.com")
|
||||
.set("Origin", "https://evil.com")
|
||||
.set("Cookie", "tsmb_session=whatever")
|
||||
.set("X-API-Key", "tsmb_abc");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { isApiKeyOnlyRequest } from "../auth/api-key-header.js";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
@@ -10,7 +11,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
* this header check covers the remaining attack surface.
|
||||
*/
|
||||
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
|
||||
if (SAFE_METHODS.has(req.method)) {
|
||||
if (SAFE_METHODS.has(req.method) || isApiKeyOnlyRequest(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { createApiKeyStore } from "../../data/api-keys.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "./requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
@@ -114,3 +115,112 @@ describe("requireAuth middleware", () => {
|
||||
expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireAuth middleware with API keys", () => {
|
||||
let botDb: BotDatabase;
|
||||
let app: express.Express;
|
||||
let adminKey: string;
|
||||
let memberKey: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const permissions = createPermissionStore(botDb.db);
|
||||
const apiKeys = createApiKeyStore(botDb.db);
|
||||
const admin = await users.createUser("alice", "pw-alice", "admin");
|
||||
const member = await users.createUser("bob", "pw-bob", "member");
|
||||
permissions.setPermissions(member.id, { capabilities: ["player.control"], bots: ["bot1"] });
|
||||
adminKey = apiKeys.create(admin.id, "ci")!.rawKey;
|
||||
memberKey = apiKeys.create(member.id, "deploy")!.rawKey;
|
||||
|
||||
app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
createRequireAuth(sessions, permissions, () => ({
|
||||
enabled: false,
|
||||
bots: "all",
|
||||
permissions: {} as any,
|
||||
}), apiKeys)
|
||||
);
|
||||
app.get("/protected", (req, res) => {
|
||||
const u: any = (req as any).user;
|
||||
res.json({
|
||||
ok: true,
|
||||
authMethod: (req as any).authMethod,
|
||||
user: u
|
||||
? {
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
capabilities: u.capabilities ? [...u.capabilities] : [],
|
||||
bots: u.bots === "all" ? "all" : [...(u.bots ?? [])],
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
botDb.close();
|
||||
});
|
||||
|
||||
it("authenticates a valid X-API-Key header and attaches the owner user", async () => {
|
||||
const res = await request(app).get("/protected").set("X-API-Key", adminKey);
|
||||
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");
|
||||
expect(res.body.authMethod).toBe("api-key");
|
||||
});
|
||||
|
||||
it("authenticates an Authorization: Bearer key", async () => {
|
||||
const res = await request(app).get("/protected").set("Authorization", `Bearer ${adminKey}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("rejects an unknown key with 401", async () => {
|
||||
const res = await request(app).get("/protected").set("X-API-Key", "tsmb_bogus");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: "invalid api key" });
|
||||
});
|
||||
|
||||
it("ignores the session cookie when a key header is present", async () => {
|
||||
// Garbage cookie + valid key → key wins.
|
||||
const res = await request(app)
|
||||
.get("/protected")
|
||||
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`)
|
||||
.set("X-API-Key", memberKey);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.username).toBe("bob");
|
||||
});
|
||||
|
||||
it("a member key inherits the member's capabilities and bot scope", async () => {
|
||||
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.role).toBe("member");
|
||||
expect(res.body.user.capabilities).toContain("player.control");
|
||||
expect(res.body.user.bots).toContain("bot1");
|
||||
expect(res.body.user.capabilities).not.toContain("bot.manage");
|
||||
});
|
||||
|
||||
it("returns 401 when a key header is present but no store is wired", async () => {
|
||||
const sessions: any = { validateAndTouch: () => null };
|
||||
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
|
||||
const mw = createRequireAuth(sessions, permissions, () => ({ enabled: false, bots: "all", permissions: {} as any }));
|
||||
const req: any = { headers: { "x-api-key": "tsmb_x" } };
|
||||
const res: any = { status(c: number) { this.statusCode = c; return this; }, json() { return this; } };
|
||||
const next = vi.fn();
|
||||
mw(req, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a key whose owner was deleted stops working", async () => {
|
||||
const users = createUserStore(botDb.db);
|
||||
const member = users.findByUsername("bob")!;
|
||||
users.deleteUser(member.id);
|
||||
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from "express";
|
||||
import type { SessionStore } from "../../data/sessions.js";
|
||||
import { SESSION_TTL_MS } from "../../data/sessions.js";
|
||||
import type { ApiKeyStore } from "../../data/api-keys.js";
|
||||
import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js";
|
||||
import type { GuestModeConfig } from "../../data/config.js";
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
extractSessionToken,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from "../auth/validateSession.js";
|
||||
import { extractApiKey } from "../auth/api-key-header.js";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
@@ -19,15 +21,42 @@ declare module "express-serve-static-core" {
|
||||
bots?: "all" | Set<string>;
|
||||
guest?: GuestPermissions;
|
||||
};
|
||||
/** How this request authenticated: browser session cookie or API key. */
|
||||
authMethod?: "session" | "api-key";
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequireAuth(
|
||||
sessions: SessionStore,
|
||||
permissions: PermissionStore,
|
||||
getGuestConfig: () => GuestModeConfig
|
||||
getGuestConfig: () => GuestModeConfig,
|
||||
apiKeys?: ApiKeyStore
|
||||
): RequestHandler {
|
||||
return function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
// ─── API-key path ──────────────────────────────────────────────────────
|
||||
// A key in a header authenticates on its own; cookies are ignored on this
|
||||
// path so the two credential types can never be mixed.
|
||||
const rawKey = extractApiKey(req);
|
||||
if (rawKey !== null) {
|
||||
const validation = apiKeys?.validateAndTouch(rawKey) ?? null;
|
||||
if (!validation) {
|
||||
res.status(401).json({ error: "invalid api key" });
|
||||
return;
|
||||
}
|
||||
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
|
||||
req.user = {
|
||||
id: validation.userId,
|
||||
username: validation.username,
|
||||
role: validation.role,
|
||||
capabilities: ctx.capabilities,
|
||||
bots: ctx.bots,
|
||||
};
|
||||
req.authMethod = "api-key";
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Session-cookie path (browser) ─────────────────────────────────────
|
||||
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
|
||||
if (!result) {
|
||||
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
@@ -56,6 +85,7 @@ export function createRequireAuth(
|
||||
bots: ctx.bots,
|
||||
guest: ctx.guest,
|
||||
};
|
||||
req.authMethod = "session";
|
||||
const token = extractSessionToken(req.headers.cookie);
|
||||
if (token) {
|
||||
res.cookie(SESSION_COOKIE_NAME, token, {
|
||||
|
||||
+9
-2
@@ -22,6 +22,7 @@ import { createFavoritesRouter } from "./api/favorites.js";
|
||||
import { createPersonalMusicRouter } from "./api/personal-music.js";
|
||||
import { createSavedQueuesRouter } from "./api/saved-queues.js";
|
||||
import { createSpotifyRouter } from "./api/spotify.js";
|
||||
import { createApiKeysRouter } from "./api/api-keys.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
import type { SpotifyProvider } from "../music/spotify/provider.js";
|
||||
import type { JellyfinProvider } from "../music/jellyfin.js";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
import { setupWebSocket } from "./websocket.js";
|
||||
import { createUserStore } from "../data/users.js";
|
||||
import { createSessionStore } from "../data/sessions.js";
|
||||
import { createApiKeyStore } from "../data/api-keys.js";
|
||||
import { createPermissionStore } from "../data/permissions.js";
|
||||
import { createRequireAuth } from "./middleware/requireAuth.js";
|
||||
import { requireAdmin } from "./middleware/requireAdmin.js";
|
||||
@@ -102,6 +104,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
const sessions = createSessionStore(options.database.db);
|
||||
const audit = createAuditStore(options.database.db);
|
||||
const permissions = createPermissionStore(options.database.db);
|
||||
const apiKeys = createApiKeyStore(options.database.db);
|
||||
|
||||
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
|
||||
// Disallow every crawler (issue #128). Declared before the static SPA
|
||||
@@ -131,7 +134,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
|
||||
|
||||
// ─── Gates for everything else under /api ───────────────────────────────
|
||||
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);
|
||||
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode, apiKeys);
|
||||
app.use("/api", csrfOriginCheck);
|
||||
app.use("/api", requireAuth);
|
||||
|
||||
@@ -225,9 +228,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
);
|
||||
|
||||
// admin-only routes
|
||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
|
||||
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions, apiKeys));
|
||||
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
|
||||
|
||||
// API-key management — interactive sessions only (guests excluded; the
|
||||
// router itself rejects key-authenticated requests).
|
||||
app.use("/api/keys", requireNotGuest, createApiKeysRouter(apiKeys, audit, logger));
|
||||
|
||||
// ─── Static SPA (public) ────────────────────────────────────────────────
|
||||
if (options.staticDir) {
|
||||
app.use(express.static(options.staticDir));
|
||||
|
||||
+154
-1
@@ -1135,6 +1135,60 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- API Keys -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">API 密钥</h2>
|
||||
<p class="apikey-hint">
|
||||
通过 API Key 调用本机的 REST API(请求头 <code>Authorization: Bearer</code> 或 <code>X-API-Key</code>)。
|
||||
权限与你的账户一致;明文只在生成时显示一次,之后仅能看到前缀。
|
||||
</p>
|
||||
<div class="user-list">
|
||||
<div v-for="k in apiKeyList" :key="k.id" class="user-item">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ k.name }}</div>
|
||||
<div class="apikey-meta">
|
||||
<code class="apikey-prefix">{{ k.keyPrefix }}…</code>
|
||||
<span>创建于 {{ formatDate(k.createdAt) }}</span>
|
||||
<span>{{ k.lastUsedAt ? `最后使用 ${formatDate(k.lastUsedAt)}` : '从未使用' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button class="btn-sm btn-delete" title="吊销此密钥" @click="onRevokeApiKey(k)">
|
||||
<Icon icon="mdi:delete" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="apiKeyList.length === 0 && !apiKeyLoadError" class="user-empty">还没有 API Key。</div>
|
||||
<div v-if="apiKeyLoadError" class="user-error">{{ apiKeyLoadError }}</div>
|
||||
</div>
|
||||
|
||||
<form class="user-add-form" @submit.prevent="onCreateApiKey">
|
||||
<input v-model="newApiKeyName" class="input" placeholder="密钥名称(如:home-assistant)" maxlength="64" required />
|
||||
<button class="btn-sm btn-primary" type="submit" :disabled="creatingApiKey">
|
||||
{{ creatingApiKey ? '生成中…' : '生成密钥' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="apiKeyMutationError" class="user-error">{{ apiKeyMutationError }}</p>
|
||||
|
||||
<!-- Created key modal: plaintext shown exactly once -->
|
||||
<div v-if="createdKey" class="edit-modal-overlay" @click.self="createdKey = null">
|
||||
<div class="edit-modal">
|
||||
<h3 class="modal-title">密钥「{{ createdKey.key.name }}」已生成</h3>
|
||||
<p class="modal-hint">请立即复制保存——这串明文只显示这一次,关闭后只能看到前缀。</p>
|
||||
<div class="apikey-raw-row">
|
||||
<code class="apikey-raw">{{ createdKey.rawKey }}</code>
|
||||
<button class="btn-sm" @click="copyCreatedKey">
|
||||
<Icon icon="mdi:content-copy" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="keyCopied" class="apikey-copied">已复制到剪贴板</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn-sm btn-primary" @click="createdKey = null">完成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audit Log -->
|
||||
<section v-if="session.isAdmin.value" class="settings-section">
|
||||
<h2 class="section-title">
|
||||
@@ -2200,6 +2254,90 @@ async function onConfirmReset() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- API Keys management ---
|
||||
interface ApiKeyEntry { id: string; name: string; keyPrefix: string; createdAt: number; lastUsedAt: number | null }
|
||||
const apiKeyList = ref<ApiKeyEntry[]>([]);
|
||||
const apiKeyLoadError = ref('');
|
||||
const apiKeyMutationError = ref('');
|
||||
const newApiKeyName = ref('');
|
||||
const creatingApiKey = ref(false);
|
||||
const createdKey = ref<{ key: ApiKeyEntry; rawKey: string } | null>(null);
|
||||
const keyCopied = ref(false);
|
||||
|
||||
async function loadApiKeys() {
|
||||
apiKeyLoadError.value = '';
|
||||
try {
|
||||
const res = await fetch('/api/keys');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
apiKeyList.value = body.keys ?? [];
|
||||
} catch (e) {
|
||||
apiKeyLoadError.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateApiKey() {
|
||||
const name = newApiKeyName.value.trim();
|
||||
if (!name) return;
|
||||
apiKeyMutationError.value = '';
|
||||
creatingApiKey.value = true;
|
||||
try {
|
||||
const res = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
createdKey.value = body;
|
||||
keyCopied.value = false;
|
||||
newApiKeyName.value = '';
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
apiKeyMutationError.value = (e as Error).message;
|
||||
} finally {
|
||||
creatingApiKey.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevokeApiKey(k: ApiKeyEntry) {
|
||||
if (!confirm(`确认吊销 API Key「${k.name}」?使用它的集成将立即失效。`)) return;
|
||||
apiKeyMutationError.value = '';
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${k.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
throw new Error(b.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await loadApiKeys();
|
||||
} catch (e) {
|
||||
apiKeyMutationError.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCreatedKey() {
|
||||
if (!createdKey.value) return;
|
||||
const text = createdKey.value.rawKey;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
keyCopied.value = true;
|
||||
} catch {
|
||||
// Clipboard API is unavailable on non-secure origins — fall back to a
|
||||
// temporary textarea + execCommand.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
keyCopied.value = document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-user permission editor (members only) ---
|
||||
const CAPABILITIES: { token: string; label: string }[] = [
|
||||
{ token: 'player.control', label: '播放控制' },
|
||||
@@ -2336,13 +2474,15 @@ function describeAction(e: AuditEntry): string {
|
||||
case 'user.password_changed': return `修改自己的密码`;
|
||||
case 'user.role_changed': return `变更 ${target} 的角色`;
|
||||
case 'user.permissions_changed': return `权限变更 → ${target}`;
|
||||
case 'api_key.created': return `${target} 生成 API Key`;
|
||||
case 'api_key.deleted': return `${target} 吊销 API Key`;
|
||||
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';
|
||||
if (action === 'user.password_reset' || action === 'user.password_changed' || action === 'api_key.deleted') return 'audit-action-warn';
|
||||
return 'audit-action-ok';
|
||||
}
|
||||
|
||||
@@ -2353,6 +2493,7 @@ onMounted(() => {
|
||||
loadIdleTimeout(); // also populates the Spotify config form (same endpoint)
|
||||
loadSpotifyStatus();
|
||||
handleSpotifyRedirect();
|
||||
loadApiKeys();
|
||||
if (session.isAdmin.value) {
|
||||
loadUsers();
|
||||
loadAudit();
|
||||
@@ -3148,6 +3289,18 @@ onUnmounted(() => {
|
||||
.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; }
|
||||
.apikey-hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.6; }
|
||||
.apikey-hint code { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 4px; padding: 1px 4px; }
|
||||
.apikey-meta { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.apikey-prefix { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.apikey-raw-row { display: flex; gap: 8px; align-items: center; }
|
||||
.apikey-raw {
|
||||
flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px; word-break: break-all; padding: 8px;
|
||||
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 6px;
|
||||
user-select: all;
|
||||
}
|
||||
.apikey-copied { font-size: 12px; color: var(--color-primary); margin: 6px 0 0; }
|
||||
.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; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user