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:
1 parent
5066f32fa4
commit
f0fe50e484
29 files changed
+1703
-315
No files matched your search
+14
-1
@@ -39,4 +39,17 @@ audio/*.m4a
|
||||
audio/*.flac
|
||||
audio/*.wav
|
||||
audio/*.aac
|
||||
audio/*.ogg
|
||||
audio/*.ogg
|
||||
# Python 缓存
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# 备份文件
|
||||
*.bak_*
|
||||
*.bak
|
||||
|
||||
# 安全报告(含敏感信息,本地保留)
|
||||
SECURITY_PENTEST.md
|
||||
SECURITY_REVIEW.md
|
||||
|
||||
# 临时归档
|
||||
*.tgz
|
||||
+335
-2
@@ -1,4 +1,4 @@
|
||||
# 交接文档 · 音乐在线电台
|
||||
# 交接文档 · Starlight专属电台
|
||||
|
||||
> 跨会话交接。**新会话先读此文件再动手**。最后更新:2026-09-25(v2.6)。
|
||||
|
||||
@@ -206,4 +206,337 @@ python -X utf8 test_write.py # 5 项(需 tmp/test_import.mp3 素材)
|
||||
- 后台服务:`svc003`(`http://127.0.0.1:3000`)
|
||||
- DB:`data.db`(6 首歌曲,封面已本地化)
|
||||
- 缓存:`hls_cache/` 约 34MB
|
||||
- Git:`2fc2548` 干净
|
||||
- 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,4 +1,4 @@
|
||||
# 音乐在线电台 · v2
|
||||
# Starlight专属电台 · v2
|
||||
|
||||
把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成**多路**永不停播的直播流,并提供 Web 控制面板管理播放列表、导入音源(网易云 / 本地上传)。前后端一体的 Node.js 应用。
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "music-online-demo",
|
||||
"version": "2.6.0",
|
||||
"description": "音乐在线电台:FFmpeg HLS 直播流服务(多路流 + SQLite + 导入 + 鉴权)",
|
||||
"description": "Starlight专属电台:FFmpeg HLS 直播流服务(多路流 + SQLite + 导入 + 鉴权)",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
|
||||
+197
-8
@@ -328,12 +328,14 @@
|
||||
.page {
|
||||
display: none;
|
||||
}
|
||||
.page.active {
|
||||
.page.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
animation: pageEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes pageEnter {
|
||||
@@ -361,6 +363,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes cardRise {
|
||||
from {
|
||||
@@ -807,15 +810,9 @@
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
.section-body {
|
||||
.section-body {
|
||||
padding: 0 0.5rem 0.5rem 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.section-body::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
@@ -2144,3 +2141,195 @@
|
||||
outline: none;
|
||||
}
|
||||
.resource-sort:hover { border-color: var(--accent); }
|
||||
|
||||
/* StarLight 用户名显示 */
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--muted, #8b949e);
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==================== 设置页:热配置表单 ==================== */
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-row label {
|
||||
font-size: var(--fs-sm, 0.85rem);
|
||||
color: var(--text, #e6edf3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.setting-row input,
|
||||
.setting-row select {
|
||||
width: 130px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--text, #e6edf3);
|
||||
font-size: var(--fs-sm, 0.85rem);
|
||||
outline: none;
|
||||
text-align: right;
|
||||
}
|
||||
.setting-row select { text-align: left; }
|
||||
.setting-row input:focus,
|
||||
.setting-row select:focus { border-color: var(--accent, #1f6feb); }
|
||||
/* ==================== 设置页:热配置表单 ==================== */
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-row label {
|
||||
font-size: var(--fs-base);
|
||||
color: var(--text-primary);
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
.setting-row input,
|
||||
.setting-row select {
|
||||
width: 140px;
|
||||
padding: 7px 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--fs-base);
|
||||
outline: none;
|
||||
text-align: right;
|
||||
font-family: inherit;
|
||||
}
|
||||
.setting-row select { text-align: left; }
|
||||
.setting-row input:focus,
|
||||
.setting-row select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
.setting-row input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* ==================== 权限管理:用户列表 ==================== */
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.user-item .user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.user-item .user-name {
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.user-item .user-sub {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: var(--fs-2xs);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.btn-mini {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all .18s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-mini:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-mini.danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||
.user-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
padding: 20px;
|
||||
}
|
||||
/* ==================== 设置页布局优化 v2 ==================== */
|
||||
/* 让整页统一滚动,卡片更透气 */
|
||||
#pageSettings {
|
||||
gap: var(--sp-5, 20px);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
#pageSettings .section-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* 热配置表单更宽松 */
|
||||
#pageSettings .settings-form {
|
||||
gap: 0;
|
||||
}
|
||||
#pageSettings .setting-row {
|
||||
padding: 12px 0;
|
||||
}
|
||||
#pageSettings .setting-row label {
|
||||
font-size: var(--fs-md, 0.92rem);
|
||||
}
|
||||
#pageSettings .setting-row input,
|
||||
#pageSettings .setting-row select {
|
||||
width: 150px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
/* 日志区限高,自身滚动(唯一允许内滚的区域) */
|
||||
#pageSettings .log-view {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* 用户列表项间距 */
|
||||
#pageSettings .user-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 循环池按钮激活态 */
|
||||
.btn-icon.js-pool.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
+76
-147
@@ -7,7 +7,7 @@
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1"
|
||||
/>
|
||||
<meta name="theme-color" content="#f4f5f9" />
|
||||
<title>Xiao の 电台</title>
|
||||
<title>Starlight专属电台</title>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="vendor/bootstrap-icons.css" />
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="brand-icon">
|
||||
<img src="favicon.png" alt="logo" />
|
||||
</div>
|
||||
<span>Xiao の 电台</span>
|
||||
<span>Starlight专属电台</span>
|
||||
<span class="live-pill"
|
||||
><span class="live-dot"></span>LIVE</span
|
||||
>
|
||||
@@ -42,6 +42,7 @@
|
||||
<button class="btn-link-sm" id="liveLink" title="复制直播流链接"
|
||||
><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="退出登录"
|
||||
><i class="bi bi-box-arrow-right"></i></button>
|
||||
</div>
|
||||
@@ -282,12 +283,82 @@
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<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>
|
||||
<button class="btn-clear" id="btnSaveSettings"><i class="bi bi-check-lg"></i> 保存并生效</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -310,145 +381,6 @@
|
||||
</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 | 后端: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 · Built with ♥ ·
|
||||
<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>
|
||||
|
||||
<!-- 频道管理模态框 -->
|
||||
@@ -514,9 +446,6 @@
|
||||
<button class="nav-tab" data-page="pageSettings">
|
||||
<i class="bi bi-gear"></i><span>设置</span>
|
||||
</button>
|
||||
<button class="nav-tab" data-page="pageAbout">
|
||||
<i class="bi bi-info-circle"></i><span>关于</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
+168
-19
@@ -1,5 +1,5 @@
|
||||
/* ============================================================
|
||||
Xiao の 电台 — app.js (v2)
|
||||
Starlight专属电台 — app.js (v2)
|
||||
多路流 + 鉴权 + 删除/导入 + 封面本地化
|
||||
============================================================ */
|
||||
|
||||
@@ -47,7 +47,16 @@ function coverSrcAttr(item) {
|
||||
|
||||
// ==================== API(统一带 cookie,401 跳登录) ====================
|
||||
async function api(url, options = {}) {
|
||||
const res = await fetch(url, { credentials: "same-origin", ...options });
|
||||
const opts = { credentials: "same-origin", ...options };
|
||||
// 写操作带 CSRF 头(双提交 Cookie)
|
||||
const method = (opts.method || "GET").toUpperCase();
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
|
||||
const m = document.cookie.match(/(?:^|;\s*)mr_csrf=([^;]+)/);
|
||||
if (m) {
|
||||
opts.headers = { ...(opts.headers || {}), "X-CSRF-Token": decodeURIComponent(m[1]) };
|
||||
}
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login";
|
||||
throw new Error("未登录");
|
||||
@@ -59,12 +68,24 @@ async function api(url, options = {}) {
|
||||
return data;
|
||||
}
|
||||
// 角色 UI:非管理员隐藏 设置页 + 本地导入
|
||||
function applyRoleUI(role) {
|
||||
function applyRoleUI(role, starlightName) {
|
||||
const isAdmin = role === "admin";
|
||||
const tab = document.querySelector('.nav-tab[data-page="pageSettings"]');
|
||||
if (tab) tab.style.display = isAdmin ? "" : "none";
|
||||
const imp = document.getElementById("importCard");
|
||||
if (imp) imp.style.display = isAdmin ? "" : "none";
|
||||
const un = document.getElementById("userName");
|
||||
if (un) {
|
||||
if (starlightName) {
|
||||
un.textContent = "你好," + starlightName;
|
||||
un.title = "StarLight 用户" + (isAdmin ? "(管理员)" : "");
|
||||
un.style.display = "";
|
||||
} else {
|
||||
un.textContent = "管理员";
|
||||
un.title = "应急密码登录";
|
||||
un.style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
function post(url, body) {
|
||||
return api(url, {
|
||||
@@ -77,7 +98,7 @@ function post(url, body) {
|
||||
// ==================== Navigation ====================
|
||||
const tabs = document.querySelectorAll(".nav-tab");
|
||||
const pages = document.querySelectorAll(".page");
|
||||
const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings", "pageAbout"];
|
||||
const pageOrder = ["pageLive", "pageSearch", "pageResources", "pagePlaylist", "pageSettings"];
|
||||
function switchPage(pageId) {
|
||||
pages.forEach((p) => p.classList.remove("active"));
|
||||
tabs.forEach((t) => t.classList.remove("active"));
|
||||
@@ -270,7 +291,7 @@ function renderResItem(item) {
|
||||
<div class="res-actions">
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,7 +306,8 @@ function renderResItem(item) {
|
||||
const el = tpl.content.firstElementChild;
|
||||
el.querySelector(".js-add").addEventListener("click", () => addToPlaylist(item.file));
|
||||
el.querySelector(".js-del").addEventListener("click", () => deleteResource(item.file, item.title));
|
||||
el.querySelector(".js-enrich").addEventListener("click", () => enrichResource(item.file));
|
||||
const poolBtn = el.querySelector(".js-pool");
|
||||
if (poolBtn) poolBtn.addEventListener("click", () => togglePool(item.file));
|
||||
return el;
|
||||
}
|
||||
function updateResItem(el, item) {
|
||||
@@ -321,6 +343,16 @@ async function loadResources() {
|
||||
try {
|
||||
const r = await api("/api/resources");
|
||||
const resources = sortResources(r.list || []);
|
||||
// 查当前流的循环池(仅管理员显示按钮),标记每首是否在池
|
||||
if (state.role === 'admin') {
|
||||
try {
|
||||
const pr = await api(`/api/streams/${currentStreamId}/pool`);
|
||||
const poolSet = new Set((pr.list || []).map(x => x.file));
|
||||
resources.forEach(x => { x.inPool = poolSet.has(x.file); });
|
||||
} catch (_) { resources.forEach(x => { x.inPool = false; }); }
|
||||
} else {
|
||||
resources.forEach(x => { x.inPool = false; });
|
||||
}
|
||||
state.resources = resources;
|
||||
const container = document.getElementById("resourceList");
|
||||
document.getElementById("resourceCount").textContent = resources.length;
|
||||
@@ -440,6 +472,19 @@ async function deleteResource(file, title) {
|
||||
await loadPlaylist();
|
||||
} catch (err) { showToast("删除失败: " + err.message, "error"); }
|
||||
}
|
||||
async function togglePool(file) {
|
||||
const item = (state.resources || []).find(x => x.file === file);
|
||||
const inPool = item && item.inPool;
|
||||
const action = inPool ? "remove" : "add";
|
||||
try {
|
||||
await post(`/api/streams/${currentStreamId}/pool/${action}`, { file });
|
||||
showToast(inPool ? "已移出循环池" : "已加入循环池", "success");
|
||||
await loadResources();
|
||||
} catch (e) {
|
||||
showToast("操作失败: " + e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichResource(file) {
|
||||
try {
|
||||
showToast("正在重新匹配...");
|
||||
@@ -594,10 +639,15 @@ async function searchMusic() {
|
||||
btn.disabled = true; btn.textContent = "查询中...";
|
||||
try {
|
||||
if (id && /^\d+$/.test(id)) {
|
||||
// 按 ID / 链接解析:所有登录用户可用
|
||||
document.getElementById("searchResults").style.display = "none";
|
||||
await showDetail(id);
|
||||
} else {
|
||||
} else if (state.role === "admin") {
|
||||
// 关键词搜索:仅管理员
|
||||
renderSearchResults(await api("/api/music/search?keyword=" + encodeURIComponent(rawInput)), rawInput);
|
||||
} else {
|
||||
// 普通用户:只能用 ID 或链接
|
||||
showToast("普通用户请用歌曲 ID 或网易云链接解析", "error");
|
||||
}
|
||||
} catch (err) { showToast("查询失败: " + err.message, "error"); }
|
||||
finally { btn.disabled = false; btn.textContent = "查询"; }
|
||||
@@ -856,23 +906,89 @@ async function loadSettings() {
|
||||
"已用 " + c.usedPercent + "%,超限时自动按热度 LRU 淘汰";
|
||||
} catch (e) { console.error("缓存信息失败", e); }
|
||||
|
||||
// 配置
|
||||
// 热配置表单:填充当前值
|
||||
try {
|
||||
const r = await api("/api/system/config");
|
||||
const cf = r.config || {};
|
||||
const items = [
|
||||
["HTTP 端口", cf.HTTP_PORT],
|
||||
["切片时长", cf.HLS_TIME + " 秒"],
|
||||
["滑动窗口", cf.SLIDE_WINDOW_SIZE + " 段"],
|
||||
["缓存上限", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0) + " MB"],
|
||||
["FFmpeg 并发", cf.MAX_FFMPEG_CONCURRENT + " 路"],
|
||||
];
|
||||
document.getElementById("setConfigGrid").innerHTML = items
|
||||
.map(([k, v]) => `<div class="config-item"><span>${esc(k)}</span><b>${esc(String(v))}</b></div>`)
|
||||
.join("");
|
||||
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
|
||||
set("cfgHlsTime", cf.HLS_TIME);
|
||||
set("cfgSlideWindow", cf.SLIDE_WINDOW_SIZE);
|
||||
set("cfgMaxCacheMB", (cf.MAX_CACHE_SIZE / 1048576).toFixed(0));
|
||||
set("cfgLogLevel", cf.LOG_LEVEL || "info");
|
||||
set("cfgSessionTtlHours", cf.SESSION_TTL_HOURS);
|
||||
set("cfgLoginMaxFails", cf.LOGIN_MAX_FAILS);
|
||||
set("cfgLoginLockMin", cf.LOGIN_LOCK_MIN);
|
||||
set("cfgSearchCacheTtlSec", cf.SEARCH_CACHE_TTL_SEC);
|
||||
set("cfgSearchRateMax", cf.SEARCH_RATE_MAX);
|
||||
set("cfgSearchRateWindowSec", cf.SEARCH_RATE_WINDOW_SEC);
|
||||
// 默认频道下拉:填选项 + 回填当前选中值
|
||||
const sel = document.getElementById("cfgDefaultStream");
|
||||
if (sel) {
|
||||
try {
|
||||
const st = await api("/api/streams");
|
||||
sel.innerHTML = (st.list || []).map(s =>
|
||||
`<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); }
|
||||
|
||||
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() {
|
||||
@@ -888,6 +1004,39 @@ async function loadLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btnSaveSettings").addEventListener("click", async () => {
|
||||
const btn = document.getElementById("btnSaveSettings");
|
||||
const val = (id) => {
|
||||
const v = document.getElementById(id).value;
|
||||
return v === "" ? undefined : (isNaN(v) ? v : Number(v));
|
||||
};
|
||||
const payload = {
|
||||
hlsTime: val("cfgHlsTime"),
|
||||
slideWindow: val("cfgSlideWindow"),
|
||||
maxCacheMB: val("cfgMaxCacheMB"),
|
||||
defaultStream: document.getElementById("cfgDefaultStream").value || undefined,
|
||||
logLevel: document.getElementById("cfgLogLevel").value,
|
||||
sessionTtlHours: val("cfgSessionTtlHours"),
|
||||
loginMaxFails: val("cfgLoginMaxFails"),
|
||||
loginLockMin: val("cfgLoginLockMin"),
|
||||
searchCacheTtlSec: val("cfgSearchCacheTtlSec"),
|
||||
searchRateMax: val("cfgSearchRateMax"),
|
||||
searchRateWindowSec: val("cfgSearchRateWindowSec"),
|
||||
};
|
||||
// 去掉 undefined
|
||||
Object.keys(payload).forEach(k => payload[k] === undefined && delete payload[k]);
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await post("/api/system/settings", payload);
|
||||
showToast("配置已保存并生效", "success");
|
||||
await loadSettings();
|
||||
} catch (e) {
|
||||
showToast("保存失败: " + e.message, "error");
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
document.getElementById("btnRefreshUsers").addEventListener("click", loadUsers);
|
||||
|
||||
document.getElementById("btnCleanup").addEventListener("click", async () => {
|
||||
if (!confirm("确定立即清理缓存?将按热度淘汰非播放中的切片。")) return;
|
||||
try {
|
||||
@@ -927,7 +1076,7 @@ document.getElementById("btnRefreshCovers").addEventListener("click", async () =
|
||||
|
||||
// ==================== Init ====================
|
||||
(async function init() {
|
||||
try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role); } catch (_) {}
|
||||
try { const me = await api("/api/auth/me"); state.role = me.role || null; applyRoleUI(state.role, me.starlightName); } catch (_) {}
|
||||
await loadStreams();
|
||||
loadLiveCover();
|
||||
await loadResources();
|
||||
|
||||
+198
-64
@@ -3,139 +3,273 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="stylesheet" href="/vendor/bootstrap-icons.css" />
|
||||
<style>
|
||||
/* ===== 与主页面一致的浅色设计 token ===== */
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--accent: #1f6feb;
|
||||
--accent-hover: #388bfd;
|
||||
--danger: #f85149;
|
||||
--bg: #f4f5f9;
|
||||
--surface: #fff;
|
||||
--surface-hover: #fafbff;
|
||||
--accent: #4f6ef7;
|
||||
--accent-hover: #3d5ce0;
|
||||
--accent-light: #eef1fe;
|
||||
--accent-glow: rgba(79, 110, 247, 0.14);
|
||||
--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; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at 30% 20%, #1a2332, var(--bg) 60%);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
padding: 20px;
|
||||
background: var(--bg);
|
||||
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 {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
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 {
|
||||
text-align: center;
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
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 {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
margin: 0 0 4px;
|
||||
font-weight: 600;
|
||||
font-size: 1.35rem;
|
||||
margin: 0 0 6px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sub {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 28px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
input[type="password"] {
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.92rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
transition: border-color .18s, box-shadow .18s, background .18s;
|
||||
}
|
||||
input[type="password"]:focus { border-color: var(--accent); }
|
||||
button {
|
||||
input[type="text"]:focus, input[type="password"]:focus {
|
||||
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%;
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
margin-top: 6px;
|
||||
padding: 13px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
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); }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-primary:active { transform: translateY(1px); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: default; box-shadow: none; }
|
||||
|
||||
.error {
|
||||
margin-top: 14px;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🎵</div>
|
||||
<h1>音乐在线电台</h1>
|
||||
<div class="sub">请输入访问密码</div>
|
||||
<h1>Starlight专属电台</h1>
|
||||
<div class="sub">使用 StarLight 账号登录</div>
|
||||
<form id="loginForm">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" autocomplete="current-password" autofocus />
|
||||
<button type="submit" id="submitBtn">登录</button>
|
||||
<div class="field">
|
||||
<label for="username">StarLight 用户名</label>
|
||||
<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>
|
||||
<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>
|
||||
<script>
|
||||
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 err = document.getElementById("error");
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const password = input.value;
|
||||
if (!password) return;
|
||||
btn.disabled = true;
|
||||
const emergencyBtn = document.getElementById("emergencyBtn");
|
||||
const emergencyPassword = document.getElementById("emergencyPassword");
|
||||
|
||||
async function doLogin(payload) {
|
||||
err.textContent = "";
|
||||
try {
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (r.ok && data.ok) {
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
err.textContent = data.error || "登录失败";
|
||||
btn.disabled = false;
|
||||
return true;
|
||||
}
|
||||
err.textContent = data.error || "登录失败";
|
||||
return false;
|
||||
} catch (e) {
|
||||
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>
|
||||
</body>
|
||||
|
||||
+132
-10
@@ -8,6 +8,9 @@ const COOKIE_NAME = "mr_session";
|
||||
|
||||
/* ==================== 密码来源 ==================== */
|
||||
// 管理员密码:ADMIN_PASSWORD(兼容旧 PANEL_PASSWORD);未设置则首次随机生成并打印
|
||||
// 支持两种格式:
|
||||
// 1) 明文 "test123"
|
||||
// 2) 哈希 "sha256:<64位hex>" ← 推荐,文件泄露也无法直接反推
|
||||
// 用户密码:USER_PASSWORD;未设置则「无用户密码」——只有管理员能登录
|
||||
let adminPassword = config.ADMIN_PASSWORD;
|
||||
let userPassword = config.USER_PASSWORD;
|
||||
@@ -18,9 +21,25 @@ if (!adminPassword) {
|
||||
}
|
||||
|
||||
/* ==================== 登录失败锁定(按 IP) ==================== */
|
||||
// 取真实客户端 IP:不信任 X-Forwarded-For(可伪造),只用 socket 地址。
|
||||
// trust proxy=false 时 req.ip 已等于 socket 地址,但这里显式取 socket 更稳。
|
||||
function getClientIp(req) {
|
||||
return (req.socket && req.socket.remoteAddress) || req.ip || "unknown";
|
||||
}
|
||||
// 可信 IP 白名单:命中的 IP 不参与登录失败锁定(防管理员被攻击者锁死)
|
||||
function isTrustedIp(ip) {
|
||||
if (!ip) return false;
|
||||
// 归一化:IPv4-mapped IPv6(::ffff:127.0.0.1)与 IPv4 视作同一
|
||||
const norm = (s) => String(s).replace(/^::ffff:/i, "").trim();
|
||||
const target = norm(ip);
|
||||
const list = config.TRUSTED_IPS || [];
|
||||
return list.some((x) => norm(x) === target);
|
||||
}
|
||||
|
||||
const failMap = new Map(); // ip -> { count, lockedUntil }
|
||||
|
||||
function isLocked(ip) {
|
||||
if (isTrustedIp(ip)) return 0; // 可信 IP 永不锁定
|
||||
const rec = failMap.get(ip);
|
||||
if (!rec) return 0;
|
||||
if (rec.lockedUntil && rec.lockedUntil > Date.now()) {
|
||||
@@ -29,6 +48,7 @@ function isLocked(ip) {
|
||||
return 0;
|
||||
}
|
||||
function recordFail(ip) {
|
||||
if (isTrustedIp(ip)) return; // 可信 IP 不计失败
|
||||
const rec = failMap.get(ip) || { count: 0, lockedUntil: 0 };
|
||||
rec.count += 1;
|
||||
if (rec.count >= config.LOGIN_MAX_FAILS) {
|
||||
@@ -54,13 +74,12 @@ function parseCookies(header) {
|
||||
}
|
||||
|
||||
/* ==================== 会话 ==================== */
|
||||
function createSession(role) {
|
||||
function createSession(role, starlightName, starlightUuid) {
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const expiresAt = Date.now() + config.SESSION_TTL_MS;
|
||||
store.createSession(token, role, expiresAt);
|
||||
return { token, expiresAt, role };
|
||||
store.createSession(token, role, expiresAt, starlightName || null, starlightUuid || null);
|
||||
return { token, expiresAt, role, starlightName: starlightName || null, starlightUuid: starlightUuid || null };
|
||||
}
|
||||
|
||||
function timingEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
@@ -68,19 +87,62 @@ function timingEqual(a, b) {
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// 返回角色 "admin" | "user" | null
|
||||
// 密码比对:支持 scrypt / sha256:<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) {
|
||||
if (!password) return null;
|
||||
if (timingEqual(password, adminPassword)) return "admin";
|
||||
if (userPassword && timingEqual(password, userPassword)) return "user";
|
||||
if (passwordMatches(password, adminPassword)) return "admin";
|
||||
if (userPassword && passwordMatches(password, userPassword)) return "user";
|
||||
return null;
|
||||
}
|
||||
|
||||
// 通过 StarLight 认证;成功返回 { role, starlightName, uuid },失败抛 Error
|
||||
async function loginViaStarLight(username, password) {
|
||||
const starlight = require("./starlight");
|
||||
const { username: name, uuid } = await starlight.authenticate(username, password);
|
||||
// 白名单绑 UUID(不可变),不绑用户名(可被抢注/改名)
|
||||
// 管理员判定:先查 admins 表(热管理),再查环境变量白名单(兼容)
|
||||
const role = (uuid && (store.isAdminUuid(String(uuid)) ||
|
||||
config.STARLIGHT_ADMINS.includes(String(uuid).toLowerCase()))) ? "admin" : "user";
|
||||
return { role, starlightName: name, uuid };
|
||||
}
|
||||
|
||||
function getValidSession(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
if (!token) return null;
|
||||
return store.getSession(token);
|
||||
const s = store.getSession(token);
|
||||
if (s) {
|
||||
// 滑动续期:活跃会话自动延长
|
||||
const newExp = Date.now() + config.SESSION_TTL_MS;
|
||||
if (newExp - s.expires_at > 60 * 1000) {
|
||||
store.touchSession(token, newExp);
|
||||
s.expires_at = newExp;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
@@ -90,19 +152,53 @@ function getRole(req) {
|
||||
const s = getValidSession(req);
|
||||
return s ? s.role || "user" : null;
|
||||
}
|
||||
function getStarlightName(req) {
|
||||
const s = getValidSession(req);
|
||||
return s ? s.starlight_name || null : null;
|
||||
}
|
||||
function getStarlightUuid(req) {
|
||||
const s = getValidSession(req);
|
||||
return s ? s.starlight_uuid || null : null;
|
||||
}
|
||||
function isAdmin(req) {
|
||||
return getRole(req) === "admin";
|
||||
}
|
||||
|
||||
// CSRF:双提交 Cookie。cookie 非 HttpOnly(JS 可读),同时前端把值放到 X-CSRF-Token 头
|
||||
const CSRF_COOKIE = "mr_csrf";
|
||||
function genCsrfToken() {
|
||||
return crypto.randomBytes(16).toString("hex");
|
||||
}
|
||||
function setCsrfCookie(res, token) {
|
||||
res.append("Set-Cookie", `${CSRF_COOKIE}=${token}; Path=/; SameSite=Lax`);
|
||||
}
|
||||
function clearCsrfCookie(res) {
|
||||
res.append("Set-Cookie", `${CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
function getCsrfFromCookie(req) {
|
||||
return parseCookies(req.headers.cookie)[CSRF_COOKIE] || "";
|
||||
}
|
||||
// 写操作校验:非 GET/HEAD/OPTIONS 必须带匹配的 X-CSRF-Token 头
|
||||
function csrfProtection(req, res, next) {
|
||||
const m = req.method.toUpperCase();
|
||||
if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next();
|
||||
const fromCookie = getCsrfFromCookie(req);
|
||||
const fromHeader = req.headers["x-csrf-token"] || "";
|
||||
if (!fromCookie || !fromHeader || fromCookie !== fromHeader) {
|
||||
return res.status(403).json({ ok: false, error: "CSRF 校验失败" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token, expiresAt) {
|
||||
const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
|
||||
res.setHeader(
|
||||
res.append(
|
||||
"Set-Cookie",
|
||||
`${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
|
||||
);
|
||||
}
|
||||
function clearSessionCookie(res) {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
res.append("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
/* ==================== 中间件 ==================== */
|
||||
@@ -120,6 +216,22 @@ function requireAdmin(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
// 读操作放行(GET/HEAD/OPTIONS),写操作要求管理员。
|
||||
// 例外:POST_READ_WHITELIST 里的路径虽是 POST,但语义是"读"(普通用户可用)。
|
||||
// 路径匹配用「去掉 /api 及模块前缀后的相对路径」,兼容挂载点。
|
||||
const POST_READ_WHITELIST = new Set([
|
||||
"/music/detail", // 按 id 查歌曲详情(普通用户预览用)
|
||||
]);
|
||||
function requireAdminForWrites(req, res, next) {
|
||||
const m = (req.method || "GET").toUpperCase();
|
||||
if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next();
|
||||
const full = (req.originalUrl || req.url || "").split("?")[0];
|
||||
// 去掉 /api 前缀后匹配白名单
|
||||
const rel = full.replace(/^\/api/, "") || "/";
|
||||
if (POST_READ_WHITELIST.has(rel)) return next();
|
||||
return requireAdmin(req, res, next);
|
||||
}
|
||||
|
||||
function logout(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
@@ -143,14 +255,24 @@ module.exports = {
|
||||
COOKIE_NAME,
|
||||
createSession,
|
||||
verifyLogin,
|
||||
loginViaStarLight,
|
||||
getValidSession,
|
||||
getStarlightName,
|
||||
getStarlightUuid,
|
||||
isAuthed,
|
||||
getRole,
|
||||
isAdmin,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
genCsrfToken,
|
||||
setCsrfCookie,
|
||||
clearCsrfCookie,
|
||||
csrfProtection,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
requireAdminForWrites,
|
||||
getClientIp,
|
||||
isTrustedIp,
|
||||
logout,
|
||||
initPasswordLog,
|
||||
isLocked,
|
||||
|
||||
+34
-1
@@ -42,6 +42,16 @@ const config = {
|
||||
|
||||
ADMIN_PASSWORD: envStr("ADMIN_PASSWORD", envStr("PANEL_PASSWORD", "")),
|
||||
USER_PASSWORD: envStr("USER_PASSWORD", ""),
|
||||
// 登录失败锁定的豁免 IP 白名单(防管理员被锁死 DoS),逗号分隔
|
||||
TRUSTED_IPS: envStr("TRUSTED_IPS", "127.0.0.1,::1,::ffff:127.0.0.1")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
|
||||
// StarLight Yggdrasil 第三方登录
|
||||
STARLIGHT_API: envStr("STARLIGHT_API", "https://skin.starlight.cool/yggdrasil"),
|
||||
// 管理员白名单(StarLight 角色的【UUID】,逗号分隔,大小写不敏感)
|
||||
// ⚠️ 绑 UUID 不绑用户名——用户名可能被抢注/改名,UUID 不可变
|
||||
STARLIGHT_ADMINS: envStr("STARLIGHT_ADMINS", "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean).map((s) => s.toLowerCase()),
|
||||
SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000,
|
||||
LOGIN_MAX_FAILS: envInt("LOGIN_MAX_FAILS", 5),
|
||||
LOGIN_LOCK_MS: envInt("LOGIN_LOCK_MIN", 10) * 60 * 1000,
|
||||
@@ -52,4 +62,27 @@ const config = {
|
||||
|
||||
config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000;
|
||||
|
||||
module.exports = config;
|
||||
/*
|
||||
* 热配置:从 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;
|
||||
@@ -41,6 +41,15 @@ CREATE TABLE IF NOT EXISTS stream_playlist (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_stream ON stream_playlist(stream_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stream_pool (
|
||||
stream_id TEXT NOT NULL,
|
||||
song_id INTEGER NOT NULL,
|
||||
played INTEGER NOT NULL DEFAULT 0,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (stream_id, song_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pool_stream ON stream_pool(stream_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
@@ -53,6 +62,12 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
function init() {
|
||||
@@ -70,6 +85,16 @@ function init() {
|
||||
db.exec("ALTER TABLE sessions ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
|
||||
logger.info("[DB] 迁移 sessions 表:新增 role 列");
|
||||
}
|
||||
// 迁移:sessions 表补 starlight_name 列(记录第三方登录来源用户名)
|
||||
if (!sessCols.some((c) => c.name === "starlight_name")) {
|
||||
db.exec("ALTER TABLE sessions ADD COLUMN starlight_name TEXT");
|
||||
logger.info("[DB] 迁移 sessions 表:新增 starlight_name 列");
|
||||
}
|
||||
// 迁移:sessions 表补 starlight_uuid 列(记录 UUID,用于用户列表/权限管理)
|
||||
if (!sessCols.some((c) => c.name === "starlight_uuid")) {
|
||||
db.exec("ALTER TABLE sessions ADD COLUMN starlight_uuid TEXT");
|
||||
logger.info("[DB] 迁移 sessions 表:新增 starlight_uuid 列");
|
||||
}
|
||||
|
||||
// 迁移:songs 表补 cover_remote 列(远程封面 URL,优先展示,省带宽)
|
||||
const songCols = db.prepare("PRAGMA table_info(songs)").all();
|
||||
|
||||
+11
-3
@@ -193,9 +193,17 @@ class LiveStream {
|
||||
ensureNotEmpty() {
|
||||
let rows = this.playlistRows();
|
||||
if (rows.length === 0) {
|
||||
const songs = store.getAllSongs();
|
||||
if (songs.length > 0) {
|
||||
const pick = songs[Math.floor(Math.random() * songs.length)];
|
||||
// 队列空:优先从【循环池】抽未播的(抽完重置);池空则降级从全部资源随机
|
||||
let pick = store.drawFromPool(this.id);
|
||||
if (pick) {
|
||||
store.markPoolPlayed(this.id, pick.id);
|
||||
} else {
|
||||
const songs = store.getAllSongs();
|
||||
if (songs.length > 0) {
|
||||
pick = songs[Math.floor(Math.random() * songs.length)];
|
||||
}
|
||||
}
|
||||
if (pick) {
|
||||
store.addToPlaylist(this.id, pick.id);
|
||||
this.currentIdx = 0;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
|
||||
+13
-8
@@ -3,12 +3,15 @@ const fs = require("fs");
|
||||
const config = require("./config");
|
||||
|
||||
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
const levelNum = LEVELS[config.LOG_LEVEL] || LEVELS.info;
|
||||
let levelNum = LEVELS[config.LOG_LEVEL] || LEVELS.info;
|
||||
function setLevel(name) {
|
||||
if (LEVELS[name]) levelNum = LEVELS[name];
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
try {
|
||||
stream = fs.createWriteStream(config.LOG_FILE, { flags: "a" });
|
||||
} catch (_) {}
|
||||
} catch (e) { console.error('[LOGGER] 日志文件打开失败:', e.message); }
|
||||
|
||||
const ring = [];
|
||||
const RING_MAX = 500;
|
||||
@@ -17,13 +20,14 @@ function fmt(level, args) {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch (_) {
|
||||
return String(a);
|
||||
let s;
|
||||
if (typeof a === "string") s = a;
|
||||
else if (a instanceof Error) s = a.stack || a.message;
|
||||
else {
|
||||
try { s = JSON.stringify(a); } catch (_) { s = String(a); }
|
||||
}
|
||||
// 过滤 CR/LF 等控制字符,防日志注入(伪造日志行)
|
||||
return String(s).replace(/[\r\n\u0000-\u001f\u007f]/g, " ");
|
||||
})
|
||||
.join(" ");
|
||||
return `[${ts}] [${level.toUpperCase()}] ${msg}`;
|
||||
@@ -46,4 +50,5 @@ module.exports = {
|
||||
warn: (...a) => emit("warn", a),
|
||||
error: (...a) => emit("error", a),
|
||||
tail: (n = 100) => ring.slice(-n),
|
||||
setLevel,
|
||||
};
|
||||
+1
-1
@@ -20,7 +20,7 @@ function killAllProcs() {
|
||||
try {
|
||||
p.kill("SIGKILL");
|
||||
n++;
|
||||
} catch (_) {}
|
||||
} catch (e) { logger.warn('[MEDIA] 计数失败:', e.message); }
|
||||
}
|
||||
activeProcs.clear();
|
||||
if (n) logger.warn(`[MEDIA] 已终止 ${n} 个残留 FFmpeg 进程`);
|
||||
|
||||
+23
-2
@@ -14,7 +14,27 @@ const NETEASE_UA =
|
||||
const NETEASE_REFERER = "http://music.163.com/";
|
||||
|
||||
/* ==================== HTTP 工具 ==================== */
|
||||
// SSRF 防护:只允许网易云官方域名(防 albumImage 被劫持成内网/恶意地址)
|
||||
const ALLOWED_HOSTS = [
|
||||
"music.163.com", "interface.music.163.com",
|
||||
"p1.music.126.net", "p2.music.126.net", "p3.music.126.net", "p4.music.126.net",
|
||||
"music.126.net",
|
||||
];
|
||||
function isAllowedHost(urlStr) {
|
||||
try {
|
||||
const u = new URL(urlStr);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||
const h = u.hostname.toLowerCase();
|
||||
return ALLOWED_HOSTS.some((a) => h === a || h.endsWith("." + a));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function request(url, redirects = 0) {
|
||||
if (!isAllowedHost(url)) {
|
||||
return Promise.reject(new Error("不允许的请求地址(域名不在白名单)"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith("https:") ? https : http;
|
||||
const req = lib.get(
|
||||
@@ -123,6 +143,7 @@ function extFromContentType(ct) {
|
||||
function downloadCover(url, key) {
|
||||
return new Promise((resolve) => {
|
||||
if (!url) return resolve("");
|
||||
if (!isAllowedHost(url)) { logger.warn("[COVER] 拒绝非白名单封面URL:", url.slice(0, 80)); return resolve(""); }
|
||||
fs.mkdirSync(config.COVERS_DIR, { recursive: true });
|
||||
const lib = url.startsWith("https:") ? https : http;
|
||||
const req = lib.get(
|
||||
@@ -170,10 +191,10 @@ async function enrichSong(file, opts = {}) {
|
||||
try {
|
||||
const probe = await media.probeAudio(filePath);
|
||||
if (probe.duration != null) fields.duration = probe.duration;
|
||||
} catch (_) {}
|
||||
} catch (e) { logger.warn('[META] 探测时长失败:', e.message); }
|
||||
try {
|
||||
fields.file_size = fs.statSync(filePath).size;
|
||||
} catch (_) {}
|
||||
} catch (e) { logger.warn('[META] 取文件大小失败:', e.message); }
|
||||
|
||||
// 若缺作者或缺封面,尝试网易云匹配
|
||||
const needArtist = !(fields.artist || (song && song.artist));
|
||||
|
||||
+54
-9
@@ -2,42 +2,87 @@
|
||||
const express = require("express");
|
||||
const auth = require("../auth");
|
||||
const logger = require("../logger");
|
||||
const config = require("../config");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/login", (req, res) => {
|
||||
const ip = req.ip || req.connection?.remoteAddress || "unknown";
|
||||
/*
|
||||
* 登录端点(双模式)
|
||||
* 1) StarLight 第三方登录:{ username, password } —— 主入口
|
||||
* 2) 应急管理员密码登录:{ password, emergency:true } 或 username 为空 —— 后门
|
||||
*
|
||||
* 走 StarLight 时,前端传 username + password;
|
||||
* 走应急后门时,前端只传 password(username 留空)。
|
||||
*/
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
const ip = auth.getClientIp(req);
|
||||
const locked = auth.isLocked(ip);
|
||||
if (locked > 0) {
|
||||
return res.status(429).json({ ok: false, error: `登录失败次数过多,请 ${locked} 秒后再试` });
|
||||
}
|
||||
|
||||
const { password } = req.body || {};
|
||||
if (!password) return res.status(400).json({ ok: false, error: "请输入密码" });
|
||||
const { username, password } = req.body || {};
|
||||
|
||||
// 类型校验前置:非法类型直接 400,不计入失败次数(防畸形请求锁死管理员)
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
return res.status(400).json({ ok: false, error: "请输入密码" });
|
||||
}
|
||||
const hasUsername = username !== undefined && username !== null && String(username).trim().length > 0;
|
||||
if (hasUsername && typeof username !== "string") {
|
||||
return res.status(400).json({ ok: false, error: "用户名格式错误" });
|
||||
}
|
||||
|
||||
// ===== 模式 1:StarLight 第三方登录 =====
|
||||
if (hasUsername) {
|
||||
try {
|
||||
const { role, starlightName, uuid } = await auth.loginViaStarLight(String(username).trim(), String(password));
|
||||
auth.clearFail(ip);
|
||||
const { token, expiresAt } = auth.createSession(role, starlightName, uuid);
|
||||
auth.setSessionCookie(res, token, expiresAt);
|
||||
auth.setCsrfCookie(res, auth.genCsrfToken());
|
||||
logger.info(`[AUTH] StarLight 登录成功 (${role}) user=${starlightName} from ${ip}`);
|
||||
return res.json({ ok: true, role, starlightName });
|
||||
} catch (e) {
|
||||
auth.recordFail(ip);
|
||||
logger.warn(`[AUTH] StarLight 登录失败 from ${ip}: ${e.message}`);
|
||||
return res.status(401).json({ ok: false, error: e.message || "登录失败" });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 模式 2:应急管理员密码登录(后门) =====
|
||||
const role = auth.verifyLogin(password);
|
||||
if (!role) {
|
||||
auth.recordFail(ip);
|
||||
logger.warn(`[AUTH] 登录失败 from ${ip}`);
|
||||
logger.warn(`[AUTH] 应急密码登录失败 from ${ip}`);
|
||||
return res.status(401).json({ ok: false, error: "密码错误" });
|
||||
}
|
||||
|
||||
auth.clearFail(ip);
|
||||
const { token, expiresAt } = auth.createSession(role);
|
||||
const { token, expiresAt } = auth.createSession(role, null);
|
||||
auth.setSessionCookie(res, token, expiresAt);
|
||||
logger.info(`[AUTH] 登录成功 (${role}) from ${ip}`);
|
||||
res.json({ ok: true, role });
|
||||
auth.setCsrfCookie(res, auth.genCsrfToken());
|
||||
logger.info(`[AUTH] 应急密码登录成功 (${role}) from ${ip}`);
|
||||
res.json({ ok: true, role, starlightName: null });
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
auth.logout(req);
|
||||
auth.clearSessionCookie(res);
|
||||
auth.clearCsrfCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get("/me", (req, res) => {
|
||||
const role = auth.getRole(req);
|
||||
res.json({ ok: true, authed: !!role, role: role || null });
|
||||
res.json({
|
||||
ok: true,
|
||||
authed: !!role,
|
||||
role: role || null,
|
||||
starlightName: auth.getStarlightName(req),
|
||||
myUuid: auth.getStarlightUuid(req),
|
||||
starLightEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+12
-12
@@ -29,24 +29,24 @@ function searchRateLimited(ip) {
|
||||
}
|
||||
|
||||
/* ==================== 网易云搜索/详情 ==================== */
|
||||
router.get("/search", async (req, res) => {
|
||||
router.get("/search", require("../auth").requireAdmin, async (req, res) => {
|
||||
const { keyword } = req.query;
|
||||
if (!keyword || !String(keyword).trim())
|
||||
return res.status(400).json({ ok: false, error: "缺少搜索关键词" });
|
||||
const kw = String(keyword).trim();
|
||||
|
||||
// 命中缓存直接返回(不消耗限流额度)
|
||||
const cached = searchCache.get(kw);
|
||||
if (cached && Date.now() - cached.at < config.SEARCH_CACHE_TTL_MS) {
|
||||
return res.json({ ok: true, ...cached.data, cached: true });
|
||||
}
|
||||
|
||||
// 未命中 → 限流检查
|
||||
// 限流检查(前置,缓存命中也要计入——防 V13 缓存绕过)
|
||||
const ip = req.ip || req.connection?.remoteAddress || "unknown";
|
||||
if (searchRateLimited(ip)) {
|
||||
return res.status(429).json({ ok: false, error: "搜索过于频繁,请稍后再试" });
|
||||
}
|
||||
|
||||
// 命中缓存直接返回
|
||||
const cached = searchCache.get(kw);
|
||||
if (cached && Date.now() - cached.at < config.SEARCH_CACHE_TTL_MS) {
|
||||
return res.json({ ok: true, ...cached.data, cached: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await meta.searchNetEase(kw);
|
||||
searchCache.set(kw, { at: Date.now(), data: r });
|
||||
@@ -177,14 +177,14 @@ router.post("/import", require("../auth").requireAdmin, upload.single("audio"),
|
||||
} catch (e) {
|
||||
try {
|
||||
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
||||
} catch (_) {}
|
||||
} catch (e) { logger.warn('[MUSIC] 删临时文件失败:', e.message); }
|
||||
logger.error("[IMPORT] 失败:", e.message);
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 删除音频(级联清理) ==================== */
|
||||
router.post("/delete", (req, res) => {
|
||||
router.post("/delete", require("../auth").requireAdmin, (req, res) => {
|
||||
const { file } = req.body || {};
|
||||
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" });
|
||||
|
||||
@@ -221,7 +221,7 @@ router.post("/delete", (req, res) => {
|
||||
});
|
||||
|
||||
/* ==================== 批量删除 ==================== */
|
||||
router.post("/delete-batch", (req, res) => {
|
||||
router.post("/delete-batch", require("../auth").requireAdmin, (req, res) => {
|
||||
const { files } = req.body || {};
|
||||
if (!Array.isArray(files)) return res.status(400).json({ ok: false, error: "无效数据" });
|
||||
const results = [];
|
||||
@@ -253,7 +253,7 @@ router.post("/delete-batch", (req, res) => {
|
||||
});
|
||||
|
||||
/* ==================== 元数据重匹配(补封面/作者) ==================== */
|
||||
router.post("/enrich", async (req, res) => {
|
||||
router.post("/enrich", require("../auth").requireAdmin, async (req, res) => {
|
||||
const { file, skipMatch } = req.body || {};
|
||||
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" });
|
||||
if (!store.getSongByFile(file)) return res.status(404).json({ ok: false, error: "歌曲不存在" });
|
||||
|
||||
@@ -159,4 +159,29 @@ router.post("/:id/playlist/jump", (req, res) => {
|
||||
res.json({ ok: true, current: f, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
/* ==================== 循环池(手动歌单,随机不重复) ==================== */
|
||||
// 查池(含每首是否已播)
|
||||
router.get("/:id/pool", (req, res) => {
|
||||
const rows = store.getPoolRows(req.params.id);
|
||||
res.json({ ok: true, list: rows.map(r => ({
|
||||
songId: r.song_id, file: r.file, name: r.name, artist: r.artist, played: !!r.played,
|
||||
})) });
|
||||
});
|
||||
// 加入池
|
||||
router.post("/:id/pool/add", (req, res) => {
|
||||
const { file } = req.body || {};
|
||||
const song = store.getSongByFile(file);
|
||||
if (!song) return res.status(404).json({ ok: false, error: "歌曲不存在" });
|
||||
store.addToPool(req.params.id, song.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// 移出池
|
||||
router.post("/:id/pool/remove", (req, res) => {
|
||||
const { file } = req.body || {};
|
||||
const song = store.getSongByFile(file);
|
||||
if (!song) return res.status(404).json({ ok: false, error: "歌曲不存在" });
|
||||
store.removeFromPool(req.params.id, song.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+64
-3
@@ -37,6 +37,13 @@ router.get("/config", (req, res) => {
|
||||
MAX_CACHE_SIZE: config.MAX_CACHE_SIZE,
|
||||
MAX_FFMPEG_CONCURRENT: config.MAX_FFMPEG_CONCURRENT,
|
||||
HTTP_PORT: config.HTTP_PORT,
|
||||
LOG_LEVEL: config.LOG_LEVEL,
|
||||
SESSION_TTL_HOURS: Math.round(config.SESSION_TTL_MS / 3600000),
|
||||
LOGIN_MAX_FAILS: config.LOGIN_MAX_FAILS,
|
||||
LOGIN_LOCK_MIN: Math.round(config.LOGIN_LOCK_MS / 60000),
|
||||
SEARCH_CACHE_TTL_SEC: Math.round(config.SEARCH_CACHE_TTL_MS / 1000),
|
||||
SEARCH_RATE_MAX: config.SEARCH_RATE_MAX,
|
||||
SEARCH_RATE_WINDOW_SEC: Math.round(config.SEARCH_RATE_WINDOW_MS / 1000),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -45,8 +52,62 @@ router.get("/settings", (req, res) => {
|
||||
res.json({ ok: true, settings: store.getSetting("app", {}) });
|
||||
});
|
||||
router.post("/settings", (req, res) => {
|
||||
store.setSetting("app", req.body || {});
|
||||
res.json({ ok: true });
|
||||
const body = req.body || {};
|
||||
// 字段白名单:只接受可热改的配置项
|
||||
const ALLOWED = ["hlsTime","slideWindow","maxCacheMB","sessionTtlHours",
|
||||
"loginMaxFails","loginLockMin","searchCacheTtlSec","searchRateMax",
|
||||
"searchRateWindowSec","logLevel","defaultStream"];
|
||||
const clean = {};
|
||||
for (const k of ALLOWED) {
|
||||
if (body[k] !== undefined) clean[k] = body[k];
|
||||
}
|
||||
// 合并旧值(部分更新)
|
||||
const old = store.getSetting("app", {}) || {};
|
||||
const merged = { ...old, ...clean };
|
||||
store.setSetting("app", merged);
|
||||
// 热生效
|
||||
config.applyOverrides(merged);
|
||||
if (merged.logLevel) logger.setLevel(merged.logLevel);
|
||||
logger.info("[SETTINGS] 配置已更新并热生效:", JSON.stringify(clean));
|
||||
res.json({ ok: true, settings: merged });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
/* ==================== 用户 & 管理员管理 ==================== */
|
||||
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
@@ -25,6 +25,13 @@ function bootstrap() {
|
||||
|
||||
// DB
|
||||
db.init();
|
||||
// 加载热配置(settings 表覆盖环境变量)
|
||||
const savedSettings = store.getSetting("app", {}) || {};
|
||||
if (Object.keys(savedSettings).length) {
|
||||
config.applyOverrides(savedSettings);
|
||||
if (savedSettings.logLevel) logger.setLevel(savedSettings.logLevel);
|
||||
logger.info("[SETTINGS] 已加载热配置:", JSON.stringify(savedSettings));
|
||||
}
|
||||
auth.initPasswordLog();
|
||||
|
||||
// 扫描资源入库
|
||||
@@ -71,7 +78,9 @@ function bootstrap() {
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// 安全:不信任 X-Forwarded-For,防止攻击者伪造 IP 绕过登录限流
|
||||
app.set("trust proxy", false);
|
||||
app.use(express.json({ limit: "64kb" }));
|
||||
|
||||
// 登录页(静态)
|
||||
app.get("/login", (req, res) => {
|
||||
@@ -91,13 +100,17 @@ function createApp() {
|
||||
// SSE(放在鉴权前,前端用 EventSource 无法带 header,靠 cookie)
|
||||
app.get("/api/events", (req, res) => {
|
||||
if (!auth.isAuthed(req)) return res.status(401).end();
|
||||
const ip = auth.getClientIp(req);
|
||||
// SSE 连接上限:超限返回 503,防 DoS
|
||||
if (!sse.addClient(res, ip)) {
|
||||
return res.status(503).json({ ok: false, error: "SSE 连接数已达上限" });
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write(":ok\n\n");
|
||||
sse.addClient(res);
|
||||
});
|
||||
|
||||
// HLS 流(不锁)
|
||||
@@ -119,15 +132,28 @@ function createApp() {
|
||||
});
|
||||
app.use(express.static(config.PUBLIC_DIR, { index: false }));
|
||||
|
||||
// 受保护的 API
|
||||
app.use("/api/music", auth.requireAuth, musicRoutes);
|
||||
app.use("/api/resources", auth.requireAuth, resourceRoutes);
|
||||
app.use("/api/streams", auth.requireAuth, streamRoutes);
|
||||
app.use("/api/system", auth.requireAdmin, systemRoutes);
|
||||
// 受保护的 API —— 写操作需 CSRF 校验
|
||||
app.use("/api/music", auth.requireAuth, auth.csrfProtection, auth.requireAdminForWrites, musicRoutes);
|
||||
app.use("/api/resources", auth.requireAuth, auth.csrfProtection, resourceRoutes);
|
||||
app.use("/api/streams", auth.requireAuth, auth.csrfProtection, auth.requireAdminForWrites, streamRoutes);
|
||||
app.use("/api/system", auth.requireAdmin, auth.csrfProtection, systemRoutes);
|
||||
|
||||
// 其余 /api 需要登录
|
||||
app.use("/api", auth.requireAuth);
|
||||
|
||||
// 全局错误处理:不向客户端吐堆栈/路径(V2 修复)
|
||||
// body-parser 等抛出的解析错误也在这里兜底
|
||||
app.use((err, req, res, next) => {
|
||||
// 记录完整错误到日志(含堆栈),但只回传通用文案
|
||||
logger.error("[ERR]", err.message, err.stack ? err.stack.split("\n").slice(0,3).join(" | ") : "");
|
||||
const isApi = (req.originalUrl || req.url || "").startsWith("/api/");
|
||||
const status = err.status || err.statusCode || 500;
|
||||
if (isApi) {
|
||||
return res.status(status).json({ ok: false, error: status === 400 ? "请求格式错误" : "服务器内部错误" });
|
||||
}
|
||||
res.status(status).type("text/plain").send(status === 400 ? "请求格式错误" : "服务器内部错误");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
+36
-4
@@ -1,9 +1,33 @@
|
||||
"use strict";
|
||||
const clients = new Set();
|
||||
|
||||
function addClient(res) {
|
||||
const clients = new Set();
|
||||
const MAX_CLIENTS = 500; // 总连接上限
|
||||
const MAX_PER_IP = 10; // 每 IP 上限
|
||||
const ipCount = new Map(); // ip -> count
|
||||
|
||||
// 返回 true=已接受,false=超限被拒
|
||||
function addClient(res, ip) {
|
||||
if (clients.size >= MAX_CLIENTS) {
|
||||
return false;
|
||||
}
|
||||
const key = ip || "unknown";
|
||||
const cur = ipCount.get(key) || 0;
|
||||
if (cur >= MAX_PER_IP) {
|
||||
return false;
|
||||
}
|
||||
res.__sseIp = key;
|
||||
clients.add(res);
|
||||
res.on("close", () => clients.delete(res));
|
||||
ipCount.set(key, cur + 1);
|
||||
|
||||
const cleanup = () => {
|
||||
clients.delete(res);
|
||||
const n = (ipCount.get(key) || 1) - 1;
|
||||
if (n <= 0) ipCount.delete(key);
|
||||
else ipCount.set(key, n);
|
||||
};
|
||||
res.on("close", cleanup);
|
||||
res.on("error", cleanup);
|
||||
return true;
|
||||
}
|
||||
|
||||
function broadcast(event, data) {
|
||||
@@ -13,6 +37,13 @@ function broadcast(event, data) {
|
||||
c.write(msg);
|
||||
} catch (_) {
|
||||
clients.delete(c);
|
||||
// 计数修正:写失败的连接也要从 ipCount 里减掉
|
||||
const key = c.__sseIp;
|
||||
if (key) {
|
||||
const n = (ipCount.get(key) || 1) - 1;
|
||||
if (n <= 0) ipCount.delete(key);
|
||||
else ipCount.set(key, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +55,7 @@ function closeAll() {
|
||||
} catch (_) {}
|
||||
}
|
||||
clients.clear();
|
||||
ipCount.clear();
|
||||
}
|
||||
|
||||
module.exports = { addClient, broadcast, closeAll };
|
||||
module.exports = { addClient, broadcast, closeAll, getClientCount: () => clients.size };
|
||||
@@ -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
@@ -46,7 +46,9 @@ function upsertSong(s) {
|
||||
return getSongByFile(s.file);
|
||||
}
|
||||
function updateSong(file, fields) {
|
||||
const keys = Object.keys(fields);
|
||||
// 字段白名单:防止动态列名注入(只允许 songs 表这些列)
|
||||
const ALLOWED = new Set(["name", "artist", "cover", "cover_remote", "duration", "file_size", "heat", "cache_status"]);
|
||||
const keys = Object.keys(fields).filter((k) => ALLOWED.has(k));
|
||||
if (!keys.length) return;
|
||||
const set = keys.map((k) => `${k}=?`).join(", ");
|
||||
getDb()
|
||||
@@ -136,11 +138,54 @@ function clearPlaylist(streamId) {
|
||||
getDb().prepare("DELETE FROM stream_playlist WHERE stream_id=?").run(streamId);
|
||||
}
|
||||
|
||||
/* ==================== stream_pool(循环池:手动歌单,随机不重复) ==================== */
|
||||
function getPoolRows(streamId) {
|
||||
return getDb()
|
||||
.prepare(`SELECT sp.song_id, sp.played, s.*
|
||||
FROM stream_pool sp JOIN songs s ON s.id = sp.song_id
|
||||
WHERE sp.stream_id = ? ORDER BY sp.added_at ASC`)
|
||||
.all(streamId);
|
||||
}
|
||||
function listPoolSongIds(streamId) {
|
||||
return getDb().prepare("SELECT song_id FROM stream_pool WHERE stream_id=?").all(streamId).map(r => r.song_id);
|
||||
}
|
||||
function isInPool(streamId, songId) {
|
||||
return !!getDb().prepare("SELECT 1 FROM stream_pool WHERE stream_id=? AND song_id=?").get(streamId, songId);
|
||||
}
|
||||
function addToPool(streamId, songId) {
|
||||
getDb().prepare(
|
||||
"INSERT INTO stream_pool (stream_id, song_id, played, added_at) VALUES (?, ?, 0, ?) ON CONFLICT(stream_id, song_id) DO NOTHING"
|
||||
).run(streamId, songId, Date.now());
|
||||
}
|
||||
function removeFromPool(streamId, songId) {
|
||||
return getDb().prepare("DELETE FROM stream_pool WHERE stream_id=? AND song_id=?").run(streamId, songId);
|
||||
}
|
||||
// 抽一首未播的;若全播过则重置 played 后再抽。返回 song row 或 null
|
||||
function drawFromPool(streamId) {
|
||||
const d = getDb();
|
||||
let unplayed = d.prepare(
|
||||
`SELECT s.* FROM stream_pool sp JOIN songs s ON s.id = sp.song_id
|
||||
WHERE sp.stream_id=? AND sp.played=0 ORDER BY RANDOM() LIMIT 1`
|
||||
).get(streamId);
|
||||
if (!unplayed) {
|
||||
// 全部播过 → 重置本轮
|
||||
d.prepare("UPDATE stream_pool SET played=0 WHERE stream_id=?").run(streamId);
|
||||
unplayed = d.prepare(
|
||||
`SELECT s.* FROM stream_pool sp JOIN songs s ON s.id = sp.song_id
|
||||
WHERE sp.stream_id=? AND sp.played=0 ORDER BY RANDOM() LIMIT 1`
|
||||
).get(streamId);
|
||||
}
|
||||
return unplayed || null;
|
||||
}
|
||||
function markPoolPlayed(streamId, songId) {
|
||||
getDb().prepare("UPDATE stream_pool SET played=1 WHERE stream_id=? AND song_id=?").run(streamId, songId);
|
||||
}
|
||||
|
||||
/* ==================== sessions ==================== */
|
||||
function createSession(token, role, expiresAt) {
|
||||
function createSession(token, role, expiresAt, starlightName, starlightUuid) {
|
||||
getDb()
|
||||
.prepare("INSERT INTO sessions (token, role, created_at, expires_at) VALUES (?, ?, ?, ?)")
|
||||
.run(token, role || "user", Date.now(), expiresAt);
|
||||
.prepare("INSERT INTO sessions (token, role, created_at, expires_at, starlight_name, starlight_uuid) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run(token, role || "user", Date.now(), expiresAt, starlightName || null, starlightUuid || null);
|
||||
}
|
||||
function getSession(token) {
|
||||
const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token);
|
||||
@@ -151,10 +196,43 @@ function getSession(token) {
|
||||
}
|
||||
return s;
|
||||
}
|
||||
// 滑动续期:把会话过期时间往后推
|
||||
function touchSession(token, newExpiresAt) {
|
||||
getDb().prepare("UPDATE sessions SET expires_at=? WHERE token=?").run(newExpiresAt, token);
|
||||
}
|
||||
function deleteSession(token) {
|
||||
getDb().prepare("DELETE FROM sessions WHERE token=?").run(token);
|
||||
}
|
||||
|
||||
/* ==================== admins(管理员名单,热管理) ==================== */
|
||||
function listAdmins() {
|
||||
return getDb().prepare("SELECT * FROM admins ORDER BY added_at ASC").all();
|
||||
}
|
||||
function getAdmin(uuid) {
|
||||
return getDb().prepare("SELECT * FROM admins WHERE uuid=?").get(uuid);
|
||||
}
|
||||
function isAdminUuid(uuid) {
|
||||
if (!uuid) return false;
|
||||
return !!getDb().prepare("SELECT 1 FROM admins WHERE uuid=?").get(uuid);
|
||||
}
|
||||
function addAdmin(uuid, name) {
|
||||
getDb().prepare(
|
||||
"INSERT INTO admins (uuid, name, added_at) VALUES (?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET name=excluded.name"
|
||||
).run(uuid, name || "", Date.now());
|
||||
return getAdmin(uuid);
|
||||
}
|
||||
function removeAdmin(uuid) {
|
||||
return getDb().prepare("DELETE FROM admins WHERE uuid=?").run(uuid);
|
||||
}
|
||||
function listUsers() {
|
||||
return getDb().prepare(
|
||||
`SELECT starlight_name AS name, starlight_uuid AS uuid,
|
||||
MAX(created_at) AS last_login, COUNT(*) AS login_count
|
||||
FROM sessions WHERE starlight_name IS NOT NULL
|
||||
GROUP BY starlight_name ORDER BY last_login DESC`
|
||||
).all();
|
||||
}
|
||||
|
||||
/* ==================== settings ==================== */
|
||||
function getSetting(key, def = null) {
|
||||
const r = getDb().prepare("SELECT value FROM settings WHERE key=?").get(key);
|
||||
@@ -179,6 +257,8 @@ module.exports = {
|
||||
createStream, listStreams, getStream, deleteStream, setStreamState,
|
||||
getPlaylistRows, playlistCount, addToPlaylist, removePlaylistEntry,
|
||||
setPlaylistOrder, clearPlaylist,
|
||||
createSession, getSession, deleteSession,
|
||||
getPoolRows, listPoolSongIds, isInPool, addToPool, removeFromPool, drawFromPool, markPoolPlayed,
|
||||
createSession, getSession, deleteSession, touchSession,
|
||||
getSetting, setSetting,
|
||||
listAdmins, getAdmin, isAdminUuid, addAdmin, removeAdmin, listUsers,
|
||||
};
|
||||
+1
-1
@@ -19,7 +19,7 @@ function getDirSize(dirPath) {
|
||||
try {
|
||||
const st = fs.statSync(full);
|
||||
size += st.isDirectory() ? getDirSize(full) : st.size;
|
||||
} catch (_) {}
|
||||
} catch (_) { /* 文件读不到就跳过,正常 */ }
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
@echo off
|
||||
set "ADMIN_PASSWORD=test123"
|
||||
set "USER_PASSWORD=user123"
|
||||
REM ===== 应急管理员密码(后门,StarLight 挂掉时用)=====
|
||||
REM 支持三种格式:
|
||||
REM 明文:set "ADMIN_PASSWORD=你的密码"
|
||||
REM sha256:set "ADMIN_PASSWORD=sha256:<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
|
||||
+11
-1
@@ -1,6 +1,8 @@
|
||||
import urllib.request, urllib.error, http.cookiejar, json, sys, time
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
import os
|
||||
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
|
||||
|
||||
def opener_with_cookies():
|
||||
cj = http.cookiejar.CookieJar()
|
||||
@@ -12,6 +14,14 @@ def call(op, path, method="GET", data=None):
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
if body:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if method.upper() not in ("GET","HEAD","OPTIONS"):
|
||||
for c in op.handlers:
|
||||
cj = getattr(c, "cookiejar", None)
|
||||
if cj:
|
||||
for ck in cj:
|
||||
if ck.name == "mr_csrf":
|
||||
req.add_header("X-CSRF-Token", ck.value); break
|
||||
break
|
||||
try:
|
||||
r = op.open(req, timeout=10)
|
||||
return r.status, r.read()
|
||||
@@ -30,7 +40,7 @@ st, body = call(anon, "/login"); check("登录页200", st == 200, f"got {st}")
|
||||
st, body = call(anon, "/"); check("未登录/=登录页", st == 200 and b"loginForm" in body, f"got {st}")
|
||||
st, _ = call(anon, "/index.html"); check("未登录index.html=登录页", st == 200 and b"loginForm" in _, f"got {st}")
|
||||
st, _ = call(auth, "/api/auth/login", "POST", {"password": "wrong"}); check("错密码401", st == 401, f"got {st}")
|
||||
st, body = call(auth, "/api/auth/login", "POST", {"password": "test123"}); check("登录成功", st == 200 and json.loads(body).get("ok"), f"got {st}")
|
||||
st, body = call(auth, "/api/auth/login", "POST", {"password": TEST_PASSWORD}); check("登录成功", st == 200 and json.loads(body).get("ok"), f"got {st}")
|
||||
|
||||
# 资源与流
|
||||
st, body = call(auth, "/api/resources"); d = json.loads(body)
|
||||
|
||||
+7
-1
@@ -1,6 +1,8 @@
|
||||
import urllib.request, urllib.error, http.cookiejar, json, sys, re
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
import os
|
||||
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
|
||||
cj = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
@@ -8,6 +10,10 @@ def call(path, method="GET", data=None, raw=False):
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=body, method=method)
|
||||
if body: req.add_header("Content-Type", "application/json")
|
||||
if method.upper() not in ("GET","HEAD","OPTIONS"):
|
||||
for ck in cj:
|
||||
if ck.name == "mr_csrf":
|
||||
req.add_header("X-CSRF-Token", ck.value); break
|
||||
try:
|
||||
r = op.open(req, timeout=10)
|
||||
return r.status, r.read()
|
||||
@@ -26,7 +32,7 @@ for path in ["/vendor/bootstrap.min.css", "/vendor/bootstrap-icons.css",
|
||||
check(f"静态 {path}", st == 200, f"got {st}")
|
||||
|
||||
# 登录
|
||||
st, body = call("/api/auth/login", "POST", {"password": "test123"})
|
||||
st, body = call("/api/auth/login", "POST", {"password": TEST_PASSWORD})
|
||||
check("登录", st == 200 and json.loads(body).get("ok"), f"got {st}")
|
||||
|
||||
# 首页返回面板(含 vendor 引用)
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
import urllib.request, urllib.error, http.cookiejar, json, sys
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
import os
|
||||
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
|
||||
cj = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
@@ -14,7 +16,7 @@ def call(path, method="GET", data=None):
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
call("/api/auth/login", "POST", {"password": "test123"})
|
||||
call("/api/auth/login", "POST", {"password": TEST_PASSWORD})
|
||||
st, body = call("/api/resources")
|
||||
songs = json.loads(body)["list"]
|
||||
print(f"共 {len(songs)} 首,开始补全元数据...\n")
|
||||
|
||||
+10
-1
@@ -1,6 +1,8 @@
|
||||
import urllib.request, urllib.error, http.cookiejar, json, sys, os, io, uuid
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
import os
|
||||
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "123test")
|
||||
cj = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
@@ -8,6 +10,10 @@ def call(path, method="GET", data=None):
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=body, method=method)
|
||||
if body: req.add_header("Content-Type", "application/json")
|
||||
if method.upper() not in ("GET","HEAD","OPTIONS"):
|
||||
for ck in cj:
|
||||
if ck.name == "mr_csrf":
|
||||
req.add_header("X-CSRF-Token", ck.value); break
|
||||
try:
|
||||
r = op.open(req, timeout=30)
|
||||
return r.status, r.read()
|
||||
@@ -19,7 +25,7 @@ def check(name, cond, extra=""):
|
||||
results.append((name, cond)); print(("PASS" if cond else "FAIL"), name, extra)
|
||||
|
||||
# 登录
|
||||
call("/api/auth/login", "POST", {"password": "test123"})
|
||||
call("/api/auth/login", "POST", {"password": TEST_PASSWORD})
|
||||
|
||||
# ---------- 读基线资源数 ----------
|
||||
st, body0 = call("/api/resources")
|
||||
@@ -54,6 +60,9 @@ body = b"".join(parts)
|
||||
|
||||
req = urllib.request.Request(BASE + "/api/music/import", data=body, method="POST")
|
||||
req.add_header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
for ck in cj:
|
||||
if ck.name == "mr_csrf":
|
||||
req.add_header("X-CSRF-Token", ck.value); break
|
||||
try:
|
||||
r = op.open(req, timeout=60)
|
||||
d = json.loads(r.read())
|
||||
|
||||
Reference in new issue
Block a user