Files
rond-with-you/internal/setting/setting.go
T
tamakyi 4015ddf373 时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关
时间框(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 干净。
2026-10-08 18:27:59 +08:00

282 lines
10 KiB
Go
Raw 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 setting
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// Settings 站点级配置,管理员可在后台调整。
type Settings struct {
SiteTitle string `json:"site_title"`
SiteSubtitle string `json:"site_subtitle"`
OwnerName string `json:"owner_name"`
OwnerAvatar string `json:"owner_avatar"`
OwnerBio string `json:"owner_bio"`
Modules []string `json:"modules"`
// Pages 是对访客开放的页面;管理员始终可以访问全部页面
Pages []string `json:"pages"`
// Regions 是访客的区域限制列表(省 / 市 / 区任意层级);
// 空表示不限制,管理员始终看到全部数据。方向由 RegionsMode 决定。
Regions []string `json:"regions"`
// RegionsMode 决定 Regions 是白名单还是黑名单:
// whitelist = 访客只看得到命中这些区域的记录;blacklist = 命中这些区域的记录对访客隐藏。
RegionsMode string `json:"regions_mode"`
HideHome bool `json:"hide_home"`
HideWork bool `json:"hide_work"`
// NotesPublic 决定地点备注是否对访客展示;默认只站长自己可见
NotesPublic bool `json:"notes_public"`
// MarksPublic 决定「推荐 / 踩雷」标记与票数是否对访客展示;默认公开
MarksPublic bool `json:"marks_public"`
MinDwellMin int `json:"min_dwell_min"`
AccentColor string `json:"accent_color"`
// VisitorHome 是访客打开站点根路径时的落地页;VisitorHomeMain 表示就停在主页。
// 站长自己访问根路径始终看主页,不受这里影响。
VisitorHome string `json:"visitor_home"`
// MapDefaultBase 是地图默认铺哪张底图,取值是底图图层的 Key(如 amap-roadnet)。
// 空 = 用第一张。点位密集时换成「纯路网」可以去掉底图自带的 POI 与路名标注,
// 自己的点看得更清楚;访客在图上手动切换过的话,浏览器会记住他自己的选择。
MapDefaultBase string `json:"map_default_base"`
// MediaBackend 是地点图片存哪:local(服务器磁盘)或 s3(S3 兼容对象存储,如 R2)。
// 对象存储的凭据在 conf/app.ini 里(s3_*),不进数据库。
MediaBackend string `json:"media_backend"`
// TrackEnabled 决定是否注入 TrackScript。默认关闭(false)——
// 统计脚本会把自己掌握的访问信息发到第三方,默认不该开。
TrackEnabled bool `json:"track_enabled"`
// TrackScript 是第三方统计 / 追踪脚本;填 URL 会包成 <script src>,
// 需要初始化代码的服务(百度统计、Umami 等)可以把整段 <script> 贴进来。
// 仅在 TrackEnabled 为真时注入,且只注入公开页面(后台与登录页不挂)。
TrackScript string `json:"track_script"`
}
// 地点图片的两种存储后端。
const (
MediaBackendLocal = "local"
MediaBackendS3 = "s3"
)
// VisitorHomeMain 表示访客落地到主页本身。
const VisitorHomeMain = "home"
// Regions 的两种方向。
const (
RegionsWhitelist = "whitelist"
RegionsBlacklist = "blacklist"
)
// AllModules 主页可开关的区块。
var AllModules = []Module{
{Key: "overview", Name: "数据总览", Desc: "去过的地方、城市、天数、总里程等核心数字"},
{Key: "map", Name: "足迹地图", Desc: "所有去过的地点在地图上的分布"},
{Key: "types", Name: "类型分布", Desc: "按「餐厅 / 商场 / 景点」等类型统计"},
{Key: "cities", Name: "城市排行", Desc: "按城市汇总到访次数与停留时长"},
{Key: "timeline", Name: "最近足迹", Desc: "最新若干条到访记录"},
{Key: "heatmap", Name: "活跃日历", Desc: "记录热度按天分布"},
{Key: "transport", Name: "出行方式", Desc: "各类交通方式的次数与里程"},
{Key: "hourly", Name: "作息分布", Desc: "到访时间在一天中的分布"},
{Key: "tags", Name: "我的标签", Desc: "自定义标签的统计"},
{Key: "weather", Name: "天气足迹", Desc: "记录期间的气温与天气"},
}
// AllPages 可控制访客可见性的页面。
var AllPages = []Module{
{Key: "map", Name: "足迹地图", Desc: "交互式地图与行程轨迹"},
{Key: "places", Name: "地点库", Desc: "全部地点的列表与详情"},
{Key: "timeline", Name: "时间线", Desc: "按天浏览到访记录"},
{Key: "stats", Name: "数据统计", Desc: "各维度统计图表与迷雾覆盖"},
{Key: "report", Name: "年度报告", Desc: "按年份汇总的足迹报告"},
}
type Module struct {
Key string
Name string
Desc string
}
func Default() Settings {
mods := make([]string, 0, len(AllModules))
for _, m := range AllModules {
mods = append(mods, m.Key)
}
pages := make([]string, 0, len(AllPages))
for _, p := range AllPages {
pages = append(pages, p.Key)
}
return Settings{
SiteTitle: "我的足迹地图",
SiteSubtitle: "记录我去过的每一个地方",
OwnerName: "站长",
OwnerAvatar: "🐾",
OwnerBio: "",
Modules: mods,
Pages: pages,
// 「家」和「工作」暴露的是常住地,默认不对访客展示
HideHome: true,
HideWork: true,
// 「推荐 / 踩雷」属于评价类信息,默认对访客公开
MarksPublic: true,
// 区域限制默认按黑名单:列出要藏起来的区域,其余照常对访客开放
RegionsMode: RegionsBlacklist,
MinDwellMin: 0,
AccentColor: "#2f7d6e",
// 默认所有人都从主页进;想让人一进来就看到地图时再改
VisitorHome: VisitorHomeMain,
// 图片默认存服务器本地磁盘;配好 R2 之后再在后台切过去
MediaBackend: MediaBackendLocal,
// 统计脚本默认关闭:要站长明确打开、并自己填上脚本,才会注入公开页面
TrackEnabled: false,
TrackScript: "",
}
}
func (s Settings) Has(module string) bool {
for _, m := range s.Modules {
if m == module {
return true
}
}
return false
}
// HasPage 报告某页面是否对访客开放。
func (s Settings) HasPage(page string) bool {
for _, p := range s.Pages {
if p == page {
return true
}
}
return false
}
// VisitorHomePath 返回访客访问根路径时应跳转到的路径,空串表示不跳。
//
// 只在目标页面已对访客开放时才返回路径:否则相当于把访客送到一个他自己看不到的
// 页面(那里会 404),比留在主页更糟。
func (s Settings) VisitorHomePath() string {
if s.VisitorHome == "" || s.VisitorHome == VisitorHomeMain {
return ""
}
for _, p := range AllPages {
if p.Key == s.VisitorHome && s.HasPage(p.Key) {
return "/" + p.Key
}
}
return ""
}
// NormalizeVisitorHome 把表单值收敛到合法取值(主页或 AllPages 里的页)。
// 表单被改坏时回落到主页——这是个「跳错地方」比不跳更糟的开关。
func NormalizeVisitorHome(v string) string {
for _, p := range AllPages {
if p.Key == v {
return v
}
}
return VisitorHomeMain
}
// RegionBlacklist 报告 Regions 是黑名单(命中即对访客隐藏)还是白名单(命中才放行)。
func (s Settings) RegionBlacklist() bool { return s.RegionsMode == RegionsBlacklist }
type Store struct{ DB *sql.DB }
func (st *Store) Load(ctx context.Context) (Settings, error) {
cur := Default()
var raw []byte
err := st.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&raw)
if err == sql.ErrNoRows {
return cur, nil
}
if err != nil {
return cur, err
}
// 先看原始 JSON 里出现过哪些键:布尔项的默认值是 true,
// 直接用零值覆盖会让「历史配置里没这一项」被误判成「显式关掉了」。
var present map[string]json.RawMessage
if err := json.Unmarshal(raw, &present); err != nil {
return cur, fmt.Errorf("站点设置解析失败: %w", err)
}
var saved Settings
if err := json.Unmarshal(raw, &saved); err != nil {
return cur, fmt.Errorf("站点设置解析失败: %w", err)
}
// 逐项覆盖,保证新增字段拿到默认值
if saved.SiteTitle != "" {
cur.SiteTitle = saved.SiteTitle
}
if saved.SiteSubtitle != "" {
cur.SiteSubtitle = saved.SiteSubtitle
}
if saved.OwnerName != "" {
cur.OwnerName = saved.OwnerName
}
if saved.OwnerAvatar != "" {
cur.OwnerAvatar = saved.OwnerAvatar
}
cur.OwnerBio = saved.OwnerBio
if saved.Modules != nil {
cur.Modules = saved.Modules
}
// pages 键出现即视为显式配置:空数组表示站长明确关闭了所有页面
if _, ok := present["pages"]; ok {
cur.Pages = saved.Pages
}
if _, ok := present["regions"]; ok {
cur.Regions = saved.Regions
}
// regions_mode 是后加的键,而它出现之前 regions 一直是白名单。
// 缺键时若直接套用新默认值(黑名单),历史配置里填过的白名单会静默反转成黑名单
// —— 本该放行的区域全被藏起来,所以缺键一律按白名单解释。
if _, ok := present["regions_mode"]; ok && saved.RegionsMode == RegionsBlacklist {
cur.RegionsMode = RegionsBlacklist
} else {
cur.RegionsMode = RegionsWhitelist
}
if _, ok := present["hide_home"]; ok {
cur.HideHome = saved.HideHome
}
if _, ok := present["hide_work"]; ok {
cur.HideWork = saved.HideWork
}
if _, ok := present["notes_public"]; ok {
cur.NotesPublic = saved.NotesPublic
}
if _, ok := present["marks_public"]; ok {
cur.MarksPublic = saved.MarksPublic
}
if saved.VisitorHome != "" {
cur.VisitorHome = NormalizeVisitorHome(saved.VisitorHome)
}
// 空串是合法值(= 跟随第一张),所以直接赋值,不做非空判断
cur.MapDefaultBase = saved.MapDefaultBase
if saved.MediaBackend == MediaBackendS3 {
cur.MediaBackend = MediaBackendS3
} else {
cur.MediaBackend = MediaBackendLocal
}
cur.MinDwellMin = saved.MinDwellMin
if saved.AccentColor != "" {
cur.AccentColor = saved.AccentColor
}
// 统计脚本两个键都用 present 判:track_enabled 是后加的键,
// 历史配置里根本没有它,若直接套 saved.TrackEnabled(零值 false)倒也正好是
// 「默认关闭」;但显式判一下能让意图写在代码里,不依赖零值恰好对。
if _, ok := present["track_enabled"]; ok {
cur.TrackEnabled = saved.TrackEnabled
}
// 脚本内容可能存过又清空(关掉开关但想留着),所以空串也是合法值,直接赋值
cur.TrackScript = saved.TrackScript
return cur, nil
}
func (st *Store) Save(ctx context.Context, s Settings) error {
raw, err := json.Marshal(s)
if err != nil {
return err
}
_, err = st.DB.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('site', $1)
ON CONFLICT (key) DO UPDATE SET value=$1, updated_at=now()`, raw)
return err
}