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>
103 lines
4.9 KiB
Python
103 lines
4.9 KiB
Python
"""公开品牌端点(Task 10)——widget/独立页/app 的品牌唯一事实源。
|
|
|
|
纪律:
|
|
- **显式字段白名单**序列化(BRAND_FIELDS 常量逐字段取值)——绝不序列化
|
|
整个 site.brand/config:api_key/smtp 等内部字段无出现在响应里的可能
|
|
(tests/test_brand_endpoint.py 白名单断言钉死);
|
|
- 响应头 ``Access-Control-Allow-Origin: *``:挂件嵌在客户站,brand 与
|
|
avatar 都是跨源加载;
|
|
- ``?site=<slug>`` 站点覆盖:空域名站点(如 jiancai 的 es)靠嵌入代码
|
|
``data-site`` 接入,未知/缺省回退中间件按 Host 解析出的 state.site
|
|
(即租户默认门面);
|
|
- 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 取运行时。
|
|
"""
|
|
import pathlib
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
|
|
from app.engine.config import STATIC_DIR
|
|
from app.engine.routes.runtime import get_runtime
|
|
|
|
router = APIRouter(prefix="/api/public", tags=["public-brand"])
|
|
|
|
# 白名单字段(与 tests/test_brand_endpoint.BRAND_EXPECTED 逐字同步):
|
|
# 增删字段必须同时改测试——"绝不多吐"是本端点的红线
|
|
BRAND_FIELDS = ("slug", "title", "color", "avatar", "welcome", "topics",
|
|
"placeholder", "whatsapp", "email", "privacy_url")
|
|
|
|
_AVATAR_FALLBACK = STATIC_DIR / "kefu-small.png"
|
|
|
|
|
|
def _effective_site(request: Request, runtime):
|
|
"""?site=<slug> 覆盖 state.site(未知 slug 回退),返回 (site, 是否命中覆盖)。"""
|
|
site_param = (request.query_params.get("site") or "").strip()
|
|
if not site_param:
|
|
return request.state.site, False
|
|
overridden = runtime.site_by_slug(site_param)
|
|
if overridden is None:
|
|
return request.state.site, False
|
|
return overridden, True
|
|
|
|
|
|
@router.get("/brand")
|
|
async def brand(request: Request):
|
|
runtime = get_runtime(request)
|
|
site, site_hit = _effective_site(request, runtime)
|
|
b = site.brand
|
|
payload = {
|
|
# slug = 租户 slug:客户端据此拼租户化 key(host id / storage key)
|
|
"slug": runtime.config.slug,
|
|
"title": b.get("title", ""),
|
|
"color": b.get("color", ""),
|
|
# avatar 是本端点的配套 URL(可能带 ?site= 让头像跟随站点覆盖),
|
|
# 客户端拼 origin 后直接加载
|
|
"avatar": "/api/public/avatar" + ("?site=" + site.slug if site_hit else ""),
|
|
"welcome": b.get("welcome", ""),
|
|
"topics": list(b.get("topics") or []),
|
|
"placeholder": b.get("placeholder", ""),
|
|
"whatsapp": b.get("whatsapp", ""),
|
|
"email": b.get("email", ""),
|
|
"privacy_url": b.get("privacy_url", ""),
|
|
}
|
|
assert set(payload.keys()) == set(BRAND_FIELDS) # 白名单自检(防手滑加字段)
|
|
return JSONResponse(payload, headers={"Access-Control-Allow-Origin": "*"})
|
|
|
|
|
|
@router.get("/avatar")
|
|
async def avatar(request: Request):
|
|
runtime = get_runtime(request)
|
|
site, _ = _effective_site(request, runtime)
|
|
# 只取文件名(PurePosixPath.name 剥掉一切路径成分)——brand.avatar 是
|
|
# "/static/kefu-small.png" 或上传后的 "<uuid>.png" 这类值,取基名后拼
|
|
# 租户目录,无路径逃逸面(单成分文件名不可能越出目录)
|
|
name = pathlib.PurePosixPath(str(site.brand.get("avatar") or "")).name
|
|
# 复审加固: PurePosixPath 不认反斜杠(整串留在 .name 里),Windows 文件
|
|
# 系统却把反斜杠当分隔符——assets/.. 会真穿出资产目录 serve 任意文件。
|
|
# 显式拒绝分隔符与目录点,命中即回退静态兜底。防线覆盖整条解析链
|
|
# (uploads 段与 seed 段消费同一个守卫后的 name)。
|
|
if name in {".", ".."} or "/" in name or "\\" in name:
|
|
name = ""
|
|
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)
|