feat: StarLight 第三方登录 + 安全加固 + 循环池 + 热配置

功能:
- StarLight Yggdrasil 第三方登录(后端中转,accessToken 不落地)
- 管理员可视化权限管理(用户列表 + 一键授予/移出)
- 设置页热配置(11 项可编辑,立即生效)
- 循环池(每流独立手动歌单,队列空时随机不重复抽取)
- 登录页统一主站浅色风格
- 全站改名「Starlight专属电台」
- 删除关于页

安全:
- 全局 CSRF 防护(双提交 Cookie)
- 登录限流防 XFF 伪造、错误信息脱敏
- 权限中间件 requireAdminForWrites(读放行/写 admin)
- 密码哈希存储(scrypt/sha256)
- SSRF 防护(封面下载域名白名单)
- 空 catch 加日志、SSE 连接上限

修复:
- 多角色账号 selectedProfile 缺失
- 普通用户越权删歌(V4)
- streams 写操作越权(V9)
- 设置页布局溢出

测试:38 项全绿(api16 / e2e17 / write5)
This commit is contained in:
Xiao-no-love committed 2026-10-06 16:37:03 +08:00
1 parent 5066f32fa4
commit f0fe50e484
29 files changed
+1703 -315

No files matched your search

+14 -1
View File
@@ -39,4 +39,17 @@ audio/*.m4a
audio/*.flac audio/*.flac
audio/*.wav audio/*.wav
audio/*.aac audio/*.aac
audio/*.ogg audio/*.ogg
# Python 缓存
__pycache__/
*.pyc
# 备份文件
*.bak_*
*.bak
# 安全报告(含敏感信息,本地保留)
SECURITY_PENTEST.md
SECURITY_REVIEW.md
# 临时归档
*.tgz
+335 -2
View File
@@ -1,4 +1,4 @@
# 交接文档 · 音乐在线电台 # 交接文档 · Starlight专属电台
> 跨会话交接。**新会话先读此文件再动手**。最后更新:2026-09-25(v2.6)。 > 跨会话交接。**新会话先读此文件再动手**。最后更新: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`) - 后台服务:`svc003`(`http://127.0.0.1:3000`)
- DB:`data.db`(6 首歌曲,封面已本地化) - DB:`data.db`(6 首歌曲,封面已本地化)
- 缓存:`hls_cache/` 约 34MB - 缓存:`hls_cache/` 约 34MB
- Git:`2fc2548` 干净 - 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:<salt>:<hash>`),兼容 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 白名单实测。
+1 -1
View File
@@ -1,4 +1,4 @@
# 音乐在线电台 · v2 # Starlight专属电台 · v2
把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成**多路**永不停播的直播流,并提供 Web 控制面板管理播放列表、导入音源(网易云 / 本地上传)。前后端一体的 Node.js 应用。 把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成**多路**永不停播的直播流,并提供 Web 控制面板管理播放列表、导入音源(网易云 / 本地上传)。前后端一体的 Node.js 应用。
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "music-online-demo", "name": "music-online-demo",
"version": "2.6.0", "version": "2.6.0",
"description": "音乐在线电台:FFmpeg HLS 直播流服务(多路流 + SQLite + 导入 + 鉴权)", "description": "Starlight专属电台:FFmpeg HLS 直播流服务(多路流 + SQLite + 导入 + 鉴权)",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js" "start": "node index.js"
+197 -8
View File
@@ -328,12 +328,14 @@
.page { .page {
display: none; display: none;
} }
.page.active { .page.active {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-4); gap: var(--sp-4);
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
animation: pageEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; animation: pageEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
} }
@keyframes pageEnter { @keyframes pageEnter {
@@ -361,6 +363,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
flex-shrink: 0;
} }
@keyframes cardRise { @keyframes cardRise {
from { from {
@@ -807,15 +810,9 @@
justify-content: center; justify-content: center;
padding: 0 5px; padding: 0 5px;
} }
.section-body { .section-body {
padding: 0 0.5rem 0.5rem 0.5rem; padding: 0 0.5rem 0.5rem 0.5rem;
margin-bottom: 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 { .section-body::-webkit-scrollbar {
width: 5px; width: 5px;
@@ -2144,3 +2141,195 @@
outline: none; outline: none;
} }
.resource-sort:hover { border-color: var(--accent); } .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);
}
+76 -147
View File
@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1"
/> />
<meta name="theme-color" content="#f4f5f9" /> <meta name="theme-color" content="#f4f5f9" />
<title>Xiao の 电台</title> <title>Starlight专属电台</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="vendor/bootstrap.min.css" /> <link rel="stylesheet" href="vendor/bootstrap.min.css" />
<link rel="stylesheet" href="vendor/bootstrap-icons.css" /> <link rel="stylesheet" href="vendor/bootstrap-icons.css" />
@@ -31,7 +31,7 @@
<div class="brand-icon"> <div class="brand-icon">
<img src="favicon.png" alt="logo" /> <img src="favicon.png" alt="logo" />
</div> </div>
<span>Xiao の 电台</span> <span>Starlight专属电台</span>
<span class="live-pill" <span class="live-pill"
><span class="live-dot"></span>LIVE</span ><span class="live-dot"></span>LIVE</span
> >
@@ -42,6 +42,7 @@
<button class="btn-link-sm" id="liveLink" title="复制直播流链接" <button class="btn-link-sm" id="liveLink" title="复制直播流链接"
><i class="bi bi-link-45deg"></i></button ><i class="bi bi-link-45deg"></i></button
> >
<span class="user-name" id="userName" title="" style="display:none"></span>
<button class="btn-link-sm" id="btnLogout" title="退出登录" <button class="btn-link-sm" id="btnLogout" title="退出登录"
><i class="bi bi-box-arrow-right"></i></button> ><i class="bi bi-box-arrow-right"></i></button>
</div> </div>
@@ -282,12 +283,82 @@
<div class="section-card"> <div class="section-card">
<div class="section-header"> <div class="section-header">
<div class="section-title"> <div class="section-title">
<i class="bi bi-gear" style="color: var(--accent)"></i> <i class="bi bi-sliders" style="color: var(--accent)"></i>
运行配置 热配置
</div> </div>
<button class="btn-clear" id="btnSaveSettings"><i class="bi bi-check-lg"></i> 保存并生效</button>
</div> </div>
<div class="section-body"> <div class="section-body">
<div class="config-grid" id="setConfigGrid"></div> <div class="settings-form" id="settingsForm">
<div class="setting-row">
<label>切片时长(秒)</label>
<input type="number" id="cfgHlsTime" min="2" max="30" placeholder="5" value="5">
</div>
<div class="setting-row">
<label>滑动窗口(段)</label>
<input type="number" id="cfgSlideWindow" min="5" max="60" placeholder="15" value="15">
</div>
<div class="setting-row">
<label>缓存上限(MB)</label>
<input type="number" id="cfgMaxCacheMB" min="100" max="100000" placeholder="1000" value="1000">
</div>
<div class="setting-row">
<label>默认频道</label>
<select id="cfgDefaultStream"></select>
</div>
<div class="setting-row">
<label>日志级别</label>
<select id="cfgLogLevel">
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</div>
<div class="setting-row">
<label>会话有效期(小时)</label>
<input type="number" id="cfgSessionTtlHours" min="1" max="720" placeholder="72" value="72">
</div>
<div class="setting-row">
<label>登录失败上限(次)</label>
<input type="number" id="cfgLoginMaxFails" min="3" max="20" placeholder="5" value="5">
</div>
<div class="setting-row">
<label>登录锁定时长(分)</label>
<input type="number" id="cfgLoginLockMin" min="1" max="1440" placeholder="10" value="10">
</div>
<div class="setting-row">
<label>搜索缓存时长(秒)</label>
<input type="number" id="cfgSearchCacheTtlSec" min="10" max="3600" placeholder="300" value="300">
</div>
<div class="setting-row">
<label>搜索限流上限(次/窗口)</label>
<input type="number" id="cfgSearchRateMax" min="5" max="500" placeholder="20" value="20">
</div>
<div class="setting-row">
<label>搜索限流窗口(秒)</label>
<input type="number" id="cfgSearchRateWindowSec" min="10" max="600" placeholder="60" value="60">
</div>
</div>
<div class="cache-stat-note" style="margin-top:12px">
ⓘ 修改后点「保存并生效」,立即应用,无需重启。切片时长等影响新生成的流。
</div>
</div>
</div>
<div class="section-card">
<div class="section-header">
<div class="section-title">
<i class="bi bi-shield-lock" style="color: var(--accent)"></i>
权限管理
</div>
<button class="btn-clear" id="btnRefreshUsers"><i class="bi bi-arrow-repeat"></i> 刷新</button>
</div>
<div class="section-body">
<div class="cache-stat-note" style="margin-bottom:12px">
ⓘ 登录过的用户列表。点「设为管理员」授予权限(下次登录生效)。
</div>
<div class="user-list" id="userList">加载中...</div>
</div> </div>
</div> </div>
@@ -310,145 +381,6 @@
</div> </div>
</div> </div>
<!-- 4. About — 保持原有风格体系 -->
<div class="page" id="pageAbout">
<div class="about-card">
<!-- Hero -->
<div class="about-hero">
<div class="about-logo">
<img src="favicon.png" alt="logo" />
</div>
<h2>Xiao の 电台</h2>
<p>基于 HLS 的个人在线音乐电台</p>
</div>
<!-- Body -->
<div class="about-body">
<!-- 项目简介 -->
<div class="about-section">
<div class="about-section-icon">
<i class="bi bi-book"></i>
</div>
<div class="about-section-content">
<h3>项目简介</h3>
<p>
一个基于 Web 的个人在线音乐电台,支持通过网易云音乐歌曲 ID、链接或歌名进行搜索、试听与下载,提供 HLS 直播流播放、资源管理与播放列表拖拽排序等功能。界面采用简约玻璃拟态风格,适配移动端与桌面端多种屏幕尺寸。
</p>
</div>
</div>
<!-- 核心功能 -->
<div class="about-section">
<div class="about-section-icon">
<i class="bi bi-lightning-charge"></i>
</div>
<div class="about-section-content">
<h3>核心功能</h3>
<div class="about-feature-list">
<div class="about-feat">
<i class="bi bi-search" style="color: var(--accent)"></i>
<span>智能搜索 — 歌曲 ID、链接或歌名关键词,自动解析并一键导入</span>
</div>
<div class="about-feat">
<i class="bi bi-broadcast" style="color: var(--success)"></i>
<span>直播流 — FFmpeg HLS 切片 + 滑动窗口,5 秒精度连续输出</span>
</div>
<div class="about-feat">
<i class="bi bi-database-check" style="color: var(--warning)"></i>
<span>智能缓存 — 预生成切片、增量追踪、LRU 淘汰与自动清理</span>
</div>
<div class="about-feat">
<i class="bi bi-hand-index-thumb" style="color: #8b5cf6"></i>
<span>播放列表 — 拖拽排序、添加/移除/跳转,SSE 实时状态推送</span>
</div>
<div class="about-feat">
<i class="bi bi-phone" style="color: #06b6d4"></i>
<span>全端适配 — 玻璃拟态 UI,手势滑动、键盘快捷键、拖拽进度条</span>
</div>
</div>
</div>
</div>
<!-- 技术栈 -->
<div class="about-section">
<div class="about-section-icon">
<i class="bi bi-tools"></i>
</div>
<div class="about-section-content">
<h3>技术栈</h3>
<div class="tech-tags">
<span>HTML5</span>
<span>CSS3</span>
<span>JavaScript ES6+</span>
<span>Bootstrap 5</span>
<span>SortableJS</span>
<span>HLS.js</span>
<span>Node.js</span>
<span>Express</span>
<span>FFmpeg</span>
<span>Chokidar</span>
</div>
</div>
</div>
<!-- 开发语言 -->
<div class="about-section">
<div class="about-section-icon">
<i class="bi bi-translate"></i>
</div>
<div class="about-section-content">
<h3>开发语言</h3>
<p>前端:HTML / CSS / JavaScript &nbsp;|&nbsp; 后端:Node.js</p>
</div>
</div>
<!-- 作者 -->
<div class="about-section">
<div class="about-section-icon">
<i class="bi bi-person"></i>
</div>
<div class="about-section-content">
<h3>作者</h3>
<p style="margin-bottom: 0.45rem">Xiao — 独立开发者</p>
<a
href="https://github.com/Xiao-no-love/"
target="_blank"
rel="noopener"
class="about-gh-link"
>
<i class="bi bi-github"></i>
<span>Xiao-no-love</span>
<i class="bi bi-box-arrow-up-right" style="font-size: 0.6rem; opacity: 0.5"></i>
</a>
</div>
</div>
<!-- 免责声明 -->
<div class="about-section disclaimer-section">
<div class="about-section-icon">
<i class="bi bi-exclamation-triangle"></i>
</div>
<div class="about-section-content">
<h3>免责声明</h3>
<p>
本项目所有音源均来自网易云音乐,仅供个人学习与技术研究使用,严禁用于任何商业用途。音乐版权归原创作者及网易云音乐平台所有。如有侵权,请联系删除。请于下载后 24 小时内清除,支持正版音乐。
</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="about-version">
v2.0.0 &middot; Built with &hearts; &middot;
<a
href="https://github.com/Xiao-no-love/"
target="_blank"
rel="noopener"
style="color: var(--accent); text-decoration: none"
>GitHub</a>
</div>
</div>
</div>
</div> </div>
<!-- 频道管理模态框 --> <!-- 频道管理模态框 -->
@@ -514,9 +446,6 @@
<button class="nav-tab" data-page="pageSettings"> <button class="nav-tab" data-page="pageSettings">
<i class="bi bi-gear"></i><span>设置</span> <i class="bi bi-gear"></i><span>设置</span>
</button> </button>
<button class="nav-tab" data-page="pageAbout">
<i class="bi bi-info-circle"></i><span>关于</span>
</button>
</div> </div>
</nav> </nav>
+168 -19
View File
@@ -1,5 +1,5 @@
/* ============================================================ /* ============================================================
Xiao の 电台 — app.js (v2) Starlight专属电台 — app.js (v2)
多路流 + 鉴权 + 删除/导入 + 封面本地化 多路流 + 鉴权 + 删除/导入 + 封面本地化
============================================================ */ ============================================================ */
@@ -47,7 +47,16 @@ function coverSrcAttr(item) {
// ==================== API(统一带 cookie,401 跳登录) ==================== // ==================== API(统一带 cookie,401 跳登录) ====================
async function api(url, options = {}) { 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) { if (res.status === 401) {
window.location.href = "/login"; window.location.href = "/login";
throw new Error("未登录"); throw new Error("未登录");
@@ -59,12 +68,24 @@ async function api(url, options = {}) {
return data; return data;
} }
// 角色 UI:非管理员隐藏 设置页 + 本地导入 // 角色 UI:非管理员隐藏 设置页 + 本地导入
function applyRoleUI(role) { function applyRoleUI(role, starlightName) {
const isAdmin = role === "admin"; const isAdmin = role === "admin";
const tab = document.querySelector('.nav-tab[data-page="pageSettings"]'); const tab = document.querySelector('.nav-tab[data-page="pageSettings"]');
if (tab) tab.style.display = isAdmin ? "" : "none"; if (tab) tab.style.display = isAdmin ? "" : "none";
const imp = document.getElementById("importCard"); const imp = document.getElementById("importCard");
if (imp) imp.style.display = isAdmin ? "" : "none"; 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) { function post(url, body) {
return api(url, { return api(url, {
@@ -77,7 +98,7 @@ function post(url, body) {
// ==================== Navigation ==================== // ==================== Navigation ====================
const tabs = document.querySelectorAll(".nav-tab"); const tabs = document.querySelectorAll(".nav-tab");
const pages = document.querySelectorAll(".page"); const pages = document.querySelectorAll(".page");
const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings", "pageAbout"]; const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings"];
function switchPage(pageId) { function switchPage(pageId) {
pages.forEach((p) => p.classList.remove("active")); pages.forEach((p) => p.classList.remove("active"));
tabs.forEach((t) => t.classList.remove("active")); tabs.forEach((t) => t.classList.remove("active"));
@@ -270,7 +291,7 @@ function renderResItem(item) {
<div class="res-actions"> <div class="res-actions">
<span class="heat-chip"><i class="bi bi-fire"></i><span class="js-heat">${item.heat}</span></span> <span class="heat-chip"><i class="bi bi-fire"></i><span class="js-heat">${item.heat}</span></span>
<button class="btn-icon js-add" title="添加到当前频道"><i class="bi bi-plus-circle-fill" style="color:var(--accent)"></i></button> <button class="btn-icon js-add" title="添加到当前频道"><i class="bi bi-plus-circle-fill" style="color:var(--accent)"></i></button>
<button class="btn-icon js-enrich" title="重新匹配作者/封面"><i class="bi bi-arrow-repeat"></i></button> ${state.role === 'admin' ? `<button class="btn-icon js-pool${item.inPool ? ' active' : ''}" title="${item.inPool ? '移出循环池' : '加入循环池'}"><i class="bi bi-${item.inPool ? 'arrow-repeat' : 'plus-circle'}"></i></button>` : ''}
<button class="btn-icon danger js-del" title="删除"><i class="bi bi-trash3-fill"></i></button> <button class="btn-icon danger js-del" title="删除"><i class="bi bi-trash3-fill"></i></button>
</div> </div>
</div> </div>
@@ -285,7 +306,8 @@ function renderResItem(item) {
const el = tpl.content.firstElementChild; const el = tpl.content.firstElementChild;
el.querySelector(".js-add").addEventListener("click", () => addToPlaylist(item.file)); el.querySelector(".js-add").addEventListener("click", () => addToPlaylist(item.file));
el.querySelector(".js-del").addEventListener("click", () => deleteResource(item.file, item.title)); 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; return el;
} }
function updateResItem(el, item) { function updateResItem(el, item) {
@@ -321,6 +343,16 @@ async function loadResources() {
try { try {
const r = await api("/api/resources"); const r = await api("/api/resources");
const resources = sortResources(r.list || []); 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; state.resources = resources;
const container = document.getElementById("resourceList"); const container = document.getElementById("resourceList");
document.getElementById("resourceCount").textContent = resources.length; document.getElementById("resourceCount").textContent = resources.length;
@@ -440,6 +472,19 @@ async function deleteResource(file, title) {
await loadPlaylist(); await loadPlaylist();
} catch (err) { showToast("删除失败: " + err.message, "error"); } } 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) { async function enrichResource(file) {
try { try {
showToast("正在重新匹配..."); showToast("正在重新匹配...");
@@ -594,10 +639,15 @@ async function searchMusic() {
btn.disabled = true; btn.textContent = "查询中..."; btn.disabled = true; btn.textContent = "查询中...";
try { try {
if (id && /^\d+$/.test(id)) { if (id && /^\d+$/.test(id)) {
// 按 ID / 链接解析:所有登录用户可用
document.getElementById("searchResults").style.display = "none"; document.getElementById("searchResults").style.display = "none";
await showDetail(id); await showDetail(id);
} else { } else if (state.role === "admin") {
// 关键词搜索:仅管理员
renderSearchResults(await api("/api/music/search?keyword=" + encodeURIComponent(rawInput)), rawInput); renderSearchResults(await api("/api/music/search?keyword=" + encodeURIComponent(rawInput)), rawInput);
} else {
// 普通用户:只能用 ID 或链接
showToast("普通用户请用歌曲 ID 或网易云链接解析", "error");
} }
} catch (err) { showToast("查询失败: " + err.message, "error"); } } catch (err) { showToast("查询失败: " + err.message, "error"); }
finally { btn.disabled = false; btn.textContent = "查询"; } finally { btn.disabled = false; btn.textContent = "查询"; }
@@ -856,23 +906,89 @@ async function loadSettings() {
"已用 " + c.usedPercent + "%,超限时自动按热度 LRU 淘汰"; "已用 " + c.usedPercent + "%,超限时自动按热度 LRU 淘汰";
} catch (e) { console.error("缓存信息失败", e); } } catch (e) { console.error("缓存信息失败", e); }
// 配置 // 热配置表单:填充当前值
try { try {
const r = await api("/api/system/config"); const r = await api("/api/system/config");
const cf = r.config || {}; const cf = r.config || {};
const items = [ const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
["HTTP 端口", cf.HTTP_PORT], set("cfgHlsTime", cf.HLS_TIME);
["切片时长", cf.HLS_TIME + " 秒"], set("cfgSlideWindow", cf.SLIDE_WINDOW_SIZE);
["滑动窗口", cf.SLIDE_WINDOW_SIZE + " 段"], set("cfgMaxCacheMB", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0));
["缓存上限", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0) + " MB"], set("cfgLogLevel", cf.LOG_LEVEL || "info");
["FFmpeg 并发", cf.MAX_FFMPEG_CONCURRENT + " 路"], set("cfgSessionTtlHours", cf.SESSION_TTL_HOURS);
]; set("cfgLoginMaxFails", cf.LOGIN_MAX_FAILS);
document.getElementById("setConfigGrid").innerHTML = items set("cfgLoginLockMin", cf.LOGIN_LOCK_MIN);
.map(([k, v]) => `<div class="config-item"><span>${esc(k)}</span><b>${esc(String(v))}</b></div>`) set("cfgSearchCacheTtlSec", cf.SEARCH_CACHE_TTL_SEC);
.join(""); 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 =>
`<option value="${esc(s.id)}">${esc(s.name)}</option>`).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); } } catch (e) { console.error("配置失败", e); }
await loadLogs(); 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 = '<div class="user-empty">暂无登录过的用户</div>';
return;
}
el.innerHTML = list.map(u => {
const badge = u.isAdmin ? '<span class="admin-badge">管理员</span>' : '';
const uuidTxt = u.uuid ? esc(u.uuid) : '(无 UUID,需重新登录)';
const canToggle = !!u.uuid;
const btn = canToggle
? (u.isAdmin
? `<button class="btn-mini danger" data-del="${esc(u.uuid)}">取消管理员</button>`
: `<button class="btn-mini" data-add="${esc(u.uuid)}" data-name="${esc(u.name)}">设为管理员</button>`)
: '';
return `<div class="user-item">
<div class="user-meta">
<span class="user-name">${esc(u.name)} ${badge}</span>
<span class="user-sub">${uuidTxt}</span>
</div>
${btn}
</div>`;
}).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 = '<div class="user-empty">加载失败: ' + esc(e.message) + '</div>';
}
} }
async function loadLogs() { 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 () => { document.getElementById("btnCleanup").addEventListener("click", async () => {
if (!confirm("确定立即清理缓存?将按热度淘汰非播放中的切片。")) return; if (!confirm("确定立即清理缓存?将按热度淘汰非播放中的切片。")) return;
try { try {
@@ -927,7 +1076,7 @@ document.getElementById("btnRefreshCovers").addEventListener("click", async () =
// ==================== Init ==================== // ==================== Init ====================
(async function 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(); await loadStreams();
loadLiveCover(); loadLiveCover();
await loadResources(); await loadResources();
+198 -64
View File
@@ -3,139 +3,273 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录 · 音乐在线电台</title> <title>Starlight专属电台</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/vendor/bootstrap-icons.css" />
<style> <style>
/* ===== 与主页面一致的浅色设计 token ===== */
:root { :root {
--bg: #0d1117; --bg: #f4f5f9;
--panel: #161b22; --surface: #fff;
--border: #30363d; --surface-hover: #fafbff;
--text: #e6edf3; --accent: #4f6ef7;
--muted: #8b949e; --accent-hover: #3d5ce0;
--accent: #1f6feb; --accent-light: #eef1fe;
--accent-hover: #388bfd; --accent-glow: rgba(79, 110, 247, 0.14);
--danger: #f85149; --text-primary: #0f1225;
--text-secondary: #5a607f;
--text-muted: #9499b8;
--border: #e8eaf2;
--border-light: #f0f1f6;
--danger: #ef4565;
--danger-light: #fef0f2;
--success: #30a46c;
--radius-sm: 10px;
--radius: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--radius-pill: 999px;
--shadow-sm: 0 2px 8px rgba(15, 18, 37, 0.06);
--shadow: 0 4px 20px rgba(15, 18, 37, 0.07);
--shadow-lg: 0 8px 40px rgba(15, 18, 37, 0.1);
--shadow-accent: 0 4px 16px rgba(79, 110, 247, 0.25);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100dvh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: radial-gradient(circle at 30% 20%, #1a2332, var(--bg) 60%); padding: 20px;
color: var(--text); background: var(--bg);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif; color: var(--text-primary);
font-family: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont,
"Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
} }
.card { .card {
width: 100%; width: 100%;
max-width: 380px; max-width: 400px;
background: var(--panel); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border-light);
border-radius: 16px; border-radius: var(--radius-xl);
padding: 40px 32px; box-shadow: var(--shadow-lg);
box-shadow: 0 20px 60px rgba(0,0,0,.5); padding: 40px 34px;
animation: cardRise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
} }
@keyframes cardRise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.logo { .logo {
text-align: center; width: 72px;
font-size: 40px; height: 72px;
margin-bottom: 8px; margin: 0 auto 18px;
border-radius: var(--radius-lg);
background: var(--accent-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
box-shadow: var(--shadow-accent);
} }
h1 { h1 {
text-align: center; text-align: center;
font-size: 20px; font-size: 1.35rem;
margin: 0 0 4px; margin: 0 0 6px;
font-weight: 600; font-weight: 700;
color: var(--text-primary);
} }
.sub { .sub {
text-align: center; text-align: center;
color: var(--muted); color: var(--text-secondary);
font-size: 13px; font-size: 0.86rem;
margin-bottom: 28px; margin-bottom: 30px;
} }
label { label {
display: block; display: block;
font-size: 13px; font-size: 0.8rem;
color: var(--muted); font-weight: 600;
margin-bottom: 8px; color: var(--text-secondary);
margin-bottom: 7px;
} }
input[type="password"] { input[type="text"], input[type="password"] {
width: 100%; width: 100%;
padding: 12px 14px; padding: 12px 14px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1.5px solid var(--border);
border-radius: 8px; border-radius: var(--radius-sm);
color: var(--text); color: var(--text-primary);
font-size: 15px; font-size: 0.92rem;
font-family: inherit;
outline: none; outline: none;
transition: border-color .15s; transition: border-color .18s, box-shadow .18s, background .18s;
} }
input[type="password"]:focus { border-color: var(--accent); } input[type="text"]:focus, input[type="password"]:focus {
button { border-color: var(--accent);
background: var(--surface);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input::placeholder { color: var(--text-muted); }
.field { margin-bottom: 18px; }
.btn-primary {
width: 100%; width: 100%;
margin-top: 20px; margin-top: 6px;
padding: 12px; padding: 13px;
background: var(--accent); background: var(--accent);
border: none; border: none;
border-radius: 8px; border-radius: var(--radius-sm);
color: #fff; color: #fff;
font-size: 15px; font-size: 0.95rem;
font-weight: 600; font-weight: 700;
font-family: inherit;
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .18s, box-shadow .18s, transform .1s;
box-shadow: var(--shadow-accent);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
} }
button:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
button:disabled { opacity: .6; cursor: default; } .btn-primary:active { transform: translateY(1px); }
.btn-primary:disabled { opacity: .6; cursor: default; box-shadow: none; }
.error { .error {
margin-top: 14px; margin-top: 14px;
color: var(--danger); color: var(--danger);
font-size: 13px; font-size: 0.82rem;
text-align: center; text-align: center;
min-height: 18px; min-height: 18px;
} }
.hint {
margin-top: 20px;
text-align: center;
font-size: 0.78rem;
color: var(--text-muted);
}
.hint a { color: var(--accent); text-decoration: none; font-weight: 600; }
.hint a:hover { text-decoration: underline; }
details {
margin-top: 22px;
border-top: 1px solid var(--border-light);
padding-top: 18px;
}
summary {
cursor: pointer;
font-size: 0.78rem;
color: var(--text-muted);
list-style: none;
user-select: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▸ "; transition: transform .2s; }
details[open] summary::before { content: "▾ "; }
details .field { margin-top: 14px; }
.btn-secondary {
width: 100%;
margin-top: 4px;
padding: 11px;
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.88rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all .18s;
}
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.btn-secondary:disabled { opacity: .6; cursor: default; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<div class="logo">🎵</div> <div class="logo">🎵</div>
<h1>音乐在线电台</h1> <h1>Starlight专属电台</h1>
<div class="sub">请输入访问密码</div> <div class="sub">使用 StarLight 账号登录</div>
<form id="loginForm"> <form id="loginForm">
<label for="password">密码</label> <div class="field">
<input type="password" id="password" autocomplete="current-password" autofocus /> <label for="username">StarLight 用户名</label>
<button type="submit" id="submitBtn">登录</button> <input type="text" id="username" placeholder="邮箱 / 用户名" autocomplete="username" autofocus />
</div>
<div class="field">
<label for="password">密码</label>
<input type="password" id="password" placeholder="请输入密码" autocomplete="current-password" />
</div>
<button type="submit" class="btn-primary" id="submitBtn">
<i class="bi bi-box-arrow-in-right"></i> 登录
</button>
</form> </form>
<div class="error" id="error"></div> <div class="error" id="error"></div>
<div class="hint">
还没有账号?<a href="https://skin.starlight.cool" target="_blank" rel="noopener">前往 StarLight 注册</a>
</div>
<details>
<summary>管理员应急登录</summary>
<div class="field">
<label for="emergencyPassword">管理员密码</label>
<input type="password" id="emergencyPassword" placeholder="应急管理密码" autocomplete="off" />
</div>
<button type="button" class="btn-secondary" id="emergencyBtn">应急登录</button>
</details>
</div> </div>
<script> <script>
const form = document.getElementById("loginForm"); const form = document.getElementById("loginForm");
const input = document.getElementById("password"); const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const btn = document.getElementById("submitBtn"); const btn = document.getElementById("submitBtn");
const err = document.getElementById("error"); const err = document.getElementById("error");
form.addEventListener("submit", async (e) => { const emergencyBtn = document.getElementById("emergencyBtn");
e.preventDefault(); const emergencyPassword = document.getElementById("emergencyPassword");
const password = input.value;
if (!password) return; async function doLogin(payload) {
btn.disabled = true;
err.textContent = ""; err.textContent = "";
try { try {
const r = await fetch("/api/auth/login", { const r = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }), body: JSON.stringify(payload),
}); });
const data = await r.json(); const data = await r.json();
if (r.ok && data.ok) { if (r.ok && data.ok) {
window.location.href = "/"; window.location.href = "/";
} else { return true;
err.textContent = data.error || "登录失败";
btn.disabled = false;
} }
err.textContent = data.error || "登录失败";
return false;
} catch (e) { } catch (e) {
err.textContent = "网络错误,请重试"; err.textContent = "网络错误,请重试";
btn.disabled = false; return false;
} }
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) {
err.textContent = "请输入用户名和密码";
return;
}
btn.disabled = true;
await doLogin({ username, password });
btn.disabled = false;
});
emergencyBtn.addEventListener("click", async () => {
const password = emergencyPassword.value;
if (!password) { err.textContent = "请输入管理员密码"; return; }
emergencyBtn.disabled = true;
await doLogin({ password });
emergencyBtn.disabled = false;
}); });
</script> </script>
</body> </body>
+132 -10
View File
@@ -8,6 +8,9 @@ const COOKIE_NAME = "mr_session";
/* ==================== 密码来源 ==================== */ /* ==================== 密码来源 ==================== */
// 管理员密码:ADMIN_PASSWORD(兼容旧 PANEL_PASSWORD);未设置则首次随机生成并打印 // 管理员密码:ADMIN_PASSWORD(兼容旧 PANEL_PASSWORD);未设置则首次随机生成并打印
// 支持两种格式:
// 1) 明文 "test123"
// 2) 哈希 "sha256:<64位hex>" ← 推荐,文件泄露也无法直接反推
// 用户密码:USER_PASSWORD;未设置则「无用户密码」——只有管理员能登录 // 用户密码:USER_PASSWORD;未设置则「无用户密码」——只有管理员能登录
let adminPassword = config.ADMIN_PASSWORD; let adminPassword = config.ADMIN_PASSWORD;
let userPassword = config.USER_PASSWORD; let userPassword = config.USER_PASSWORD;
@@ -18,9 +21,25 @@ if (!adminPassword) {
} }
/* ==================== 登录失败锁定(按 IP) ==================== */ /* ==================== 登录失败锁定(按 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 } const failMap = new Map(); // ip -> { count, lockedUntil }
function isLocked(ip) { function isLocked(ip) {
if (isTrustedIp(ip)) return 0; // 可信 IP 永不锁定
const rec = failMap.get(ip); const rec = failMap.get(ip);
if (!rec) return 0; if (!rec) return 0;
if (rec.lockedUntil && rec.lockedUntil > Date.now()) { if (rec.lockedUntil && rec.lockedUntil > Date.now()) {
@@ -29,6 +48,7 @@ function isLocked(ip) {
return 0; return 0;
} }
function recordFail(ip) { function recordFail(ip) {
if (isTrustedIp(ip)) return; // 可信 IP 不计失败
const rec = failMap.get(ip) || { count: 0, lockedUntil: 0 }; const rec = failMap.get(ip) || { count: 0, lockedUntil: 0 };
rec.count += 1; rec.count += 1;
if (rec.count >= config.LOGIN_MAX_FAILS) { 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 token = crypto.randomBytes(24).toString("hex");
const expiresAt = Date.now() + config.SESSION_TTL_MS; const expiresAt = Date.now() + config.SESSION_TTL_MS;
store.createSession(token, role, expiresAt); store.createSession(token, role, expiresAt, starlightName || null, starlightUuid || null);
return { token, expiresAt, role }; return { token, expiresAt, role, starlightName: starlightName || null, starlightUuid: starlightUuid || null };
} }
function timingEqual(a, b) { function timingEqual(a, b) {
const ba = Buffer.from(String(a)); const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b)); const bb = Buffer.from(String(b));
@@ -68,19 +87,62 @@ function timingEqual(a, b) {
return crypto.timingSafeEqual(ba, bb); return crypto.timingSafeEqual(ba, bb);
} }
// 返回角色 "admin" | "user" | null // 密码比对:支持 scrypt / sha256:<hex> / 明文 三种格式
// scrypt 格式:scrypt:<salt_hex>:<hash_hex>(推荐,带盐慢哈希)
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) { function verifyLogin(password) {
if (!password) return null; if (!password) return null;
if (timingEqual(password, adminPassword)) return "admin"; if (passwordMatches(password, adminPassword)) return "admin";
if (userPassword && timingEqual(password, userPassword)) return "user"; if (userPassword && passwordMatches(password, userPassword)) return "user";
return null; 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) { function getValidSession(req) {
const cookies = parseCookies(req.headers.cookie); const cookies = parseCookies(req.headers.cookie);
const token = cookies[COOKIE_NAME]; const token = cookies[COOKIE_NAME];
if (!token) return null; 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) { function isAuthed(req) {
@@ -90,19 +152,53 @@ function getRole(req) {
const s = getValidSession(req); const s = getValidSession(req);
return s ? s.role || "user" : null; 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) { function isAdmin(req) {
return getRole(req) === "admin"; 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) { function setSessionCookie(res, token, expiresAt) {
const maxAge = Math.floor((expiresAt - Date.now()) / 1000); const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
res.setHeader( res.append(
"Set-Cookie", "Set-Cookie",
`${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}` `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
); );
} }
function clearSessionCookie(res) { 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(); 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) { function logout(req) {
const cookies = parseCookies(req.headers.cookie); const cookies = parseCookies(req.headers.cookie);
const token = cookies[COOKIE_NAME]; const token = cookies[COOKIE_NAME];
@@ -143,14 +255,24 @@ module.exports = {
COOKIE_NAME, COOKIE_NAME,
createSession, createSession,
verifyLogin, verifyLogin,
loginViaStarLight,
getValidSession, getValidSession,
getStarlightName,
getStarlightUuid,
isAuthed, isAuthed,
getRole, getRole,
isAdmin, isAdmin,
setSessionCookie, setSessionCookie,
clearSessionCookie, clearSessionCookie,
genCsrfToken,
setCsrfCookie,
clearCsrfCookie,
csrfProtection,
requireAuth, requireAuth,
requireAdmin, requireAdmin,
requireAdminForWrites,
getClientIp,
isTrustedIp,
logout, logout,
initPasswordLog, initPasswordLog,
isLocked, isLocked,
+34 -1
View File
@@ -42,6 +42,16 @@ const config = {
ADMIN_PASSWORD: envStr("ADMIN_PASSWORD", envStr("PANEL_PASSWORD", "")), ADMIN_PASSWORD: envStr("ADMIN_PASSWORD", envStr("PANEL_PASSWORD", "")),
USER_PASSWORD: envStr("USER_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, SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000,
LOGIN_MAX_FAILS: envInt("LOGIN_MAX_FAILS", 5), LOGIN_MAX_FAILS: envInt("LOGIN_MAX_FAILS", 5),
LOGIN_LOCK_MS: envInt("LOGIN_LOCK_MIN", 10) * 60 * 1000, LOGIN_LOCK_MS: envInt("LOGIN_LOCK_MIN", 10) * 60 * 1000,
@@ -52,4 +62,27 @@ const config = {
config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000; config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000;
module.exports = config; /*
* 热配置:从 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;
+25
View File
@@ -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 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 ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
@@ -53,6 +62,12 @@ CREATE TABLE IF NOT EXISTS sessions (
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at); 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() { function init() {
@@ -70,6 +85,16 @@ function init() {
db.exec("ALTER TABLE sessions ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); db.exec("ALTER TABLE sessions ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
logger.info("[DB] 迁移 sessions 表:新增 role 列"); 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,优先展示,省带宽) // 迁移:songs 表补 cover_remote 列(远程封面 URL,优先展示,省带宽)
const songCols = db.prepare("PRAGMA table_info(songs)").all(); const songCols = db.prepare("PRAGMA table_info(songs)").all();
+11 -3
View File
@@ -193,9 +193,17 @@ class LiveStream {
ensureNotEmpty() { ensureNotEmpty() {
let rows = this.playlistRows(); let rows = this.playlistRows();
if (rows.length === 0) { if (rows.length === 0) {
const songs = store.getAllSongs(); // 队列空:优先从【循环池】抽未播的(抽完重置);池空则降级从全部资源随机
if (songs.length > 0) { let pick = store.drawFromPool(this.id);
const pick = songs[Math.floor(Math.random() * songs.length)]; 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); store.addToPlaylist(this.id, pick.id);
this.currentIdx = 0; this.currentIdx = 0;
this.currentSongSegmentIdx = 0; this.currentSongSegmentIdx = 0;
+13 -8
View File
@@ -3,12 +3,15 @@ const fs = require("fs");
const config = require("./config"); const config = require("./config");
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; 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; let stream = null;
try { try {
stream = fs.createWriteStream(config.LOG_FILE, { flags: "a" }); stream = fs.createWriteStream(config.LOG_FILE, { flags: "a" });
} catch (_) {} } catch (e) { console.error('[LOGGER] 日志文件打开失败:', e.message); }
const ring = []; const ring = [];
const RING_MAX = 500; const RING_MAX = 500;
@@ -17,13 +20,14 @@ function fmt(level, args) {
const ts = new Date().toISOString(); const ts = new Date().toISOString();
const msg = args const msg = args
.map((a) => { .map((a) => {
if (typeof a === "string") return a; let s;
if (a instanceof Error) return a.stack || a.message; if (typeof a === "string") s = a;
try { else if (a instanceof Error) s = a.stack || a.message;
return JSON.stringify(a); else {
} catch (_) { try { s = JSON.stringify(a); } catch (_) { s = String(a); }
return String(a);
} }
// 过滤 CR/LF 等控制字符,防日志注入(伪造日志行)
return String(s).replace(/[\r\n\u0000-\u001f\u007f]/g, " ");
}) })
.join(" "); .join(" ");
return `[${ts}] [${level.toUpperCase()}] ${msg}`; return `[${ts}] [${level.toUpperCase()}] ${msg}`;
@@ -46,4 +50,5 @@ module.exports = {
warn: (...a) => emit("warn", a), warn: (...a) => emit("warn", a),
error: (...a) => emit("error", a), error: (...a) => emit("error", a),
tail: (n = 100) => ring.slice(-n), tail: (n = 100) => ring.slice(-n),
setLevel,
}; };
+1 -1
View File
@@ -20,7 +20,7 @@ function killAllProcs() {
try { try {
p.kill("SIGKILL"); p.kill("SIGKILL");
n++; n++;
} catch (_) {} } catch (e) { logger.warn('[MEDIA] 计数失败:', e.message); }
} }
activeProcs.clear(); activeProcs.clear();
if (n) logger.warn(`[MEDIA] 已终止 ${n} 个残留 FFmpeg 进程`); if (n) logger.warn(`[MEDIA] 已终止 ${n} 个残留 FFmpeg 进程`);
+23 -2
View File
@@ -14,7 +14,27 @@ const NETEASE_UA =
const NETEASE_REFERER = "http://music.163.com/"; const NETEASE_REFERER = "http://music.163.com/";
/* ==================== HTTP 工具 ==================== */ /* ==================== 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) { function request(url, redirects = 0) {
if (!isAllowedHost(url)) {
return Promise.reject(new Error("不允许的请求地址(域名不在白名单)"));
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const lib = url.startsWith("https:") ? https : http; const lib = url.startsWith("https:") ? https : http;
const req = lib.get( const req = lib.get(
@@ -123,6 +143,7 @@ function extFromContentType(ct) {
function downloadCover(url, key) { function downloadCover(url, key) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!url) return 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 }); fs.mkdirSync(config.COVERS_DIR, { recursive: true });
const lib = url.startsWith("https:") ? https : http; const lib = url.startsWith("https:") ? https : http;
const req = lib.get( const req = lib.get(
@@ -170,10 +191,10 @@ async function enrichSong(file, opts = {}) {
try { try {
const probe = await media.probeAudio(filePath); const probe = await media.probeAudio(filePath);
if (probe.duration != null) fields.duration = probe.duration; if (probe.duration != null) fields.duration = probe.duration;
} catch (_) {} } catch (e) { logger.warn('[META] 探测时长失败:', e.message); }
try { try {
fields.file_size = fs.statSync(filePath).size; fields.file_size = fs.statSync(filePath).size;
} catch (_) {} } catch (e) { logger.warn('[META] 取文件大小失败:', e.message); }
// 若缺作者或缺封面,尝试网易云匹配 // 若缺作者或缺封面,尝试网易云匹配
const needArtist = !(fields.artist || (song && song.artist)); const needArtist = !(fields.artist || (song && song.artist));
+54 -9
View File
@@ -2,42 +2,87 @@
const express = require("express"); const express = require("express");
const auth = require("../auth"); const auth = require("../auth");
const logger = require("../logger"); const logger = require("../logger");
const config = require("../config");
const router = express.Router(); 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); const locked = auth.isLocked(ip);
if (locked > 0) { if (locked > 0) {
return res.status(429).json({ ok: false, error: `登录失败次数过多,请 ${locked} 秒后再试` }); return res.status(429).json({ ok: false, error: `登录失败次数过多,请 ${locked} 秒后再试` });
} }
const { password } = req.body || {}; const { username, password } = req.body || {};
if (!password) return res.status(400).json({ ok: false, error: "请输入密码" });
// 类型校验前置:非法类型直接 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); const role = auth.verifyLogin(password);
if (!role) { if (!role) {
auth.recordFail(ip); auth.recordFail(ip);
logger.warn(`[AUTH] 登录失败 from ${ip}`); logger.warn(`[AUTH] 应急密码登录失败 from ${ip}`);
return res.status(401).json({ ok: false, error: "密码错误" }); return res.status(401).json({ ok: false, error: "密码错误" });
} }
auth.clearFail(ip); auth.clearFail(ip);
const { token, expiresAt } = auth.createSession(role); const { token, expiresAt } = auth.createSession(role, null);
auth.setSessionCookie(res, token, expiresAt); auth.setSessionCookie(res, token, expiresAt);
logger.info(`[AUTH] 登录成功 (${role}) from ${ip}`); auth.setCsrfCookie(res, auth.genCsrfToken());
res.json({ ok: true, role }); logger.info(`[AUTH] 应急密码登录成功 (${role}) from ${ip}`);
res.json({ ok: true, role, starlightName: null });
}); });
router.post("/logout", (req, res) => { router.post("/logout", (req, res) => {
auth.logout(req); auth.logout(req);
auth.clearSessionCookie(res); auth.clearSessionCookie(res);
auth.clearCsrfCookie(res);
res.json({ ok: true }); res.json({ ok: true });
}); });
router.get("/me", (req, res) => { router.get("/me", (req, res) => {
const role = auth.getRole(req); 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; module.exports = router;
+12 -12
View File
@@ -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; const { keyword } = req.query;
if (!keyword || !String(keyword).trim()) if (!keyword || !String(keyword).trim())
return res.status(400).json({ ok: false, error: "缺少搜索关键词" }); return res.status(400).json({ ok: false, error: "缺少搜索关键词" });
const kw = String(keyword).trim(); const kw = String(keyword).trim();
// 命中缓存直接返回(不消耗限流额度) // 限流检查(前置,缓存命中也要计入——防 V13 缓存绕过)
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 });
}
// 未命中 → 限流检查
const ip = req.ip || req.connection?.remoteAddress || "unknown"; const ip = req.ip || req.connection?.remoteAddress || "unknown";
if (searchRateLimited(ip)) { if (searchRateLimited(ip)) {
return res.status(429).json({ ok: false, error: "搜索过于频繁,请稍后再试" }); 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 { try {
const r = await meta.searchNetEase(kw); const r = await meta.searchNetEase(kw);
searchCache.set(kw, { at: Date.now(), data: r }); searchCache.set(kw, { at: Date.now(), data: r });
@@ -177,14 +177,14 @@ router.post("/import", require("../auth").requireAdmin, upload.single("audio"),
} catch (e) { } catch (e) {
try { try {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
} catch (_) {} } catch (e) { logger.warn('[MUSIC] 删临时文件失败:', e.message); }
logger.error("[IMPORT] 失败:", e.message); logger.error("[IMPORT] 失败:", e.message);
res.status(400).json({ ok: false, error: 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 || {}; const { file } = req.body || {};
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" }); 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 || {}; const { files } = req.body || {};
if (!Array.isArray(files)) return res.status(400).json({ ok: false, error: "无效数据" }); if (!Array.isArray(files)) return res.status(400).json({ ok: false, error: "无效数据" });
const results = []; 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 || {}; const { file, skipMatch } = req.body || {};
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" }); if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" });
if (!store.getSongByFile(file)) return res.status(404).json({ ok: false, error: "歌曲不存在" }); if (!store.getSongByFile(file)) return res.status(404).json({ ok: false, error: "歌曲不存在" });
+25
View File
@@ -159,4 +159,29 @@ router.post("/:id/playlist/jump", (req, res) => {
res.json({ ok: true, current: f, list: snapshot(req.params.id) }); 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; module.exports = router;
+64 -3
View File
@@ -37,6 +37,13 @@ router.get("/config", (req, res) => {
MAX_CACHE_SIZE: config.MAX_CACHE_SIZE, MAX_CACHE_SIZE: config.MAX_CACHE_SIZE,
MAX_FFMPEG_CONCURRENT: config.MAX_FFMPEG_CONCURRENT, MAX_FFMPEG_CONCURRENT: config.MAX_FFMPEG_CONCURRENT,
HTTP_PORT: config.HTTP_PORT, 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", {}) }); res.json({ ok: true, settings: store.getSetting("app", {}) });
}); });
router.post("/settings", (req, res) => { router.post("/settings", (req, res) => {
store.setSetting("app", req.body || {}); const body = req.body || {};
res.json({ ok: true }); // 字段白名单:只接受可热改的配置项
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; /* ==================== 用户 & 管理员管理 ==================== */
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;
+33 -7
View File
@@ -25,6 +25,13 @@ function bootstrap() {
// DB // DB
db.init(); 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(); auth.initPasswordLog();
// 扫描资源入库 // 扫描资源入库
@@ -71,7 +78,9 @@ function bootstrap() {
function createApp() { function createApp() {
const app = express(); 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) => { app.get("/login", (req, res) => {
@@ -91,13 +100,17 @@ function createApp() {
// SSE(放在鉴权前,前端用 EventSource 无法带 header,靠 cookie) // SSE(放在鉴权前,前端用 EventSource 无法带 header,靠 cookie)
app.get("/api/events", (req, res) => { app.get("/api/events", (req, res) => {
if (!auth.isAuthed(req)) return res.status(401).end(); 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, { res.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
Connection: "keep-alive", Connection: "keep-alive",
}); });
res.write(":ok\n\n"); res.write(":ok\n\n");
sse.addClient(res);
}); });
// HLS 流(不锁) // HLS 流(不锁)
@@ -119,15 +132,28 @@ function createApp() {
}); });
app.use(express.static(config.PUBLIC_DIR, { index: false })); app.use(express.static(config.PUBLIC_DIR, { index: false }));
// 受保护的 API // 受保护的 API —— 写操作需 CSRF 校验
app.use("/api/music", auth.requireAuth, musicRoutes); app.use("/api/music", auth.requireAuth, auth.csrfProtection, auth.requireAdminForWrites, musicRoutes);
app.use("/api/resources", auth.requireAuth, resourceRoutes); app.use("/api/resources", auth.requireAuth, auth.csrfProtection, resourceRoutes);
app.use("/api/streams", auth.requireAuth, streamRoutes); app.use("/api/streams", auth.requireAuth, auth.csrfProtection, auth.requireAdminForWrites, streamRoutes);
app.use("/api/system", auth.requireAdmin, systemRoutes); app.use("/api/system", auth.requireAdmin, auth.csrfProtection, systemRoutes);
// 其余 /api 需要登录 // 其余 /api 需要登录
app.use("/api", auth.requireAuth); 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; return app;
} }
+36 -4
View File
@@ -1,9 +1,33 @@
"use strict"; "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); 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) { function broadcast(event, data) {
@@ -13,6 +37,13 @@ function broadcast(event, data) {
c.write(msg); c.write(msg);
} catch (_) { } catch (_) {
clients.delete(c); 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 (_) {} } catch (_) {}
} }
clients.clear(); clients.clear();
ipCount.clear();
} }
module.exports = { addClient, broadcast, closeAll }; module.exports = { addClient, broadcast, closeAll, getClientCount: () => clients.size };
+115
View File
@@ -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 };
+85 -5
View File
@@ -46,7 +46,9 @@ function upsertSong(s) {
return getSongByFile(s.file); return getSongByFile(s.file);
} }
function updateSong(file, fields) { 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; if (!keys.length) return;
const set = keys.map((k) => `${k}=?`).join(", "); const set = keys.map((k) => `${k}=?`).join(", ");
getDb() getDb()
@@ -136,11 +138,54 @@ function clearPlaylist(streamId) {
getDb().prepare("DELETE FROM stream_playlist WHERE stream_id=?").run(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 ==================== */ /* ==================== sessions ==================== */
function createSession(token, role, expiresAt) { function createSession(token, role, expiresAt, starlightName, starlightUuid) {
getDb() getDb()
.prepare("INSERT INTO sessions (token, role, created_at, expires_at) VALUES (?, ?, ?, ?)") .prepare("INSERT INTO sessions (token, role, created_at, expires_at, starlight_name, starlight_uuid) VALUES (?, ?, ?, ?, ?, ?)")
.run(token, role || "user", Date.now(), expiresAt); .run(token, role || "user", Date.now(), expiresAt, starlightName || null, starlightUuid || null);
} }
function getSession(token) { function getSession(token) {
const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token); const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token);
@@ -151,10 +196,43 @@ function getSession(token) {
} }
return s; return s;
} }
// 滑动续期:把会话过期时间往后推
function touchSession(token, newExpiresAt) {
getDb().prepare("UPDATE sessions SET expires_at=? WHERE token=?").run(newExpiresAt, token);
}
function deleteSession(token) { function deleteSession(token) {
getDb().prepare("DELETE FROM sessions WHERE token=?").run(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 ==================== */ /* ==================== settings ==================== */
function getSetting(key, def = null) { function getSetting(key, def = null) {
const r = getDb().prepare("SELECT value FROM settings WHERE key=?").get(key); const r = getDb().prepare("SELECT value FROM settings WHERE key=?").get(key);
@@ -179,6 +257,8 @@ module.exports = {
createStream, listStreams, getStream, deleteStream, setStreamState, createStream, listStreams, getStream, deleteStream, setStreamState,
getPlaylistRows, playlistCount, addToPlaylist, removePlaylistEntry, getPlaylistRows, playlistCount, addToPlaylist, removePlaylistEntry,
setPlaylistOrder, clearPlaylist, setPlaylistOrder, clearPlaylist,
createSession, getSession, deleteSession, getPoolRows, listPoolSongIds, isInPool, addToPool, removeFromPool, drawFromPool, markPoolPlayed,
createSession, getSession, deleteSession, touchSession,
getSetting, setSetting, getSetting, setSetting,
listAdmins, getAdmin, isAdminUuid, addAdmin, removeAdmin, listUsers,
}; };
+1 -1
View File
@@ -19,7 +19,7 @@ function getDirSize(dirPath) {
try { try {
const st = fs.statSync(full); const st = fs.statSync(full);
size += st.isDirectory() ? getDirSize(full) : st.size; size += st.isDirectory() ? getDirSize(full) : st.size;
} catch (_) {} } catch (_) { /* 文件读不到就跳过,正常 */ }
} }
return size; return size;
} }
+18 -2
View File
@@ -1,4 +1,20 @@
@echo off @echo off
set "ADMIN_PASSWORD=test123" REM ===== 应急管理员密码(后门,StarLight 挂掉时用)=====
set "USER_PASSWORD=user123" REM 支持三种格式:
REM 明文:set "ADMIN_PASSWORD=你的密码"
REM sha256:set "ADMIN_PASSWORD=sha256:<hex>"
REM scrypt:set "ADMIN_PASSWORD=scrypt:<salt_hex>:<hash_hex>" ← 推荐(加盐慢哈希)
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 node index.js
+11 -1
View File
@@ -1,6 +1,8 @@
import urllib.request, urllib.error, http.cookiejar, json, sys, time import urllib.request, urllib.error, http.cookiejar, json, sys, time
BASE = "http://127.0.0.1:3000" BASE = "http://127.0.0.1:3000"
import os
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
def opener_with_cookies(): def opener_with_cookies():
cj = http.cookiejar.CookieJar() 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) req = urllib.request.Request(url, data=body, method=method)
if body: if body:
req.add_header("Content-Type", "application/json") 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: try:
r = op.open(req, timeout=10) r = op.open(req, timeout=10)
return r.status, r.read() 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, 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(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, _ = 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) st, body = call(auth, "/api/resources"); d = json.loads(body)
+7 -1
View File
@@ -1,6 +1,8 @@
import urllib.request, urllib.error, http.cookiejar, json, sys, re import urllib.request, urllib.error, http.cookiejar, json, sys, re
BASE = "http://127.0.0.1:3000" BASE = "http://127.0.0.1:3000"
import os
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
cj = http.cookiejar.CookieJar() cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) 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 body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(BASE + path, data=body, method=method) req = urllib.request.Request(BASE + path, data=body, method=method)
if body: req.add_header("Content-Type", "application/json") 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: try:
r = op.open(req, timeout=10) r = op.open(req, timeout=10)
return r.status, r.read() 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}") 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}") check("登录", st == 200 and json.loads(body).get("ok"), f"got {st}")
# 首页返回面板(含 vendor 引用) # 首页返回面板(含 vendor 引用)
+3 -1
View File
@@ -1,6 +1,8 @@
import urllib.request, urllib.error, http.cookiejar, json, sys import urllib.request, urllib.error, http.cookiejar, json, sys
BASE = "http://127.0.0.1:3000" BASE = "http://127.0.0.1:3000"
import os
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
cj = http.cookiejar.CookieJar() cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) 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: except urllib.error.HTTPError as e:
return e.code, e.read() 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") st, body = call("/api/resources")
songs = json.loads(body)["list"] songs = json.loads(body)["list"]
print(f"共 {len(songs)} 首,开始补全元数据...\n") print(f"共 {len(songs)} 首,开始补全元数据...\n")
+10 -1
View File
@@ -1,6 +1,8 @@
import urllib.request, urllib.error, http.cookiejar, json, sys, os, io, uuid import urllib.request, urllib.error, http.cookiejar, json, sys, os, io, uuid
BASE = "http://127.0.0.1:3000" BASE = "http://127.0.0.1:3000"
import os
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
cj = http.cookiejar.CookieJar() cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) 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 body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(BASE + path, data=body, method=method) req = urllib.request.Request(BASE + path, data=body, method=method)
if body: req.add_header("Content-Type", "application/json") 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: try:
r = op.open(req, timeout=30) r = op.open(req, timeout=30)
return r.status, r.read() 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) 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") 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 = urllib.request.Request(BASE + "/api/music/import", data=body, method="POST")
req.add_header("Content-Type", "multipart/form-data; boundary=" + boundary) 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: try:
r = op.open(req, timeout=60) r = op.open(req, timeout=60)
d = json.loads(r.read()) d = json.loads(r.read())