Files
Xiao-no-love f0fe50e484 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)
2026-10-06 16:37:03 +08:00

542 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 交接文档 · Starlight专属电台
> 跨会话交接。**新会话先读此文件再动手**。最后更新:2026-09-25(v2.6)。
---
## 0. TL;DR
- **项目**:`F:\music_online\demo` — Node.js + FFmpeg 的 HLS 个人音乐电台
- **状态**:v2.6,功能完整。回归测试 **api 16 / e2e 17 / write 5** 全绿
- **服务**:`svc003` 运行中,`http://127.0.0.1:3000`
- **密码**:管理员 `test123` / 用户 `user123`(start.cmd 里设的环境变量,测试用)
- **Git**:`a6a01b7`(v2.5) → `2fc2548`(v2.6 最新)。工作区干净。
---
## 1. 运行 / 环境
- Windows 11,Node v24.5.0,FFmpeg 9.0.1(`F:\ffmpge\ffmpeg-9.0.1-essentials_build\bin`,含 ffprobe),Python 3.11
- **启动**(推荐托管):
```cmd
cmd /c "set ADMIN_PASSWORD=test123&& set USER_PASSWORD=user123&& node index.js"
```
或双击 `start.cmd`(已配好双密码)
- 登录:浏览器开 `http://127.0.0.1:3000`
- 依赖:`better-sqlite3`、`multer`、`chokidar`、`express`(已装)
---
## 2. 目录结构
```
demo/
├── index.js # 入口,仅 require("./src/server").start()
├── start.cmd # 启动脚本(含 ADMIN_PASSWORD/USER_PASSWORD)
├── data.db # SQLite(WAL,自动生成)
├── HANDOFF.md / README.md
├── src/
│ ├── server.js # 装配:中间件/路由/启动/优雅退出
│ ├── config.js # 全部配置(环境变量解析)
│ ├── logger.js # 分级日志 + 落盘 + 内存环形缓冲(tail)
│ ├── db.js # SQLite init + schema(5表) + WAL + 运行时迁移
│ ├── store.js # 数据访问层,所有 SQL 收敛于此
│ ├── util.js # fileHash/文件名解析/原子写/目录大小
│ ├── media.js # ffprobe + FFmpeg 切片 + 并发队列 + 进程追踪
│ ├── metadata.js # 网易云 API + 封面下载 + 元数据补全
│ ├── livestream.js # LiveStream 类 + 多路流 + 缓存状态/清理
│ ├── scanner.js # audio/ 监听 + 同步 DB
│ ├── auth.js # 双密码鉴权 + 角色 + 会话 + 登录锁定
│ ├── sse.js # SSE 广播
│ └── routes/ # auth / music / resources / streams / system
├── public/
│ ├── index.html # 主面板(6 tab)
│ ├── login.html # 登录页(独立暗色主题)
│ ├── css/style.css # token 体系(v2.4 重构)
│ ├── js/app.js # 前端逻辑
│ ├── vendor/ # 本地化:bootstrap/icons/sortable/hls
│ └── covers/ # 本地封面
├── audio/ hls/ hls_cache/ # 音频 / 直播输出 / 切片缓存(均 gitignore)
├── scripts/fetch_vendor.py
└── test_api.py / test_e2e.py / test_write.py / test_enrich.py
```
---
## 3. 数据模型(SQLite, src/db.js)
```sql
songs(id, file UNIQUE, name, artist, cover, cover_remote, duration,
file_size, heat, cache_status, created_at)
-- file 存「文件名」非绝对路径;cover 本地路径;cover_remote 远程 URL(优先展示)
streams(id PK, name, enabled, current_pos, media_seq, created_at)
-- 默认流 id='main',不可删
stream_playlist(id PK, stream_id, song_id, position) -- 外键 CASCADE
settings(key PK, value)
sessions(token PK, role, created_at, expires_at) -- role: admin|user
```
- 切片缓存 `hls_cache/{md5(文件名)}/` 与流无关,多路共享
- **运行时迁移**:init() 用 `PRAGMA table_info` 检测缺列后 ALTER(已支持 sessions.role、songs.cover_remote)
---
## 4. API 清单
除 `/api/auth/*`、`/hls`、`/hls_cache`、静态资源、`/login` 外**全部需登录**。
🔒 = 需**管理员**。
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/login` | `{password}` → `{ok,role}`;失败过多返回 429 |
| POST | `/api/auth/logout` · GET `/api/auth/me` | 登出 / 返回 `{authed,role}` |
| GET | `/api/resources` | 资源列表(含 albumImage/coverLocal/cacheSize) |
| GET | `/api/music/search?keyword=` | 网易云搜索(后端**缓存+限流**) |
| POST | `/api/music/detail` · `/download` | 详情 / 下载入库 |
| POST | `/api/music/import` 🔒 | **multipart**:audio+title+artist |
| POST | `/api/music/delete` · `/delete-batch` | 级联删 |
| POST | `/api/music/enrich` | `{file,skipMatch?}` 重匹配元数据 |
| POST | `/api/music/refresh-covers` 🔒 | **一键刷新所有缺失封面** |
| GET/POST/DELETE | `/api/streams[/:id]` · `/start\|stop` | 流管理 |
| GET/POST | `/api/streams/:id/playlist[/add\|remove\|reorder\|clear\|next\|prev\|jump]` | 播放列表 |
| GET | `/api/system/cache` 🔒 · POST `/cache/cleanup` 🔒 | 缓存 |
| GET | `/api/system/logs?tail=` 🔒 · `/config` 🔒 · `/settings` 🔒 | 系统 |
| GET | `/api/events` | SSE(cookie 鉴权) |
统一返回 `{ok:true,...}` / `{ok:false,error:"..."}`。
---
## 5. 直播流地址
- 默认 `/hls/main/index.m3u8`;其它 `/hls/{streamId}/index.m3u8`
- 切片 `/hls_cache/{hash}/indexN.ts`
---
## 6. 关键设计决策(勿轻易推翻)
1. **SQLite better-sqlite3 + WAL**:单进程同步 API 最省事,WAL 解读写互斥
2. **不迁 v1 JSON 数据**(旧 heat/metadata/playlist.json 已删)
3. **鉴权整站 Cookie 会话**;HLS 流**不锁**(播放器难带 cookie)
4. **封面/资源全本地化**:`public/covers/`、`public/vendor/`;系统字体
5. **songs.file 存文件名**非绝对路径,可移植
6. **导入先落 tmp 再 rename**,避免 chokidar 扫到半截
7. **双密码角色**:用户 = 管理员 − 上传 − 设置页;后端 `requireAdmin` 硬挡,非只藏前端
---
## 7. 测试方法
服务起来后:
```cmd
cd /d F:\music_online\demo
set PYTHONIOENCODING=utf-8
python -X utf8 test_api.py # 16 项
python -X utf8 test_e2e.py # 17 项
python -X utf8 test_write.py # 5 项(需 tmp/test_import.mp3 素材)
```
素材生成:
```cmd
"F:\ffmpge\ffmpeg-9.0.1-essentials_build\bin\ffmpeg.exe" -y -f lavfi -i anullsrc=r=44100:cl=stereo -t 3 -q:a 9 tmp\test_import.mp3
```
> ⚠️ 测试只覆盖 HTTP 层,**浏览器 JS 交互未自动化**。
---
## 8. 已知问题 / 待实测
- **浏览器端未实测**:SSE 实时刷新、拖拽 reorder、mini player、上传进度、频道弹窗交互
- `metadata.js enrichSong` 启动重匹配慢歌 → 可接受
- 多路流并发预生成排队 → 观察中
- `app.js esc()` 用 `String.fromCharCode(38)` 绕过工具转义,**勿优化**
- 登录失败锁定 `failMap` 是内存态,重启清零(可接受)
---
## 9. 工具层踩坑记录
1. **XML 实体转义吃 `&` `"`**:写 JS/CSS 用 `String.fromCharCode(38)` 或字符码构造
2. **⚠️ powershell 行号偏移**:`Select-String`/`Get-Content` 行号比 replace_lines 实际大 ~2,照它改必错位!**用 node 查行号**:
```js
fs.readFileSync(f,"utf8").split("\n").forEach((l,i)=>{if(l.includes(p))console.log((i+1)+": "+l)})
```
3. **replace_lines 多段连锁崩**:同一文件多段改,某段行号错会留残留。**逐条改**,改前 node 查号、改后 `node --check`
4. **文件被改乱**:别手修,`git checkout <commit> -- <file>` 恢复
5. **后台服务用 action background="true"**(勿 `start /b`)
6. **service_stop 连子进程一起杀**(含 node 的 ffmpeg 子进程)
7. **改完源码要重启 node** 才生效
8. **Windows 无 head/tail**,用 findstr 或 node
9. **cmd 下 curl**:`%{http_code}` 要写 `%%{http_code}`;中文输出 GBK 乱码(功能正常)
10. **git 提交中文**:powershell 写**无 BOM** UTF-8 文件 + `git -c i18n.commitEncoding=utf-8 commit -F 文件`
11. **python 输出**:`chcp 65001` + `set PYTHONIOENCODING=utf-8`;脚本末尾 `sys.stdout.flush()` 再 `os._exit(0)`
---
## 10. 版本时间线
| 版本 | 日期 | 要点 |
|------|------|------|
| v2 | 09-23 | SQLite+多路流+鉴权+导入/删除+资源本地化+模块化重构 |
| v2.1 | 09-23 | 频道管理模态框、死代码清理、forgetSong 内存泄漏修复 |
| v2.2 | 09-24 | 站内 HLS 收听页、设置页、上传进度、正在播放实时化 |
| v2.2.1 | 09-24 | 资源排序(6 种) |
| v2.3 | 09-24 | UI 去 AI 味:清全部渐变/glow,单强调色(依 ibelick/ui-skills) |
| v2.4 | 09-24 | UI token 化:字号 42→9、圆角 20→8、硬编码色清零 |
| v2.5 | 09-25 | **双密码+角色权限**、登录锁定、搜索缓存限流、**封面远程优先** |
| v2.6 | 09-25 | 设置页**一键刷新缺失封面** |
各版本详细变更见 git log(`git log --oneline`)。
---
## 11. 下一步可选
1. **浏览器手动过一遍**所有交互,修 Console 报错 ← 最优先
2. 播放延迟调节(HLS_TIME 热更新,老板曾暂缓)
3. 密码设置 UI(现走环境变量)
4. 缓存生成进度条、搜索分页/加载更多
5. mini player 音量控制、播放历史/最热排序(heat 字段已有)
6. 正式化:固定强密码、服务化启动
---
## 12. 当前运行态
- 后台服务:`svc003`(`http://127.0.0.1:3000`)
- DB:`data.db`(6 首歌曲,封面已本地化)
- 缓存:`hls_cache/` 约 34MB
- 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 白名单实测。