diff --git a/.workbuddy/memory/2026-10-22.md b/.workbuddy/memory/2026-10-22.md index 2668ac0..3338aff 100644 --- a/.workbuddy/memory/2026-10-22.md +++ b/.workbuddy/memory/2026-10-22.md @@ -36,6 +36,124 @@ 位图唯一的理由是素材要给非 CSS 场景复用。文档与 INSTALL.txt 都已改。 - `image_bucket_cdn_host` 沿用 app.ini 同名键(只写主机名),`export_assets.py` 据此拼公开地址。 +## 线上 521 排障(box.shiroko.one 打不开) + +- 现象:`https://box.shiroko.one` 全路径 **521**,同机 IP+端口正常。 +- 实测链路:`box.shiroko.one` → CNAME `cloudflare.182682.xyz`(第三方 CF SaaS 接入/优选, + 上游 `source.cfnode.eu.org`),**不是** `.cfargotunnel.com`。DNS/TLS 均正常(tls=0), + 响应体 `error code: 521`,5 次请求全 LAX,0.6s 快速失败(RST 特征,非超时)。 +- 对照实验有价值:`box.tama.guru` 走**同一个** CNAME 目标却正常 301 → 中转 zone 边缘健康; + 它不回源,而 box.shiroko.one 要回源才 521 ⇒ 断点在「CF → 源站」这一段。 +- `shiroko.one` / `www` / `tama.guru` apex 都 CNAME `navi-ew2.pages.dev`(CF Pages 导航站),与本站无关, + 别拿它们的 200 当证据。 +- 候选根因(待服务器侧确认):① cloudflared 断开(严格说 tunnel 无 connector 多为 502/1033); + ② `scripts/edgeone-origin-whitelist.sh` 的 `REJECT tcp-reset` 拦掉 CF 直连回源——脚本注释里 + 「521」本就是白名单漏放的既有症状;③ 回源 IP 过期(服务器 IP 变动 / 第三方面板未更新)。 +- 注意矛盾点:用户本地能连 IP+端口 ⇒ 白名单大概率没生效或不覆盖该端口 ⇒ 更偏向 ③/tunnel 侧。 + +**根因已确认(服务器实测)** +- `systemctl is-active cloudflared` = **inactive**,`pgrep` 无进程 → Tunnel 断。 + journald 最后一条 2026-10-21T21:42:22Z(CST 10-22 05:42),此后无日志 ⇒ **停了约 7 小时**。 +- 死前日志模式:`accept stream listener encountered a failure while serving` + + `failed to accept QUIC stream: timeout: no recent network activity` + `datagram handler + error="context canceled"` + `Retrying connection in up to 1s`,connIndex 2/3 反复 → + UDP 7844 QUIC 长时间不稳(美西 23.94.211.138)。**inactive 而非 failed ⇒ 是被正常 stop 的, + 不是崩溃**,与用户「昨晚升级 nginx 到 1.30.x」的时间点吻合(升级流程连带停服)。 +- 应用侧全好:`*:5877` 监听(tamabox pid),loopback 200。 +- `iptables -S INPUT` 只有 `-i eth0 --dport 8118/5877 -j DROP`,**EO_IN / ipset EO_ORIGIN 都不存在** + ⇒ EdgeOne 白名单脚本当前未生效。且 DROP 限定 `-i eth0`,网卡名若不叫 eth0 则规则空转。 +- 关键推论:CF 一定走 tunnel 而非直连 5877(否则 DROP 空转应 200 而非 521)⇒ 521 完全由 tunnel 断解释。 + +**续:start 之后仍 521** +- 用户确认:**5877 只允许 Tailscale(100.84.113.84:5877)访问**,公网靠 iptables DROP 封掉。 + ⇒ 排除「CF 直连公网 5877」这条路径,CF 只能经 tunnel 回源。 +- `systemctl start cloudflared` → `active`,`sleep 5` 后 curl 域名仍 **521**。 +- 待分辨:① connector 尚未完成边缘注册(sleep 5 偏短);② 本机 tunnel 与 CF/SaaS 侧 + `box.shiroko.one` 绑定的 origin(应为 `.cfargotunnel.com`)不是同一个; + ③ 该 hostname 的 Public Hostname / ingress 未指向 `http://127.0.0.1:5877`。 +- 决定性隔离实验:临时 quick tunnel(`cloudflared tunnel --url http://127.0.0.1:5877`) + 拿 trycloudflare 域名直测 —— 200 则应用+出站正常,问题在 CF 侧绑定。 + +**journald 取证(关键)** +- `protocol=quic`,`location=la01`。从 10-21 13:53:30 起就是死循环: + `INF Registered tunnel connection` → 约 3s 后 `ERR failed to accept QUIC stream: + timeout: no recent network activity` + `datagram handler error="context canceled"` + + `WRN failed to serve tunnel connection error="accept stream listener encountered a failure + while serving"` → `INF Retrying connection in up to 1s` → 再注册。connIndex 0/1/2 全部如此, + 每 ~3 分钟一轮,一直持续到 10-21 21:42:40。 +- ⇒ **UDP 7844 的 QUIC 在这台机器上基本不可用**(RackNerd 美西 → CF la01),这是历史顽疾, + 不是本次新故障。根治手段 = `TUNNEL_TRANSPORT_PROTOCOL=http2`(走 TCP 443)。 +- 🔴 但 12:56 `systemctl start` 之后 journal 里**一条新日志都没有**(最后仍是 10-21 21:42:40, + PID 626)⇒ 新进程疑似启动即退出/token 无效,`is-active=active` 可能是重启窗口里的瞬时值。 + 需 `systemctl status` 看 Restart 计数与 ExecMainStatus。 +- 旁证:EdgeOne 那条备用通道同样不通(18080 无监听、EO_IN/ipset 均不存在)。 + +**结论定案** +- 重大纠正:cloudflared **从未死过**。`systemctl status` 显示 `active (running) since 2026-10-20 05:32:44`, + Main PID 626 存活 2 天+。前面「21:42 后无日志」不是进程退出,而是 QUIC 连接最后注册成功后没再报错 + (只有 24h 一次的版本提醒)。所以「tunnel 断导致 521」这个假设是错的。 +- 改 `TUNNEL_TRANSPORT_PROTOCOL=http2` 后:4 个 connector 全部 `Registered tunnel connection + ... protocol=http2`,`SUMMARY: Environment is healthy. Cloudflared will use 'http2' as primary + protocol`,零错误 ⇒ **tunnel 侧完全健康**。QUIC 抖动问题也顺手解决了(该 drop-in 值得长期保留)。 +- 但站点仍 521 ⇒ **问题 100% 在 CF 侧 origin/路由,与本机 tunnel 无关**。 +- 公网实测 `23.94.211.138`:5877 / 18080 / 80 / 443 **全部 timeout**(不止 5877)。 + ⇒ 若第三方 SaaS 的 origin 填的是「公网 IP:5877」,CF 同样连不上 → 521。 + 这解释了「突然」:用户近期把 5877 收敛为仅 Tailscale(100.84.113.84)可达。 +- 本机 tunnel 身份(从 token 解出,注意 token 已明文暴露需轮换): + tunnel id `ba34e35d-e517-498a-b2cd-7275c22e354e`,account tag `0c99c40b475a65b393abaca3ca91731b`。 +- 修复方向:①(推荐)第三方面板把 origin 改成 `ba34e35d-...cfargotunnel.com`,并在 + Zero Trust → Tunnels → 该 tunnel 的 Public Hostnames 补 `box.shiroko.one → http://127.0.0.1:5877`; + ②(不推荐)放行 CF 回源网段到 5877,等于重新暴露源站。 +- ⚠️ 安全 P0:tunnel token 在对话截图里明文出现,等同凭据泄露 → 必须 Refresh token 后重装服务。 + +**真凶:SaaS origin 指向被封的公网** +- SaaS 自定义主机名 `box.shiroko.one` 的「源服务器」= `box-direct.337845818.best`。 +- 实测该 origin 域名 **解析到 Cloudflare 自己的 IP**(104.21.47.52 / 172.67.144.141, + `Server: cloudflare`,CF-RAY)⇒ 它是**橙云(proxied)记录**,不是灰云 A 记录。 + `http://` 301(Always Use HTTPS)、`https://` **521** —— 它自己也回源失败。 +- 源站 `23.94.211.138`(colocrossing,RackNerd 上游):22 / 80 / 443 / 5877 / 18080 + **全部 timeout**。不只应用端口,连 SSH 都不通 ⇒ 该机对公网几乎无入站(仅白名单/Tailscale)。 +- 结论链:CF 回源 `box-direct` → 该记录橙云 → 真实源站端口被封 → **521**。 + 所有共用 `-direct` origin 的站点同时 521 ⇒ 用户说的「cloudflared 构建的网站全部 521」 + 与 cloudflared 无关,是**公网入站收敛**这一个动作把整批站点打掉的。nginx 升级纯属时间巧合。 +- 修复(保持「只允许组网」的安全目标):① Zero Trust → 该 tunnel → Public Hostnames 加 + `box.shiroko.one → http://127.0.0.1:5877`;② SaaS 面板把每个自定义主机名的「源服务器」 + 从 `xxx-direct.337845818.best` 改为 `ba34e35d-e517-498a-b2cd-7275c22e354e.cfargotunnel.com`。 + 注意不能把 `-direct` 改成「灰云 CNAME 到 cfargotunnel」——tunnel 路由要求橙云,SaaS origin 又 + 要求非橙云,两者冲突,必须直接在 origin 字段填 cfargotunnel。 + +**⚠️ 纠正(官方文档)** +- 上一条的后半段判断错了。CF for SaaS 文档明确:custom origin **必须**是该账号下 + **proxied(橙云)的 A / AAAA / CNAME** 记录,不能用 IP。 + ⇒ `box-direct.337845818.best` 是橙云,**格式上完全正确**,不是配置错误。 + 用 tunnel 的正确形态就是「橙云 CNAME → `.cfargotunnel.com`」,所以那条路径依然成立, + 只是理由不是「橙云错了」。 +- 真正断点仍是:`23.94.211.138` 的 **443 从公网不可达**(实测 timeout)。 + 但用户贴的 `iptables -S INPUT` 里**只有 8118 / 5877 两条 DROP,没有 443** + ⇒ 对 443 的封锁来自 iptables 之外(云厂商安全组 / 上游 / 其他链),或者 nginx 压根没监听 443。 +- 我上一轮的探测命令 `grep ':(80|5877|18080)'` **漏了 443**,这是取证缺口,需要补 + `ss -tlnp | grep -E ':(443|80|5877)'` + `systemctl is-active nginx`。 +- 注意:SaaS 回源默认 Host = 原始自定义主机名(box.shiroko.one),而 tunnel 的 Public Hostname + 只能从账号内 zone 选(shiroko.one 不在 CF)⇒ 走 tunnel 时必须用 Origin Rule / 源站主机头覆盖, + 否则 tunnel 会 404。 + +**最终根因(用户自查确认):nginx 停了 11 小时** +- 时间线:`10-22 00:12:43 Started`(升级后)→ `02:31:10 Stopping...` → + `02:31:15 Stopping timed out. Terminating.` → `Failed with result 'timeout'` → `Stopped` + → **中间 11 小时无任何记录** → `13:34:42` 用户手动 `systemctl start` 才起来。 +- ⇒ 全站 521 的直接原因就是 nginx 不在 443 监听;CF 回源 `box-direct`(橙云 A → 23.94.211.138:443) + 拿不到东西。cloudflared/tunnel 全程健康且**从未参与这条链路**。 +- 重启后实测:`box.shiroko.one` 连续 3 次 200,`box-direct` 200,源站 443 OPEN。 +- 教训(我的误判链,值得记住):排障时先确认「443 上跑的是什么、进程在不在」(`ss -tlnp` + + `systemctl is-active nginx`),再往 DNS/CDN/tunnel 方向挖。这次因为 CNAME 指向第三方 SaaS + + cloudflared 恰好健康,被带了很远。 +- 遗留技术债: + ① `listen 443 ssl http2` 在 1.25.1+ 已废弃 → 改 `listen 443 ssl;` + `http2 on;`; + ② `Failed to parse PID from file /run/nginx.pid: Invalid argument` —— 大概率是上次 SIGKILL + 残留的 pid 文件,配合 `TimeoutStopSec` 过短(5s 就 Terminating)导致 stop 卡死; + ③ 全站无可用性监控(11 小时空窗没人知道)→ 建议外部 uptime + cron watchdog + `*/5 * * * * systemctl is-active --quiet nginx || systemctl start nginx`。 + **6) 窄屏覆盖被作用域块压死(真 bug,已修)** - 现象:`@media (max-width:640px)` 里把 `--tb-deco` 全写 none,手机上却照样是桌面装饰层。 - 根因:媒体查询**不提升特异性**。作用域块 `html[data-theme-style][data-page-scope="box"]` @@ -49,3 +167,14 @@ - 手机端最终决定**不放装饰层**:试过顶栏与卡片之间那条 35px 带子,box 页 OK、question/admin 页卡片从 y=110 起就切半个 → 变成黏在卡片边的粉色碎块。改用加强版底纹(透明度 ×1.9、格子 180px)。 - 埋点:`gofmt -l internal/theme` 会报 `slots_test.go`(它是 CRLF),新增文件是 LF,别顺手格式化它。 + +## 站内公告功能(配置驱动) + +- 新增 `[announcement]` 段(content/start/end),改完重启生效;判定全在服务端按 time.Now()。 +- 改动:conf/announcement.go(解析+Active+单测)、conf.Init MapTo、keynorm sectionKeySets 登记(漏了会误报未知段)、 + template.FuncMap 新增 `announcement`(返回安全 HTML,空串 falsy 配 `{{ with }}`)、 + internal/template/announcement.go(切段转义→URL 包链接,不能「先转义再正则」否则 " 被吞进 URL)、 + header.html 导航下方插 `uk-container-xsmall` 容器包 `.tb-announce`、base.css 样式(StaticVersion → 20261022a)。 +- 时间格式:`2006-01-02 15:04` / 带秒 / 纯日期 / RFC3339;裸时间按服务器本地时区(容器是 UTC),启动 INFO 打印解析结果。 +- 时间写错只 WARN 关公告不阻断启动(与未知键口径一致);窗口左闭右开。 +- 预览验证:TEST_PREVIEW=1 + 临时 test 设 conf.Announcement 渲染 question/list,chrome --headless=old 截图 OK(亮/暗都看过)。 diff --git a/conf/app.sample.ini b/conf/app.sample.ini index a668f1d..33df01a 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -262,6 +262,24 @@ chat_max_messages = 200 ; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数) daily_message_limit_max = 999 +[announcement] +; ── 站内公告:全站每页导航栏下方展示,改完重启生效 ────────────────────── +; content :公告正文,留空 = 关闭公告。用 \n 表示换行;http(s) 地址会自动变成链接; +; 内容按纯文本渲染,不支持 HTML 标签(写 会原样显示)。 +; start / end:展示时间窗,左闭右开 —— 到 end 那一刻下线。留空表示该侧不限: +; 只写 start = 定时上线;只写 end = 现在起展示到某时刻;都留空 = 一直展示。 +; 支持 2026-10-25 02:00 / 2026-10-25 02:00:59 / 2026-10-25 / RFC3339 +; (2026-10-25T02:00:00+08:00)。 +; ⚠️ 不带时区偏移的时间按【服务器本地时区】解释(容器默认 UTC), +; 要精确到分钟请写 RFC3339;启动日志会打出解析后的时间与时区,可据此核对。 +; 时间格式写错不会导致启动失败:日志 WARN 后关闭公告。 +; 例:content = "10 月 25 日 02:00–04:00 机房维护,期间可能短暂无法访问。详情见 https://status.shiroko.one" +; start = "2026-10-24 00:00" +; end = "2026-10-25 04:00" +content = "" +start = "" +end = "" + [theme] ; 主题系统:只改配色 / 图案 / 整页背景,不涉及布局。 ; 作用范围:提问箱页面(主页 / 单题 / 密码 / 分享卡)、箱主本人的投票页与 /user 后台; diff --git a/internal/conf/announcement.go b/internal/conf/announcement.go new file mode 100644 index 0000000..4ff0ded --- /dev/null +++ b/internal/conf/announcement.go @@ -0,0 +1,118 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package conf + +import ( + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +// announcementConfig 站内公告([announcement] 段)。 +// +// Content 留空即关闭公告;Start / End 留空表示该侧不限。 +// 展示窗口是左闭右开 [Start, End):到 end 那一刻公告消失, +// 运维不必为了「准时下线」把时间掐到秒后还担心多挂一分钟。 +type announcementConfig struct { + Content string `ini:"content"` + Start string `ini:"start"` + End string `ini:"end"` + + // 解析后的时间点,零值表示该侧不限。 + // ini tag 为 "-":既不参与 ini 解析,也不会被未知键检测登记为合法键名。 + StartAt time.Time `ini:"-"` + EndAt time.Time `ini:"-"` +} + +// 支持的时间格式。裸时间(不带时区偏移)按服务器本地时区解释。 +var announcementTimeLayouts = []string{ + "2006-01-02 15:04", + "2006-01-02 15:04:05", + "2006-01-02", + time.RFC3339, +} + +// Active 判断给定时刻是否处于公告展示窗口,并返回公告正文。 +// 窗口为左闭右开 [Start, End),两侧留空表示不限。 +func (a announcementConfig) Active(now time.Time) (string, bool) { + if a.Content == "" { + return "", false + } + if !a.StartAt.IsZero() && now.Before(a.StartAt) { + return "", false + } + if !a.EndAt.IsZero() && !now.Before(a.EndAt) { + return "", false + } + return a.Content, true +} + +// sanitizeAnnouncement 解析公告时间窗。 +// +// 时间写错只告警并关闭公告,不阻断启动:公告是展示性配置,拼错一个日期不该让整站 +// 502;但必须留下能定位的日志 —— 否则现象就是「配了却不显示」,和未知键被静默 +// 忽略属于同一类坑(见 unknown.go)。 +func sanitizeAnnouncement() { + Announcement.Content = strings.TrimSpace(Announcement.Content) + if Announcement.Content == "" { + return + } + + disable := func(key, value, reason string) { + logrus.WithFields(logrus.Fields{ + "section": "announcement", + "key": key, + "value": value, + "reason": reason, + }).Warn("公告配置无效,已关闭公告(站点其它功能不受影响)") + Announcement = announcementConfig{} + } + + var ok bool + if Announcement.StartAt, ok = parseAnnouncementTime(Announcement.Start); !ok { + disable("start", Announcement.Start, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339") + return + } + if Announcement.EndAt, ok = parseAnnouncementTime(Announcement.End); !ok { + disable("end", Announcement.End, "支持 2006-01-02 15:04 / 2006-01-02 15:04:05 / 2006-01-02 / RFC3339") + return + } + if !Announcement.StartAt.IsZero() && !Announcement.EndAt.IsZero() && !Announcement.EndAt.After(Announcement.StartAt) { + disable("end", Announcement.End, "end 必须晚于 start,否则公告永远不会展示") + return + } + + _, activeNow := Announcement.Active(time.Now()) + logrus.WithFields(logrus.Fields{ + "start": announcementBoundLabel(Announcement.StartAt), + "end": announcementBoundLabel(Announcement.EndAt), + "zone": time.Local.String(), + "active_now": activeNow, + }).Info("站内公告已配置") +} + +// parseAnnouncementTime 解析单个时间点;空串返回零值(表示不限)且视为合法。 +func parseAnnouncementTime(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, true + } + for _, layout := range announcementTimeLayouts { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// announcementBoundLabel 把时间点格式化成带时区偏移的字符串,供启动日志核对 +// 「我写的 10:00 到底被当成了几点」——裸时间按服务器本地时区解释,容器多为 UTC。 +func announcementBoundLabel(t time.Time) string { + if t.IsZero() { + return "不限" + } + return t.Format("2006-01-02 15:04:05 -07:00") +} diff --git a/internal/conf/announcement_test.go b/internal/conf/announcement_test.go new file mode 100644 index 0000000..94d1efa --- /dev/null +++ b/internal/conf/announcement_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package conf + +import ( + "testing" + "time" +) + +// setAnnouncement 在测试期间覆盖全局公告配置,结束后恢复。 +func setAnnouncement(t *testing.T, content, start, end string) { + t.Helper() + old := Announcement + t.Cleanup(func() { Announcement = old }) + Announcement = announcementConfig{Content: content, Start: start, End: end} + sanitizeAnnouncement() +} + +func mustParse(t *testing.T, layout, value string) time.Time { + t.Helper() + parsed, err := time.ParseInLocation(layout, value, time.Local) + if err != nil { + t.Fatalf("parse %q: %v", value, err) + } + return parsed +} + +func TestAnnouncementDisabledWhenContentEmpty(t *testing.T) { + setAnnouncement(t, " ", "2026-10-24 00:00", "2026-10-26 00:00") + + if _, ok := Announcement.Active(time.Now()); ok { + t.Fatal("content 为空时公告应关闭") + } +} + +func TestAnnouncementWindowIsHalfOpen(t *testing.T) { + setAnnouncement(t, "维护通知", "2026-10-24 00:00", "2026-10-26 00:00") + + start := mustParse(t, "2006-01-02 15:04", "2026-10-24 00:00") + end := mustParse(t, "2006-01-02 15:04", "2026-10-26 00:00") + + cases := []struct { + name string + now time.Time + want bool + }{ + {"开始前一秒", start.Add(-time.Second), false}, + {"开始时刻(闭区间)", start, true}, + {"窗口内", start.Add(12 * time.Hour), true}, + {"结束时刻(开区间)", end, false}, + {"结束后", end.Add(time.Hour), false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + content, ok := Announcement.Active(c.now) + if ok != c.want { + t.Fatalf("Active(%s) = %v, want %v", c.now, ok, c.want) + } + if ok && content != "维护通知" { + t.Fatalf("content = %q, want 维护通知", content) + } + }) + } +} + +// 只写一侧的边界必须保持「该侧不限」,不能因为另一侧为空就整体失效。 +func TestAnnouncementOneSidedWindow(t *testing.T) { + now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.Local) + + setAnnouncement(t, "只有开始", "2026-10-23 00:00", "") + if _, ok := Announcement.Active(now); ok { + t.Fatal("只写 start 且尚未到点时应不展示") + } + if _, ok := Announcement.Active(now.Add(48 * time.Hour)); !ok { + t.Fatal("只写 start 时到点后应一直展示") + } + + setAnnouncement(t, "只有结束", "", "2026-10-23 00:00") + if _, ok := Announcement.Active(now); !ok { + t.Fatal("只写 end 且未到点时应立即展示") + } + if _, ok := Announcement.Active(now.Add(48 * time.Hour)); ok { + t.Fatal("只写 end 时过点后应下线") + } + + setAnnouncement(t, "一直展示", "", "") + if _, ok := Announcement.Active(now.AddDate(5, 0, 0)); !ok { + t.Fatal("起止都留空时应一直展示") + } +} + +func TestAnnouncementTimeFormats(t *testing.T) { + // 带偏移的 RFC3339 必须按字面时刻解析,而不是再套一层本地时区。 + at, ok := parseAnnouncementTime("2026-10-25T02:00:00+08:00") + if !ok { + t.Fatal("RFC3339 应可解析") + } + want := time.Date(2026, 10, 24, 18, 0, 0, 0, time.UTC) + if !at.Equal(want) { + t.Fatalf("解析结果 = %s, want %s", at.UTC(), want) + } + + if at, ok := parseAnnouncementTime("2026-10-25"); !ok || at.Hour() != 0 { + t.Fatalf("纯日期应解析为当天 00:00,got %s ok=%v", at, ok) + } + if at, ok := parseAnnouncementTime("2026-10-25 02:00:59"); !ok || at.Second() != 59 { + t.Fatalf("秒级格式应可解析,got %s ok=%v", at, ok) + } + if _, ok := parseAnnouncementTime("2026/10/25 02:00"); ok { + t.Fatal("错误格式应解析失败") + } + if _, ok := parseAnnouncementTime("2026-13-01"); ok { + t.Fatal("非法月份应解析失败") + } +} + +// 时间写错只关公告,不能让站点起不来;同时必须清空解析结果,避免残留时间窗。 +func TestAnnouncementInvalidTimeDisables(t *testing.T) { + for _, c := range []struct{ name, start, end string }{ + {"start 格式错", "2026/10/25 02:00", ""}, + {"end 格式错", "", "10月26日"}, + {"end 早于 start", "2026-10-26 00:00", "2026-10-25 00:00"}, + {"end 等于 start", "2026-10-25 00:00", "2026-10-25 00:00"}, + } { + t.Run(c.name, func(t *testing.T) { + setAnnouncement(t, "内容", c.start, c.end) + if Announcement.Content != "" { + t.Fatal("配置无效时公告应被关闭") + } + if !Announcement.StartAt.IsZero() || !Announcement.EndAt.IsZero() { + t.Fatal("配置无效时应清空已解析的时间点") + } + }) + } +} diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 8426955..0c643a2 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -102,6 +102,11 @@ func Init() error { return errors.Wrap(err, "map 'theme'") } + if err := File.Section("announcement").MapTo(&Announcement); err != nil { + return errors.Wrap(err, "map 'announcement'") + } + sanitizeAnnouncement() + if err := File.Section("mail").MapTo(&Mail); err != nil { return errors.Wrap(err, "map 'mail'") } diff --git a/internal/conf/keynorm.go b/internal/conf/keynorm.go index 2710a24..80624c2 100644 --- a/internal/conf/keynorm.go +++ b/internal/conf/keynorm.go @@ -143,18 +143,19 @@ func legalKeysOf(v interface{}) map[string]bool { // 枚举,为避免误报这里不登记(漏报优于误报)。 func sectionKeySets() map[string]map[string]bool { return map[string]map[string]bool{ - "app": legalKeysOf(&App), - "theme": legalKeysOf(&Theme), - "security": legalKeysOf(&Security), - "server": legalKeysOf(&Server), - "database": legalKeysOf(&Database), - "redis": legalKeysOf(&Redis), - "recaptcha": legalKeysOf(&Recaptcha), - "pixel": legalKeysOf(&Pixel), - "upload": legalKeysOf(&Upload), - "limits": legalKeysOf(&Limits), - "mail": legalKeysOf(&Mail), - "service": legalKeysOf(&Service), + "app": legalKeysOf(&App), + "theme": legalKeysOf(&Theme), + "announcement": legalKeysOf(&Announcement), + "security": legalKeysOf(&Security), + "server": legalKeysOf(&Server), + "database": legalKeysOf(&Database), + "redis": legalKeysOf(&Redis), + "recaptcha": legalKeysOf(&Recaptcha), + "pixel": legalKeysOf(&Pixel), + "upload": legalKeysOf(&Upload), + "limits": legalKeysOf(&Limits), + "mail": legalKeysOf(&Mail), + "service": legalKeysOf(&Service), } } diff --git a/internal/conf/static.go b/internal/conf/static.go index b54c84e..ebbc979 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -10,7 +10,7 @@ var ( BuildCommit = "dev" // StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。 - StaticVersion = "20261021d" + StaticVersion = "20261022a" ) // 文本审核通道取值([security] text_censor_source)。定义在 conf 侧, @@ -47,6 +47,9 @@ var ( Available string `ini:"available"` } + // Announcement 站内公告([announcement] 段)。解析与展示判定见 announcement.go。 + Announcement announcementConfig + Security struct { EnableTextCensor bool `ini:"enable_text_censor"` IPBlacklist string `ini:"ip_blacklist"` diff --git a/internal/template/announcement.go b/internal/template/announcement.go new file mode 100644 index 0000000..2d44e5d --- /dev/null +++ b/internal/template/announcement.go @@ -0,0 +1,50 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package template + +import ( + "html" + "regexp" + "strings" + + htmltemplate "html/template" +) + +// 公告正文里的 http(s) 地址自动变链接。 +var announcementURLRe = regexp.MustCompile(`https?://[^\s<>"']+`) + +// 句末标点不属于 URL:中文公告里「详见 https://example.com/x。」很常见, +// 不剥离就会把「。」一起链进去。 +const announcementURLTailChars = ".,;:!?)]},。;:!?)】」”’" + +// AnnouncementHTML 把配置里的纯文本公告渲染成安全 HTML。 +// +// 按 URL 切段、逐段转义,而不是「先整体转义再正则」:转义后的 " 会被 URL +// 字符类当成普通字符吞进链接。链接的 href 与可见文本共用同一份转义结果, +// 因此不存在属性逃逸;内容里的 HTML 标签一律按字面显示。 +func AnnouncementHTML(raw string) htmltemplate.HTML { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, `\r\n`, "\n") + raw = strings.ReplaceAll(raw, `\n`, "\n") + + var b strings.Builder + last := 0 + for _, loc := range announcementURLRe.FindAllStringIndex(raw, -1) { + url := raw[loc[0]:loc[1]] + core := strings.TrimRight(url, announcementURLTailChars) + tail := url[len(core):] + b.WriteString(escapeAnnouncementText(raw[last:loc[0]])) + escaped := html.EscapeString(core) + b.WriteString(`` + escaped + ``) + b.WriteString(escapeAnnouncementText(tail)) + last = loc[1] + } + b.WriteString(escapeAnnouncementText(raw[last:])) + return htmltemplate.HTML(b.String()) +} + +func escapeAnnouncementText(s string) string { + return strings.ReplaceAll(html.EscapeString(s), "\n", "
") +} diff --git a/internal/template/announcement_test.go b/internal/template/announcement_test.go new file mode 100644 index 0000000..d5b567e --- /dev/null +++ b/internal/template/announcement_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 tamakyi. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package template + +import "testing" + +func TestAnnouncementHTML(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"纯文本原样输出", "机房维护通知", "机房维护通知"}, + {"真实换行转 br", "第一行\n第二行", "第一行
第二行"}, + {"字面量 \\n 也转 br", `第一行\n第二行`, "第一行
第二行"}, + {"crlf 不产生多余 br", "第一行\r\n第二行", "第一行
第二行"}, + { + "标签按字面转义", + ``, + `<script>alert(1)</script>`, + }, + { + "地址自动变链接", + "详见 https://status.example.com/incident 谢谢", + `详见 https://status.example.com/incident 谢谢`, + }, + { + "查询串的 & 被转义", + "https://example.com/a?b=1&c=2", + `https://example.com/a?b=1&c=2`, + }, + { + "句末中文标点不进链接", + "见 https://example.com/x。", + `见 https://example.com/x。`, + }, + { + "句末英文标点不进链接", + "https://example.com/x, thanks", + `https://example.com/x, thanks`, + }, + { + "裸 scheme 不当链接", + "https:// 后面没写地址", + "https:// 后面没写地址", + }, + { + "引号无法逃逸属性", + `https://example.com/">`, + `https://example.com/"><img src=x onerror=alert(1)>`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(AnnouncementHTML(c.in)) + if got != c.want { + t.Fatalf("AnnouncementHTML(%q)\n got %q\n want %q", c.in, got, c.want) + } + }) + } +} diff --git a/internal/template/template.go b/internal/template/template.go index ff6e4d7..e0e32b0 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -65,6 +65,15 @@ func FuncMap() []template.FuncMap { "SentryDSN": func() string { return conf.App.SentryDSN }, + // announcement 返回当前应展示的公告(已转义的安全 HTML),窗口外返回空串。 + // 时间窗判定放在服务端:不依赖访客时钟,也不会先渲染出来再被脚本抹掉。 + "announcement": func() template.HTML { + content, ok := conf.Announcement.Active(time.Now()) + if !ok { + return "" + } + return AnnouncementHTML(content) + }, "ParsePublicURLs": func(input datatypes.JSON) string { urls := make(map[string]string) if err := json.Unmarshal(input, &urls); err != nil { diff --git a/static/css/base.css b/static/css/base.css index b3fb585..706f275 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -777,6 +777,38 @@ a.tb-chip:hover { border-radius: var(--tb-radius-xs); } +/* 站内公告:配置驱动(conf/app.ini [announcement]),位于导航栏下方、正文之上。 + 时间窗由服务端判定,窗口外模板整块不渲染,因此这里不需要任何隐藏状态类。 */ +.tb-announce { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 16px; + padding: 10px 14px; + border: 1px solid var(--tb-border); + border-left: 3px solid var(--tb-primary); + border-radius: var(--tb-radius-sm); + background: var(--tb-primary-weak); + color: var(--tb-text); + font-size: var(--tb-fs-md); + line-height: 1.6; + /* 贴长链接时不要把卡片撑破 */ + overflow-wrap: anywhere; +} +.tb-announce-icon { + flex: none; + display: inline-flex; + margin-top: 2px; + color: var(--tb-primary); +} +.tb-announce-body { + min-width: 0; +} +.tb-announce-body a { + color: var(--tb-primary); + text-decoration: underline; +} + /* 移动端底部操作栏:固定底栏承载语言切换与深色模式,桌面端隐藏 */ #tb-mobile-utils { display: none; diff --git a/templates/base/header.html b/templates/base/header.html index 27e0d94..a801a6b 100644 --- a/templates/base/header.html +++ b/templates/base/header.html @@ -371,6 +371,17 @@ document.addEventListener("DOMContentLoaded", function() { })(); +{{ with announcement }} + +
+
+ +
{{ . }}
+
+
+{{ end }} + {{ if .IsPixel }}
{{ else }}