Spaces:
Running
Running
File size: 5,663 Bytes
c9a637a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | """管理端「文件中转」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})
|