fix(webui): 星图依赖本地化 —— 不再从公网 CDN 拉 three.js

用户点「星图」看到「3D 星图不可用(CDN 加载失败)」。

服务端 curl 那两个 URL 都是 **200** ⇒ 不是服务端的问题,是**浏览器**
访问不到公网 CDN(内网 / 出口受限 / 断网)。dashboard.html 从 cdnjs 与
jsdelivr 拉 4 个库:three.js、OrbitControls、marked、DOMPurify。

换 CDN 只是把同一个赌注重下遍。HomeAgent 明确支持离线与内网部署,
前端却有 4 个硬依赖在公网上 ⇒ 断网即坏,且用户无从修复。

顺带两个收益:
- **安全**:DOMPurify 是净化 Markdown 的关键一环,它挂掉前端会退化到
  「不净化」分支(dashboard.js 里有 typeof 检查)—— 那是安全降级,
  比星图坏更值得修。
- **体积**:四个库共 ~700KB,embed 后由本服务同源提供,省掉 4 个跨域握手,
  也不再受第三方可用性影响。二进制 84MB → 84.7MB(+0.8%)。

- `internal/plugins/webui/static/` 放四个库(固定版本,随二进制走)
- `//go:embed static` + 新增 `/static/` 路由
- dashboard.html 的四个 src 改指 `/static/...`
- 加载失败兜底 8s → 3s(本地是毫秒级;仍超时就说明真有问题)

`/static/` **不走 requireWeb**:未登录时页面也要加载这些库才能渲染登录框,
加认证会让用户看到白屏(比 401 更难自查)。这些资源不含用户数据。

- TestDashboardHasNoExternalCDN:页面不得引用任何公网 CDN
- TestStarmapDependenciesAreLocal:星图两个库必须来自本地
- TestVendorFilesExistInSourceTree:vendor 文件必须在(embed 的前提)
- TestStaticVendorRoutesServeRealLibraries:路由**必须真的返回库内容**

★ 最后一条的判据强度是补出来的:初版只判状态码,变异「返回 200 + 空体」
时**仍然绿** —— 而空体在浏览器里的症状与 404 完全一样(都报加载失败)。
改为同时判体积与特征串(REVISION / OrbitControls / marked / DOMPurify)
后,变异「只写前 10 字节」判红。

变异验证 3 条:HTML 改回 CDN → 2 条判红;路由挂回 requireWeb → 503 判红;
截断响应体 → 体积判红。

全量 38 包全绿。
This commit is contained in:
JianFeeeee
2026-09-26 14:34:18 +08:00
parent 41d754334e
commit 7212ab4aad
7 changed files with 1280 additions and 5 deletions

View File

@ -6,25 +6,28 @@
<title>HomeAgent Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<script
src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"
src="/static/three.min.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"
src="/static/OrbitControls.js"
onerror="window._THREE_FAILED = true"
></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"
src="/static/marked.min.js"
onerror="console.warn('marked CDN failed')"
></script>
<script
src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"
src="/static/purify.min.js"
onerror="console.warn('DOMPurify CDN failed')"
></script>
<script>
// 兜底:本地库是毫秒级加载,3 秒足够;仍超时说明真的出了问题
// (比如二进制没带上 static/),标记失败好让页面给出明确提示
// 而不是一直转圈。
setTimeout(function () {
if (!window.THREE) window._THREE_FAILED = true;
}, 8000);
}, 3000);
</script>
<style>
{{DASHBOARD_CSS}}

View File

@ -18,6 +18,7 @@ import (
"errors"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"net/http"
"path"
)
// 本文件是 WebUI 的骨架:嵌入式前端资源、Handler 结构、构造、路由表、
@ -26,6 +27,26 @@ import (
//go:embed dashboard.html dashboard.css dashboard.js mascot.webp logo.svg
var dashboardFS embed.FS
// static/ 放前端第三方库(three.js / OrbitControls / marked / DOMPurify)。
//
// ★ 为什么必须 vendor 而不是走 CDN(这是星图「看不了」的直接原因):
//
// dashboard.html 原先从 cdnjs / jsdelivr 拉这四个库。用户点「星图」
// 看到「3D 星图不可用(CDN 加载失败)」—— 服务端 curl 同一 URL 是 200,
// 说明是**浏览器**访问不到公网 CDN(内网 / 出口受限 / 断网)。
//
// 换 CDN 只是把同一个赌注重下<E9878D><E4B88B><EFBFBD>遍:HomeAgent 明确支持离线与内网部署,
// 前端却有 4 个硬依赖在公网上,断网即坏且用户无从修复。
//
// 连带的两个理由:
// - XSS:DOMPurify 是净化 Markdown 的关键一环,它挂了前端会退化到
// 「不净化」分支(见 dashboard.js 的 typeof 检查)—— 那是安全降级。
// - 体积:四个库共 ~700KB,embed 进二进制后由本服务同源提供,
// 省掉 4 个跨域握手,也不再受第三方可用性影响。
//
//go:embed static
var staticFS embed.FS
var dashboardHTML string
const loginHTML = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>HomeAgent 登录</title><style>
@ -516,6 +537,11 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/proxy", h.requireAPI(h.handleProxyInfo))
// 设备网关发现:客户端(GUI/鸿蒙/waiter)据此自动链接,不再自己拼地址。
mux.HandleFunc("/api/v1/device/gateway", h.requireAPI(h.handleDeviceGatewayDiscovery))
// 静态资源(页面本体 + 内嵌第三方库)**不要求认证**:
// 登录页自身就要加载 CSS 与库才能渲染出来,若加认证则未登录时
// 页面连样式都加载不到(503/401),用户看到的是「白屏 + 控制台报错」。
// 这类资源不含任何用户数据,公开无害。
mux.HandleFunc("/static/", h.handleStaticVendor)
mux.HandleFunc("/", h.requireWeb(h.handleStatic))
}
@ -615,6 +641,31 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleStaticVendor 提供内嵌的第三方前端库(/static/<file>)。
//
// 独立于 handleStatic 且**不走 requireWeb**:未登录时页面也要能加载这些库
// 才能渲染出登录框,否则用户看到白屏(比 401 更难自查)。
//
// 用 path.Base 挡目录穿越;文件不存在一律 404(而不是 200 + 空体 ——
// 空体在浏览器里的症状与 404 完全一样,但日志里更难看出是文件缺失)。
func (h *Handler) handleStaticVendor(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.URL.Path)
if name == "" || name == "." || name == "/" {
http.NotFound(w, r)
return
}
data, err := staticFS.ReadFile("static/" + name)
if err != nil {
http.NotFound(w, r)
return
}
// 内容不会变(vendored 固定版本),可长缓存;不用 immutable ——
// 将来换库版本时同 URL 的强缓存会让客户端长期用旧文件。
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(data)
}
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")

View File

@ -0,0 +1,161 @@
package webui
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// ===== 星图依赖 CDN 不可达 =====
//
// 真实现象:用户点「星图」看到「3D 星图不可用(CDN 加载失败)」。
// 服务端 curl 同一批 URL 是 200 —— 说明**不是**服务端的问题,是浏览器
// 访问不到那两个公共 CDN(内网 / 出口受限 / 断网 / 被墙)。
//
// 为什么必须本地化而不是「换个 CDN」:
// 换 CDN 只是把同一个赌注重下一遍。HomeAgent 明确支持离线/内网部署,
// 前端却有 4 个硬依赖在公网上 ⇒ 断网时星图必坏、且用户无从修复。
// marked / DOMPurify 同理(Markdown 渲染与 XSS 净化)。
//
// 判据钉住:页面**不得**再引用任何外部 CDN。
// html 不得引用公网 CDN 资源(这是根因的直接判据)。
func TestDashboardHasNoExternalCDN(t *testing.T) {
html := readDashboardHTML(t)
// 只查 src/href 的绝对 URL;页面里出现的普通文本不算依赖。
re := regexp.MustCompile(`(?:src|href)\s*=\s*["'](https?://[^"']+)["']`)
matches := re.FindAllStringSubmatch(html, -1)
for _, m := range matches {
url := m[1]
t.Errorf("dashboard.html 仍引用外部 CDN:%s\n"+
" 断网/内网/出口受限环境下该功能必坏(星图即因此失效)。\n"+
" 修法:把库 vendor 进二进制,由本服务同源提供。", url)
}
}
// 具体到星图:three.js 与 OrbitControls 必须来自本地路由。
func TestStarmapDependenciesAreLocal(t *testing.T) {
html := readDashboardHTML(t)
if strings.Contains(html, "cdnjs.cloudflare.com/ajax/libs/three.js") {
t.Error("three.js 仍走 cdnjs CDN —— 星图在无公网环境下必坏")
}
if strings.Contains(html, "three@0.128.0/examples/js/controls/OrbitControls.js") {
t.Error("OrbitControls 仍走 jsdelivr CDN —— 星图无法旋转视角")
}
// 必须有本地引用
if !strings.Contains(html, "/static/three.min.js") {
t.Error("未引用本地 /static/three.min.js")
}
if !strings.Contains(html, "/static/OrbitControls.js") {
t.Error("未引用本地 /static/OrbitControls.js")
}
}
// 本地路由必须真的能返回这些库(且是 JS 内容类型,不是 HTML 错误页)。
//
// ★ 这一条是「容易假绿」的地方:路由写了但没 embed 成功时,
//
// 返回 404 或空体,页面依然报「CDN 加载失败」,症状与修复前完全一样。
// 所以判据必须实际取内容并验证它**像 three.js**(含特征串)。
func TestStaticVendorRoutesServeRealLibraries(t *testing.T) {
h, _ := newTestHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
cases := []struct {
path string
mustHave string
}{
// three.js UMD 头里有 REVISION;OrbitControls 是非压缩源码
{"/static/three.min.js", "REVISION"},
{"/static/OrbitControls.js", "OrbitControls"},
{"/static/marked.min.js", "marked"},
{"/static/purify.min.js", "DOMPurify"},
}
for _, c := range cases {
resp, err := http.Get(srv.URL + c.path)
if err != nil {
t.Errorf("%s: %v", c.path, err)
continue
}
body := readAllLimited(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("%s 应 200(否则浏览器拿到的是错误页,症状与修复前一模一样),实际 %d",
c.path, resp.StatusCode)
continue
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("%s 的 Content-Type = %q,应为 javascript(浏览器会拒绝执行)", c.path, ct)
}
// ★ 必须同时判「状态码」与「内容」。变异验证发现:把 w.Write(data)
// 去掉(只写 200 头 + 空体)时,**只判状态码的判据会通过** ——
// 而浏览器拿到空体同样报「加载失败」,症状与修复前一模一样。
// 这类「假绿」是本轮踩到的真陷阱,所以体积与特征串都要判。
if len(body) < 1000 {
t.Errorf("%s 体积仅 %d 字节,疑似空体/占位(这几个库都是几万~几十万字节)。"+
"空体在浏览器里的症状与 404 完全一样,但更难看出是文件没写出来",
c.path, len(body))
continue
}
if !strings.Contains(body, c.mustHave) {
t.Errorf("%s 内容不含特征串 %q —— 不是真正的库(可能被 embed 成了错误文本)",
c.path, c.mustHave)
}
}
}
// vendor 文件必须真的存在于源码树(embed 的前提)。
//
// 单独判这一条的原因://go:embed 找不到文件会**编译失败**,
// 但如果有人改成运行时读文件,缺文件就变成「运行时才发现」——
// 从「编译期报错」退化成「用户看到星图坏了」。用判据钉住前提。
func TestVendorFilesExistInSourceTree(t *testing.T) {
dir := filepath.Join(".", "static")
for _, name := range []string{
"three.min.js", "OrbitControls.js", "marked.min.js", "purify.min.js",
} {
p := filepath.Join(dir, name)
st, err := os.Stat(p)
if err != nil {
t.Errorf("缺少 vendor 文件 %s —— 星图/Markdown 依赖它,缺了就静默失效", p)
continue
}
if st.Size() < 1000 {
t.Errorf("%s 仅 %d 字节,疑似下载失败的占位文件", p, st.Size())
}
}
}
func readDashboardHTML(t *testing.T) string {
t.Helper()
raw, err := dashboardFS.ReadFile("dashboard.html")
if err != nil {
t.Fatalf("读 dashboard.html: %v", err)
}
// 模板占位符不影响 CDN 检查,保留原样即可
return string(raw)
}
func readAllLimited(t *testing.T, resp *http.Response) string {
t.Helper()
buf := make([]byte, 0, 64*1024)
tmp := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil || len(buf) > 2*1024*1024 {
break
}
}
return string(buf)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long