xtc-backend / app /api /admin_transfer.py
a3216's picture
sync from GitHub 838c507: feat: add file transfer functionality with API and admin interface
c9a637a verified
Raw History Blame Contribute Delete
5.66 kB
"""管理端「文件中转」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})