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:
+25
-15
@@ -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)
|
||||
|
||||
@@ -86,6 +86,7 @@ from app.platform.routes.config_routes import (
|
||||
settings_router as platform_settings_router,
|
||||
)
|
||||
from app.platform.routes.tenant_routes import router as platform_tenant_router
|
||||
from app.platform.routes.upload_routes import router as platform_upload_router
|
||||
app.include_router(chat_router)
|
||||
app.include_router(chat_rest_router)
|
||||
app.include_router(public_router)
|
||||
@@ -97,6 +98,9 @@ app.include_router(platform_tenant_router)
|
||||
# 前缀的 Host 豁免由 Task 3 的中间件规则统一覆盖。
|
||||
app.include_router(platform_config_router)
|
||||
app.include_router(platform_settings_router)
|
||||
# 头像上传(Task 7):multipart 魔数校验 + uuid 重命名 + 配置版本管线
|
||||
# (require_tenant);/api/public/avatar 的三级解析链消费上传产物。
|
||||
app.include_router(platform_upload_router)
|
||||
# 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下
|
||||
# /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为
|
||||
# /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析);
|
||||
|
||||
@@ -90,8 +90,8 @@ class TenantRuntime:
|
||||
|
||||
加载期就绪:config(TenantConfig:data=快照⊕出厂默认,含 sites)/
|
||||
db_path(该租户 SQLite 文件路径)/seed_dir(文件源=租户目录;DB 源
|
||||
None——avatar 解析链对其回退静态兜底)/compiled_wall(墙编译产物)/
|
||||
prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一
|
||||
None——avatar 解析链的 seed 资产段对其跳过)/compiled_wall(墙编译
|
||||
产物)/prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一
|
||||
入口——$DEFLECTION_POOL 话术池注入的单一消费口)。
|
||||
懒建:engine/session_factory(Registry 首次 resolve,ensure_schema 已跑)。
|
||||
manager(Task 5b)由装配线另行构造注入,不在本类持有。
|
||||
@@ -123,6 +123,12 @@ class TenantRegistry:
|
||||
self._by_slug: dict[str, TenantRuntime] = {}
|
||||
self._domain_index: dict[str, tuple[str, str]] = {}
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
# 头像上传根目录(Task 7):生产 data_dir = <repo>/tenants,与
|
||||
# <repo>/data/ 是仓库根兄弟目录(spec 布局,.gitignore 已忽略 data/)
|
||||
# → 取 data_dir.parent 派生;测试注入 tmp data_dir 时上传自动落
|
||||
# tmp 内(DB 源夹具 data_dir=tmp/tenants → tmp/data/uploads),
|
||||
# 隔离免污染仓库目录。布局若变,只改这一处。
|
||||
self.uploads_root = self._data_dir.parent / "data" / "uploads"
|
||||
|
||||
# ---- 加载 -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""头像上传(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}
|
||||
Reference in New Issue
Block a user