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

73 lines
3.3 KiB
Python

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()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
def call(op, path, method="GET", data=None):
url = BASE + path
body = json.dumps(data).encode() if data is not None else 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()
except urllib.error.HTTPError as e:
return e.code, e.read()
anon = opener_with_cookies()
auth = opener_with_cookies()
results = []
def check(name, cond, extra=""):
results.append((name, cond)); print(("PASS" if cond else "FAIL"), name, extra)
# 鉴权
st, _ = call(anon, "/api/resources"); check("未登录API=401", st == 401, f"got {st}")
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": TEST_PASSWORD}); check("登录成功", st == 200 and json.loads(body).get("ok"), f"got {st}")
# 资源与流
st, body = call(auth, "/api/resources"); d = json.loads(body)
check("资源列表非空", st == 200 and len(d["list"]) >= 1, f"{len(d.get('list',[]))}项")
first = d["list"][0]["file"]
tid = "t%d" % int(time.time())
st, body = call(auth, "/api/streams", "POST", {"id": tid, "name": "临时测试流"})
check("创建流", st == 200 and json.loads(body).get("ok"), body[:80].decode("utf-8","ignore"))
st, body = call(anon, "/hls/%s/index.m3u8" % tid); check("新流m3u8可达", st == 200, f"got {st}")
st, body = call(auth, "/api/streams/%s/playlist/add" % tid, "POST", {"file": first})
check("新流加歌", st == 200, f"got {st}")
st, body = call(auth, "/api/streams"); d = json.loads(body)
check("流列表含新流", any(s["id"] == tid for s in d["list"]), "")
# 清理
st, body = call(auth, "/api/streams/%s" % tid, "DELETE", {})
check("删除流", st == 200, f"got {st}")
# 缓存/日志
st, body = call(auth, "/api/system/cache"); d = json.loads(body)
check("缓存信息", st == 200 and "total" in d, f"{d.get('total')}B")
st, body = call(auth, "/api/system/logs?tail=5")
check("日志接口", st == 200 and len(json.loads(body).get("lines", [])) > 0, "")
# 登出
st, _ = call(auth, "/api/auth/logout", "POST"); check("登出", st == 200, f"got {st}")
st, _ = call(auth, "/api/resources"); check("登出后401", st == 401, f"got {st}")
print("\n=== 汇总:", sum(1 for _, c in results if c), "/", len(results), "通过 ===")
sys.exit(0 if all(c for _, c in results) else 1)