xtc-backend / app /api /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
4.38 kB
"""文件中转公开 API:/transfer/api/*
外部工具凭管理面板生成的「上传密钥」把文件上传到后端中转站:
- POST /transfer/api/upload 上传单个文件(multipart/form-data,字段名 file,可选 alias)
鉴权:请求头 ``x-xtc-transfer-key: <key>``,或 ``Authorization: Bearer <key>``。
不接受 query 传 key(会写进访问日志/浏览器历史,等同泄露长期凭证)。
安全设计:
- 单文件大小上限 10MB(超出返回 413),空文件拒绝
- 存储文件名为服务端生成的随机英文名,原始文件名只作为展示别名(alias)
- 请求路径不在 request_log 记录范围内,避免把文件内容写进请求日志
- 限流:/transfer/ 前缀走速率限制中间件(see rate_limit_store.is_rate_limited_path)
"""
from __future__ import annotations
import asyncio
import logging
import time
import uuid
from typing import Optional
from fastapi import APIRouter, File, Form, Request, UploadFile
from ..errors import HttpError
from ..services import rate_limit_store, transfer_store
from ._common import ok_with_cors
router = APIRouter(prefix="/transfer/api", tags=["transfer"])
logger = logging.getLogger(__name__)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
def _extract_key(request: Request) -> str:
k = (request.headers.get("x-xtc-transfer-key") or "").strip()
if k:
return k
auth = (request.headers.get("authorization") or "").strip()
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return ""
def _make_storage_filename(original: str, key: str) -> str:
"""根据原始文件名生成安全的英文存储名,保留扩展名。
规则:t_{key前8位}_{时间戳后6位}.{ext},例如 "我的报告.pdf" -> "t_a1b2c3d4_8858a1.pdf"
"""
raw = str(original or "").strip()
ext = ""
dot_idx = raw.rfind(".")
if dot_idx > 0:
raw_ext = raw[dot_idx + 1:].lower()
safe_ext = "".join(c for c in raw_ext if c.isalnum())[:8]
if safe_ext:
ext = "." + safe_ext
short_key = key[:8] if key else "00000000"
short_ts = str(int(time.time()))[-6:]
return f"t_{short_key}_{short_ts}{ext}"
async def _require_transfer_key(request: Request) -> str:
key = _extract_key(request)
if not key:
raise HttpError("missing transfer key", status=401, code="unauthorized")
# verify_key 可能触发 Hub 拉取,放线程池避免阻塞事件循环
valid = await asyncio.to_thread(transfer_store.verify_key, key)
if not valid:
raise HttpError("invalid or revoked transfer key", status=401, code="unauthorized")
return key
@router.post("/upload")
async def upload_file(
request: Request,
file: UploadFile = File(...),
alias: Optional[str] = Form(default=None),
):
"""上传文件中转。返回文件 key 与元信息(文件可在管理面板「文件中转」中查看)。"""
key = await _require_transfer_key(request)
content = await file.read()
if not content:
raise HttpError("empty file", status=400, code="bad_request")
if len(content) > MAX_FILE_SIZE:
raise HttpError(
f"file too large: {len(content)} bytes > 10MB ({MAX_FILE_SIZE} bytes)",
status=413, code="too_large",
)
file_key = uuid.uuid4().hex
display_name = str(alias or "").strip() or str(file.filename or "").strip() or "unnamed"
if len(display_name) > 255:
display_name = display_name[:255]
filename = _make_storage_filename(display_name, file_key)
mime = (file.content_type or "application/octet-stream").strip() or "application/octet-stream"
meta = await transfer_store.save_transfer_file(
key=file_key,
filename=filename,
alias=display_name,
mime=mime,
content=content,
uploader_key=key,
client_ip=rate_limit_store.get_client_ip(request) or "",
)
logger.info(
"[transfer] upload ok key=%s alias=%s size=%d by_key=%s",
file_key, display_name, meta["size"], transfer_store.mask_key(key),
)
return ok_with_cors({
"key": meta["key"],
"filename": meta["filename"],
"alias": meta["alias"],
"mime": meta["mime"],
"size": meta["size"],
"sha256": meta["sha256"],
"uploadedAt": meta["uploaded_at"],
})