diff --git a/static/widget.js b/static/widget.js index ac66e15..affc983 100644 --- a/static/widget.js +++ b/static/widget.js @@ -24,6 +24,16 @@ var autoInvite = script.getAttribute("data-auto-invite") !== "false"; var isMobile = script.getAttribute("data-mobile") === "true"; var wsBase = new URL(script.src).origin; + // spec 2026-10-19 §4: 脚本地址自携带租户路径(/t//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 强制;只可整块替换,不可单边修改 ===== */ var DEFAULT_COLOR = "#066fc0"; @@ -129,7 +139,7 @@ var brandSlug = "default"; 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) { return r.ok ? r.json() : null; }).catch(function () { @@ -161,9 +171,9 @@ email = contactHref(emailAttr || b.email || "", "mail"); palette = derivePalette(colorAttr || (b.color || "").trim() || DEFAULT_COLOR); avatarUrl = avatarAttr - ? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, "")) - : (b.avatar ? wsBase + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar) - : wsBase + DEFAULT_AVATAR_PATH); + ? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + basePath + "/" + avatarAttr.replace(/^\//, "")) + : (b.avatar ? wsBase + basePath + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar) + : wsBase + basePath + DEFAULT_AVATAR_PATH); welcome = b.welcome || ""; topics = Array.isArray(b.topics) ? b.topics.filter(function (t) { return typeof t === "string" && t; }) @@ -682,7 +692,7 @@ function wConnect() { var proto = wsBase.indexOf("https") === 0 ? "wss" : "ws"; var wsHost = wsBase.replace(/^https?/, proto); - wWs = new WebSocket(wsHost + "/ws/chat"); + wWs = new WebSocket(wsHost + basePath + "/ws/chat"); wWs.onopen = function () { // Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端 diff --git a/tests/test_theme_static.py b/tests/test_theme_static.py index 0743538..641cf78 100644 --- a/tests/test_theme_static.py +++ b/tests/test_theme_static.py @@ -124,7 +124,8 @@ def test_widget_avatar_src_uses_wsbase(): src = _read("widget.js") assert 'DEFAULT_AVATAR_PATH = "/static/kefu-small.png"' in src 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(): diff --git a/tests/test_widget_static.py b/tests/test_widget_static.py new file mode 100644 index 0000000..b00c34c --- /dev/null +++ b/tests/test_widget_static.py @@ -0,0 +1,33 @@ +"""widget.js 静态守卫(spec 2026-10-19 §4):脚本地址自推导 basePath, +三类端点构造(brand/avatar/WS)统一带前缀;Host 模式前缀为空串零变化。 + +路径模式(/t//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