a43c654337
- 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>
110 lines
5.3 KiB
Python
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}
|