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>
This commit is contained in:
浅念
2026-10-13 10:35:41 +08:00
parent 7502176190
commit a43c654337
6 changed files with 382 additions and 17 deletions
+25 -15
View File
@@ -9,8 +9,10 @@
- ``?site=<slug>`` 站点覆盖:空域名站点(如 jiancai 的 es)靠嵌入代码
``data-site`` 接入,未知/缺省回退中间件按 Host 解析出的 state.site
(即租户默认门面);
- avatar 按当前租户解析 ``seed_profiles/<slug>/assets/<brand.avatar 文件名>``,
文件不存在回退 ``static/kefu-small.png``(两租户头像各归各,字节级不同)。
- avatar 按当前租户走**三级解析链**(Task 7):``data/uploads/<slug>/
<brand.avatar 文件名>``(后台上传的头像,优先)→ ``seed_profiles/<slug>/
assets/<同名>``(出厂档案资产)→ ``static/kefu-small.png`` 兜底。文件名
的穿越防线(拒 ``/`` ``\\`` ``.`` ``..``,Phase 1)同样覆盖 uploads 段。
中间件已保证到达路由的请求必有 state.tenant/state.site;本路由照常经
中间件(租户域名下访问),经 get_runtime 取运行时。
@@ -73,20 +75,28 @@ async def avatar(request: Request):
runtime = get_runtime(request)
site, _ = _effective_site(request, runtime)
# 只取文件名(PurePosixPath.name 剥掉一切路径成分)——brand.avatar 是
# "/static/kefu-small.png" 这类路径,取基名后拼租户 assets 目录,无路径
# 逃逸面(单成分文件名不可能越出 assets/)
# "/static/kefu-small.png" 或上传后的 "<uuid>.png" 这类值,取基名后拼
# 租户目录,无路径逃逸面(单成分文件名不可能越出目录)
name = pathlib.PurePosixPath(str(site.brand.get("avatar") or "")).name
# 复审加固: PurePosixPath 不认反斜杠(整串留在 .name 里),Windows 文件
# 系统却把反斜杠当分隔符——seed_dir/assets/.. 会真穿出资产目录 serve
# 任意文件。显式拒绝分隔符与目录点,命中即回退静态兜底。
# 系统却把反斜杠当分隔符——assets/.. 会真穿出资产目录 serve 任意文件。
# 显式拒绝分隔符与目录点,命中即回退静态兜底。防线覆盖整条解析链
# (uploads 段与 seed 段消费同一个守卫后的 name)。
if name in {".", ".."} or "/" in name or "\\" in name:
name = ""
# seed_dir=None = DB 源租户(Task 4;资产目录尚无 DB 侧存储)——直接
# 落静态兜底,不让 None 进路径拼接炸 500
candidate = (runtime.seed_dir / "assets" / name
if name and runtime.seed_dir is not None else None)
if candidate is not None and candidate.is_file():
return FileResponse(
candidate, headers={"Access-Control-Allow-Origin": "*"})
return FileResponse(
_AVATAR_FALLBACK, headers={"Access-Control-Allow-Origin": "*"})
headers = {"Access-Control-Allow-Origin": "*"}
if name:
# 三级解析链(Task 7):uploads(后台上传,优先)→ seed 资产 → 静态
# 兜底。uploads_root 生产行为见 registry(仓库根 data/,gitignore);
# registry 经中间件保证存在(本请求已过 Host 解析)。
uploaded = (request.app.state.registry.uploads_root
/ runtime.config.slug / name)
if uploaded.is_file():
return FileResponse(uploaded, headers=headers)
# seed_dir=None = DB 源租户(Task 4;出厂资产无 DB 侧存储)——
# 跳过 seed 段落静态兜底,不让 None 进路径拼接炸 500
if runtime.seed_dir is not None:
seeded = runtime.seed_dir / "assets" / name
if seeded.is_file():
return FileResponse(seeded, headers=headers)
return FileResponse(_AVATAR_FALLBACK, headers=headers)