mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
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:
@ -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}}
|
||||
|
||||
@ -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")
|
||||
|
||||
161
internal/plugins/webui/starmap_vendor_test.go
Normal file
161
internal/plugins/webui/starmap_vendor_test.go
Normal 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)
|
||||
}
|
||||
1045
internal/plugins/webui/static/OrbitControls.js
Normal file
1045
internal/plugins/webui/static/OrbitControls.js
Normal file
File diff suppressed because it is too large
Load Diff
6
internal/plugins/webui/static/marked.min.js
vendored
Normal file
6
internal/plugins/webui/static/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
internal/plugins/webui/static/purify.min.js
vendored
Normal file
3
internal/plugins/webui/static/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
internal/plugins/webui/static/three.min.js
vendored
Normal file
6
internal/plugins/webui/static/three.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user