announcement v1
This commit is contained in:
@@ -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`),**不是** `<UUID>.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(应为 `<uuid>.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 → `<uuid>.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(亮/暗都看过)。
|
||||
|
||||
@@ -262,6 +262,24 @@ chat_max_messages = 200
|
||||
; 单用户每日私信条数上限(用户可设范围 0..该值;0 表示不限制每日条数)
|
||||
daily_message_limit_max = 999
|
||||
|
||||
[announcement]
|
||||
; ── 站内公告:全站每页导航栏下方展示,改完重启生效 ──────────────────────
|
||||
; content :公告正文,留空 = 关闭公告。用 \n 表示换行;http(s) 地址会自动变成链接;
|
||||
; 内容按纯文本渲染,不支持 HTML 标签(写 <b> 会原样显示)。
|
||||
; 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 后台;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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("配置无效时应清空已解析的时间点")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
|
||||
+13
-12
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(`<a href="` + escaped + `" target="_blank" rel="noopener noreferrer nofollow">` + escaped + `</a>`)
|
||||
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", "<br>")
|
||||
}
|
||||
@@ -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第二行", "第一行<br>第二行"},
|
||||
{"字面量 \\n 也转 br", `第一行\n第二行`, "第一行<br>第二行"},
|
||||
{"crlf 不产生多余 br", "第一行\r\n第二行", "第一行<br>第二行"},
|
||||
{
|
||||
"标签按字面转义",
|
||||
`<script>alert(1)</script>`,
|
||||
`<script>alert(1)</script>`,
|
||||
},
|
||||
{
|
||||
"地址自动变链接",
|
||||
"详见 https://status.example.com/incident 谢谢",
|
||||
`详见 <a href="https://status.example.com/incident" target="_blank" rel="noopener noreferrer nofollow">https://status.example.com/incident</a> 谢谢`,
|
||||
},
|
||||
{
|
||||
"查询串的 & 被转义",
|
||||
"https://example.com/a?b=1&c=2",
|
||||
`<a href="https://example.com/a?b=1&c=2" target="_blank" rel="noopener noreferrer nofollow">https://example.com/a?b=1&c=2</a>`,
|
||||
},
|
||||
{
|
||||
"句末中文标点不进链接",
|
||||
"见 https://example.com/x。",
|
||||
`见 <a href="https://example.com/x" target="_blank" rel="noopener noreferrer nofollow">https://example.com/x</a>。`,
|
||||
},
|
||||
{
|
||||
"句末英文标点不进链接",
|
||||
"https://example.com/x, thanks",
|
||||
`<a href="https://example.com/x" target="_blank" rel="noopener noreferrer nofollow">https://example.com/x</a>, thanks`,
|
||||
},
|
||||
{
|
||||
"裸 scheme 不当链接",
|
||||
"https:// 后面没写地址",
|
||||
"https:// 后面没写地址",
|
||||
},
|
||||
{
|
||||
"引号无法逃逸属性",
|
||||
`https://example.com/"><img src=x onerror=alert(1)>`,
|
||||
`<a href="https://example.com/" target="_blank" rel="noopener noreferrer nofollow">https://example.com/</a>"><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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -371,6 +371,17 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{ with announcement }}
|
||||
<!-- 站内公告:内容与展示时间窗来自 conf/app.ini 的 [announcement] 段。
|
||||
由服务端按当前时间判定,窗口外整块不渲染,不留空白占位。 -->
|
||||
<div class="uk-container uk-container-xsmall">
|
||||
<div class="tb-announce" role="status">
|
||||
<span class="tb-announce-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M3 11v2a1 1 0 0 0 1 1h2l5 4V6L6 10H4a1 1 0 0 0-1 1z"/><path d="M16 8.5a5 5 0 0 1 0 7"/><path d="M19 6a9 9 0 0 1 0 12"/></svg></span>
|
||||
<div class="tb-announce-body">{{ . }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .IsPixel }}
|
||||
<div>
|
||||
{{ else }}
|
||||
|
||||
Reference in New Issue
Block a user