Files
yunke/yunke-server/app/api/routers/media.py
T
浅念 caef32891e fix(security): 路径穿越防护+导出行数上限+并发同步锁(3 critical/important)
- media.py: GET /api/media/{hash} 现强制 file 必须位于 Settings.media_dir
  沙箱内(resolve + relative_to),杜绝 local_path 越权读取任意文件。
- export.py: /api/export/chat-html 加 EXPORT_MAX_ROWS=5000 硬上限(.limit()),
  超出时仅渲染前 5000 条并插入 .export-warning 横幅,防止 OOM。
- scheduler.py: _safe_run 增加 SyncRun advisory lock(60min TTL),发现同
  entity 存在 status='running' 且在 TTL 内的记录即返回 skipped SyncResult;
  同时为本轮 stake 一行 wrapping SyncRun,让并发 CLI/scheduler/手动触发
  能相互避让。崩溃场景由 TTL 自动恢复。
- tests: 新增 path-traversal 拒绝/export 截断警告/advisory lock skip+TTL
  恢复 5 个测试;test_api_media 的 fixture 改为 MEDIA_DIR=tmp_path 以适配
  新沙箱语义。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 10:37:13 +08:00

68 lines
2.7 KiB
Python

"""GET /api/media/{media_hash} — stream a cached media blob (Task 2.8).
Looks up the :class:`app.db.models.Media` row keyed by ``media_hash`` and
streams the local file with the correct ``Content-Type``. HTTP Range requests
are honored (Starlette's :class:`fastapi.responses.FileResponse` handles
``Range`` / ``206 Partial Content`` natively). Returns ``404`` when the row is
absent, has no local file recorded, or the underlying file is missing on disk.
The endpoint reads the absolute ``local_path`` straight off the row (the
downloader stores it absolute), so it does not need to know the
:class:`ObjectStore` layout — only that the file exists where the row points.
"""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.config import get_settings
from app.db.models import Media
from app.scraper.media import guess_mime_type
router = APIRouter(prefix="/api", tags=["media"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.get("/media/{media_hash}")
async def get_media(media_hash: str, session: SessionDep) -> FileResponse:
"""Stream the cached media blob for ``media_hash``."""
row = (
await session.execute(
sa.select(Media).where(Media.media_hash == media_hash)
)
).scalar_one_or_none()
if row is None or not row.local_path:
raise HTTPException(status_code=404, detail="media not found")
# Path-traversal defense: resolve absolute and confirm the file lives under
# the configured ``media_dir`` sandbox. A row whose ``local_path`` escapes
# media_dir (an absolute path elsewhere, or a ``..``-laced value) is treated
# as not found — never served. Defense-in-depth: even if a future bug or a
# hostile scrape payload ever lets ``local_path`` point outside the sandbox,
# this check keeps the file server constrained to media_dir.
media_dir = get_settings().media_dir.resolve()
path = Path(row.local_path).resolve()
try:
path.relative_to(media_dir)
except ValueError:
raise HTTPException(status_code=404, detail="media not found")
if not path.is_file():
# Row exists but the blob was removed out-of-band — treat as not-found.
raise HTTPException(status_code=404, detail="media file missing")
media_type = (
row.mime_type
or guess_mime_type(path.suffix)
or "application/octet-stream"
)
return FileResponse(path, media_type=media_type, filename=path.name)