diff --git a/.workbuddy/memory/2026-10-09.md b/.workbuddy/memory/2026-10-09.md new file mode 100644 index 0000000..51d4aba --- /dev/null +++ b/.workbuddy/memory/2026-10-09.md @@ -0,0 +1,93 @@ +# 2026-10-09 + +## 排查:/admin/settings 报错(旧二进制 + 新模板) + +**现象**:「统计脚本做成后台开关」那次提交(4015ddf)之后,访问 /admin/settings 报错。 + +**根因(已实测复现)**:跑的是**旧二进制**(根目录 `rond.exe`,10-08 18:06 编译,早于 18:27 的提交), +磁盘上的模板却是新的。`debug=true` 下 `pageSet()` 每次渲染都从磁盘重解析模板, +于是新 `settings.html:148` 引用了旧 `setting.Settings` 结构体里没有的 `TrackEnabled` → 执行期报错: + +``` +渲染 admin/settings 失败: template: settings.html:148:85: executing "content" +at <.Settings.TrackEnabled>: can't evaluate field TrackEnabled in type setting.Settings +``` + +`render` 是流式直写 ⇒ header 已发(HTTP 200)但表单在「统计脚本」卡片处截断, +没保存按钮、没 ``。这就是用户看到的「报错」。 + +**判据**:`strings <二进制> | grep -c cleanTrackScript` —— 旧根二进制 0,新编译的 2。 +(注意别用 `strings | grep TrackScript` 判定,config 里本来就有这个名字,旧二进制也命中。) + +**修复**:`go build -o rond.exe .`(+ `bin/rond.exe`),重启。旧二进制备份到 +`bin/rond.exe.old-20261008-stale`。修后 /admin/settings 完整(19642B,含 ``/保存按钮)。 + +**教训**:Go 结构与模板字段是成对改的,`debug=true` 的热加载只覆盖模板 —— 改 Go 必须重编译重启, +否则就是「新模板 + 旧结构 = 执行期报错」。想根治可在启动时把所有页面各渲染一遍做冒烟(现在没有)。 + +## 其他发现(未改代码) + +1. **P1 待确认**:`settings` 行 `pages=null`(写入时间 2026-10-08 18:14:05)⇒ 访客侧 + `/map /places /timeline /stats /report` 全部 404、导航只剩 1 个链接。代码语义是 + 「全不选 = 访客只能看主页」,属合法状态,像是测 404 分档时留下的。**没动数据,等确认**。 +2. **P2**:`cleanTrackScript` 的 `src=` 检查是**全文 `strings.Index`**,会误伤脚本体内的 + `src=`。实测:`` → 返回 `""`(整段被静默清空), + 而页面仍提示「设置已保存」⇒ 静默失效。建议只在标签属性里查 src,或把「被清空」回传前端显式提示。 +3. **P2**:开闭标签只拒 `shut > open`;末尾未闭合 `` 会被判为粘贴错误」漏了「少一个也会拒」(判据已改成严格配平); + ②「`conf/app.ini` 的 `track_script` 会作为这里的默认值」有歧义:它**不会回填文本框**, + 而是「这个框留空时,注入时回落到 ini 的值」。已改成准确说法。 + 实测热加载生效(debug=true,无需重编),页面 19996B、含 ``,旧文案 0 命中。 +- 验收:官方 `./scripts/verify.sh` → **PASS=346 FAIL=0 SKIP=0**(改动前那次全量; + 之后只动了这一个模板文案,重跑了全部模板门禁与 `TestSmokeRender*`,全绿)。 + diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index 93d3f68..e2187a8 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -110,7 +110,7 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块 ## 十八、重启与操作纪律 -- **重编重启**:`go build -o bin/rond.exe .` → 停掉占 8080 的进程 → `./bin/rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。 +- **重编重启**:`go build -o rond.exe .`(**只构建到仓库根,`bin/` 已废弃、2026-10-09 删除**)→ 停掉占 8080 的进程 → `./rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。 - 生产 `life.shiroko.one` **只走 IPv4**(curl 必须 `-4`);会话 cookie `rond_session=uid|token`(HMAC);HTTP 303 **不能跟随**(Location 里带 `ok=`/`err=`)。`/tmpfog/` 不是运行必需,`tmpfog/_ck.txt` 是**死票**(实打接口被 302)⇒ 不必轮换 `secret_key`。 - **`gofmt -l` 会报假警**:仓库存 LF、`core.autocrlf=true` 把工作区换成 CRLF(已加 `.gitattributes` 钉住);**用 python `open(p,'w')` 回写 Go/CSS 会把行尾变 CRLF**,要二进制模式读写。 - **同一文件绝不并行改**:多条 Edit 一起发给同一文件时,各自「读整份-替换-写整份」会互相覆盖——工具**报 success 但磁盘上没落**(实测一次发 3 条给 `map.html`,`map-count` 那条丢了)。**一个文件串行改,改完 `grep` 复核关键标记,再编译。** @@ -879,14 +879,69 @@ Esc 关闭、焦点自动进输入框、其余部分自动 inert。旧浏览器 `settingsSubmit()` 的表单读取。`Load()` 里布尔项一律用 `present[key]` 判: 历史配置没有这个键时若直接套零值,语义会悄悄反转。 -### `cleanTrackScript` 的判据(这里踩过一次) +### `cleanTrackScript` 的判据(这里踩过两次,2026-10-09 重写) ⚠️ **不能用「出现 ``,一拒就把正常用法挡死了(我第一版就是这么写的, -被自己的测试当场抓住)。真正的注入面只有一种:**未闭合的 `` 会提前闭合**,后面内容当正文渲染。判据改成**开闭标签数配平** -(`shut > open` 才拒),配平则整段原样放行。 -另挡 `src=` 的 `javascript:` / `data:` 伪协议(只放行 http/https 与 `/` 开头), -超 4096 截断。 -门禁 `TestCleanTrackScript` 除了逐条边界,还钉了**定义性不变量**: -任何输入都不得产出「含 `` 标签必须严格配平(`open != shut` 就拒)**。少一个 `` + 会提前闭合、后面内容当正文渲染;多一个 `` 旧版返回 `""`,现在原样放行。 + 超 4096 截断。 + +**收尾要显式**:函数签名改成 `(script, warn string)`,说明码 +(`truncated` / `unbalanced` / `badsrc`)经 `?warn=` 跳到设置页、由 `trackWarnMessage` +翻成人话显示。用**固定码**而不是回显查询串原文(避免反射内容)。 +判为危险时**整段不采纳、库里原脚本与开关都不动**(把站长手上好用的脚本一并清掉更糟)。 + +门禁 `TestCleanTrackScript` 逐条钉边界 + **定义性不变量**: +采纳了非空结果时,里面的开闭标签数必定相等(不是「不得含 `,看着像开了其实什么也没统计。 - next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != "" + script, trackWarn := cleanTrackScript(r.PostFormValue("track_script")) + if trackWarnRejected(trackWarn) { + // 判为危险:这次改动整段不采纳,库里原有的脚本与开关都保持不动 + // (next := cur 已经带上了它们)。把站长手上本来好用的脚本一并清掉, + // 比拒绝这次改动更糟。 + } else { + next.TrackScript = script + // 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开, + // 否则 layout 里会挂一个空 的数量不配对,本次改动已拒绝,库里原有脚本保持不变" + case warnTrackBadSrc: + return "统计脚本的 src 只接受 http://、https:// 或站内相对路径,本次改动已拒绝,库里原有脚本保持不变" + } + return "" +} + +// trackWarnRejected 报告这个说明码是否代表「整段没被采纳」。 +func trackWarnRejected(code string) bool { + return code == warnTrackUnbalanced || code == warnTrackBadSrc +} + +// cleanTrackScript 收拾管理员粘进来的统计脚本,返回采纳后的内容与一个说明码(空 = 原样通过)。 // // 这段内容会被原样注入**每一个公开页面**,所以要挡住几类会伤到访客的输入。 // 但**不能**简单地把「出现 ,一拒就把正常用法挡死了。 // -// 真正的注入面只有一种:**未闭合的 会提前闭合**, -// 后面的内容当正文渲染,页面布局散架。判据因此是「奇偶」: -// 开标签数与闭合标签数不配平 ⇒ 有提前闭合 ⇒ 拒。配平则整段原样放行。 +// 两条判据: +// - 开闭 会提前闭合、后面的内容 +// 当正文渲染(页面布局散架),多一个 提前闭合、把后面的内容 - // 从「脚本」变成「正文」注入到页面里。 - open := strings.Count(low, " open { - return "" - } - // src 只放行 http/https 与站内相对路径,挡掉 javascript: / data: 伪协议 - if idx := strings.Index(low, "src="); idx >= 0 { - rest := strings.TrimLeft(v[idx+4:], "\"'") - if rest != "" && !strings.HasPrefix(rest, "http://") && - !strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") { - return "" + open := 0 + for i := 0; ; { + j := strings.Index(low[i:], "') + if end < 0 { + break // 这个开标签没闭合,交给下面的配平判定处理 + } + tag := low[start : start+end] + if k := strings.Index(tag, "src="); k >= 0 { + rest := strings.TrimLeft(tag[k+4:], "'\" \t") + if rest != "" && !strings.HasPrefix(rest, "http://") && + !strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") { + return "", warnTrackBadSrc + } + } + i = start + end + 1 } - return v + if shut := strings.Count(low, " 0 { + return fmt.Errorf("模板自检未通过,多半是 Go 结构体与模板字段不同步(改完 Go 要重新编译):\n %s", + strings.Join(failed, "\n ")) + } + return nil +} + +func (s *Server) smokeOne(key string, data any) (err error) { + // 模板里调到的方法可能对着空数据 panic,别让它把整个启动带走—— + // 这里要的是一个可读的「哪一页坏了」,不是一段崩栈。 + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%s: 渲染时 panic: %v", key, r) + } + }() + set, err := s.pageSet(key) + if err != nil { + return fmt.Errorf("%s: %w", key, err) + } + var buf bytes.Buffer + if err := set.t.ExecuteTemplate(&buf, set.entry, data); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + return nil +} diff --git a/internal/web/template_smoke_test.go b/internal/web/template_smoke_test.go new file mode 100644 index 0000000..2149fce --- /dev/null +++ b/internal/web/template_smoke_test.go @@ -0,0 +1,77 @@ +package web + +import ( + "html/template" + "path/filepath" + "strings" + "testing" + + "rond-with-you/internal/config" +) + +// TestSmokeRenderAllPages 把启动自检搬进 go test:每个页面都要能用「最小可用数据」渲染到底。 +// +// 比 TestPageTemplatesParse 更强的地方在于**执行**:模板能解析不代表渲染得完, +// 「字段不存在 / 对着 nil 解引用」只在执行期暴露,而线上表现是「HTTP 200 + 半张页面」。 +// 2026-10-09 的 /admin/settings 截断就是这么来的(新模板引用了旧结构体里没有的字段)。 +func TestSmokeRenderAllPages(t *testing.T) { + srv := &Server{cfg: &config.Config{}} + pages := make(map[string]pageSet, len(pageFiles)) + for key, def := range pageFiles { + tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()). + ParseFS(tmplFS, def.files...) + if err != nil { + t.Fatalf("解析模板 %s 失败: %v", key, err) + } + pages[key] = pageSet{t: tpl, entry: def.entry} + } + srv.pages = pages + + if err := srv.smokeRender(); err != nil { + t.Fatal(err) + } + + // 定义性不变量:pageFiles 里每加一个页面,就必须在 pageProbe 里配一份自检数据, + // 否则那一页会悄悄逃过自检(smokeRender 会把缺项报成失败,这里再独立钉一次)。 + for key := range pageFiles { + if _, ok := pageProbe[key]; !ok { + t.Errorf("页面 %s 没有自检数据(pageProbe 缺项)——新加页面时要一起补", key) + } + } + for key := range pageProbe { + if _, ok := pageFiles[key]; !ok { + t.Errorf("pageProbe 里的 %s 不是已注册页面(改名后忘了同步?)", key) + } + } +} + +// TestSmokeRenderCatchesFieldDrift 变异验证:把页面数据换成**字段更少**的类型时必须报错。 +// +// 光断言「当前代码自检通过」证明不了自检有效——它可能只是什么都没检查。 +// 这里用 Page 顶替 SettingsData(模板引用的 .Modules / .S3Info 都不在 Page 上), +// 自检必须红,且错误信息里要能看到是哪一页。 +func TestSmokeRenderCatchesFieldDrift(t *testing.T) { + srv := &Server{cfg: &config.Config{}} + pages := make(map[string]pageSet, len(pageFiles)) + for key, def := range pageFiles { + tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()). + ParseFS(tmplFS, def.files...) + if err != nil { + t.Fatalf("解析模板 %s 失败: %v", key, err) + } + pages[key] = pageSet{t: tpl, entry: def.entry} + } + srv.pages = pages + + orig := pageProbe["admin/settings"] + pageProbe["admin/settings"] = func() any { return probePage("admin") } + t.Cleanup(func() { pageProbe["admin/settings"] = orig }) + + err := srv.smokeRender() + if err == nil { + t.Fatal("页面数据缺字段时自检必须报错,否则它什么也挡不住") + } + if !strings.Contains(err.Error(), "admin/settings") { + t.Errorf("自检报错要指名是哪个页面,实际为:%v", err) + } +} diff --git a/internal/web/templates/admin/settings.html b/internal/web/templates/admin/settings.html index b5a51e0..7289312 100644 --- a/internal/web/templates/admin/settings.html +++ b/internal/web/templates/admin/settings.html @@ -9,6 +9,7 @@ {{if .Flash}}
设置已保存
{{end}} + {{if .Warn}}
{{.Warn}}
{{end}}
@@ -154,11 +155,12 @@
填一个 https://… 地址会包成 <script src=…>; 需要初始化代码的服务(Umami、百度统计等)可以把整段 <script> 贴进来。
-
整段 <script> 需要闭合标签配对(多一个 </script> 会被判为粘贴错误); +
整段脚本的 <script> 与 </script> 必须数量相同: + 多一个 </script> 会提前闭合、少一个会把页面收尾吞进脚本,两种情况都拒收; src 只接受 https://、http:// 或以 / 开头的地址, - 超过 4096 字符会被截断。填了但没勾开关不会生效。
-
conf/app.ini 的 track_script 会作为这里的默认值—— - 它也需要开关打开才注入。
+ 超过 4096 字符会被截断(并给出提示)。填了但没勾开关不会生效。
+
这个框留空时回落到 conf/app.ini 的 track_script + ——它不会回填到上面这个框(改 ini 要重启)。
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 822c392..481e697 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -3,13 +3,16 @@