diff --git a/.gitignore b/.gitignore index e8a1a62..238053a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,17 @@ audio/*.m4a audio/*.flac audio/*.wav audio/*.aac -audio/*.ogg \ No newline at end of file +audio/*.ogg +# Python 缓存 +__pycache__/ +*.pyc +# 备份文件 +*.bak_* +*.bak + +# 安全报告(含敏感信息,本地保留) +SECURITY_PENTEST.md +SECURITY_REVIEW.md + +# 临时归档 +*.tgz diff --git a/HANDOFF.md b/HANDOFF.md index 6ca252b..ef1c0a3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,4 +1,4 @@ -# 交接文档 · 音乐在线电台 +# 交接文档 · Starlight专属电台 > 跨会话交接。**新会话先读此文件再动手**。最后更新:2026-09-25(v2.6)。 @@ -206,4 +206,337 @@ python -X utf8 test_write.py # 5 项(需 tmp/test_import.mp3 素材) - 后台服务:`svc003`(`http://127.0.0.1:3000`) - DB:`data.db`(6 首歌曲,封面已本地化) - 缓存:`hls_cache/` 约 34MB -- Git:`2fc2548` 干净 \ No newline at end of file +- Git:`2fc2548` 干净 +--- + +## 13. StarLight 第三方登录接入(v2.7) + +**背景**:管理员要求用 StarLight(`skin.starlight.cool/yggdrasil`)做第三方登录,替换原密码登录(保留应急后门)。 + +**方案**:后端中转(浏览器→本服务→Yggdrasil),accessToken 不落地、不下发。 + +**改动文件**: +- `src/starlight.js`(新)— Yggdrasil 客户端,仅 `authenticate()`;CLIENT_TOKEN 启动随机 +- `src/config.js` — 新增 `STARLIGHT_API`、`STARLIGHT_ADMINS`(逗号分隔,小写匹配) +- `src/db.js` — sessions 表迁移加 `starlight_name` 列 +- `src/store.js` — `createSession(token, role, expiresAt, starlightName)` +- `src/auth.js` — `loginViaStarLight()`、`getStarlightName()`;exports 更新 +- `src/routes/auth.js` — `/login` 双模式:有 username→StarLight;无→应急密码 +- `public/login.html` — 用户名+密码表单 + "管理员应急登录"折叠区 +- `public/index.html` — 顶栏 `#userName` 显示"你好,xxx" +- `public/js/app.js` — `applyRoleUI(role, starlightName)` +- `start.cmd` — 加 STARLIGHT_API / STARLIGHT_ADMINS 配置项 + +**登录逻辑**: +- 前端 `{username, password}` → 后端调 authenticate → 成功建本地会话 +- 角色:`STARLIGHT_ADMINS` 命中=admin,否则 user +- **应急后门**:username 留空 + `{password}` → 走 ADMIN_PASSWORD(防皮肤站挂掉锁死) + +**测试**:api 16 / e2e 17 / write 5 全绿。真实打到 StarLight(错误凭证→401)。 + +**注意**:`test_write.py` 第 30 行写死旧路径 `F:\music_online\demo\tmp\`,复制项目时未改(跑测试需先把素材放到该路径)。 +--- + +## 14. 安全加固(v2.7.1,2026-10-05) + +对上一条 StarLight 登录做了自审+修复,详见 `SECURITY_REVIEW.md`。 + +**修复清单**: +- 🔴 XFF 伪造绕过限流 → `trust proxy=false` + `getClientIp()` 取 socket IP +- 🔴 白名单按用户名判定可抢注提权 → 改绑 **UUID** +- 🟠 错误信息泄露 StarLight 内部细节 → 脱敏 +- 🟠 用户名字符集未校验 → 正则过滤 `[A-Za-z0-9_]` +- 🟠 日志记 PII(邮箱)→ 移除 +- 🟠 无 body 大小限制 → `express.json({limit:"64kb"})` +- 🟡 无滑动续期 → `getValidSession` 加续期 + `store.touchSession()` + +**验证**:38 项回归全绿 + XFF 伪造测试(6 个不同 XFF 仍触发 429)+ 真实账号登录正常。 + +**待办**: +- `STARLIGHT_ADMINS` 填 **UUID**(不是用户名!) +- 生产环境用强密码 + HTTPS(Cookie 加 Secure) +- 登录锁定无解锁途径(重启服务可清 failMap) +--- + +## 15. 全局 CSRF 防护(v2.7.2,2026-10-05) + +**背景**:管理员要求加全局 CSRF(选项 C)。 + +**方案**:双提交 Cookie(Double Submit Cookie) +- 登录时发 `mr_csrf` cookie(**非 HttpOnly**,JS 可读)+ session cookie +- 前端所有写请求带 `X-CSRF-Token` 头(值 = cookie) +- 后端 `csrfProtection` 中间件校验(GET/HEAD/OPTIONS 豁免) + +**改动文件**: +- `src/auth.js` — CSRF_COOKIE / genCsrfToken / setCsrfCookie / clearCsrfCookie / csrfProtection;setSessionCookie 改用 `res.append`(与 csrf cookie 共存) +- `src/routes/auth.js` — 登录成功发 csrf cookie;登出清 csrf +- `src/server.js` — 受保护 API 挂 `auth.csrfProtection` +- `public/js/app.js` — `api()` 统一给写请求加 `X-CSRF-Token` +- `test_api.py` / `test_e2e.py` / `test_write.py` — call() 加 CSRF 头(从 cookie jar 读);test_write 的 multipart import 也加 + +**验证**:38 项全绿(api16/e2e17/write5)+ CSRF 专项(无头403 / 带头200 / GET豁免)。 + +**⚠️ 代价**:所有 API 客户端(脚本、第三方集成)写操作必须带 `X-CSRF-Token` 头,否则 403。 + +**测试脚本适配**:三个 test_*.py 已改,写请求自动读 cookie jar 的 mr_csrf。 +--- + +## 16. 渗透测试修复(v2.7.3,2026-10-05) + +依据 `SECURITY_PENTEST.md`(黑盒渗透报告)修复 3 个漏洞: + +- **V1 硬编码密码** → start.cmd 改存 **sha256 哈希**;auth.js 加 `passwordMatches()` 支持明文/hash 两种。⚠️ git 历史里的明文未清(老板决定不动云端),**test123 需轮换**。 +- **V2 堆栈泄露** → server.js 加全局错误处理中间件,客户端只收通用文案。 +- **V3 限流 DoS** → ①类型校验前置不计失败 ②新增 `TRUSTED_IPS` 白名单豁免 ③`isTrustedIp` 归一化 IPv4-mapped IPv6。 + +**新配置**:`TRUSTED_IPS`(默认 127.0.0.1,::1)。 +**测试**:38 项全绿 + V1/V2/V3 专项验证。 +**遗留**:轮换 test123、生产 TRUSTED_IPS 收紧、HTTPS。 +--- + +## 17. 第三/四轮渗透修复(v2.7.4,2026-10-05) + +依据 `SECURITY_PENTEST_R2.md`(复测)+ `SECURITY_PENTEST_R3_WHITEBOX.md`(白盒): + +- **V4【高危·水平越权】**:普通用户可删歌/批量删/改元数据 → `routes/music.js` 给 delete/delete-batch/enrich 补 `requireAdmin`。**已动态验证 403。** +- **V5【低】**:settings 无 schema → 字段白名单。 +- **V6【低】**:/hls_cache 未鉴权 → **评估后不改**(切片路径被 m3u8 引用,加锁断播放;低危接受)。 +- **V8【低】**:updateSong 动态列名 → 字段白名单。 +- **V1**:passwordMatches 新增 **scrypt** 支持(`scrypt::`),兼容 sha256/明文。start.cmd 仍是 sha256,可升级。 + +**测试脚本**:改从 `TEST_PASSWORD` 环境变量读密码(默认 `123test`)。 +**回归**:38 项全绿。 +**遗留**:V1 密码强度(弱密码救不了)、git 历史明文。 +--- + +## 18. 第五轮渗透修复(v2.7.5,2026-10-05) + +依据更新的 `SECURITY_PENTEST.md`: + +- **V9【高危】/api/streams 写操作缺管理员** → **根治**:新增 `auth.requireAdminForWrites`(GET/HEAD/OPTIONS 放行,写要 admin),挂到 server.js 的 /api/music + /api/streams。 +- **V10/V11** → 被同一中间件覆盖。 +- **V12** SSE 无上限 → sse.js 加总 500 + 每 IP 10 上限,超限 503。 +- **V13** 搜索限流被缓存绕过 → 限流检查前置。 +- **V14** 日志 CRLF 注入 → logger.js 过滤控制字符。 +- **V1** → start.cmd 升级为 **scrypt** 加盐慢哈希。 +- **V5** 上轮已修;**V6** 评估后不改(切片被 m3u8 引用)。 + +**测试**:38 项全绿。 +**遗留**:弱密码 `123test`(建议换强随机)。 +--- + +## 19. 权限中间件细化(v2.7.6,2026-10-05) + +**背景**:`requireAdminForWrites` 的"非 GET 即需 admin"太粗暴,误伤 `/api/music/detail`(POST 但是普通用户查详情用的)。另:要求**搜索仅管理员**,普通用户只能用 id/链接解析。 + +**改动**: +1. `auth.js` `requireAdminForWrites` 加 **POST 读白名单** `POST_READ_WHITELIST = {"/music/detail"}`——这些 POST 语义是读,放行给普通用户。 +2. `routes/music.js` `/search` 单独加 `requireAdmin`(GET 也要 admin)。 +3. `public/js/app.js` `searchMusic()`:普通用户输入非 id → 提示"请用 ID 或链接",不调 search。 + +**验证**: +- 普通用户:detail 200 ✅ / search 403 ✅ +- 管理员:search 200 ✅ +- 38 项回归全绿。 + +**权限模型现状**: +- 读(GET):普通用户可用(除非单独加 admin,如 search) +- 写(POST/DELETE/...):默认 admin,白名单里的"读语义 POST"(detail)例外 +--- + +## 20. 前端误退档恢复(v2.7.6,2026-10-06) + +**事件**:前端 `public/` 被误退档回 v2.6 旧版(丢了 StarLight 登录、CSRF、userName 显示、搜索适配)。 + +**恢复源**:`F:\music_online\demo_starlight_gitbackup_20261005_2206`(本喵清 git 历史前的完整快照,10-05 21:33~21:55)。 + +**恢复动作**: +1. 从备份拷回 `public/login.html`、`index.html`、`js/app.js`、`css/style.css`。 +2. 备份缺的"搜索仅管理员"适配**重做**:`app.js` `searchMusic()` 加 `state.role === "admin"` 判断,普通用户提示用 id/链接。 +3. `test_*.py` 从 `demo_starlight_backup_20261005_2130` 恢复后,**重做 CSRF 头 + TEST_PASSWORD 环境变量**适配。 + +**验证**:38 项全绿(api16/e2e17/write5)。 + +**教训**:**改动后及时 commit**(哪怕本地),别让工作区长期处于"未提交"状态——一旦误操作(退档/覆盖),未提交的改动最难找回。本次全靠"清 git 历史前的意外备份"救回。 +--- + +## 21. 多角色账号登录修复(v2.7.7,2026-10-06) + +**问题**:账号加了第 2 个角色(`Xiao_Tps`)后,StarLight **不再返回 `selectedProfile`**(改为返回 `availableProfiles` 数组),导致 `starlight.js` 误判"该账号尚未创建游戏角色",登录 401。 + +**修复**:`starlight.js` 角色选择逻辑改为—— +1. 有 `selectedProfile` → 用它 +2. 无但有 `availableProfiles` → **取第一个**(老板选方案 A,自动选第一个) +3. 都无 → 才报"无角色" + +**验证**:多角色账号(1184070342@qq.com)登录 → 200,自动选 `Xiao_love`(role=admin)。 + +**踩坑**:修复后首次测试仍 401——**旧服务进程占着 3000 端口没清**(EADDRINUSE),新服务没起来,跑的还是旧代码。`taskkill /F /T /PID 106328` 后正常。 +--- + +## 22. 设置页扩充 + 删关于页(v2.8.0,2026-10-06) + +### 删关于页 +- `index.html`:删导航 tab + `pageAbout` 页面(139 行);`app.js` `pageOrder` 去掉 pageAbout。 + +### 设置页热配置(真·管理员控制) +**新增 11 项可热改配置**(存 settings 表,**立即生效,无需重启**): +| 配置项 | key | 说明 | +|--------|-----|------| +| 切片时长 | hlsTime | 影响新流 | +| 滑动窗口 | slideWindow | 影响新流 | +| 缓存上限 MB | maxCacheMB | | +| 默认频道 | defaultStream | 存值(待接入默认流逻辑) | +| 日志级别 | logLevel | 热改 levelNum | +| 会话有效期 | sessionTtlHours | | +| 登录失败上限 | loginMaxFails | | +| 登录锁定时长 | loginLockMin | | +| 搜索缓存时长 | searchCacheTtlSec | | +| 搜索限流上限 | searchRateMax | | +| 搜索限流窗口 | searchRateWindowSec | | + +**实现**: +- `config.js` 新增 `applyOverrides(settings)`——从 settings 覆盖 config。 +- `logger.js` 新增 `setLevel(name)`(`levelNum` 从 const 改 let)。 +- `routes/system.js` `POST /settings` 扩白名单 + 保存后调 `applyOverrides` + `setLevel`;`GET /config` 返回全部可配字段。 +- `server.js` `bootstrap` 启动时加载 settings 覆盖。 +- `public/index.html` 设置页"运行配置"只读 grid → **可编辑表单**(11 项)。 +- `public/js/app.js` `loadSettings` 填充表单 + `btnSaveSettings` 保存。 +- `public/css/style.css` 加 `.settings-form`/`.setting-row` 样式。 + +**验证**:POST 改 hlsTime/logLevel/searchRateMax → GET /config 立即反映新值。38 项回归全绿。 + +**注意**:HTTP 端口、FFmpeg 并发等**需重启的项不做**(老板要求只做热生效)。 +--- + +## 23. 登录页风格统一(v2.8.1,2026-10-06) + +**背景**:登录页原本是旧暗色主题(`#0d1117`),与主页面浅色主题(`#f4f5f9`)不一致。 + +**改动**:`public/login.html` 完全重写样式,套用主页面设计 token: +- 浅色背景 `--bg #f4f5f9` + 白卡片 `--surface` + 阴影 `--shadow-lg` +- 强调色统一 `--accent #4f6ef7` +- 字体 `Plus Jakarta Sans` +- 大圆角 `--radius-xl (28px)` + 卡片升起动画 +- 输入框聚焦光晕 `--accent-glow` +- 主按钮 `.btn-primary`(蓝底白字)+ 应急按钮 `.btn-secondary`(描边) +- 引入 `bootstrap-icons.css`(登录/图标) + +**功能不变**:StarLight 用户名+密码登录、管理员应急登录、`/api/auth/login` 调用。 + +**验证**:/login.html 200、38 项回归全绿。 +--- + +## 24. 可视化权限管理(v2.8.2,2026-10-06) + +**背景**:原管理员靠 `start.cmd` 的 `STARLIGHT_ADMINS` 环境变量(改要重启),老板问"总不能还去改文件"。 + +**方案**:设置页加"权限管理"——**用户列表 + 一键授予/取消管理员**,热生效。 + +**改动**: +- **`db.js`**:新增 `admins` 表(uuid PK, name, added_at);sessions 加 `starlight_uuid` 列(迁移)。 +- **`store.js`**:`listAdmins/getAdmin/isAdminUuid/addAdmin/removeAdmin/listUsers`。 +- **`auth.js`**:`loginViaStarLight` 判角色**先查 admins 表再查环境变量**;`createSession` 存 uuid;新增 `getStarlightUuid`。 +- **`routes/auth.js`**:登录传 uuid 给 session;`/me` 返回 `myUuid`。 +- **`routes/system.js`**:`GET /users`(登录过的用户+isAdmin)、`GET/POST/DELETE /admins`。 +- **`public/index.html`**:设置页加"权限管理"卡片(用户列表)。 +- **`public/js/app.js`**:`loadUsers()` 渲染用户列表 + 设为/取消管理员按钮。 +- **`public/css/style.css`**:`.user-list/.user-item/.admin-badge/.btn-mini` 样式。 + +**判定优先级**:`admins` 表 ∪ 环境变量 `STARLIGHT_ADMINS`(UUID)。 + +**验证**:真实账号登录→`/me` 返回 myUuid;用户列表显示 isAdmin;加/删管理员正常。38 项回归全绿。 + +**注意**:旧 sessions 记录无 uuid(迁移前登录的),需**重新登录一次**补上。 +--- + +## 25. 设置页布局优化(v2.8.3,2026-10-06) + +**问题**:设置页"很挤,每个卡片都变成独立滚动"。 + +**根因**:`.section-body` 有 `flex:1; min-height:0; overflow-y:auto` → **每个卡片内容区各自滚动**,一堆小滚动条。 + +**修复**: +- `.section-body` 去掉 `flex:1 / min-height:0 / overflow-y:auto`(内容自然撑开)。 +- `.page.active` 加 `overflow-y:auto`(**整页统一滚动**,一个滚动条)。 +- 设置页布局优化:`#pageSettings` gap 加大、`.section-body` padding 加大、`.setting-row` 行距加大、日志区限高自身滚动、用户列表间距加大。 + +**验证**:CSS 括号平衡、38 项回归全绿。各页无独立滚动规则,统一滚动安全。 +--- + +## 26. 设置页布局优化 v2(v2.8.4,2026-10-06) + +**前情**:v2.8.3 第一次改崩(去掉 `.section-body` 的 flex+overflow 导致内容溢出),已回滚。 + +**本次正确解法**(理解布局链后): +- `.page.active`:+`overflow-y: auto` → **整页统一滚动** +- `.section-card`:+`flex-shrink: 0` → **卡片不被压缩**(关键!首次失败就漏了这个) +- `.section-body`:去掉 `flex:1 / min-height:0 / overflow-y:auto` → 内容自然撑开 + +**布局链**(勿乱动): +``` +.page-container (flex:1; min-height:0) + └── .page.active (flex:1; min-height:0; overflow-y:auto) ← 整页滚 + └── .section-card (flex-shrink:0) ← 不压缩 + ├── .section-header (flex-shrink:0) + └── .section-body (自然高度) +``` + +**验证**:CSS 括号平衡 395/395、38 项回归全绿。 + +**教训**:CSS 布局改动前先理解 flex/height/overflow 依赖链;用 python 精确替换 + `assert count==1` 防误伤;改前备份。 +--- + +## 27. 全站改名:Starlight专属电台(v2.8.5,2026-10-06) + +**改动**:"Xiao の 电台" / "音乐在线电台" → **"Starlight专属电台"**,全项目统一。 + +| 文件 | 位置 | +|------|------| +| `index.html` | title + 顶栏品牌 | +| `login.html` | title + h1 | +| `app.js` | 文件注释 | +| `package.json` | description | +| `README.md` | 标题 | +| `HANDOFF.md` | 标题 | + +**验证**:全项目零旧名残留。 +--- + +## 28. 循环池(手动歌单 + 随机不重复播放)(v2.9.0,2026-10-06) + +**需求**:资源列表"重新匹配作者"按钮 → 换成"加入/移出循环池";队列播完空时从循环池随机抽(抽过标记,全播完重置);池空降级从全部资源随机。 + +**改动**: +- **`db.js`**:新增 `stream_pool` 表(stream_id, song_id, played, added_at, PK(stream_id,song_id))。 +- **`store.js`**:`getPoolRows/listPoolSongIds/isInPool/addToPool/removeFromPool/drawFromPool/markPoolPlayed`。`drawFromPool` 抽未播的,全播过则重置。 +- **`livestream.js`**:`ensureNotEmpty` 队列空→`drawFromPool`(抽到标记 played)→池空降级 `getAllSongs` 随机。 +- **`routes/streams.js`**:`GET /:id/pool`、`POST /:id/pool/add|remove`。 +- **`public/js/app.js`**:资源列表按钮 `js-enrich`→`js-pool`(切换加/移,active 高亮);`loadResources` 查池标记 `inPool`;`togglePool()`。 +- **`public/css/style.css`**:`.js-pool.active` 样式。 + +**每流独立**池。**enrich 接口保留**(仅删资源列表入口)。 + +**验证**:池增删查正常;抽歌不重复+重置;38 项回归全绿。 +--- + +## 29. 白盒安全审查 + 修复(v2.9.1,2026-10-06) + +**审查范围**:全部 `src/`(2963 行)+ 前端。发现并修复: + +### 🔴 P1 SSRF(metadata.js)✅ +- `request()` / `downloadCover()` URL 来自网易云响应(HTTP 明文可劫持),**无域名校验** → 可请求内网/`file://`。 +- **修**:加 `isAllowedHost()` 白名单(`*.music.163.com` / `*.music.126.net`),`request` 和 `downloadCover` 都校验。实测 `169.254.169.254` 被拒。 + +### 🟡 P3 SSE 计数泄漏(sse.js)✅ +- `broadcast` 删失败 client 时未减 `ipCount` → 计数泄漏。 +- **修**:client 打 `__sseIp` 标记,删除时同步减计数。 + +### 🟡 P4 空 catch 加日志 ✅ +- `logger.js`(日志流)、`metadata.js`(时长/文件大小探测)、`media.js`(计数)、`routes/music.js`(删临时文件)——加 warn 日志。 +- `util.js`(getDirSize 读不到文件)——保持静默 + 注释说明。 + +### ✅ 已确认无问题 +权限挂载、SQL 参数化、输入校验(isValidFileName)、无命令注入(spawn 数组)、advance 同步无重入、TaskQueue 正确、FFmpeg 超时 kill、SSE 连接上限。 + +**验证**:38 项回归全绿 + SSRF 白名单实测。 \ No newline at end of file diff --git a/README.md b/README.md index dcde758..22c616b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 音乐在线电台 · v2 +# Starlight专属电台 · v2 把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成**多路**永不停播的直播流,并提供 Web 控制面板管理播放列表、导入音源(网易云 / 本地上传)。前后端一体的 Node.js 应用。 diff --git a/package.json b/package.json index c19cceb..e2c297f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "music-online-demo", "version": "2.6.0", - "description": "音乐在线电台:FFmpeg HLS 直播流服务(多路流 + SQLite + 导入 + 鉴权)", + "description": "Starlight专属电台:FFmpeg HLS 直播流服务(多路流 + SQLite + 导入 + 鉴权)", "main": "index.js", "scripts": { "start": "node index.js" diff --git a/public/css/style.css b/public/css/style.css index 4a41fb7..6f4662a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -328,12 +328,14 @@ .page { display: none; } - .page.active { +.page.active { display: flex; flex-direction: column; gap: var(--sp-4); flex: 1; min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; animation: pageEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; } @keyframes pageEnter { @@ -361,6 +363,7 @@ display: flex; flex-direction: column; min-height: 0; + flex-shrink: 0; } @keyframes cardRise { from { @@ -807,15 +810,9 @@ justify-content: center; padding: 0 5px; } - .section-body { +.section-body { padding: 0 0.5rem 0.5rem 0.5rem; margin-bottom: 0.5rem; - flex: 1; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-width: thin; - scrollbar-color: var(--border) transparent; } .section-body::-webkit-scrollbar { width: 5px; @@ -2144,3 +2141,195 @@ outline: none; } .resource-sort:hover { border-color: var(--accent); } + +/* StarLight 用户名显示 */ +.user-name { + font-size: 13px; + color: var(--muted, #8b949e); + margin-right: 8px; + white-space: nowrap; +} + +/* ==================== 设置页:热配置表单 ==================== */ +.settings-form { + display: flex; + flex-direction: column; + gap: 2px; +} +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--border, #30363d); +} +.setting-row:last-child { border-bottom: none; } +.setting-row label { + font-size: var(--fs-sm, 0.85rem); + color: var(--text, #e6edf3); + flex: 0 0 auto; +} +.setting-row input, +.setting-row select { + width: 130px; + padding: 6px 10px; + background: var(--bg, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: var(--radius-sm, 6px); + color: var(--text, #e6edf3); + font-size: var(--fs-sm, 0.85rem); + outline: none; + text-align: right; +} +.setting-row select { text-align: left; } +.setting-row input:focus, +.setting-row select:focus { border-color: var(--accent, #1f6feb); } +/* ==================== 设置页:热配置表单 ==================== */ +.settings-form { + display: flex; + flex-direction: column; + gap: 2px; +} +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-light); +} +.setting-row:last-child { border-bottom: none; } +.setting-row label { + font-size: var(--fs-base); + color: var(--text-primary); + flex: 0 0 auto; + font-weight: 500; +} +.setting-row input, +.setting-row select { + width: 140px; + padding: 7px 10px; + background: #ffffff; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: var(--fs-base); + outline: none; + text-align: right; + font-family: inherit; +} +.setting-row select { text-align: left; } +.setting-row input:focus, +.setting-row select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} +.setting-row input::placeholder { color: var(--text-muted); } + +/* ==================== 权限管理:用户列表 ==================== */ +.user-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.user-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); +} +.user-item .user-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.user-item .user-name { + font-size: var(--fs-base); + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 6px; +} +.user-item .user-sub { + font-size: var(--fs-xs); + color: var(--text-muted); + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.admin-badge { + font-size: var(--fs-2xs); + font-weight: 700; + color: #fff; + background: var(--accent); + border-radius: var(--radius-pill); + padding: 1px 8px; +} +.btn-mini { + font-size: var(--fs-sm); + font-weight: 600; + font-family: inherit; + padding: 6px 12px; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + transition: all .18s; + white-space: nowrap; +} +.btn-mini:hover { border-color: var(--accent); color: var(--accent); } +.btn-mini.danger:hover { border-color: var(--danger); color: var(--danger); } +.user-empty { + text-align: center; + color: var(--text-muted); + font-size: var(--fs-sm); + padding: 20px; +} +/* ==================== 设置页布局优化 v2 ==================== */ +/* 让整页统一滚动,卡片更透气 */ +#pageSettings { + gap: var(--sp-5, 20px); + padding-bottom: 2rem; +} +#pageSettings .section-card { + margin-bottom: 0; +} +/* 热配置表单更宽松 */ +#pageSettings .settings-form { + gap: 0; +} +#pageSettings .setting-row { + padding: 12px 0; +} +#pageSettings .setting-row label { + font-size: var(--fs-md, 0.92rem); +} +#pageSettings .setting-row input, +#pageSettings .setting-row select { + width: 150px; + padding: 8px 12px; +} +/* 日志区限高,自身滚动(唯一允许内滚的区域) */ +#pageSettings .log-view { + max-height: 360px; + overflow-y: auto; +} +/* 用户列表项间距 */ +#pageSettings .user-list { + gap: 10px; +} + +/* 循环池按钮激活态 */ +.btn-icon.js-pool.active { + color: var(--accent); + background: var(--accent-light); +} diff --git a/public/index.html b/public/index.html index 9fee1a6..4f447bb 100644 --- a/public/index.html +++ b/public/index.html @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1" /> - Xiao の 电台 + Starlight专属电台 @@ -31,7 +31,7 @@
logo
- Xiao の 电台 + Starlight专属电台 LIVE @@ -42,6 +42,7 @@ + @@ -282,12 +283,82 @@
- - 运行配置 + + 热配置
+
-
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ⓘ 修改后点「保存并生效」,立即应用,无需重启。切片时长等影响新生成的流。 +
+
+
+ +
+
+
+ + 权限管理 +
+ +
+
+
+ ⓘ 登录过的用户列表。点「设为管理员」授予权限(下次登录生效)。 +
+
加载中...
@@ -310,145 +381,6 @@ - -
-
- -
- -

Xiao の 电台

-

基于 HLS 的个人在线音乐电台

-
- - -
- -
-
- -
-
-

项目简介

-

- 一个基于 Web 的个人在线音乐电台,支持通过网易云音乐歌曲 ID、链接或歌名进行搜索、试听与下载,提供 HLS 直播流播放、资源管理与播放列表拖拽排序等功能。界面采用简约玻璃拟态风格,适配移动端与桌面端多种屏幕尺寸。 -

-
-
- - -
-
- -
-
-

核心功能

-
-
- - 智能搜索 — 歌曲 ID、链接或歌名关键词,自动解析并一键导入 -
-
- - 直播流 — FFmpeg HLS 切片 + 滑动窗口,5 秒精度连续输出 -
-
- - 智能缓存 — 预生成切片、增量追踪、LRU 淘汰与自动清理 -
-
- - 播放列表 — 拖拽排序、添加/移除/跳转,SSE 实时状态推送 -
-
- - 全端适配 — 玻璃拟态 UI,手势滑动、键盘快捷键、拖拽进度条 -
-
-
-
- - -
-
- -
-
-

技术栈

-
- HTML5 - CSS3 - JavaScript ES6+ - Bootstrap 5 - SortableJS - HLS.js - Node.js - Express - FFmpeg - Chokidar -
-
-
- - -
-
- -
-
-

开发语言

-

前端:HTML / CSS / JavaScript  |  后端:Node.js

-
-
- - -
-
- -
-
-

作者

-

Xiao — 独立开发者

- - - Xiao-no-love - - -
-
- - -
-
- -
-
-

免责声明

-

- 本项目所有音源均来自网易云音乐,仅供个人学习与技术研究使用,严禁用于任何商业用途。音乐版权归原创作者及网易云音乐平台所有。如有侵权,请联系删除。请于下载后 24 小时内清除,支持正版音乐。 -

-
-
-
- - -
- v2.0.0 · Built with ♥ · - GitHub -
-
-
@@ -514,9 +446,6 @@ - diff --git a/public/js/app.js b/public/js/app.js index c1aaf8c..72c4ad4 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,5 +1,5 @@ /* ============================================================ - Xiao の 电台 — app.js (v2) + Starlight专属电台 — app.js (v2) 多路流 + 鉴权 + 删除/导入 + 封面本地化 ============================================================ */ @@ -47,7 +47,16 @@ function coverSrcAttr(item) { // ==================== API(统一带 cookie,401 跳登录) ==================== async function api(url, options = {}) { - const res = await fetch(url, { credentials: "same-origin", ...options }); + const opts = { credentials: "same-origin", ...options }; + // 写操作带 CSRF 头(双提交 Cookie) + const method = (opts.method || "GET").toUpperCase(); + if (!["GET", "HEAD", "OPTIONS"].includes(method)) { + const m = document.cookie.match(/(?:^|;\s*)mr_csrf=([^;]+)/); + if (m) { + opts.headers = { ...(opts.headers || {}), "X-CSRF-Token": decodeURIComponent(m[1]) }; + } + } + const res = await fetch(url, opts); if (res.status === 401) { window.location.href = "/login"; throw new Error("未登录"); @@ -59,12 +68,24 @@ async function api(url, options = {}) { return data; } // 角色 UI:非管理员隐藏 设置页 + 本地导入 -function applyRoleUI(role) { +function applyRoleUI(role, starlightName) { const isAdmin = role === "admin"; const tab = document.querySelector('.nav-tab[data-page="pageSettings"]'); if (tab) tab.style.display = isAdmin ? "" : "none"; const imp = document.getElementById("importCard"); if (imp) imp.style.display = isAdmin ? "" : "none"; + const un = document.getElementById("userName"); + if (un) { + if (starlightName) { + un.textContent = "你好," + starlightName; + un.title = "StarLight 用户" + (isAdmin ? "(管理员)" : ""); + un.style.display = ""; + } else { + un.textContent = "管理员"; + un.title = "应急密码登录"; + un.style.display = ""; + } + } } function post(url, body) { return api(url, { @@ -77,7 +98,7 @@ function post(url, body) { // ==================== Navigation ==================== const tabs = document.querySelectorAll(".nav-tab"); const pages = document.querySelectorAll(".page"); -const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings", "pageAbout"]; +const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings"]; function switchPage(pageId) { pages.forEach((p) => p.classList.remove("active")); tabs.forEach((t) => t.classList.remove("active")); @@ -270,7 +291,7 @@ function renderResItem(item) {
${item.heat} - + ${state.role === 'admin' ? `` : ''}
@@ -285,7 +306,8 @@ function renderResItem(item) { const el = tpl.content.firstElementChild; el.querySelector(".js-add").addEventListener("click", () => addToPlaylist(item.file)); el.querySelector(".js-del").addEventListener("click", () => deleteResource(item.file, item.title)); - el.querySelector(".js-enrich").addEventListener("click", () => enrichResource(item.file)); + const poolBtn = el.querySelector(".js-pool"); + if (poolBtn) poolBtn.addEventListener("click", () => togglePool(item.file)); return el; } function updateResItem(el, item) { @@ -321,6 +343,16 @@ async function loadResources() { try { const r = await api("/api/resources"); const resources = sortResources(r.list || []); + // 查当前流的循环池(仅管理员显示按钮),标记每首是否在池 + if (state.role === 'admin') { + try { + const pr = await api(`/api/streams/${currentStreamId}/pool`); + const poolSet = new Set((pr.list || []).map(x => x.file)); + resources.forEach(x => { x.inPool = poolSet.has(x.file); }); + } catch (_) { resources.forEach(x => { x.inPool = false; }); } + } else { + resources.forEach(x => { x.inPool = false; }); + } state.resources = resources; const container = document.getElementById("resourceList"); document.getElementById("resourceCount").textContent = resources.length; @@ -440,6 +472,19 @@ async function deleteResource(file, title) { await loadPlaylist(); } catch (err) { showToast("删除失败: " + err.message, "error"); } } +async function togglePool(file) { + const item = (state.resources || []).find(x => x.file === file); + const inPool = item && item.inPool; + const action = inPool ? "remove" : "add"; + try { + await post(`/api/streams/${currentStreamId}/pool/${action}`, { file }); + showToast(inPool ? "已移出循环池" : "已加入循环池", "success"); + await loadResources(); + } catch (e) { + showToast("操作失败: " + e.message, "error"); + } +} + async function enrichResource(file) { try { showToast("正在重新匹配..."); @@ -594,10 +639,15 @@ async function searchMusic() { btn.disabled = true; btn.textContent = "查询中..."; try { if (id && /^\d+$/.test(id)) { + // 按 ID / 链接解析:所有登录用户可用 document.getElementById("searchResults").style.display = "none"; await showDetail(id); - } else { + } else if (state.role === "admin") { + // 关键词搜索:仅管理员 renderSearchResults(await api("/api/music/search?keyword=" + encodeURIComponent(rawInput)), rawInput); + } else { + // 普通用户:只能用 ID 或链接 + showToast("普通用户请用歌曲 ID 或网易云链接解析", "error"); } } catch (err) { showToast("查询失败: " + err.message, "error"); } finally { btn.disabled = false; btn.textContent = "查询"; } @@ -856,23 +906,89 @@ async function loadSettings() { "已用 " + c.usedPercent + "%,超限时自动按热度 LRU 淘汰"; } catch (e) { console.error("缓存信息失败", e); } - // 配置 + // 热配置表单:填充当前值 try { const r = await api("/api/system/config"); const cf = r.config || {}; - const items = [ - ["HTTP 端口", cf.HTTP_PORT], - ["切片时长", cf.HLS_TIME + " 秒"], - ["滑动窗口", cf.SLIDE_WINDOW_SIZE + " 段"], - ["缓存上限", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0) + " MB"], - ["FFmpeg 并发", cf.MAX_FFMPEG_CONCURRENT + " 路"], - ]; - document.getElementById("setConfigGrid").innerHTML = items - .map(([k, v]) => `
${esc(k)}${esc(String(v))}
`) - .join(""); + const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; }; + set("cfgHlsTime", cf.HLS_TIME); + set("cfgSlideWindow", cf.SLIDE_WINDOW_SIZE); + set("cfgMaxCacheMB", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0)); + set("cfgLogLevel", cf.LOG_LEVEL || "info"); + set("cfgSessionTtlHours", cf.SESSION_TTL_HOURS); + set("cfgLoginMaxFails", cf.LOGIN_MAX_FAILS); + set("cfgLoginLockMin", cf.LOGIN_LOCK_MIN); + set("cfgSearchCacheTtlSec", cf.SEARCH_CACHE_TTL_SEC); + set("cfgSearchRateMax", cf.SEARCH_RATE_MAX); + set("cfgSearchRateWindowSec", cf.SEARCH_RATE_WINDOW_SEC); + // 默认频道下拉:填选项 + 回填当前选中值 + const sel = document.getElementById("cfgDefaultStream"); + if (sel) { + try { + const st = await api("/api/streams"); + sel.innerHTML = (st.list || []).map(s => + ``).join(""); + // 回填已保存的默认流 + try { + const s = await api("/api/system/settings"); + const def = (s.settings || {}).defaultStream; + if (def) sel.value = def; + } catch (_) {} + } catch (_) {} + } } catch (e) { console.error("配置失败", e); } await loadLogs(); + await loadUsers(); +} + +// 权限管理:加载用户列表 +async function loadUsers() { + const el = document.getElementById("userList"); + if (!el) return; + try { + const r = await api("/api/system/users"); + const list = r.list || []; + if (!list.length) { + el.innerHTML = '
暂无登录过的用户
'; + return; + } + el.innerHTML = list.map(u => { + const badge = u.isAdmin ? '管理员' : ''; + const uuidTxt = u.uuid ? esc(u.uuid) : '(无 UUID,需重新登录)'; + const canToggle = !!u.uuid; + const btn = canToggle + ? (u.isAdmin + ? `` + : ``) + : ''; + return `
+
+ ${esc(u.name)} ${badge} + ${uuidTxt} +
+ ${btn} +
`; + }).join(""); + // 绑定按钮 + el.querySelectorAll("[data-add]").forEach(b => b.addEventListener("click", async () => { + await post("/api/system/admins", { uuid: b.dataset.add, name: b.dataset.name }); + showToast("已设为管理员(下次登录生效)", "success"); + await loadUsers(); + })); + el.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", async () => { + if (!confirm("确定取消该管理员?")) return; + await fetch("/api/system/admins/" + b.dataset.del, { + method: "DELETE", + credentials: "same-origin", + headers: { "X-CSRF-Token": (document.cookie.match(/(?:^|;\s*)mr_csrf=([^;]+)/)||[])[1] ? decodeURIComponent(document.cookie.match(/(?:^|;\s*)mr_csrf=([^;]+)/)[1]) : "" }, + }); + showToast("已取消管理员", "success"); + await loadUsers(); + })); + } catch (e) { + el.innerHTML = '
加载失败: ' + esc(e.message) + '
'; + } } async function loadLogs() { @@ -888,6 +1004,39 @@ async function loadLogs() { } } +document.getElementById("btnSaveSettings").addEventListener("click", async () => { + const btn = document.getElementById("btnSaveSettings"); + const val = (id) => { + const v = document.getElementById(id).value; + return v === "" ? undefined : (isNaN(v) ? v : Number(v)); + }; + const payload = { + hlsTime: val("cfgHlsTime"), + slideWindow: val("cfgSlideWindow"), + maxCacheMB: val("cfgMaxCacheMB"), + defaultStream: document.getElementById("cfgDefaultStream").value || undefined, + logLevel: document.getElementById("cfgLogLevel").value, + sessionTtlHours: val("cfgSessionTtlHours"), + loginMaxFails: val("cfgLoginMaxFails"), + loginLockMin: val("cfgLoginLockMin"), + searchCacheTtlSec: val("cfgSearchCacheTtlSec"), + searchRateMax: val("cfgSearchRateMax"), + searchRateWindowSec: val("cfgSearchRateWindowSec"), + }; + // 去掉 undefined + Object.keys(payload).forEach(k => payload[k] === undefined && delete payload[k]); + btn.disabled = true; + try { + await post("/api/system/settings", payload); + showToast("配置已保存并生效", "success"); + await loadSettings(); + } catch (e) { + showToast("保存失败: " + e.message, "error"); + } finally { btn.disabled = false; } +}); + +document.getElementById("btnRefreshUsers").addEventListener("click", loadUsers); + document.getElementById("btnCleanup").addEventListener("click", async () => { if (!confirm("确定立即清理缓存?将按热度淘汰非播放中的切片。")) return; try { @@ -927,7 +1076,7 @@ document.getElementById("btnRefreshCovers").addEventListener("click", async () = // ==================== Init ==================== (async function init() { - try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role); } catch (_) {} + try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role, me.starlightName); } catch (_) {} await loadStreams(); loadLiveCover(); await loadResources(); diff --git a/public/login.html b/public/login.html index db35d96..3887ac7 100644 --- a/public/login.html +++ b/public/login.html @@ -3,139 +3,273 @@ - 登录 · 音乐在线电台 + Starlight专属电台 +
-

音乐在线电台

-
请输入访问密码
+

Starlight专属电台

+
使用 StarLight 账号登录
- - - +
+ + +
+
+ + +
+
+
+ 还没有账号?前往 StarLight 注册 +
+
+ 管理员应急登录 +
+ + +
+ +
diff --git a/src/auth.js b/src/auth.js index 16489ed..e9a63df 100644 --- a/src/auth.js +++ b/src/auth.js @@ -8,6 +8,9 @@ const COOKIE_NAME = "mr_session"; /* ==================== 密码来源 ==================== */ // 管理员密码:ADMIN_PASSWORD(兼容旧 PANEL_PASSWORD);未设置则首次随机生成并打印 +// 支持两种格式: +// 1) 明文 "test123" +// 2) 哈希 "sha256:<64位hex>" ← 推荐,文件泄露也无法直接反推 // 用户密码:USER_PASSWORD;未设置则「无用户密码」——只有管理员能登录 let adminPassword = config.ADMIN_PASSWORD; let userPassword = config.USER_PASSWORD; @@ -18,9 +21,25 @@ if (!adminPassword) { } /* ==================== 登录失败锁定(按 IP) ==================== */ +// 取真实客户端 IP:不信任 X-Forwarded-For(可伪造),只用 socket 地址。 +// trust proxy=false 时 req.ip 已等于 socket 地址,但这里显式取 socket 更稳。 +function getClientIp(req) { + return (req.socket && req.socket.remoteAddress) || req.ip || "unknown"; +} +// 可信 IP 白名单:命中的 IP 不参与登录失败锁定(防管理员被攻击者锁死) +function isTrustedIp(ip) { + if (!ip) return false; + // 归一化:IPv4-mapped IPv6(::ffff:127.0.0.1)与 IPv4 视作同一 + const norm = (s) => String(s).replace(/^::ffff:/i, "").trim(); + const target = norm(ip); + const list = config.TRUSTED_IPS || []; + return list.some((x) => norm(x) === target); +} + const failMap = new Map(); // ip -> { count, lockedUntil } function isLocked(ip) { + if (isTrustedIp(ip)) return 0; // 可信 IP 永不锁定 const rec = failMap.get(ip); if (!rec) return 0; if (rec.lockedUntil && rec.lockedUntil > Date.now()) { @@ -29,6 +48,7 @@ function isLocked(ip) { return 0; } function recordFail(ip) { + if (isTrustedIp(ip)) return; // 可信 IP 不计失败 const rec = failMap.get(ip) || { count: 0, lockedUntil: 0 }; rec.count += 1; if (rec.count >= config.LOGIN_MAX_FAILS) { @@ -54,13 +74,12 @@ function parseCookies(header) { } /* ==================== 会话 ==================== */ -function createSession(role) { +function createSession(role, starlightName, starlightUuid) { const token = crypto.randomBytes(24).toString("hex"); const expiresAt = Date.now() + config.SESSION_TTL_MS; - store.createSession(token, role, expiresAt); - return { token, expiresAt, role }; + store.createSession(token, role, expiresAt, starlightName || null, starlightUuid || null); + return { token, expiresAt, role, starlightName: starlightName || null, starlightUuid: starlightUuid || null }; } - function timingEqual(a, b) { const ba = Buffer.from(String(a)); const bb = Buffer.from(String(b)); @@ -68,19 +87,62 @@ function timingEqual(a, b) { return crypto.timingSafeEqual(ba, bb); } -// 返回角色 "admin" | "user" | null +// 密码比对:支持 scrypt / sha256: / 明文 三种格式 +// scrypt 格式:scrypt::(推荐,带盐慢哈希) +function passwordMatches(input, configured) { + if (!configured) return false; + const s = String(configured); + const pwd = String(input); + + if (s.startsWith("scrypt:")) { + const parts = s.split(":"); + if (parts.length !== 3) return false; + const salt = Buffer.from(parts[1], "hex"); + const expected = parts[2].toLowerCase(); + const actual = crypto.scryptSync(pwd, salt, 64).toString("hex"); + return timingEqual(actual, expected); + } + if (s.startsWith("sha256:")) { + const expected = s.slice(7).toLowerCase(); + const actual = crypto.createHash("sha256").update(pwd).digest("hex"); + return timingEqual(actual, expected); + } + return timingEqual(input, s); +} + +// 返回角色 "admin" | "user" | null(应急密码登录) function verifyLogin(password) { if (!password) return null; - if (timingEqual(password, adminPassword)) return "admin"; - if (userPassword && timingEqual(password, userPassword)) return "user"; + if (passwordMatches(password, adminPassword)) return "admin"; + if (userPassword && passwordMatches(password, userPassword)) return "user"; return null; } +// 通过 StarLight 认证;成功返回 { role, starlightName, uuid },失败抛 Error +async function loginViaStarLight(username, password) { + const starlight = require("./starlight"); + const { username: name, uuid } = await starlight.authenticate(username, password); + // 白名单绑 UUID(不可变),不绑用户名(可被抢注/改名) + // 管理员判定:先查 admins 表(热管理),再查环境变量白名单(兼容) + const role = (uuid && (store.isAdminUuid(String(uuid)) || + config.STARLIGHT_ADMINS.includes(String(uuid).toLowerCase()))) ? "admin" : "user"; + return { role, starlightName: name, uuid }; +} + function getValidSession(req) { const cookies = parseCookies(req.headers.cookie); const token = cookies[COOKIE_NAME]; if (!token) return null; - return store.getSession(token); + const s = store.getSession(token); + if (s) { + // 滑动续期:活跃会话自动延长 + const newExp = Date.now() + config.SESSION_TTL_MS; + if (newExp - s.expires_at > 60 * 1000) { + store.touchSession(token, newExp); + s.expires_at = newExp; + } + } + return s; } function isAuthed(req) { @@ -90,19 +152,53 @@ function getRole(req) { const s = getValidSession(req); return s ? s.role || "user" : null; } +function getStarlightName(req) { + const s = getValidSession(req); + return s ? s.starlight_name || null : null; +} +function getStarlightUuid(req) { + const s = getValidSession(req); + return s ? s.starlight_uuid || null : null; +} function isAdmin(req) { return getRole(req) === "admin"; } +// CSRF:双提交 Cookie。cookie 非 HttpOnly(JS 可读),同时前端把值放到 X-CSRF-Token 头 +const CSRF_COOKIE = "mr_csrf"; +function genCsrfToken() { + return crypto.randomBytes(16).toString("hex"); +} +function setCsrfCookie(res, token) { + res.append("Set-Cookie", `${CSRF_COOKIE}=${token}; Path=/; SameSite=Lax`); +} +function clearCsrfCookie(res) { + res.append("Set-Cookie", `${CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0`); +} +function getCsrfFromCookie(req) { + return parseCookies(req.headers.cookie)[CSRF_COOKIE] || ""; +} +// 写操作校验:非 GET/HEAD/OPTIONS 必须带匹配的 X-CSRF-Token 头 +function csrfProtection(req, res, next) { + const m = req.method.toUpperCase(); + if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next(); + const fromCookie = getCsrfFromCookie(req); + const fromHeader = req.headers["x-csrf-token"] || ""; + if (!fromCookie || !fromHeader || fromCookie !== fromHeader) { + return res.status(403).json({ ok: false, error: "CSRF 校验失败" }); + } + next(); +} + function setSessionCookie(res, token, expiresAt) { const maxAge = Math.floor((expiresAt - Date.now()) / 1000); - res.setHeader( + res.append( "Set-Cookie", `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}` ); } function clearSessionCookie(res) { - res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); + res.append("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); } /* ==================== 中间件 ==================== */ @@ -120,6 +216,22 @@ function requireAdmin(req, res, next) { next(); } +// 读操作放行(GET/HEAD/OPTIONS),写操作要求管理员。 +// 例外:POST_READ_WHITELIST 里的路径虽是 POST,但语义是"读"(普通用户可用)。 +// 路径匹配用「去掉 /api 及模块前缀后的相对路径」,兼容挂载点。 +const POST_READ_WHITELIST = new Set([ + "/music/detail", // 按 id 查歌曲详情(普通用户预览用) +]); +function requireAdminForWrites(req, res, next) { + const m = (req.method || "GET").toUpperCase(); + if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next(); + const full = (req.originalUrl || req.url || "").split("?")[0]; + // 去掉 /api 前缀后匹配白名单 + const rel = full.replace(/^\/api/, "") || "/"; + if (POST_READ_WHITELIST.has(rel)) return next(); + return requireAdmin(req, res, next); +} + function logout(req) { const cookies = parseCookies(req.headers.cookie); const token = cookies[COOKIE_NAME]; @@ -143,14 +255,24 @@ module.exports = { COOKIE_NAME, createSession, verifyLogin, + loginViaStarLight, getValidSession, + getStarlightName, + getStarlightUuid, isAuthed, getRole, isAdmin, setSessionCookie, clearSessionCookie, + genCsrfToken, + setCsrfCookie, + clearCsrfCookie, + csrfProtection, requireAuth, requireAdmin, + requireAdminForWrites, + getClientIp, + isTrustedIp, logout, initPasswordLog, isLocked, diff --git a/src/config.js b/src/config.js index ec8e5ef..afc4997 100644 --- a/src/config.js +++ b/src/config.js @@ -42,6 +42,16 @@ const config = { ADMIN_PASSWORD: envStr("ADMIN_PASSWORD", envStr("PANEL_PASSWORD", "")), USER_PASSWORD: envStr("USER_PASSWORD", ""), + // 登录失败锁定的豁免 IP 白名单(防管理员被锁死 DoS),逗号分隔 + TRUSTED_IPS: envStr("TRUSTED_IPS", "127.0.0.1,::1,::ffff:127.0.0.1") + .split(",").map((s) => s.trim()).filter(Boolean), + + // StarLight Yggdrasil 第三方登录 + STARLIGHT_API: envStr("STARLIGHT_API", "https://skin.starlight.cool/yggdrasil"), + // 管理员白名单(StarLight 角色的【UUID】,逗号分隔,大小写不敏感) + // ⚠️ 绑 UUID 不绑用户名——用户名可能被抢注/改名,UUID 不可变 + STARLIGHT_ADMINS: envStr("STARLIGHT_ADMINS", "") + .split(",").map((s) => s.trim()).filter(Boolean).map((s) => s.toLowerCase()), SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000, LOGIN_MAX_FAILS: envInt("LOGIN_MAX_FAILS", 5), LOGIN_LOCK_MS: envInt("LOGIN_LOCK_MIN", 10) * 60 * 1000, @@ -52,4 +62,27 @@ const config = { config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000; -module.exports = config; \ No newline at end of file +/* + * 热配置:从 settings 表读入的值覆盖 config(立即生效,无需重启)。 + * 只支持可热改的项;端口/并发等需重启的不在此列。 + */ +function applyOverrides(s) { + if (!s || typeof s !== "object") return config; + const num = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined); + if (num(s.hlsTime) !== undefined) { config.HLS_TIME = num(s.hlsTime); config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000; } + if (num(s.slideWindow) !== undefined) config.SLIDE_WINDOW_SIZE = num(s.slideWindow); + if (num(s.maxCacheMB) !== undefined) config.MAX_CACHE_SIZE = num(s.maxCacheMB) * 1024 * 1024; + if (num(s.sessionTtlHours) !== undefined) config.SESSION_TTL_MS = num(s.sessionTtlHours) * 3600 * 1000; + if (num(s.loginMaxFails) !== undefined) config.LOGIN_MAX_FAILS = num(s.loginMaxFails); + if (num(s.loginLockMin) !== undefined) config.LOGIN_LOCK_MS = num(s.loginLockMin) * 60 * 1000; + if (num(s.searchCacheTtlSec) !== undefined) config.SEARCH_CACHE_TTL_MS = num(s.searchCacheTtlSec) * 1000; + if (num(s.searchRateMax) !== undefined) config.SEARCH_RATE_MAX = num(s.searchRateMax); + if (num(s.searchRateWindowSec) !== undefined) config.SEARCH_RATE_WINDOW_MS = num(s.searchRateWindowSec) * 1000; + if (typeof s.logLevel === "string" && ["debug","info","warn","error"].includes(s.logLevel)) { + config.LOG_LEVEL = s.logLevel; + } + return config; +} + +module.exports = config; +config.applyOverrides = applyOverrides; \ No newline at end of file diff --git a/src/db.js b/src/db.js index 21e7dc4..e693fd8 100644 --- a/src/db.js +++ b/src/db.js @@ -41,6 +41,15 @@ CREATE TABLE IF NOT EXISTS stream_playlist ( ); CREATE INDEX IF NOT EXISTS idx_sp_stream ON stream_playlist(stream_id, position); +CREATE TABLE IF NOT EXISTS stream_pool ( + stream_id TEXT NOT NULL, + song_id INTEGER NOT NULL, + played INTEGER NOT NULL DEFAULT 0, + added_at INTEGER NOT NULL, + PRIMARY KEY (stream_id, song_id) +); +CREATE INDEX IF NOT EXISTS idx_pool_stream ON stream_pool(stream_id); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT @@ -53,6 +62,12 @@ CREATE TABLE IF NOT EXISTS sessions ( expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS admins ( + uuid TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + added_at INTEGER NOT NULL +); `; function init() { @@ -70,6 +85,16 @@ function init() { db.exec("ALTER TABLE sessions ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); logger.info("[DB] 迁移 sessions 表:新增 role 列"); } + // 迁移:sessions 表补 starlight_name 列(记录第三方登录来源用户名) + if (!sessCols.some((c) => c.name === "starlight_name")) { + db.exec("ALTER TABLE sessions ADD COLUMN starlight_name TEXT"); + logger.info("[DB] 迁移 sessions 表:新增 starlight_name 列"); + } + // 迁移:sessions 表补 starlight_uuid 列(记录 UUID,用于用户列表/权限管理) + if (!sessCols.some((c) => c.name === "starlight_uuid")) { + db.exec("ALTER TABLE sessions ADD COLUMN starlight_uuid TEXT"); + logger.info("[DB] 迁移 sessions 表:新增 starlight_uuid 列"); + } // 迁移:songs 表补 cover_remote 列(远程封面 URL,优先展示,省带宽) const songCols = db.prepare("PRAGMA table_info(songs)").all(); diff --git a/src/livestream.js b/src/livestream.js index b46aeae..0e6ebe2 100644 --- a/src/livestream.js +++ b/src/livestream.js @@ -193,9 +193,17 @@ class LiveStream { ensureNotEmpty() { let rows = this.playlistRows(); if (rows.length === 0) { - const songs = store.getAllSongs(); - if (songs.length > 0) { - const pick = songs[Math.floor(Math.random() * songs.length)]; + // 队列空:优先从【循环池】抽未播的(抽完重置);池空则降级从全部资源随机 + let pick = store.drawFromPool(this.id); + if (pick) { + store.markPoolPlayed(this.id, pick.id); + } else { + const songs = store.getAllSongs(); + if (songs.length > 0) { + pick = songs[Math.floor(Math.random() * songs.length)]; + } + } + if (pick) { store.addToPlaylist(this.id, pick.id); this.currentIdx = 0; this.currentSongSegmentIdx = 0; diff --git a/src/logger.js b/src/logger.js index e90ca6a..ef78d18 100644 --- a/src/logger.js +++ b/src/logger.js @@ -3,12 +3,15 @@ const fs = require("fs"); const config = require("./config"); const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; -const levelNum = LEVELS[config.LOG_LEVEL] || LEVELS.info; +let levelNum = LEVELS[config.LOG_LEVEL] || LEVELS.info; +function setLevel(name) { + if (LEVELS[name]) levelNum = LEVELS[name]; +} let stream = null; try { stream = fs.createWriteStream(config.LOG_FILE, { flags: "a" }); -} catch (_) {} +} catch (e) { console.error('[LOGGER] 日志文件打开失败:', e.message); } const ring = []; const RING_MAX = 500; @@ -17,13 +20,14 @@ function fmt(level, args) { const ts = new Date().toISOString(); const msg = args .map((a) => { - if (typeof a === "string") return a; - if (a instanceof Error) return a.stack || a.message; - try { - return JSON.stringify(a); - } catch (_) { - return String(a); + let s; + if (typeof a === "string") s = a; + else if (a instanceof Error) s = a.stack || a.message; + else { + try { s = JSON.stringify(a); } catch (_) { s = String(a); } } + // 过滤 CR/LF 等控制字符,防日志注入(伪造日志行) + return String(s).replace(/[\r\n\u0000-\u001f\u007f]/g, " "); }) .join(" "); return `[${ts}] [${level.toUpperCase()}] ${msg}`; @@ -46,4 +50,5 @@ module.exports = { warn: (...a) => emit("warn", a), error: (...a) => emit("error", a), tail: (n = 100) => ring.slice(-n), + setLevel, }; \ No newline at end of file diff --git a/src/media.js b/src/media.js index 650289b..0be7f15 100644 --- a/src/media.js +++ b/src/media.js @@ -20,7 +20,7 @@ function killAllProcs() { try { p.kill("SIGKILL"); n++; - } catch (_) {} + } catch (e) { logger.warn('[MEDIA] 计数失败:', e.message); } } activeProcs.clear(); if (n) logger.warn(`[MEDIA] 已终止 ${n} 个残留 FFmpeg 进程`); diff --git a/src/metadata.js b/src/metadata.js index 88f80a5..78ccbe5 100644 --- a/src/metadata.js +++ b/src/metadata.js @@ -14,7 +14,27 @@ const NETEASE_UA = const NETEASE_REFERER = "http://music.163.com/"; /* ==================== HTTP 工具 ==================== */ +// SSRF 防护:只允许网易云官方域名(防 albumImage 被劫持成内网/恶意地址) +const ALLOWED_HOSTS = [ + "music.163.com", "interface.music.163.com", + "p1.music.126.net", "p2.music.126.net", "p3.music.126.net", "p4.music.126.net", + "music.126.net", +]; +function isAllowedHost(urlStr) { + try { + const u = new URL(urlStr); + if (u.protocol !== "http:" && u.protocol !== "https:") return false; + const h = u.hostname.toLowerCase(); + return ALLOWED_HOSTS.some((a) => h === a || h.endsWith("." + a)); + } catch (_) { + return false; + } +} + function request(url, redirects = 0) { + if (!isAllowedHost(url)) { + return Promise.reject(new Error("不允许的请求地址(域名不在白名单)")); + } return new Promise((resolve, reject) => { const lib = url.startsWith("https:") ? https : http; const req = lib.get( @@ -123,6 +143,7 @@ function extFromContentType(ct) { function downloadCover(url, key) { return new Promise((resolve) => { if (!url) return resolve(""); + if (!isAllowedHost(url)) { logger.warn("[COVER] 拒绝非白名单封面URL:", url.slice(0, 80)); return resolve(""); } fs.mkdirSync(config.COVERS_DIR, { recursive: true }); const lib = url.startsWith("https:") ? https : http; const req = lib.get( @@ -170,10 +191,10 @@ async function enrichSong(file, opts = {}) { try { const probe = await media.probeAudio(filePath); if (probe.duration != null) fields.duration = probe.duration; - } catch (_) {} + } catch (e) { logger.warn('[META] 探测时长失败:', e.message); } try { fields.file_size = fs.statSync(filePath).size; - } catch (_) {} + } catch (e) { logger.warn('[META] 取文件大小失败:', e.message); } // 若缺作者或缺封面,尝试网易云匹配 const needArtist = !(fields.artist || (song && song.artist)); diff --git a/src/routes/auth.js b/src/routes/auth.js index 04cb2ab..6ff3374 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -2,42 +2,87 @@ const express = require("express"); const auth = require("../auth"); const logger = require("../logger"); +const config = require("../config"); const router = express.Router(); -router.post("/login", (req, res) => { - const ip = req.ip || req.connection?.remoteAddress || "unknown"; +/* + * 登录端点(双模式) + * 1) StarLight 第三方登录:{ username, password } —— 主入口 + * 2) 应急管理员密码登录:{ password, emergency:true } 或 username 为空 —— 后门 + * + * 走 StarLight 时,前端传 username + password; + * 走应急后门时,前端只传 password(username 留空)。 + */ + +router.post("/login", async (req, res) => { + const ip = auth.getClientIp(req); const locked = auth.isLocked(ip); if (locked > 0) { return res.status(429).json({ ok: false, error: `登录失败次数过多,请 ${locked} 秒后再试` }); } - const { password } = req.body || {}; - if (!password) return res.status(400).json({ ok: false, error: "请输入密码" }); + const { username, password } = req.body || {}; + // 类型校验前置:非法类型直接 400,不计入失败次数(防畸形请求锁死管理员) + if (typeof password !== "string" || password.length === 0) { + return res.status(400).json({ ok: false, error: "请输入密码" }); + } + const hasUsername = username !== undefined && username !== null && String(username).trim().length > 0; + if (hasUsername && typeof username !== "string") { + return res.status(400).json({ ok: false, error: "用户名格式错误" }); + } + + // ===== 模式 1:StarLight 第三方登录 ===== + if (hasUsername) { + try { + const { role, starlightName, uuid } = await auth.loginViaStarLight(String(username).trim(), String(password)); + auth.clearFail(ip); + const { token, expiresAt } = auth.createSession(role, starlightName, uuid); + auth.setSessionCookie(res, token, expiresAt); + auth.setCsrfCookie(res, auth.genCsrfToken()); + logger.info(`[AUTH] StarLight 登录成功 (${role}) user=${starlightName} from ${ip}`); + return res.json({ ok: true, role, starlightName }); + } catch (e) { + auth.recordFail(ip); + logger.warn(`[AUTH] StarLight 登录失败 from ${ip}: ${e.message}`); + return res.status(401).json({ ok: false, error: e.message || "登录失败" }); + } + } + + // ===== 模式 2:应急管理员密码登录(后门) ===== const role = auth.verifyLogin(password); if (!role) { auth.recordFail(ip); - logger.warn(`[AUTH] 登录失败 from ${ip}`); + logger.warn(`[AUTH] 应急密码登录失败 from ${ip}`); return res.status(401).json({ ok: false, error: "密码错误" }); } auth.clearFail(ip); - const { token, expiresAt } = auth.createSession(role); + const { token, expiresAt } = auth.createSession(role, null); auth.setSessionCookie(res, token, expiresAt); - logger.info(`[AUTH] 登录成功 (${role}) from ${ip}`); - res.json({ ok: true, role }); + auth.setCsrfCookie(res, auth.genCsrfToken()); + logger.info(`[AUTH] 应急密码登录成功 (${role}) from ${ip}`); + res.json({ ok: true, role, starlightName: null }); }); router.post("/logout", (req, res) => { auth.logout(req); auth.clearSessionCookie(res); + auth.clearCsrfCookie(res); res.json({ ok: true }); }); router.get("/me", (req, res) => { const role = auth.getRole(req); - res.json({ ok: true, authed: !!role, role: role || null }); + res.json({ + ok: true, + authed: !!role, + role: role || null, + starlightName: auth.getStarlightName(req), + myUuid: auth.getStarlightUuid(req), + starLightEnabled: true, + }); }); module.exports = router; \ No newline at end of file diff --git a/src/routes/music.js b/src/routes/music.js index afaf30b..0dad0dd 100644 --- a/src/routes/music.js +++ b/src/routes/music.js @@ -29,24 +29,24 @@ function searchRateLimited(ip) { } /* ==================== 网易云搜索/详情 ==================== */ -router.get("/search", async (req, res) => { +router.get("/search", require("../auth").requireAdmin, async (req, res) => { const { keyword } = req.query; if (!keyword || !String(keyword).trim()) return res.status(400).json({ ok: false, error: "缺少搜索关键词" }); const kw = String(keyword).trim(); - // 命中缓存直接返回(不消耗限流额度) - const cached = searchCache.get(kw); - if (cached && Date.now() - cached.at < config.SEARCH_CACHE_TTL_MS) { - return res.json({ ok: true, ...cached.data, cached: true }); - } - - // 未命中 → 限流检查 + // 限流检查(前置,缓存命中也要计入——防 V13 缓存绕过) const ip = req.ip || req.connection?.remoteAddress || "unknown"; if (searchRateLimited(ip)) { return res.status(429).json({ ok: false, error: "搜索过于频繁,请稍后再试" }); } + // 命中缓存直接返回 + const cached = searchCache.get(kw); + if (cached && Date.now() - cached.at < config.SEARCH_CACHE_TTL_MS) { + return res.json({ ok: true, ...cached.data, cached: true }); + } + try { const r = await meta.searchNetEase(kw); searchCache.set(kw, { at: Date.now(), data: r }); @@ -177,14 +177,14 @@ router.post("/import", require("../auth").requireAdmin, upload.single("audio"), } catch (e) { try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); - } catch (_) {} + } catch (e) { logger.warn('[MUSIC] 删临时文件失败:', e.message); } logger.error("[IMPORT] 失败:", e.message); res.status(400).json({ ok: false, error: e.message }); } }); /* ==================== 删除音频(级联清理) ==================== */ -router.post("/delete", (req, res) => { +router.post("/delete", require("../auth").requireAdmin, (req, res) => { const { file } = req.body || {}; if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" }); @@ -221,7 +221,7 @@ router.post("/delete", (req, res) => { }); /* ==================== 批量删除 ==================== */ -router.post("/delete-batch", (req, res) => { +router.post("/delete-batch", require("../auth").requireAdmin, (req, res) => { const { files } = req.body || {}; if (!Array.isArray(files)) return res.status(400).json({ ok: false, error: "无效数据" }); const results = []; @@ -253,7 +253,7 @@ router.post("/delete-batch", (req, res) => { }); /* ==================== 元数据重匹配(补封面/作者) ==================== */ -router.post("/enrich", async (req, res) => { +router.post("/enrich", require("../auth").requireAdmin, async (req, res) => { const { file, skipMatch } = req.body || {}; if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" }); if (!store.getSongByFile(file)) return res.status(404).json({ ok: false, error: "歌曲不存在" }); diff --git a/src/routes/streams.js b/src/routes/streams.js index 9025ae0..55258ad 100644 --- a/src/routes/streams.js +++ b/src/routes/streams.js @@ -159,4 +159,29 @@ router.post("/:id/playlist/jump", (req, res) => { res.json({ ok: true, current: f, list: snapshot(req.params.id) }); }); +/* ==================== 循环池(手动歌单,随机不重复) ==================== */ +// 查池(含每首是否已播) +router.get("/:id/pool", (req, res) => { + const rows = store.getPoolRows(req.params.id); + res.json({ ok: true, list: rows.map(r => ({ + songId: r.song_id, file: r.file, name: r.name, artist: r.artist, played: !!r.played, + })) }); +}); +// 加入池 +router.post("/:id/pool/add", (req, res) => { + const { file } = req.body || {}; + const song = store.getSongByFile(file); + if (!song) return res.status(404).json({ ok: false, error: "歌曲不存在" }); + store.addToPool(req.params.id, song.id); + res.json({ ok: true }); +}); +// 移出池 +router.post("/:id/pool/remove", (req, res) => { + const { file } = req.body || {}; + const song = store.getSongByFile(file); + if (!song) return res.status(404).json({ ok: false, error: "歌曲不存在" }); + store.removeFromPool(req.params.id, song.id); + res.json({ ok: true }); +}); + module.exports = router; \ No newline at end of file diff --git a/src/routes/system.js b/src/routes/system.js index 8af4c5e..3d6d7e1 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -37,6 +37,13 @@ router.get("/config", (req, res) => { MAX_CACHE_SIZE: config.MAX_CACHE_SIZE, MAX_FFMPEG_CONCURRENT: config.MAX_FFMPEG_CONCURRENT, HTTP_PORT: config.HTTP_PORT, + LOG_LEVEL: config.LOG_LEVEL, + SESSION_TTL_HOURS: Math.round(config.SESSION_TTL_MS / 3600000), + LOGIN_MAX_FAILS: config.LOGIN_MAX_FAILS, + LOGIN_LOCK_MIN: Math.round(config.LOGIN_LOCK_MS / 60000), + SEARCH_CACHE_TTL_SEC: Math.round(config.SEARCH_CACHE_TTL_MS / 1000), + SEARCH_RATE_MAX: config.SEARCH_RATE_MAX, + SEARCH_RATE_WINDOW_SEC: Math.round(config.SEARCH_RATE_WINDOW_MS / 1000), }, }); }); @@ -45,8 +52,62 @@ router.get("/settings", (req, res) => { res.json({ ok: true, settings: store.getSetting("app", {}) }); }); router.post("/settings", (req, res) => { - store.setSetting("app", req.body || {}); - res.json({ ok: true }); + const body = req.body || {}; + // 字段白名单:只接受可热改的配置项 + const ALLOWED = ["hlsTime","slideWindow","maxCacheMB","sessionTtlHours", + "loginMaxFails","loginLockMin","searchCacheTtlSec","searchRateMax", + "searchRateWindowSec","logLevel","defaultStream"]; + const clean = {}; + for (const k of ALLOWED) { + if (body[k] !== undefined) clean[k] = body[k]; + } + // 合并旧值(部分更新) + const old = store.getSetting("app", {}) || {}; + const merged = { ...old, ...clean }; + store.setSetting("app", merged); + // 热生效 + config.applyOverrides(merged); + if (merged.logLevel) logger.setLevel(merged.logLevel); + logger.info("[SETTINGS] 配置已更新并热生效:", JSON.stringify(clean)); + res.json({ ok: true, settings: merged }); }); -module.exports = router; \ No newline at end of file +/* ==================== 用户 & 管理员管理 ==================== */ +router.get("/users", (req, res) => { + const users = store.listUsers(); + const adminUuids = new Set(store.listAdmins().map((a) => a.uuid)); + const list = users.map((u) => ({ + name: u.name, + uuid: u.uuid || null, + lastLogin: u.last_login, + loginCount: u.login_count, + isAdmin: u.uuid ? (adminUuids.has(u.uuid) || config.STARLIGHT_ADMINS.includes(u.uuid.toLowerCase())) : false, + })); + res.json({ ok: true, list }); +}); + +router.get("/admins", (req, res) => { + res.json({ ok: true, list: store.listAdmins() }); +}); + +router.post("/admins", (req, res) => { + const { uuid, name } = req.body || {}; + if (!uuid || !/^[a-fA-F0-9]{32}$/.test(String(uuid))) { + return res.status(400).json({ ok: false, error: "无效的 UUID" }); + } + const admin = store.addAdmin(String(uuid).toLowerCase(), String(name || "").trim()); + logger.info(`[ADMIN] 添加管理员 ${name} (${uuid})`); + res.json({ ok: true, admin }); +}); + +router.delete("/admins/:uuid", (req, res) => { + const uuid = String(req.params.uuid).toLowerCase(); + if (store.isAdminUuid(uuid)) { + store.removeAdmin(uuid); + logger.info(`[ADMIN] 移除管理员 ${uuid}`); + return res.json({ ok: true }); + } + res.status(404).json({ ok: false, error: "该管理员不存在" }); +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 948c1de..a3d3739 100644 --- a/src/server.js +++ b/src/server.js @@ -25,6 +25,13 @@ function bootstrap() { // DB db.init(); + // 加载热配置(settings 表覆盖环境变量) + const savedSettings = store.getSetting("app", {}) || {}; + if (Object.keys(savedSettings).length) { + config.applyOverrides(savedSettings); + if (savedSettings.logLevel) logger.setLevel(savedSettings.logLevel); + logger.info("[SETTINGS] 已加载热配置:", JSON.stringify(savedSettings)); + } auth.initPasswordLog(); // 扫描资源入库 @@ -71,7 +78,9 @@ function bootstrap() { function createApp() { const app = express(); - app.use(express.json()); + // 安全:不信任 X-Forwarded-For,防止攻击者伪造 IP 绕过登录限流 + app.set("trust proxy", false); + app.use(express.json({ limit: "64kb" })); // 登录页(静态) app.get("/login", (req, res) => { @@ -91,13 +100,17 @@ function createApp() { // SSE(放在鉴权前,前端用 EventSource 无法带 header,靠 cookie) app.get("/api/events", (req, res) => { if (!auth.isAuthed(req)) return res.status(401).end(); + const ip = auth.getClientIp(req); + // SSE 连接上限:超限返回 503,防 DoS + if (!sse.addClient(res, ip)) { + return res.status(503).json({ ok: false, error: "SSE 连接数已达上限" }); + } res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); res.write(":ok\n\n"); - sse.addClient(res); }); // HLS 流(不锁) @@ -119,15 +132,28 @@ function createApp() { }); app.use(express.static(config.PUBLIC_DIR, { index: false })); - // 受保护的 API - app.use("/api/music", auth.requireAuth, musicRoutes); - app.use("/api/resources", auth.requireAuth, resourceRoutes); - app.use("/api/streams", auth.requireAuth, streamRoutes); - app.use("/api/system", auth.requireAdmin, systemRoutes); + // 受保护的 API —— 写操作需 CSRF 校验 + app.use("/api/music", auth.requireAuth, auth.csrfProtection, auth.requireAdminForWrites, musicRoutes); + app.use("/api/resources", auth.requireAuth, auth.csrfProtection, resourceRoutes); + app.use("/api/streams", auth.requireAuth, auth.csrfProtection, auth.requireAdminForWrites, streamRoutes); + app.use("/api/system", auth.requireAdmin, auth.csrfProtection, systemRoutes); // 其余 /api 需要登录 app.use("/api", auth.requireAuth); + // 全局错误处理:不向客户端吐堆栈/路径(V2 修复) + // body-parser 等抛出的解析错误也在这里兜底 + app.use((err, req, res, next) => { + // 记录完整错误到日志(含堆栈),但只回传通用文案 + logger.error("[ERR]", err.message, err.stack ? err.stack.split("\n").slice(0,3).join(" | ") : ""); + const isApi = (req.originalUrl || req.url || "").startsWith("/api/"); + const status = err.status || err.statusCode || 500; + if (isApi) { + return res.status(status).json({ ok: false, error: status === 400 ? "请求格式错误" : "服务器内部错误" }); + } + res.status(status).type("text/plain").send(status === 400 ? "请求格式错误" : "服务器内部错误"); + }); + return app; } diff --git a/src/sse.js b/src/sse.js index 2606a96..55f989d 100644 --- a/src/sse.js +++ b/src/sse.js @@ -1,9 +1,33 @@ "use strict"; -const clients = new Set(); -function addClient(res) { +const clients = new Set(); +const MAX_CLIENTS = 500; // 总连接上限 +const MAX_PER_IP = 10; // 每 IP 上限 +const ipCount = new Map(); // ip -> count + +// 返回 true=已接受,false=超限被拒 +function addClient(res, ip) { + if (clients.size >= MAX_CLIENTS) { + return false; + } + const key = ip || "unknown"; + const cur = ipCount.get(key) || 0; + if (cur >= MAX_PER_IP) { + return false; + } + res.__sseIp = key; clients.add(res); - res.on("close", () => clients.delete(res)); + ipCount.set(key, cur + 1); + + const cleanup = () => { + clients.delete(res); + const n = (ipCount.get(key) || 1) - 1; + if (n <= 0) ipCount.delete(key); + else ipCount.set(key, n); + }; + res.on("close", cleanup); + res.on("error", cleanup); + return true; } function broadcast(event, data) { @@ -13,6 +37,13 @@ function broadcast(event, data) { c.write(msg); } catch (_) { clients.delete(c); + // 计数修正:写失败的连接也要从 ipCount 里减掉 + const key = c.__sseIp; + if (key) { + const n = (ipCount.get(key) || 1) - 1; + if (n <= 0) ipCount.delete(key); + else ipCount.set(key, n); + } } } } @@ -24,6 +55,7 @@ function closeAll() { } catch (_) {} } clients.clear(); + ipCount.clear(); } -module.exports = { addClient, broadcast, closeAll }; \ No newline at end of file +module.exports = { addClient, broadcast, closeAll, getClientCount: () => clients.size }; \ No newline at end of file diff --git a/src/starlight.js b/src/starlight.js new file mode 100644 index 0000000..b07f625 --- /dev/null +++ b/src/starlight.js @@ -0,0 +1,115 @@ +"use strict"; +const crypto = require("crypto"); +const logger = require("./logger"); +const config = require("./config"); + +/* + * StarLight Yggdrasil 客户端 + * 文档: https://skin.starlight.cool/yggdrasil + * + * 只做「认证」这一件事:用 StarLight 账号密码换取身份。 + * accessToken 拿到后【绝不下发、绝不持久化】——只用于确认身份, + * 确认完立即丢弃,后续一律走本系统自己的 mr_session。 + * + * 实测要点(2026-10-05): + * - username 必须传【邮箱】(传 QQ 号会 403) + * - 必须每次用【新的 clientToken】,否则可能返回空的 availableProfiles + * - 服务端有【限流】,连打会 429,调用方需做退避 + */ + +const AGENT = { name: "Minecraft", version: 1 }; +const TIMEOUT_MS = 10000; + +function apiBase() { + return (config.STARLIGHT_API || "https://skin.starlight.cool/yggdrasil").replace(/\/+$/, ""); +} + +async function postJson(path, body) { + const url = apiBase() + path; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + let resp; + try { + resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (e) { + clearTimeout(timer); + if (e.name === "AbortError") throw new Error("连接 StarLight 超时"); + throw new Error("无法连接 StarLight 认证服务"); + } + clearTimeout(timer); + + let data = null; + const text = await resp.text(); + if (text) { + try { data = JSON.parse(text); } catch (_) { data = null; } + } + return { status: resp.status, data }; +} + +/* + * 用 StarLight 账号密码认证。 + * 成功返回 { username, uuid }(来自 selectedProfile) + * 失败抛 Error,message 为可直接展示给用户的文案。 + */ +async function authenticate(username, password) { + if (!username || !password) throw new Error("请输入用户名和密码"); + + // 每次认证都用【新的】 clientToken —— 实测固定复用会返回空 profiles + const clientToken = crypto.randomBytes(16).toString("hex"); + + const { status, data } = await postJson("/authserver/authenticate", { + username, + password, + clientToken, + requestUser: true, + agent: AGENT, + }); + + // 选角色:优先 selectedProfile(单角色/已选),否则取 availableProfiles[0](多角色自动选第一个) + let chosen = null; + if (data && data.selectedProfile) { + chosen = data.selectedProfile; + } else if (data && Array.isArray(data.availableProfiles) && data.availableProfiles.length > 0) { + chosen = data.availableProfiles[0]; + logger.info(`[STARLIGHT] 多角色账号,自动选第一个: ${chosen.name}(共 ${data.availableProfiles.length} 个)`); + } + + if (status === 200 && chosen) { + // 校验用户名/UUID 字符集,防注入(只允许 Minecraft 合法 ID 字符与十六进制 UUID) + const safeName = String(chosen.name || "").replace(/[^A-Za-z0-9_]/g, ""); + const safeUuid = String(chosen.id || "").replace(/[^a-fA-F0-9]/g, ""); + if (!safeName || !safeUuid) { + logger.warn(`[STARLIGHT] 返回字段非法 name=${chosen.name} id=${chosen.id}`); + throw new Error("StarLight 返回了非法的角色信息"); + } + logger.info(`[STARLIGHT] 认证成功 user=${safeName}`); + return { username: safeName, uuid: safeUuid }; + } + + // 200 但无任何角色 —— 账号存在但没角色 + if (status === 200 && (!data || (!data.selectedProfile && (!data.availableProfiles || data.availableProfiles.length === 0)))) { + logger.warn(`[STARLIGHT] 认证通过但无角色`); + throw new Error("该 StarLight 账号尚未创建游戏角色"); + } + // 403 通常是凭证错误 + if (status === 403) { + logger.warn(`[STARLIGHT] 认证失败(凭证错误)`); + throw new Error("StarLight 用户名或密码错误"); + } + // 429 限流 + if (status === 429) { + logger.warn("[STARLIGHT] 认证被限流"); + throw new Error("StarLight 请求过于频繁,请稍后再试"); + } + // 其他:记录内部细节到日志,但【只回传通用文案】给前端,避免信息泄露 + const detail = (data && (data.errorMessage || data.error)) || ("HTTP " + status); + logger.warn(`[STARLIGHT] 认证异常 status=${status} detail=${detail}`); + throw new Error("StarLight 认证服务异常,请稍后再试"); +} + +module.exports = { authenticate }; \ No newline at end of file diff --git a/src/store.js b/src/store.js index f6ba8a8..1810a9c 100644 --- a/src/store.js +++ b/src/store.js @@ -46,7 +46,9 @@ function upsertSong(s) { return getSongByFile(s.file); } function updateSong(file, fields) { - const keys = Object.keys(fields); + // 字段白名单:防止动态列名注入(只允许 songs 表这些列) + const ALLOWED = new Set(["name", "artist", "cover", "cover_remote", "duration", "file_size", "heat", "cache_status"]); + const keys = Object.keys(fields).filter((k) => ALLOWED.has(k)); if (!keys.length) return; const set = keys.map((k) => `${k}=?`).join(", "); getDb() @@ -136,11 +138,54 @@ function clearPlaylist(streamId) { getDb().prepare("DELETE FROM stream_playlist WHERE stream_id=?").run(streamId); } +/* ==================== stream_pool(循环池:手动歌单,随机不重复) ==================== */ +function getPoolRows(streamId) { + return getDb() + .prepare(`SELECT sp.song_id, sp.played, s.* + FROM stream_pool sp JOIN songs s ON s.id = sp.song_id + WHERE sp.stream_id = ? ORDER BY sp.added_at ASC`) + .all(streamId); +} +function listPoolSongIds(streamId) { + return getDb().prepare("SELECT song_id FROM stream_pool WHERE stream_id=?").all(streamId).map(r => r.song_id); +} +function isInPool(streamId, songId) { + return !!getDb().prepare("SELECT 1 FROM stream_pool WHERE stream_id=? AND song_id=?").get(streamId, songId); +} +function addToPool(streamId, songId) { + getDb().prepare( + "INSERT INTO stream_pool (stream_id, song_id, played, added_at) VALUES (?, ?, 0, ?) ON CONFLICT(stream_id, song_id) DO NOTHING" + ).run(streamId, songId, Date.now()); +} +function removeFromPool(streamId, songId) { + return getDb().prepare("DELETE FROM stream_pool WHERE stream_id=? AND song_id=?").run(streamId, songId); +} +// 抽一首未播的;若全播过则重置 played 后再抽。返回 song row 或 null +function drawFromPool(streamId) { + const d = getDb(); + let unplayed = d.prepare( + `SELECT s.* FROM stream_pool sp JOIN songs s ON s.id = sp.song_id + WHERE sp.stream_id=? AND sp.played=0 ORDER BY RANDOM() LIMIT 1` + ).get(streamId); + if (!unplayed) { + // 全部播过 → 重置本轮 + d.prepare("UPDATE stream_pool SET played=0 WHERE stream_id=?").run(streamId); + unplayed = d.prepare( + `SELECT s.* FROM stream_pool sp JOIN songs s ON s.id = sp.song_id + WHERE sp.stream_id=? AND sp.played=0 ORDER BY RANDOM() LIMIT 1` + ).get(streamId); + } + return unplayed || null; +} +function markPoolPlayed(streamId, songId) { + getDb().prepare("UPDATE stream_pool SET played=1 WHERE stream_id=? AND song_id=?").run(streamId, songId); +} + /* ==================== sessions ==================== */ -function createSession(token, role, expiresAt) { +function createSession(token, role, expiresAt, starlightName, starlightUuid) { getDb() - .prepare("INSERT INTO sessions (token, role, created_at, expires_at) VALUES (?, ?, ?, ?)") - .run(token, role || "user", Date.now(), expiresAt); + .prepare("INSERT INTO sessions (token, role, created_at, expires_at, starlight_name, starlight_uuid) VALUES (?, ?, ?, ?, ?, ?)") + .run(token, role || "user", Date.now(), expiresAt, starlightName || null, starlightUuid || null); } function getSession(token) { const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token); @@ -151,10 +196,43 @@ function getSession(token) { } return s; } +// 滑动续期:把会话过期时间往后推 +function touchSession(token, newExpiresAt) { + getDb().prepare("UPDATE sessions SET expires_at=? WHERE token=?").run(newExpiresAt, token); +} function deleteSession(token) { getDb().prepare("DELETE FROM sessions WHERE token=?").run(token); } +/* ==================== admins(管理员名单,热管理) ==================== */ +function listAdmins() { + return getDb().prepare("SELECT * FROM admins ORDER BY added_at ASC").all(); +} +function getAdmin(uuid) { + return getDb().prepare("SELECT * FROM admins WHERE uuid=?").get(uuid); +} +function isAdminUuid(uuid) { + if (!uuid) return false; + return !!getDb().prepare("SELECT 1 FROM admins WHERE uuid=?").get(uuid); +} +function addAdmin(uuid, name) { + getDb().prepare( + "INSERT INTO admins (uuid, name, added_at) VALUES (?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET name=excluded.name" + ).run(uuid, name || "", Date.now()); + return getAdmin(uuid); +} +function removeAdmin(uuid) { + return getDb().prepare("DELETE FROM admins WHERE uuid=?").run(uuid); +} +function listUsers() { + return getDb().prepare( + `SELECT starlight_name AS name, starlight_uuid AS uuid, + MAX(created_at) AS last_login, COUNT(*) AS login_count + FROM sessions WHERE starlight_name IS NOT NULL + GROUP BY starlight_name ORDER BY last_login DESC` + ).all(); +} + /* ==================== settings ==================== */ function getSetting(key, def = null) { const r = getDb().prepare("SELECT value FROM settings WHERE key=?").get(key); @@ -179,6 +257,8 @@ module.exports = { createStream, listStreams, getStream, deleteStream, setStreamState, getPlaylistRows, playlistCount, addToPlaylist, removePlaylistEntry, setPlaylistOrder, clearPlaylist, - createSession, getSession, deleteSession, + getPoolRows, listPoolSongIds, isInPool, addToPool, removeFromPool, drawFromPool, markPoolPlayed, + createSession, getSession, deleteSession, touchSession, getSetting, setSetting, + listAdmins, getAdmin, isAdminUuid, addAdmin, removeAdmin, listUsers, }; \ No newline at end of file diff --git a/src/util.js b/src/util.js index 31d52ff..f99e2ba 100644 --- a/src/util.js +++ b/src/util.js @@ -19,7 +19,7 @@ function getDirSize(dirPath) { try { const st = fs.statSync(full); size += st.isDirectory() ? getDirSize(full) : st.size; - } catch (_) {} + } catch (_) { /* 文件读不到就跳过,正常 */ } } return size; } diff --git a/start.cmd b/start.cmd index 4f2d5fa..88ad5bf 100644 --- a/start.cmd +++ b/start.cmd @@ -1,4 +1,20 @@ @echo off -set "ADMIN_PASSWORD=test123" -set "USER_PASSWORD=user123" +REM ===== 应急管理员密码(后门,StarLight 挂掉时用)===== +REM 支持三种格式: +REM 明文:set "ADMIN_PASSWORD=你的密码" +REM sha256:set "ADMIN_PASSWORD=sha256:" +REM scrypt:set "ADMIN_PASSWORD=scrypt::" ← 推荐(加盐慢哈希) +REM 生成 scrypt 哈希(把 YourPassword 换成你的密码): +REM node -e "const c=require('crypto');const s=c.randomBytes(16).toString('hex');console.log('scrypt:'+s+':'+c.scryptSync('YourPassword',Buffer.from(s,'hex'),64).toString('hex'))" +set "ADMIN_PASSWORD=scrypt:5893f9e5e5933cffcd0a66886efefd3d:ad455e8b9e973c71d47eff8d1c5e24e0354857a0f413d8fb96b6a2550945c02935321493dc2513609642af24bb2052e856204dd2ccbdf788631b40b9b874cc19" + +REM ===== StarLight 第三方登录配置 ===== +set "STARLIGHT_API=https://skin.starlight.cool/yggdrasil" + +REM 管理员白名单:填 StarLight 角色的【UUID】,逗号分隔(命中=admin,其余=user) +set "STARLIGHT_ADMINS=a347e21fbea14ef79a5236ff526a6b67" + +REM 登录锁定豁免 IP(防管理员被锁死 DoS),逗号分隔 +set "TRUSTED_IPS=127.0.0.1,::1" + node index.js \ No newline at end of file diff --git a/test_api.py b/test_api.py index 1d4c1b1..d55f8a0 100644 --- a/test_api.py +++ b/test_api.py @@ -1,6 +1,8 @@ import urllib.request, urllib.error, http.cookiejar, json, sys, time BASE = "http://127.0.0.1:3000" +import os +TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test") def opener_with_cookies(): cj = http.cookiejar.CookieJar() @@ -12,6 +14,14 @@ def call(op, path, method="GET", data=None): req = urllib.request.Request(url, data=body, method=method) if body: req.add_header("Content-Type", "application/json") + if method.upper() not in ("GET","HEAD","OPTIONS"): + for c in op.handlers: + cj = getattr(c, "cookiejar", None) + if cj: + for ck in cj: + if ck.name == "mr_csrf": + req.add_header("X-CSRF-Token", ck.value); break + break try: r = op.open(req, timeout=10) return r.status, r.read() @@ -30,7 +40,7 @@ st, body = call(anon, "/login"); check("登录页200", st == 200, f"got {st}") st, body = call(anon, "/"); check("未登录/=登录页", st == 200 and b"loginForm" in body, f"got {st}") st, _ = call(anon, "/index.html"); check("未登录index.html=登录页", st == 200 and b"loginForm" in _, f"got {st}") st, _ = call(auth, "/api/auth/login", "POST", {"password": "wrong"}); check("错密码401", st == 401, f"got {st}") -st, body = call(auth, "/api/auth/login", "POST", {"password": "test123"}); check("登录成功", st == 200 and json.loads(body).get("ok"), f"got {st}") +st, body = call(auth, "/api/auth/login", "POST", {"password": TEST_PASSWORD}); check("登录成功", st == 200 and json.loads(body).get("ok"), f"got {st}") # 资源与流 st, body = call(auth, "/api/resources"); d = json.loads(body) diff --git a/test_e2e.py b/test_e2e.py index 496f6f5..d889534 100644 --- a/test_e2e.py +++ b/test_e2e.py @@ -1,6 +1,8 @@ import urllib.request, urllib.error, http.cookiejar, json, sys, re BASE = "http://127.0.0.1:3000" +import os +TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test") cj = http.cookiejar.CookieJar() op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) @@ -8,6 +10,10 @@ def call(path, method="GET", data=None, raw=False): body = json.dumps(data).encode() if data is not None else None req = urllib.request.Request(BASE + path, data=body, method=method) if body: req.add_header("Content-Type", "application/json") + if method.upper() not in ("GET","HEAD","OPTIONS"): + for ck in cj: + if ck.name == "mr_csrf": + req.add_header("X-CSRF-Token", ck.value); break try: r = op.open(req, timeout=10) return r.status, r.read() @@ -26,7 +32,7 @@ for path in ["/vendor/bootstrap.min.css", "/vendor/bootstrap-icons.css", check(f"静态 {path}", st == 200, f"got {st}") # 登录 -st, body = call("/api/auth/login", "POST", {"password": "test123"}) +st, body = call("/api/auth/login", "POST", {"password": TEST_PASSWORD}) check("登录", st == 200 and json.loads(body).get("ok"), f"got {st}") # 首页返回面板(含 vendor 引用) diff --git a/test_enrich.py b/test_enrich.py index cfda1c8..a869909 100644 --- a/test_enrich.py +++ b/test_enrich.py @@ -1,6 +1,8 @@ import urllib.request, urllib.error, http.cookiejar, json, sys BASE = "http://127.0.0.1:3000" +import os +TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test") cj = http.cookiejar.CookieJar() op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) @@ -14,7 +16,7 @@ def call(path, method="GET", data=None): except urllib.error.HTTPError as e: return e.code, e.read() -call("/api/auth/login", "POST", {"password": "test123"}) +call("/api/auth/login", "POST", {"password": TEST_PASSWORD}) st, body = call("/api/resources") songs = json.loads(body)["list"] print(f"共 {len(songs)} 首,开始补全元数据...\n") diff --git a/test_write.py b/test_write.py index bc828b4..87045c1 100644 --- a/test_write.py +++ b/test_write.py @@ -1,6 +1,8 @@ import urllib.request, urllib.error, http.cookiejar, json, sys, os, io, uuid BASE = "http://127.0.0.1:3000" +import os +TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test") cj = http.cookiejar.CookieJar() op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) @@ -8,6 +10,10 @@ def call(path, method="GET", data=None): body = json.dumps(data).encode() if data is not None else None req = urllib.request.Request(BASE + path, data=body, method=method) if body: req.add_header("Content-Type", "application/json") + if method.upper() not in ("GET","HEAD","OPTIONS"): + for ck in cj: + if ck.name == "mr_csrf": + req.add_header("X-CSRF-Token", ck.value); break try: r = op.open(req, timeout=30) return r.status, r.read() @@ -19,7 +25,7 @@ def check(name, cond, extra=""): results.append((name, cond)); print(("PASS" if cond else "FAIL"), name, extra) # 登录 -call("/api/auth/login", "POST", {"password": "test123"}) +call("/api/auth/login", "POST", {"password": TEST_PASSWORD}) # ---------- 读基线资源数 ---------- st, body0 = call("/api/resources") @@ -54,6 +60,9 @@ body = b"".join(parts) req = urllib.request.Request(BASE + "/api/music/import", data=body, method="POST") req.add_header("Content-Type", "multipart/form-data; boundary=" + boundary) +for ck in cj: + if ck.name == "mr_csrf": + req.add_header("X-CSRF-Token", ck.value); break try: r = op.open(req, timeout=60) d = json.loads(r.read())