"""头像上传(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//,目录懒建)→ 写版本 → 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}