"""文件中转公开 API:/transfer/api/* 外部工具凭管理面板生成的「上传密钥」把文件上传到后端中转站: - POST /transfer/api/upload 上传单个文件(multipart/form-data,字段名 file,可选 alias) 鉴权:请求头 ``x-xtc-transfer-key: ``,或 ``Authorization: Bearer ``。 不接受 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"], })