announcement v1
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user