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:
+15
-5
@@ -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 接入,服务端
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user