caef32891e
- 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>
68 lines
2.7 KiB
Python
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)
|