时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关

时间框(flatpickr allowInput 的手输体验)
- flatpickr 把 <input type=datetime-local> 就地改成 type=text,原生分段编辑器被替换掉;
  allowInput 又是「整串解析」语义:实测 parseDate('2026') 只出年份、'202666' 才出月份,
  于是「年份很长才跳下一位」;'20260606' 还会被静默错解成 2030-12-06 且不报错。
- 新增 static/js/datetime-input.js:自定义 parseDate(纯数字只收 4/6/8/12/14 位,
  歧义与非法日期一律拒)+ 输入引导(段满即补分隔符,敲完 4 位年份立刻跳月份位)。
  按标准格式不做自动补零、不前滚;越界值原样保留交报错,不静默改成合法值。
- 三处页面(编辑/照片/专题)共用 attachPicker,配置与原先一致,模板净减 5 行。

弹窗
- window.prompt 会把提示文本与默认值写进地址栏并留进历史(confirm/alert 无此问题)。
- 新增 static/js/prompt-box.js 替代:原生 <dialog>.showModal(),遮罩/Esc/焦点齐全,
  旧浏览器降级成页面顶部一条。已换 report.html 复制链接兜底与 edit.html 活动改名。
- 门禁 no_prompt_test.go:静态扫模板禁止 prompt(,并钉住 JS 与 CSS 都在。

统计脚本
- 原来只能改 conf/app.ini(要重启),现在后台「站点设置」可开关 + 填写,默认关闭。
- cleanTrackScript 挡住注入面:开闭 <script> 标签数不配平(提前闭合会让页面散架)、
  src 的 javascript:/data: 伪协议、超长截断。注意不能用「出现 </script 就拒」当规则——
  百度统计、Umami 贴的就是完整 HTML,正常用法会被挡死。

门禁
- datetime-input.test.js:34 条断言(node 直跑,无依赖)。
- TestCleanTrackScript:逐条边界 + 定义性不变量(任何输入都不得产出含 </script 的非空结果)。
- 三项均做过变异验证,改坏实现都会被对应测试抓到。

实测:真实站点逐键 2→20→202→2026-→2026-06-→2026-06-06T;
统计脚本六个场景(默认关/只填不开/开+URL/后台与登录页不挂/危险输入/关掉恢复)全部符合预期。
全量 internal/web 204 例 0 skip,其余包全绿,gofmt 干净。
This commit is contained in:
tamakyi
2026-10-08 18:27:59 +08:00
parent 5c7cf1a0e1
commit 4015ddf373
17 changed files with 1142 additions and 18 deletions
+46
View File
@@ -581,6 +581,10 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request) {
if r.PostFormValue("regions_mode") == setting.RegionsBlacklist {
next.RegionsMode = setting.RegionsBlacklist
}
next.TrackScript = cleanTrackScript(r.PostFormValue("track_script"))
// 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开,
// 否则 layout 里会挂一个空 <script>,看着像开了其实什么也没统计。
next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != ""
if err := s.sets.Save(ctx, next); err != nil {
s.serverError(w, r, err)
return
@@ -588,6 +592,48 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/settings?saved=1", http.StatusSeeOther)
}
// cleanTrackScript 收拾管理员粘进来的统计脚本。
//
// 这段内容会被原样注入**每一个公开页面**,所以要挡住几类会伤到访客的输入。
// 但**不能**简单地把「出现 </script 就拒」当规则:百度统计、Umami 这类服务
// 贴进来的就是完整 HTML,一行里必然带 </script>,一拒就把正常用法挡死了。
//
// 真正的注入面只有一种:**未闭合的 <script> 里出现 </script> 会提前闭合**,
// 后面的内容当正文渲染,页面布局散架。判据因此是「奇偶」:
// 开标签数与闭合标签数不配平 ⇒ 有提前闭合 ⇒ 拒。配平则整段原样放行。
//
// 另外挡住:javascript: / data: 这类伪协议的 src(点击即执行),
// 以及超长内容(多半是误粘了整份 HTML 文档,截断)。
func cleanTrackScript(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
const maxLen = 4096
if len(v) > maxLen {
v = v[:maxLen]
}
low := strings.ToLower(v)
// 开闭标签数必须配平,否则有人能靠 </script> 提前闭合、把后面的内容
// 从「脚本」变成「正文」注入到页面里。
open := strings.Count(low, "<script")
// 去掉属性里的 <script 字样不算开标签(如 data-src="<script"),这里只做保守计数:
// 宁可多拒一次,也别放过真的注入。
shut := strings.Count(low, "</script")
if shut > 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 ""
}
}
return v
}
// ---------- 修改口令 ----------
func (s *Server) passwordPage(w http.ResponseWriter, r *http.Request) {