Archived
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留 大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述, 会误导后来者按不复存在的架构去改代码。 拆分(对外导入路径不变,行为零变更): - music_downloader.py -> music_downloader/(models/downloader/cover/_http/_log/_concurrency) - auth_system.py -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...) - music_api.py -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors) - services/runtime.py -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/ streaming/tasks);services/runtime.py 保留为兼容门面 兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点 (auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性 动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。 修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字 等价」校验逐一定位,静态检查只会报出其中 4 处): - core/config.py ).strip() or '*' - core/state.py threading.Lock() x2 - qr_login.py datetime.now() / .strip() - music_downloader/downloader.py .lower() - services/podcast.py set() / time.time() x2 清理与删除: - 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt - 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等 失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明) - _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含 - pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败 验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed; 真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
98 lines
4.2 KiB
Python
98 lines
4.2 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""FastAPI 中间件:响应侧统一处理(安全头 / 直链签名 / 指纹抹除)。
|
||
|
||
处理要点:
|
||
1. 音频直链保护——JSON 响应中的网易云音频地址替换为 HMAC 签名代理地址
|
||
(`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 core.security 同源,
|
||
故校验口径一致。
|
||
2. 零成本安全响应头(nosniff / DENY / Referrer-Policy / CSP 基线)。
|
||
3. 抹去服务器版本指纹(Server 头统一为 NeteaseUrl)。
|
||
4. 429 统一带 Retry-After,供客户端退避。
|
||
|
||
实现注意:
|
||
- 用纯 ASGI 中间件逐段转发(不用 BaseHTTPMiddleware,避免它缓冲整段流式响应,
|
||
破坏大 ZIP / 音频流式下载)。
|
||
- 仅对「JSON 且 200」的响应缓冲改写;此时必须延迟发送 response.start,待改写完成
|
||
后**重算 Content-Length**——否则长度不一致会让客户端 IncompleteRead(实测踩过)。
|
||
"""
|
||
import json
|
||
|
||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||
|
||
from core import security
|
||
|
||
_SECURITY_HEADERS = (
|
||
(b'x-content-type-options', b'nosniff'),
|
||
(b'x-frame-options', b'DENY'),
|
||
(b'referrer-policy', b'same-origin'),
|
||
(b'content-security-policy',
|
||
b"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
|
||
b"img-src 'self' data: http: https:; media-src 'self' blob:; connect-src 'self'; "
|
||
b"font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'"),
|
||
)
|
||
|
||
|
||
def _augment_headers(headers: list[tuple[bytes, bytes]], status_code: int) -> list[tuple[bytes, bytes]]:
|
||
existing = {k.lower() for k, _ in headers}
|
||
out = list(headers)
|
||
for hk, hv in _SECURITY_HEADERS:
|
||
if hk not in existing:
|
||
out.append((hk, hv))
|
||
out.append((b'server', b'NeteaseUrl'))
|
||
if status_code == 429 and b'retry-after' not in existing:
|
||
out.append((b'retry-after', b'30'))
|
||
return out
|
||
|
||
|
||
class ResponseHygieneMiddleware:
|
||
"""响应侧统一处理(安全头 + 音频直链保护 + 版本指纹抹除)。"""
|
||
|
||
def __init__(self, app: ASGIApp) -> None:
|
||
self.app = app
|
||
|
||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||
if scope['type'] != 'http':
|
||
await self.app(scope, receive, send)
|
||
return
|
||
|
||
state = {'hold': False, 'start': None, 'chunks': []}
|
||
|
||
async def send_wrapper(message: Message) -> None:
|
||
mtype = message['type']
|
||
if mtype == 'http.response.start':
|
||
status = message['status']
|
||
headers = _augment_headers(list(message.get('headers') or []), status)
|
||
ctype = b''.join(v.lower() for k, v in headers if k.lower() == b'content-type')
|
||
if status == 200 and b'application/json' in ctype:
|
||
# 需改写 body:延迟发送 start,待重算 Content-Length
|
||
state['hold'] = True
|
||
state['start'] = message
|
||
message['headers'] = headers
|
||
else:
|
||
message['headers'] = headers
|
||
await send(message)
|
||
elif mtype == 'http.response.body':
|
||
if not state['hold']:
|
||
await send(message)
|
||
return
|
||
state['chunks'].append(message.get('body', b''))
|
||
if message.get('more_body'):
|
||
return
|
||
await self._flush(state, send)
|
||
|
||
await self.app(scope, receive, send_wrapper)
|
||
|
||
async def _flush(self, state: dict, send: Send) -> None:
|
||
raw = b''.join(state['chunks'])
|
||
try:
|
||
data = json.loads(raw)
|
||
if isinstance(data, (dict, list)) and security.protect_audio_urls(data):
|
||
raw = json.dumps(data, ensure_ascii=False).encode('utf-8')
|
||
except Exception:
|
||
pass
|
||
headers = [(k, v) for k, v in state['start']['headers'] if k.lower() != b'content-length']
|
||
headers.append((b'content-length', str(len(raw)).encode('ascii')))
|
||
state['start']['headers'] = headers
|
||
await send(state['start'])
|
||
await send({'type': 'http.response.body', 'body': raw, 'more_body': False})
|