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

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

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

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

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

No files matched your search

+10 -1
View File
@@ -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())