feat(widget): 脚本地址自推导 basePath(路径模式端点前缀)

spec 2026-10-19 §4: 路径模式(/t/<slug>/static/widget.js)下从 script.src
剥尾推导 basePath,brand/avatar 三分支/WS 四处端点构造统一加前缀;
Host 模式 indexOf 命中在 0 → basePath="" 端点零变化。
test_theme_static.py 头像锚定守卫随构造更新(wsBase + basePath + …,
语义不变:绝对锚定 + 禁相对路径)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-19 18:13:49 +08:00
parent 3862577143
commit 0baa9ab2ed
3 changed files with 50 additions and 6 deletions
+15 -5
View File
@@ -24,6 +24,16 @@
var autoInvite = script.getAttribute("data-auto-invite") !== "false"; var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var isMobile = script.getAttribute("data-mobile") === "true"; var isMobile = script.getAttribute("data-mobile") === "true";
var wsBase = new URL(script.src).origin; var wsBase = new URL(script.src).origin;
// spec 2026-10-19 §4: 脚本地址自携带租户路径(/t/<slug>/static/widget.js)
// → 剥掉 /static/widget.js 起的前缀得 basePath("" = Host 模式零变化)。
// script.src 永远指向本文件,indexOf 命中即安全;query(?v=)不影响 pathname。
// 拼接安全: basePath 仅进 URL 上下文(fetch/WebSocket/img.src),
// 绝不进 innerHTML 字符串拼 HTML(防线 §2/§3)。
var basePath = (function () {
var p = new URL(script.src).pathname;
var i = p.indexOf("/static/widget.js");
return i > 0 ? p.slice(0, i) : "";
})();
/* ===== 主题派生单元:与 static/js/theme.js 逐字同步,tests/test_theme_static.py 强制;只可整块替换,不可单边修改 ===== */ /* ===== 主题派生单元:与 static/js/theme.js 逐字同步,tests/test_theme_static.py 强制;只可整块替换,不可单边修改 ===== */
var DEFAULT_COLOR = "#066fc0"; var DEFAULT_COLOR = "#066fc0";
@@ -129,7 +139,7 @@
var brandSlug = "default"; var brandSlug = "default";
function fetchBrand() { function fetchBrand() {
var url = wsBase + "/api/public/brand" + (siteAttr ? "?site=" + encodeURIComponent(siteAttr) : ""); var url = wsBase + basePath + "/api/public/brand" + (siteAttr ? "?site=" + encodeURIComponent(siteAttr) : "");
var p = fetch(url, { cache: "no-store" }).then(function (r) { var p = fetch(url, { cache: "no-store" }).then(function (r) {
return r.ok ? r.json() : null; return r.ok ? r.json() : null;
}).catch(function () { }).catch(function () {
@@ -161,9 +171,9 @@
email = contactHref(emailAttr || b.email || "", "mail"); email = contactHref(emailAttr || b.email || "", "mail");
palette = derivePalette(colorAttr || (b.color || "").trim() || DEFAULT_COLOR); palette = derivePalette(colorAttr || (b.color || "").trim() || DEFAULT_COLOR);
avatarUrl = avatarAttr avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, "")) ? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + basePath + "/" + avatarAttr.replace(/^\//, ""))
: (b.avatar ? wsBase + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar) : (b.avatar ? wsBase + basePath + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar)
: wsBase + DEFAULT_AVATAR_PATH); : wsBase + basePath + DEFAULT_AVATAR_PATH);
welcome = b.welcome || ""; welcome = b.welcome || "";
topics = Array.isArray(b.topics) topics = Array.isArray(b.topics)
? b.topics.filter(function (t) { return typeof t === "string" && t; }) ? b.topics.filter(function (t) { return typeof t === "string" && t; })
@@ -682,7 +692,7 @@
function wConnect() { function wConnect() {
var proto = wsBase.indexOf("https") === 0 ? "wss" : "ws"; var proto = wsBase.indexOf("https") === 0 ? "wss" : "ws";
var wsHost = wsBase.replace(/^https?/, proto); var wsHost = wsBase.replace(/^https?/, proto);
wWs = new WebSocket(wsHost + "/ws/chat"); wWs = new WebSocket(wsHost + basePath + "/ws/chat");
wWs.onopen = function () { wWs.onopen = function () {
// Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端 // Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端
+2 -1
View File
@@ -124,7 +124,8 @@ def test_widget_avatar_src_uses_wsbase():
src = _read("widget.js") src = _read("widget.js")
assert 'DEFAULT_AVATAR_PATH = "/static/kefu-small.png"' in src assert 'DEFAULT_AVATAR_PATH = "/static/kefu-small.png"' in src
assert 'src="/static/kefu-small.png"' not in src, "挂件禁用相对路径头像(客户站会 404)" assert 'src="/static/kefu-small.png"' not in src, "挂件禁用相对路径头像(客户站会 404)"
assert "wsBase + DEFAULT_AVATAR_PATH" in src # spec 2026-10-19 §4: 默认头像同样走 basePath 前缀(Host 模式为空串零变化)
assert "wsBase + basePath + DEFAULT_AVATAR_PATH" in src
def test_style_root_defaults_match_theme_palette(): def test_style_root_defaults_match_theme_palette():
+33
View File
@@ -0,0 +1,33 @@
"""widget.js 静态守卫(spec 2026-10-19 §4):脚本地址自推导 basePath,
三类端点构造(brand/avatar/WS)统一带前缀;Host 模式前缀为空串零变化。
路径模式(/t/<slug>/static/widget.js)下脚本地址自携带租户路径,剥掉
/static/widget.js 尾部即得 basePath;Host 模式 pathname 以 /static/
开头(indexOf 命中在 0)→ basePath="" 所有端点与现状零差异。守卫三点:
推导逻辑在场、四处端点构造不再出现裸拼接、默认头像路径同走前缀。
"""
from pathlib import Path
STATIC = Path(__file__).resolve().parent.parent / "static"
def read(rel):
return (STATIC / rel).read_text(encoding="utf-8")
def test_有_basepath_推导():
src = read("widget.js")
assert 'indexOf("/static/widget.js")' in src # 从 script.src 剥尾推导
def test_端点构造全部走_basePath():
src = read("widget.js")
# 不允许再出现裸 wsBase + "/api/ 或 wsHost + "/ws/(必须经 basePath)
assert 'wsBase + "/api/' not in src
assert 'wsHost + "/ws/' not in src
assert "wsBase + basePath" in src
def test_默认头像路径带前缀():
src = read("widget.js")
assert "wsBase + basePath + DEFAULT_AVATAR_PATH" in src