Files
ai-kefu-admin/app/platform/routes/upload_routes.py
T
浅念 a43c654337 feat: 头像上传(uuid重命名/魔数校验/2MB/配置版本管线)与三级解析链
- POST /api/platform/tenants/{slug}/brand/avatar(require_tenant):
  multipart 收文件,魔数判定类型(\x89PNG/\xff\xd8\xff/RIFF....WEBP,
  优先于扩展名——改后缀骗不过,存盘扩展名取自内容)、≤2MB(413,
  upload.size 预检免拷贝)、服务端 uuid4 重命名(原始文件名不参与
  路径),落 data/uploads/<slug>/(目录懒建,gitignore 已忽略)
- 走 Task 6 配置版本管线(复用 config_routes 内部 helper):读当前
  快照 → brand 组只替换 avatar 字段 → validate_group →
  write_config_version(comment=头像上传) → registry_reload 热生效
- /api/public/avatar 三级解析链:uploads → seed 资产 → 静态兜底;
  Phase 1 文件名穿越防线(拒 / \ . ..)扩到 uploads 段
- registry.uploads_root:自 data_dir.parent 派生(生产=<repo>/data/
  uploads;测试注入 tmp data_dir 时自动落 tmp 内,隔离免污染)
- 新依赖 python-multipart(multipart 表单解析)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 10:35:41 +08:00

110 lines
5.3 KiB
Python

"""头像上传(Phase 2 Task 7):POST /api/platform/tenants/{slug}/brand/avatar。
安全纪律(三防):
- **魔数校验优先于扩展名**:类型只认文件内容(\x89PNG / \xff\xd8\xff /
RIFF....WEBP),改后缀骗不过——.png 名的 JPEG 按内容存 .jpg,纯文本/
RIFF 非 WEBP 一律 400。存盘扩展名取自魔数判定的规范名,自洽无伪装面;
- **服务端 uuid 重命名**:uuid4().hex + "." + 扩展,原始文件名不参与
路径(带 ../ 的文件名无从生效);目录段 slug 取自 DB 行(tenant.slug),
路径参数先过 _get_tenant_or_404 才拼盘——未知/伪造 slug 到不了文件系统;
- **大小 ≤2MB**(413);缺 file 字段 400(不留给 FastAPI 422 抢答,与
配置 API 的 400 契约一致)。
配置版本管线(与 Task 6 的 PUT 同款模式,复用 config_routes 内部 helper):
读当前快照 → brand 组**只替换 avatar 字段**(其余字段原样保留——与 PUT
的整组替换不同,上传是单字段编辑)→ validate_group(brand) →
write_config_version(comment="头像上传") → registry_reload 热生效。
落盘顺序:校验全过 → 写文件(uploads_root/<slug>/,目录懒建)→ 写版本
→ commit → reload。文件先落盘:commit 失败只留无害孤儿文件;反过来
(先版本后文件)失败会留下指向不存在文件的 avatar。
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
# request.form() 产出 starlette 的 UploadFile;fastapi.UploadFile 是其子类
# (File() 参数专用),isinstance 反向判 False——必须对基类判
from starlette.datastructures import UploadFile
from app.platform.config_validation import ValidationContext, validate_group
from app.platform.db import write_config_version
from app.platform.routes.config_routes import _load_current, _updated_by
from app.platform.routes.tenant_routes import _get_tenant_or_404, _tenant_guard
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-upload"])
MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2MB(spec 头像上限)
# 魔数 → 规范扩展名(检测只认内容;存盘名与之自洽)
_MAGIC_SIGNATURES = (
(b"\x89PNG\r\n\x1a\n", "png"),
(b"\xff\xd8\xff", "jpg"),
)
def _sniff_image(data: bytes) -> str | None:
"""按魔数嗅探图片类型,返回规范扩展名;非 png/jpg/webp → None。"""
for signature, ext in _MAGIC_SIGNATURES:
if data.startswith(signature):
return ext
# WEBP 容器: "RIFF" + 4 字节长度 + "WEBP"(头尾两段,无法单串 startswith)
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
return None
@router.post("/{slug}/brand/avatar")
async def upload_avatar(slug: str, request: Request,
user=Depends(_tenant_guard)):
"""multipart 头像上传:魔数判定类型 → uuid 重命名落盘 → 配置版本管线
写 brand.avatar → reload 热生效。返回 {slug, avatar, version}。"""
form = await request.form()
upload = form.get("file")
if upload is None or not isinstance(upload, UploadFile):
raise HTTPException(
status_code=400, detail="缺少 multipart 文件字段 file")
# 预检 upload.size(starlette 解析期已跟踪的实际字节数):超标直接 413,
# 不把超大 body 再 read() 进内存(spool 盘上的部分不复制)
if upload.size is not None and upload.size > MAX_AVATAR_BYTES:
raise HTTPException(
status_code=413,
detail=f"头像超过 2MB 上限(实际 {upload.size} 字节)")
data = await upload.read()
if len(data) > MAX_AVATAR_BYTES:
raise HTTPException(
status_code=413,
detail=f"头像超过 2MB 上限(实际 {len(data)} 字节)")
ext = _sniff_image(data)
if ext is None:
raise HTTPException(
status_code=400,
detail="不支持的图片类型: 只允许 png/jpg/jpeg/webp"
"(按文件内容判定,改后缀无效)")
filename = f"{uuid.uuid4().hex}.{ext}"
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
_, current = await _load_current(session, tenant.id, slug)
brand = dict(current.get("brand") or {})
brand["avatar"] = filename
errors = validate_group("brand", brand, ValidationContext())
if errors: # 管线纪律:落库前过校验(理论不触发)
raise HTTPException(status_code=400, detail="; ".join(errors))
new_snapshot = dict(current)
new_snapshot["brand"] = brand
version = await write_config_version(
session, tenant.id, new_snapshot,
updated_by=_updated_by(user), comment="头像上传")
# 目录段用 DB 行的 slug(路径 slug 已过 404 守卫,二者必一致——
# 但以库为准拼路径,杜绝任何路径参数形态的逃逸面)
dest_dir = request.app.state.registry.uploads_root / tenant.slug
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / filename).write_bytes(data)
await session.commit()
await request.app.state.registry.registry_reload(slug)
return {"slug": tenant.slug, "avatar": filename, "version": version}