排查:/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)。
100 lines
5.1 KiB
Go
100 lines
5.1 KiB
Go
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
|
||
}
|