Files
tamakyi d4e4257a23 启动模板自检 + 站长导航放行 + 统计脚本关口收紧
排查:/admin/settings 渲染到一半就断
- 现象:页面在「统计脚本」那张卡片处截断,没有保存按钮、没有 </form></html>,而 HTTP 仍是 200。
- 根因:跑的是旧二进制(改动前编译),磁盘上的模板却是新的。debug=true 下 pageSet()
  每次渲染都从磁盘重解析模板,于是新 settings.html 引用了旧 setting.Settings 里没有的
  TrackEnabled —— 执行期报错,而 render 只把错误写进日志,客户端只看到半张页面。
- 判据:strings <二进制> | grep -c cleanTrackScript(旧 0 / 新 2)。别用 strings | grep
  TrackScript —— config 里本来就有这个名字,旧二进制也命中,是个假信号。

启动自检(防复发)
- template_smoke.go:pageProbe 给 24 个页面各配一份最小可用数据,New() 里全部渲染一遍,
  任一页失败就拒绝启动。pageProbe 与 pageFiles 双向校验,新加页面必须补探针,
  漏配按失败处理(不是省事)。
- smokeOne 用 recover 兜住方法里的 panic,报「哪一页坏了」而不是崩栈。
- 实测:往 settings.html 插一个不存在的字段 → 启动即报
  「模板自检未通过…admin/settings: can't evaluate field …」,退出码 1。
- 门禁 template_smoke_test.go:一条跑全量自检,一条变异验证(探针换成字段更少的类型
  必须报错且点名该页)。

站长导航被页面开关误伤
- guardPage 只拦访客(站长始终能访问全部公开页),但导航写的是 {{if .Settings.HasPage "x"}},
  没有 IsAdmin 分支 ⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
  改成 {{if or .IsAdmin (.Settings.HasPage "x")}}(map/places/timeline/stats/report 五处)。
- 门禁 nav_visibility_test.go:三向不变量(站长仍有全部入口 / 访客一个都没有 /
  全开放时访客也看得到),做过变异验证。
- 访客侧「只有主页」是按后台配置应有的状态,没有动数据。

统计脚本关口收紧(cleanTrackScript)
- src= 只在 <script …> 开标签内部找。原来对整段做 strings.Index,脚本体里一句
  `img.src=u` 就被命中、整段被判危险而清空 —— 页面照样提示「设置已保存」,站长看不出没挂上。
- 开闭标签从「只拒 shut > open」改成严格配平:末尾未闭合的 <script> 会把页面收尾整段吞进脚本。
- 不再静默:签名改成 (script, warn),说明码经 ?warn= 回显成人话;判为危险时整段不采纳、
  库里原脚本与开关都不动(把站长手上好用的脚本一并清掉更糟)。
- 门禁 TestCleanTrackScript 重写:逐条边界 + 定义性不变量(采纳了非空结果时,
  里面开闭标签数必定相等)+ TestTrackWarnMessage(未识别码不得回显)。变异验证做过。
- 文案订正:settings.html 的「conf/app.ini 的 track_script 会作为这里的默认值」有歧义 ——
  它不回填文本框,而是「这个框留空时、注入那一刻才回落到 ini」。

其余
- bin/ 目录删除(15 个 exe / 412MB),二进制只留仓库根 rond.exe;
  技能 rond-web-verify 与 memory details 十八里 bin/rond.exe 的写法同步改掉。
- 验收:./scripts/verify.sh 全绿且 0 skip(PASS=346 FAIL=0 SKIP=0)。
2026-10-09 11:06:41 +08:00

100 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"fmt"
"strings"
"rond-with-you/internal/setting"
)
// 启动时的模板自检:把每个页面各用一份「最小可用数据」渲染一遍,任一页失败就拒绝启动。
//
// 防的是一类**只在执行期暴露**的错误:模板引用了 Go 结构体里不存在的字段
// (典型是改了结构体却没重新编译,磁盘上的新模板被老二进制拿去渲染),
// 或者模板里对着 nil 解引用。这类错误既不会让模板解析失败,render 又只把错误写进日志,
// 于是线上表现成「页面渲染到一半就断了、HTTP 却仍是 200」——用户对着半张没有收尾的页面
// 猜半天,服务端日志里只有孤零零一行。
//
// 2026-10-09 就是这么踩的:提交里同时改了 setting.Settings 和 settings.html,
// 但跑的是改动前编译的二进制,debug 模式下模板走磁盘热加载 ⇒ 新模板 + 旧结构体,
// /admin/settings 在「统计脚本」那张卡片处被截断。启动自检能把这种组合直接挡在门外。
//
// 探针值只求「类型对、不炸」:这些页面本来就得能渲染空数据(还没导入数据时的首页就是空的),
// 所以空值探针是合理的下限,而不是凑数。
var pageProbe = map[string]func() any{
"home": func() any { return HomeData{Page: probePage("home")} },
"map": func() any { return MapData{Page: probePage("map")} },
"places": func() any { return PlacesData{Page: probePage("places")} },
"place": func() any { return PlaceData{Page: probePage("places")} },
"timeline": func() any { return TimelineData{Page: probePage("timeline")} },
"stats": func() any { return StatsData{Page: probePage("stats")} },
"report": func() any { return ReportData{Page: probePage("report")} },
"topics": func() any { return TopicListData{Page: probePage("topics")} },
"topic": func() any { return TopicData{Page: probePage("topics")} },
"admin/index": func() any { return AdminData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
"admin/password": func() any { return PasswordData{Page: probePage("admin"), Username: "admin"} },
"admin/twofa": func() any { return TwoFAData{Page: probePage("admin")} },
"admin/fog-mask": func() any { return FogMaskData{Page: probePage("admin")} },
"admin/fog-preview": func() any { return FogPreviewData{Page: probePage("admin")} },
"admin/backup-preview": func() any { return BackupDiffData{Page: probePage("admin")} },
"admin/baseline": func() any { return BaselineData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
"admin/fillup": func() any { return FillupData{Page: probePage("admin")} },
"admin/notes": func() any { return NotesData{Page: probePage("admin")} },
"admin/edit": func() any { return EditData{Page: probePage("admin")} },
"admin/topics": func() any { return TopicAdminData{Page: probePage("admin")} },
"admin/photos": func() any { return PhotoData{Page: probePage("admin")} },
"admin/login": func() any { return LoginData{Settings: setting.Default()} },
"admin/twofa-login": func() any { return TwoFALoginData{Settings: setting.Default()} },
"admin/settings": func() any {
return SettingsData{Page: probePage("admin"), Modules: setting.AllModules,
Pages: setting.AllPages, MapLayers: []MapLayerOption{{Key: "k", Label: "l"}}}
},
}
// probePage 是所有探针共用的页面上下文:给足站点设置,免得模板在取值阶段就断。
func probePage(nav string) Page {
return Page{Nav: nav, Title: "模板自检", Settings: setting.Default()}
}
// smokeRender 把所有页面各渲染一遍,专为触发执行期错误。
// 任一页失败都返回错误,让 New 拒绝启动——宁可起不来,也不要起来后一半页面是残的。
func (s *Server) smokeRender() error {
var failed []string
for key := range pageFiles {
probe, ok := pageProbe[key]
if !ok {
// 没有探针的页面等于没被自检覆盖。这是漏,不是省事,所以按失败处理。
failed = append(failed, key+": pageProbe 里没有配自检数据")
continue
}
if err := s.smokeOne(key, probe()); err != nil {
failed = append(failed, err.Error())
}
}
if len(failed) > 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
}