feat(routes): 迁移解析端点 /song /playlist /album + 响应卫生中间件

迁移路由(/api/v1/*,与 Flask 同端点逐字段对齐):
- song(type: url/name/lyric/json)、playlist、album
  个人账号路径实现全局账号冒用拦截(同 Flask 口径);json 模式 asyncio.gather 并发
  song/url/lyric(歌词失败不阻断,与 Flask 线程池语义一致)
- core/media.py:format_file_size/quality_display_name/parse_cookie_str 纯函数复用

web/middleware.py(镜像 Flask after_request):
- 音频直链 → HMAC 签名代理地址(密钥同源,两栈 token 互认)
- 安全头 nosniff/DENY/Referrer-Policy/CSP 基线;Server 抹为 NeteaseUrl;429 带 Retry-After
- 纯 ASGI 逐段转发(不破坏流式下载);JSON 200 延迟 start 并**重算 Content-Length**
  ——修实测 IncompleteRead(原实现转发旧长度 + 新 body)

契约奇偶校验:12 项全 OK(新增 album/playlist/song×3);
token/CDN 主机归一化(均为易变值,非契约漂移)。

验证:pytest 36/36;ruff All checks passed;pyright 0 errors;
uvicorn 实跑 /api/v1/song 直链已代理、CSP/Server 头正确。
This commit is contained in:
snowy-yang committed 2026-09-26 01:12:54 +08:00
1 parent 2c55b55e03
commit a2758aa0da
5 files changed
+452 -8

No files matched your search

+18
View File
@@ -18,7 +18,9 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from core.config import APP_VERSION
from core.security import init_secret_key
from web.errors import install_error_handlers
from web.middleware import ResponseHygieneMiddleware
from web.routers import music as music_router
from web.routers import site as site_router
from web.routers import system as system_router
@@ -30,14 +32,27 @@ async def _lifespan(app: FastAPI):
yield
try:
from core import aiohttp_compat, http_client
await http_client.client.close()
await aiohttp_compat.close()
except Exception:
pass
def _init_audio_secret() -> None:
"""注入音频签名密钥(与 Flask 同源,令两栈 token 互认)。"""
try:
import auth_system
init_secret_key(auth_system.get_secret_key())
except Exception:
pass
def create_app(serve_flask_fallback: bool = False) -> FastAPI:
"""创建 FastAPI 应用。"""
_init_audio_secret()
app = FastAPI(
title='云村小助手API服务',
version=APP_VERSION,
@@ -47,6 +62,9 @@ def create_app(serve_flask_fallback: bool = False) -> FastAPI:
lifespan=_lifespan,
)
# 响应侧统一处理:安全头 + 音频直链签名保护 + 版本指纹抹除
app.add_middleware(ResponseHygieneMiddleware)
# 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比)
app.include_router(system_router.router, prefix='/api/v1', tags=['system'])
app.include_router(site_router.router, prefix='/api/v1', tags=['site'])
+97
View File
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""FastAPI 中间件:镜像 Flask after_request 的响应侧行为。
与 main.py `after_request` 对齐的关键点:
1. 音频直链保护——JSON 响应中的网易云音频地址替换为 HMAC 签名代理地址
(`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 Flask 同源,
故两栈签发的 token 互相可校验。
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})
+215 -8
View File
@@ -1,24 +1,32 @@
# -*- coding: utf-8 -*-
"""音乐查询端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""音乐端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
已迁移(免费解析类,需登录,不扣次数):
- POST/GET /api/v1/toplist ↔ Flask /toplist
- POST/GET /api/v1/artist_songs ↔ Flask /artist_songs
- POST/GET /api/v1/comments ↔ Flask /comments
- POST/GET /api/v1/search ↔ Flask /search
已迁移:
- 免费解析类(需登录,不扣次数):
POST/GET /api/v1/toplist ↔ Flask /toplist
POST/GET /api/v1/artist_songs ↔ Flask /artist_songs
POST/GET /api/v1/comments ↔ Flask /comments
POST/GET /api/v1/search ↔ Flask /search
POST/GET /api/v1/album ↔ Flask /album
POST/GET /api/v1/playlist ↔ Flask /playlist
POST/GET /api/v1/song ↔ Flask /song(四种 type:url/name/lyric/json)
参数获取沿用 Flask 口径:GET query / POST json / POST form 三路兼容。
上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。
"""
import asyncio
import hmac
import logging
import re
import time
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
import music_api
from core import repo
from core.state import SERVER_NICK_CACHE
from core import media, repo
from core.config import VALID_QUALITY_LEVELS
from core.state import GLOBAL_MUSIC_U_CACHE, REQ_LOG, SERVER_NICK_CACHE
from music_api import APIException
from web import ratelimit
from web.deps import SiteUser, require_login
@@ -28,6 +36,10 @@ router = APIRouter()
LoginUser = Annotated[SiteUser, Depends(require_login)]
logger = logging.getLogger('web.music')
_VALID_SONG_TYPES = ['url', 'name', 'lyric', 'json']
_SERVER_NICK_LOCK: asyncio.Lock | None = None
@@ -214,3 +226,198 @@ async def search(user: LoginUser, request: Request):
if song.get('name'):
song['name'] = await mask_server_nickname(song['name'], user)
return success(result, '搜索完成')
# ============ 个人/全局账号 Cookie ============
def _global_music_u() -> str:
"""全局账号 MUSIC_U(5 分钟缓存,与 Flask main._global_music_u 同源同缓存)。"""
now = time.time()
if GLOBAL_MUSIC_U_CACHE['v'] is None or now - GLOBAL_MUSIC_U_CACHE['ts'] > 300:
try:
ck = _cookies()
GLOBAL_MUSIC_U_CACHE['v'] = (ck.get('MUSIC_U') or '').strip()
except Exception:
GLOBAL_MUSIC_U_CACHE['v'] = ''
GLOBAL_MUSIC_U_CACHE['ts'] = now
return GLOBAL_MUSIC_U_CACHE['v']
async def _resolve_user_cookies(data: dict[str, Any], user: SiteUser, request: Request) -> dict[str, str]:
"""解析个人账号 Cookie(浏览器传入的 user_cookie,不落服务器)。
全局账号(cookie.txt)零个人身份权限:浏览器提交的 MUSIC_U 若与全局账号一致
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计(与 Flask 同口径)。
"""
ck = media.parse_cookie_str(data.get('user_cookie'))
mu = (ck.get('MUSIC_U') or '').strip()
gmu = _global_music_u()
if mu and gmu and hmac.compare_digest(mu, gmu):
try:
import auth_system
await asyncio.to_thread(auth_system.add_log, 'fail', user.username,
ratelimit.client_ip(request), '全局账号冒用拦截',
'浏览器提交的 MUSIC_U 与全局账号一致,已按未登录拒绝')
except Exception:
pass
logger.warning('[全局账号冒用拦截] %s 提交的 MUSIC_U 与全局账号一致,已按未登录处理',
request.url.path)
return {}
return ck
def _record_log(action: str, ip: str, song: str) -> None:
"""用户解析/下载操作记录(IP)——写入共享 REQ_LOG(与 Flask _record_log 同口径)。"""
REQ_LOG.append({
'action': action,
'ip': ip,
'song': str(song or '')[:100],
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
})
if len(REQ_LOG) > 2000:
del REQ_LOG[:len(REQ_LOG) - 2000]
# ============ 迁移端:专辑 / 歌单 / 歌曲 ============
@router.api_route('/album', methods=['GET', 'POST'])
async def album(_user: LoginUser, request: Request):
"""专辑详情。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
album_id = data.get('id')
if not album_id:
return error("参数 'album_id' 不能为空", 400)
album_id = _extract_id(album_id)
if not str(album_id).strip().isdigit():
return error('无效的专辑ID,请输入数字ID或分享链接')
try:
result = await music_api.NeteaseAPI().get_album_detail(int(album_id), _cookies())
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
return success({'status': 200, 'album': result}, '获取专辑详情成功')
@router.api_route('/playlist', methods=['GET', 'POST'])
async def playlist(user: LoginUser, request: Request):
"""歌单详情(非管理员遮蔽服务器账号昵称)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
playlist_id = data.get('id')
if not playlist_id:
return error("参数 'playlist_id' 不能为空", 400)
playlist_id = _extract_id(playlist_id)
if not str(playlist_id).strip().isdigit():
return error('无效的歌单ID,请输入数字ID或分享链接')
try:
result = await music_api.NeteaseAPI().get_playlist_detail(int(playlist_id), _cookies())
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
if isinstance(result, dict):
if result.get('creator'):
result['creator'] = await mask_server_nickname(result['creator'], user)
if result.get('name'):
result['name'] = await mask_server_nickname(result['name'], user)
return success({'status': 'success', 'playlist': result}, '获取歌单详情成功')
@router.api_route('/song', methods=['GET', 'POST'])
async def song(user: LoginUser, request: Request):
"""歌曲信息(type: url/name/lyric/json)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
song_ids = data.get('ids') or data.get('id')
url = data.get('url')
level = data.get('level', 'lossless')
info_type = data.get('type', 'url')
if not song_ids and not url:
return error("必须提供 'ids'、'id' 或 'url' 参数")
music_id = _extract_id(song_ids or url)
if not str(music_id).strip().isdigit():
return error('无效的歌曲ID,请输入数字ID或分享链接')
if level not in VALID_QUALITY_LEVELS:
return error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}")
if info_type not in _VALID_SONG_TYPES:
return error(f"无效的类型参数,支持: {', '.join(_VALID_SONG_TYPES)}")
account = data.get('account', 'admin')
if account == 'user':
cookies = await _resolve_user_cookies(data, user, request)
if not cookies:
return error('请先扫码登录个人账号后再使用个人解析', 401)
else:
cookies = _cookies()
api = music_api.NeteaseAPI()
try:
if info_type == 'url':
result = await api.get_song_url(int(music_id), level, cookies)
if result and result.get('data') and len(result['data']) > 0:
song_data = result['data'][0] or {}
if not song_data:
return error('获取音乐URL失败,可能是版权限制或音质不支持', 404)
return success({
'id': song_data.get('id'),
'url': song_data.get('url'),
'level': song_data.get('level'),
'quality_name': media.quality_display_name(song_data.get('level', level)),
'size': song_data.get('size'),
'size_formatted': media.format_file_size(song_data.get('size', 0)),
'type': song_data.get('type'),
'bitrate': song_data.get('br'),
}, '获取歌曲URL成功')
return error('获取音乐URL失败,可能是版权限制或音质不支持', 404)
if info_type == 'name':
return success(await api.get_song_detail(int(music_id)), '获取歌曲信息成功')
if info_type == 'lyric':
return success(await api.get_lyric(int(music_id), cookies), '获取歌词成功')
# info_type == 'json':并行取详情/URL,歌词失败不阻断
song_info, url_info, lyric_info = await asyncio.gather(
api.get_song_detail(int(music_id)),
api.get_song_url(int(music_id), level, cookies),
api.get_lyric(int(music_id), cookies),
return_exceptions=True,
)
if isinstance(lyric_info, BaseException):
lyric_info = None
if isinstance(url_info, BaseException):
url_info = None
if isinstance(song_info, BaseException) or not song_info or not song_info.get('songs'):
return error('未找到歌曲信息', 404)
song_data = song_info['songs'][0]
_al = song_data.get('al') or {}
_ar_names = ', '.join(filter(None, ((a or {}).get('name') for a in (song_data.get('ar') or []))))
response_data = {
'id': music_id,
'name': song_data.get('name') or '',
'ar_name': _ar_names,
'al_name': _al.get('name') or '',
'pic': _al.get('picUrl') or '',
'level': level,
'lyric': ((lyric_info or {}).get('lrc') or {}).get('lyric') or '',
'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '',
'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or '',
}
if url_info and url_info.get('data') and len(url_info['data']) > 0:
url_data = url_info['data'][0]
response_data.update({
'url': url_data.get('url', ''),
'size': media.format_file_size(url_data.get('size', 0)),
'level': url_data.get('level', level),
})
else:
response_data.update({'url': '', 'size': '获取失败'})
try:
_record_log('解析', ratelimit.client_ip(request), response_data.get('name') or str(music_id))
except Exception:
pass
return success(response_data, '获取歌曲信息成功')
except APIException:
return error('服务暂时不可用,请稍后再试', 500)