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})