"""管理端「文件中转」API:/admin/api/transfer/* - POST /admin/api/transfer/keys 生成上传密钥(完整密钥仅返回一次) - GET /admin/api/transfer/keys 列出上传密钥(掩码) - POST /admin/api/transfer/keys/revoke 按公开短 ID 吊销密钥 - GET /admin/api/transfer/files 列出中转文件(支持 q / refresh) - GET /admin/api/transfer/files/{key}/download 下载中转文件 - DELETE /admin/api/transfer/files/{key} 删除中转文件 持久化:内容与元信息落本地缓存 + SQLite + HF Hub(见 services/transfer_store.py), SQLite 为空(HF Space 重建)时会自动从 Hub 恢复元信息。 """ from __future__ import annotations import asyncio import logging import urllib.parse from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import Response from ..errors import HttpError from ..services import transfer_store from ._common import CORS_HEADERS, audit_admin, ok_with_cors, read_json_body from .admin import _require_admin router = APIRouter(prefix="/admin/api/transfer", tags=["admin-transfer"]) logger = logging.getLogger(__name__) # ===== 上传密钥管理 ===== @router.post("/keys") async def create_transfer_key( request: Request, admin_key: str = Depends(_require_admin), ): """生成上传密钥(body 可选 {name})。完整密钥仅本次响应返回。""" body = await read_json_body(request) name = str(body.get("name") or "").strip() info = await transfer_store.generate_key(name) audit_admin("admin.transfer.key.create", admin_key, target=info["id"], name=name) return ok_with_cors(info) @router.get("/keys") async def list_transfer_keys(_admin: str = Depends(_require_admin)): return ok_with_cors({"keys": transfer_store.list_keys()}) @router.post("/keys/revoke") async def revoke_transfer_key( request: Request, admin_key: str = Depends(_require_admin), ): """吊销上传密钥(body: {id},id 为列表中的公开短 ID)。""" body = await read_json_body(request) key_id = str(body.get("id") or "").strip() if not key_id: raise HttpError("id is required", status=400, code="bad_request") revoked = await transfer_store.revoke_key(key_id) if not revoked: raise HttpError(f"transfer key not found: {key_id}", status=404, code="not_found") audit_admin("admin.transfer.key.revoke", admin_key, target=key_id) return ok_with_cors({"revoked": True, "id": key_id}) # ===== 中转文件管理 ===== @router.get("/files") async def list_transfer_files( _admin: str = Depends(_require_admin), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), q: str | None = Query(default=None), refresh: int = Query(default=0, ge=0, le=1), ): """列出中转文件。refresh=1 时强制从 Hub 全量恢复元信息(默认按 TTL 自动恢复)。""" try: await asyncio.to_thread(transfer_store.sync_from_hub, bool(refresh)) except Exception as e: logger.warning("[admin transfer] sync from hub failed: %s", e) total, items = await asyncio.to_thread( transfer_store.list_files, q=q, limit=limit, offset=offset ) return ok_with_cors({"items": items, "count": total}) @router.get("/files/{key}/download") async def download_transfer_file( key: str, admin_key: str = Depends(_require_admin), ): """管理员下载中转文件内容。""" meta = transfer_store.get_file(key) if not meta: raise HttpError(f"item not found: {key}", status=404, code="not_found") try: content = await asyncio.wait_for( asyncio.to_thread(transfer_store.download_content, key), timeout=30.0 ) except asyncio.TimeoutError: raise HttpError("content fetch timeout", status=504, code="upstream_timeout") if content is None: raise HttpError("content missing", status=410, code="gone") audit_admin( "admin.transfer.file.download", admin_key, target=key, size=len(content), ) alias = meta.get("alias") or meta.get("filename") or "unnamed" encoded = urllib.parse.quote(alias) ascii_name = meta.get("filename") or "unnamed" try: ascii_name.encode("latin-1") except (UnicodeEncodeError, UnicodeDecodeError): ascii_name = key mime = meta.get("mime") or "application/octet-stream" headers = { **CORS_HEADERS, # 中转文件可能由第三方上传,强制 attachment 避免浏览器内联渲染 "Content-Disposition": f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{encoded}", "Content-Type": mime, "Content-Length": str(len(content)), "X-Content-Type-Options": "nosniff", "X-XTC-Kind": "transfer", } return Response(content=content, status_code=200, headers=headers, media_type=mime) @router.delete("/files/{key}") async def delete_transfer_file( key: str, admin_key: str = Depends(_require_admin), ): """删除中转文件(SQLite 元信息 + 本地缓存 + Hub)。""" meta = transfer_store.get_file(key) if not meta: raise HttpError(f"item not found: {key}", status=404, code="not_found") await transfer_store.delete_file(key) logger.info("[admin transfer] deleted file key=%s alias=%s", key, meta.get("alias")) audit_admin( "admin.transfer.file.delete", admin_key, target=key, alias=meta.get("alias"), size=meta.get("size"), ) return ok_with_cors({"deleted": True, "key": key})