"""公开品牌端点(Task 10)——widget/独立页/app 的品牌唯一事实源。 纪律: - **显式字段白名单**序列化(BRAND_FIELDS 常量逐字段取值)——绝不序列化 整个 site.brand/config:api_key/smtp 等内部字段无出现在响应里的可能 (tests/test_brand_endpoint.py 白名单断言钉死); - 响应头 ``Access-Control-Allow-Origin: *``:挂件嵌在客户站,brand 与 avatar 都是跨源加载; - ``?site=`` 站点覆盖:空域名站点(如 jiancai 的 es)靠嵌入代码 ``data-site`` 接入,未知/缺省回退中间件按 Host 解析出的 state.site (即租户默认门面); - avatar 按当前租户走**三级解析链**(Task 7):``data/uploads// ``(后台上传的头像,优先)→ ``seed_profiles// 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= 覆盖 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" 或上传后的 ".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)