feat: 站点单页、时间线导入与行级 BBCode 短代码

首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 19:52:06 +08:00
parent 2aacf99a49
commit a83276060f
52 changed files with 5073 additions and 648 deletions

View File

@@ -5,6 +5,9 @@ import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/markdown"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -12,17 +15,23 @@ import (
// ===== 公开接口 =====
// AnnouncementsList 已发布公告列表(首页右栏 / 公告页)
// AnnouncementsList 已发布公告列表(分页:page/size,默认 size=20)
func (h *Handlers) AnnouncementsList(c *gin.Context) {
list, err := h.Announcement.ListPublished(10)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
list, total, err := h.Announcement.ListPublishedPage(page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
return
}
c.JSON(http.StatusOK, gin.H{"announcements": nonNilSlice(list)})
c.JSON(http.StatusOK, gin.H{
"announcements": nonNilSlice(list),
"total": total,
"page": page,
})
}
// AnnouncementDetail 已发布公告详情
// AnnouncementDetail 已发布公告详情(按读者能力脱敏 [hide])
func (h *Handlers) AnnouncementDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@@ -38,7 +47,18 @@ func (h *Handlers) AnnouncementDetail(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取公告失败"})
return
}
c.JSON(http.StatusOK, gin.H{"announcement": a})
caps := markdown.ViewerCaps{}
if claims := middleware.CurrentUser(c); claims != nil {
caps.LoggedIn = true
if model.RoleLevel(model.Role(claims.Role)) >= model.RoleLevel(model.RoleAdmin) {
caps.Bypass = true
}
}
// 公告不可评论:回复块若有残留也永不解锁(保存期已拒收)
sanitized, _ := markdown.SanitizeForViewer(a.Content, caps)
out := *a
out.Content = sanitized
c.JSON(http.StatusOK, gin.H{"announcement": out})
}
// ===== 管理接口(RequireStaff + PermAnnouncements,前端不做权限判定) =====
@@ -92,6 +112,25 @@ func (h *Handlers) AdminUpdateAnnouncement(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"announcement": a})
}
// AdminToggleAnnouncementPin 切换公告置顶
func (h *Handlers) AdminToggleAnnouncementPin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的公告 ID"})
return
}
a, err := h.Announcement.TogglePin(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "公告不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
return
}
c.JSON(http.StatusOK, gin.H{"announcement": a})
}
// AdminDeleteAnnouncement 删除公告
func (h *Handlers) AdminDeleteAnnouncement(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)

View File

@@ -21,6 +21,7 @@ type Handlers struct {
OverviewSvc *service.OverviewService
Checkin *service.CheckinService
Announcement *service.AnnouncementService
SitePage *service.SitePageService
Upload *service.UploadService
PostFile *service.PostFileService
Points *service.PointsService

View File

@@ -29,12 +29,14 @@ func (h *Handlers) Overview(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"announcements": data.Announcements,
"new_users": data.NewUsers,
"checkin": data.Checkin,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"boards": data.Boards,
"announcements": data.Announcements,
"announcements_total": data.AnnouncementsTotal,
"sidebar_pages": data.SidebarPages,
"new_users": data.NewUsers,
"checkin": data.Checkin,
})
}

View File

@@ -21,26 +21,27 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
type updateSettingsRequest struct {
Accent *string `json:"accent"`
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
SiteName *string `json:"site_name"`
SiteDescription *string `json:"site_description"`
AllowRegister *bool `json:"allow_register"`
AllowComments *bool `json:"allow_comments"`
AllowMessages *bool `json:"allow_messages"`
PostCooldownHours *int `json:"post_cooldown_hours"`
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
UIAnimations *bool `json:"ui_animations"`
AnimCodeFold *bool `json:"anim_code_fold"`
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
AnimChrome *bool `json:"anim_chrome"`
PostLinkNewTab *bool `json:"post_link_new_tab"`
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
Accent *string `json:"accent"`
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
SiteName *string `json:"site_name"`
SiteDescription *string `json:"site_description"`
AllowRegister *bool `json:"allow_register"`
AllowComments *bool `json:"allow_comments"`
AllowMessages *bool `json:"allow_messages"`
PostCooldownHours *int `json:"post_cooldown_hours"`
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
UIAnimations *bool `json:"ui_animations"`
AnimCodeFold *bool `json:"anim_code_fold"`
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
AnimChrome *bool `json:"anim_chrome"`
PostLinkNewTab *bool `json:"post_link_new_tab"`
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
AttachmentExts *[]string `json:"attachment_exts"`
AttachmentMaxMB *int `json:"attachment_max_mb"`
AttachmentMaxCount *int `json:"attachment_max_count"`
ImageMaxMB *int `json:"image_max_mb"`
AttachmentMaxMB *int `json:"attachment_max_mb"`
AttachmentMaxCount *int `json:"attachment_max_count"`
ImageMaxMB *int `json:"image_max_mb"`
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
}
func settingsPayload(saved service.PublicSiteSettings) gin.H {
@@ -75,7 +76,24 @@ func (req *updateSettingsRequest) hasAny() bool {
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.TimelineGitImport != nil
}
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
func (h *Handlers) AdminGetSettings(c *gin.Context) {
saved, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
adapter, err := h.Setting.TimelineGitAdapterJSON()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提交导入配置失败"})
return
}
out := settingsPayload(saved)
out["timeline_git_import"] = adapter
c.JSON(http.StatusOK, out)
}
// PUT /api/admin/settings
@@ -247,6 +265,16 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
}
}
var savedAdapter string
if req.TimelineGitImport != nil {
normalized, err := h.Setting.SetTimelineGitAdapterJSON(*req.TimelineGitImport)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedAdapter = normalized
}
saved, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
@@ -260,5 +288,8 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
})
out := settingsPayload(saved)
out["ok"] = true
if req.TimelineGitImport != nil {
out["timeline_git_import"] = savedAdapter
}
c.JSON(http.StatusOK, out)
}

View File

@@ -0,0 +1,93 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// SitePageDetail 公开单页详情(按 slug)
func (h *Handlers) SitePageDetail(c *gin.Context) {
slug := c.Param("slug")
p, err := h.SitePage.GetPublishedBySlug(slug)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取页面失败"})
return
}
c.JSON(http.StatusOK, gin.H{"page": p})
}
// AdminListSitePages 后台单页列表
func (h *Handlers) AdminListSitePages(c *gin.Context) {
list, err := h.SitePage.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取页面失败"})
return
}
c.JSON(http.StatusOK, gin.H{"pages": nonNilSlice(list)})
}
// AdminCreateSitePage 新建单页
func (h *Handlers) AdminCreateSitePage(c *gin.Context) {
var in service.SitePageInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
p, err := h.SitePage.Create(&in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"page": p})
}
// AdminUpdateSitePage 编辑单页
func (h *Handlers) AdminUpdateSitePage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面 ID"})
return
}
var in service.SitePageInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
p, err := h.SitePage.Update(uint(id), &in)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"page": p})
}
// AdminDeleteSitePage 删除单页
func (h *Handlers) AdminDeleteSitePage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面 ID"})
return
}
if err := h.SitePage.Delete(uint(id)); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -0,0 +1,29 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// TimelineFromGit 从 Git commits 页 URL 导入时间线条目(登录 + CSRF + 限流)
func (h *Handlers) TimelineFromGit(c *gin.Context) {
var req struct {
URLs []string `json:"urls"`
FollowPages *bool `json:"follow_pages"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
return
}
follow := true
if req.FollowPages != nil {
follow = *req.FollowPages
}
result, err := h.Setting.ImportTimelineFromGit(req.URLs, follow)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}

View File

@@ -1,9 +1,10 @@
// Package markdown 提供帖子正文隐藏块(:::hide)的解析、校验与脱敏。
// Package markdown 提供帖子正文隐藏块(行级 BBCode [hide]…[/hide])的解析、校验与脱敏。
package markdown
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"unicode/utf8"
@@ -43,22 +44,25 @@ type DerivedAccess struct {
// ViewerCaps 读者对隐藏块的能力(由 service 填充)
type ViewerCaps struct {
Bypass bool // 作者 / 版主
LoggedIn bool
HasReplied bool
PointsPaid bool // 已支付本帖积分解锁
PasswordUnlocked map[int]bool // 已凭密码解锁的隐藏块下标
Bypass bool // 作者 / 版主
LoggedIn bool
HasReplied bool
PointsPaid bool // 已支付本帖积分解锁
PasswordUnlocked map[int]bool // 已凭密码解锁的隐藏块下标
}
var (
ErrHideNested = errors.New("隐藏块不可嵌套")
ErrHideUnclosed = errors.New("隐藏块未正确闭合")
ErrHideInvalid = errors.New("隐藏块语法无效")
ErrHidePointsNeed = errors.New("积分可见块须指定 1–100000 的积分")
ErrHidePasswordNeed = errors.New("密码可见块须设置 1–64 字符且不含空格的密码")
ErrHideNested = errors.New("隐藏块不可嵌套")
ErrHideUnclosed = errors.New("隐藏块未正确闭合")
ErrHideInvalid = errors.New("隐藏块语法无效")
ErrHidePointsNeed = errors.New("积分可见块须指定 1–100000 的积分")
ErrHidePasswordNeed = errors.New("密码可见块须设置 1–64 字符且不含空格的密码")
)
// ParseHideBlocks 扫描正文中的 :::hide 块(忽略代码围栏内伪语法)。
// 开标记:整行 [hide <kind> …];闭标记:整行 [/hide]
var hideOpenRe = regexp.MustCompile(`(?i)^\[hide(?:\s+(.+))?\]$`)
// ParseHideBlocks 扫描正文中的 [hide]…[/hide] 块(忽略代码围栏内伪语法)。
func ParseHideBlocks(content string) ([]HideBlock, error) {
lines := splitLines(content)
var blocks []HideBlock
@@ -175,7 +179,7 @@ func DeriveAccessFromContent(content string) (DerivedAccess, error) {
}
// SanitizeForViewer 按读者能力脱敏:未满足条件的块清空正文并加 locked。
// 密码块即使已解锁,也不向读者回传明文密码(开标记写成 :::hide password)。
// 密码块即使已解锁,也不向读者回传明文密码(开标记写成 [hide password] 或 [hide password locked])。
func SanitizeForViewer(content string, caps ViewerCaps) (sanitized string, fullyLocked bool) {
blocks, err := ParseHideBlocks(content)
if err != nil {
@@ -209,10 +213,10 @@ func SanitizeForViewer(content string, caps ViewerCaps) (sanitized string, fully
anyVisible = true
}
}
out = append(out, ":::")
out = append(out, "[/hide]")
} else {
out = append(out, openMarkerLine(b.Kind, b.Points, "", true))
out = append(out, ":::")
out = append(out, "[/hide]")
}
cursor = b.End + 1
}
@@ -249,16 +253,16 @@ func WrapContentAsHide(kind string, points int, content string) string {
body := strings.TrimRight(content, "\n")
open := openMarkerLine(kind, points, "", false)
if body == "" {
return open + "\n:::\n"
return open + "\n[/hide]\n"
}
return open + "\n" + body + "\n:::\n"
return open + "\n" + body + "\n[/hide]\n"
}
// HasHideBlocks 快速判断正文是否已含隐藏块(迁移幂等)
func HasHideBlocks(content string) bool {
blocks, err := ParseHideBlocks(content)
if err != nil {
return strings.Contains(content, ":::hide")
return strings.Contains(strings.ToLower(content), "[hide")
}
return len(blocks) > 0
}
@@ -281,28 +285,30 @@ func blockUnlocked(b HideBlock, caps ViewerCaps) bool {
// openMarkerLine 生成开标记。密码仅在原文存储时写入;对外脱敏输出传空 password。
func openMarkerLine(kind string, points int, password string, locked bool) string {
var b strings.Builder
b.WriteString(":::hide ")
b.WriteString("[hide ")
b.WriteString(kind)
if kind == HideKindPoints && points > 0 {
b.WriteByte(' ')
b.WriteString("=")
b.WriteString(strconv.Itoa(points))
}
if kind == HideKindPassword && password != "" && !locked {
b.WriteByte(' ')
b.WriteString("=")
b.WriteString(password)
}
if locked {
b.WriteString(" locked")
}
b.WriteByte(']')
return b.String()
}
// parseOpenMarker 解析 :::hide <kind> [points|password] [locked]
// parseOpenMarker 解析 [hide <kind>[=arg] [locked]]
func parseOpenMarker(trimmed string) (kind string, points int, password string, locked bool, ok bool) {
if !strings.HasPrefix(trimmed, ":::hide") {
m := hideOpenRe.FindStringSubmatch(trimmed)
if m == nil {
return "", 0, "", false, false
}
rest := strings.TrimSpace(trimmed[len(":::hide"):])
rest := strings.TrimSpace(m[1])
if rest == "" {
return "", 0, "", false, false
}
@@ -310,32 +316,43 @@ func parseOpenMarker(trimmed string) (kind string, points int, password string,
if len(parts) == 0 {
return "", 0, "", false, false
}
kind = parts[0]
head := parts[0]
kind = head
arg := ""
if i := strings.IndexByte(head, '='); i >= 0 {
kind = head[:i]
arg = head[i+1:]
}
kind = strings.ToLower(kind)
switch kind {
case HideKindLogin, HideKindReply, HideKindPoints, HideKindPassword:
default:
return "", 0, "", false, false
}
idx := 1
if kind == HideKindPoints {
if idx >= len(parts) {
if arg == "" {
return "", 0, "", false, false
}
n, err := strconv.Atoi(parts[idx])
n, err := strconv.Atoi(arg)
if err != nil {
return "", 0, "", false, false
}
points = n
idx++
} else if kind == HideKindPassword {
// 允许::::hide password locked(脱敏)或 :::hide password <pwd> [locked]
if idx < len(parts) && parts[idx] != "locked" {
password = parts[idx]
idx++
// [hide password=secret] 或脱敏 [hide password locked]
if arg != "" {
password = arg
}
} else if arg != "" {
// login/reply 不应带 =arg
return "", 0, "", false, false
}
for ; idx < len(parts); idx++ {
if parts[idx] == "locked" {
if strings.EqualFold(parts[idx], "locked") {
locked = true
} else {
return "", 0, "", false, false
@@ -356,7 +373,7 @@ func validatePassword(pwd string) error {
}
func isCloseMarker(trimmed string) bool {
return trimmed == ":::"
return strings.EqualFold(trimmed, "[/hide]")
}
func splitLines(s string) []string {
@@ -378,7 +395,7 @@ func ValidateHideContent(content string) error {
case errors.Is(err, ErrHideNested):
return fmt.Errorf("隐藏块不可嵌套")
case errors.Is(err, ErrHideUnclosed):
return fmt.Errorf("隐藏块未正确闭合,请检查 ::: 标记")
return fmt.Errorf("隐藏块未正确闭合,请检查 [/hide] 标记")
case errors.Is(err, ErrHidePointsNeed):
return fmt.Errorf("积分可见块须指定 1–100000 的积分")
case errors.Is(err, ErrHidePasswordNeed):

View File

@@ -6,7 +6,7 @@ import (
)
func TestParseIgnoreCodeFence(t *testing.T) {
src := "公开\n\n```\n:::hide login\n假的\n:::\n```\n\n结尾\n"
src := "公开\n\n```\n[hide login]\n假的\n[/hide]\n```\n\n结尾\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -17,7 +17,7 @@ func TestParseIgnoreCodeFence(t *testing.T) {
}
func TestParseMixedAndDerive(t *testing.T) {
src := "公开段\n\n:::hide login\n登录内容\n:::\n\n:::hide points 10\n积分A\n:::\n\n:::hide points 5\n积分B\n:::\n"
src := "公开段\n\n[hide login]\n登录内容\n[/hide]\n\n[hide points=10]\n积分A\n[/hide]\n\n[hide points=5]\n积分B\n[/hide]\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -35,7 +35,7 @@ func TestParseMixedAndDerive(t *testing.T) {
}
func TestParseNestedRejected(t *testing.T) {
src := ":::hide login\n外\n:::hide reply\n内\n:::\n:::\n"
src := "[hide login]\n外\n[hide reply]\n内\n[/hide]\n[/hide]\n"
_, err := ParseHideBlocks(src)
if err != ErrHideNested {
t.Fatalf("want ErrHideNested, got %v", err)
@@ -43,7 +43,7 @@ func TestParseNestedRejected(t *testing.T) {
}
func TestSanitizeLocksBody(t *testing.T) {
src := "公开\n\n:::hide login\n秘密内容\n:::\n\n尾\n"
src := "公开\n\n[hide login]\n秘密内容\n[/hide]\n\n尾\n"
out, fully := SanitizeForViewer(src, ViewerCaps{})
if fully {
t.Fatal("should not be fully locked")
@@ -51,7 +51,7 @@ func TestSanitizeLocksBody(t *testing.T) {
if strings.Contains(out, "秘密内容") {
t.Fatalf("leaked: %q", out)
}
if !strings.Contains(out, ":::hide login locked") {
if !strings.Contains(out, "[hide login locked]") {
t.Fatalf("missing locked marker: %q", out)
}
if !strings.Contains(out, "公开") || !strings.Contains(out, "尾") {
@@ -60,7 +60,7 @@ func TestSanitizeLocksBody(t *testing.T) {
}
func TestSanitizeUnlockLogin(t *testing.T) {
src := ":::hide login\n秘密\n:::\n"
src := "[hide login]\n秘密\n[/hide]\n"
out, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true})
if fully {
t.Fatal("should see content")
@@ -74,7 +74,7 @@ func TestSanitizeUnlockLogin(t *testing.T) {
}
func TestSanitizeBypass(t *testing.T) {
src := ":::hide points 9\n付费\n:::\n"
src := "[hide points=9]\n付费\n[/hide]\n"
out, _ := SanitizeForViewer(src, ViewerCaps{Bypass: true})
if !strings.Contains(out, "付费") {
t.Fatalf("bypass failed: %q", out)
@@ -82,7 +82,7 @@ func TestSanitizeBypass(t *testing.T) {
}
func TestSanitizePointsPaid(t *testing.T) {
src := ":::hide points 3\n付费文\n:::\n"
src := "[hide points=3]\n付费文\n[/hide]\n"
out, _ := SanitizeForViewer(src, ViewerCaps{LoggedIn: true, PointsPaid: true})
if !strings.Contains(out, "付费文") {
t.Fatalf("paid unlock failed: %q", out)
@@ -90,7 +90,7 @@ func TestSanitizePointsPaid(t *testing.T) {
}
func TestFullyLocked(t *testing.T) {
src := ":::hide reply\n仅回复\n:::\n"
src := "[hide reply]\n仅回复\n[/hide]\n"
_, fully := SanitizeForViewer(src, ViewerCaps{LoggedIn: true})
if !fully {
t.Fatal("expected fully locked")
@@ -98,7 +98,7 @@ func TestFullyLocked(t *testing.T) {
}
func TestCodeInsideHide(t *testing.T) {
src := ":::hide login\n```\n:::hide reply\n伪\n:::\n```\n真\n:::\n"
src := "[hide login]\n```\n[hide reply]\n伪\n[/hide]\n```\n真\n[/hide]\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -112,7 +112,7 @@ func TestCodeInsideHide(t *testing.T) {
}
func TestParsePasswordAndSanitize(t *testing.T) {
src := "公开\n\n:::hide password secret1\n密码内容\n:::\n"
src := "公开\n\n[hide password=secret1]\n密码内容\n[/hide]\n"
blocks, err := ParseHideBlocks(src)
if err != nil {
t.Fatal(err)
@@ -131,7 +131,7 @@ func TestParsePasswordAndSanitize(t *testing.T) {
if strings.Contains(out, "secret1") || strings.Contains(out, "密码内容") {
t.Fatalf("leaked: %q", out)
}
if !strings.Contains(out, ":::hide password locked") {
if !strings.Contains(out, "[hide password locked]") {
t.Fatalf("%q", out)
}
out2, _ := SanitizeForViewer(src, ViewerCaps{PasswordUnlocked: map[int]bool{0: true}})
@@ -144,7 +144,7 @@ func TestParsePasswordAndSanitize(t *testing.T) {
}
func TestMatchPasswordBlocks(t *testing.T) {
src := ":::hide password aaa\nA\n:::\n\n:::hide password bbb\nB\n:::\n"
src := "[hide password=aaa]\nA\n[/hide]\n\n[hide password=bbb]\nB\n[/hide]\n"
hit, err := MatchPasswordBlocks(src, "bbb")
if err != nil {
t.Fatal(err)
@@ -155,7 +155,7 @@ func TestMatchPasswordBlocks(t *testing.T) {
}
func TestPasswordNeed(t *testing.T) {
_, err := ParseHideBlocks(":::hide password\n无密码\n:::\n")
_, err := ParseHideBlocks("[hide password]\n无密码\n[/hide]\n")
if err != ErrHidePasswordNeed {
t.Fatalf("got %v", err)
}
@@ -163,10 +163,34 @@ func TestPasswordNeed(t *testing.T) {
func TestWrapContentAsHide(t *testing.T) {
got := WrapContentAsHide("points", 20, "旧正文")
if !strings.HasPrefix(got, ":::hide points 20\n") {
if !strings.HasPrefix(got, "[hide points=20]\n") {
t.Fatalf("%q", got)
}
if !strings.Contains(got, "旧正文") {
t.Fatal(got)
}
}
func TestTimelineDoesNotBreakHideValidate(t *testing.T) {
src := "[timeline]\n## 2026-09-16 feat\n说明\n[/timeline]\n"
if err := ValidateHideContent(src); err != nil {
t.Fatalf("timeline-only should pass: %v", err)
}
}
func TestRewriteLegacyDirectives(t *testing.T) {
src := "公开\n\n:::hide login\n秘\n:::\n\n:::timeline\n## 2026-01-01 A\n:::\n"
got := RewriteLegacyDirectives(src)
if strings.Contains(got, ":::") {
t.Fatalf("legacy remains: %q", got)
}
if !strings.Contains(got, "[hide login]") || !strings.Contains(got, "[/hide]") {
t.Fatalf("hide missing: %q", got)
}
if !strings.Contains(got, "[timeline]") || !strings.Contains(got, "[/timeline]") {
t.Fatalf("timeline missing: %q", got)
}
if err := ValidateHideContent(got); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,158 @@
package markdown
import (
"strconv"
"strings"
)
// RewriteLegacyDirectives 将旧式 :::hide / :::timeline / 裸 ::: 改写为行级 BBCode。
// 幂等:已是 BBCode 的内容原样返回(若无旧标记)。代码围栏内不改写。
func RewriteLegacyDirectives(content string) string {
if content == "" {
return content
}
if !strings.Contains(content, ":::") {
return content
}
lines := splitLines(content)
var out []string
inCode := false
i := 0
for i < len(lines) {
trimmed := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimmed, "```") {
inCode = !inCode
out = append(out, lines[i])
i++
continue
}
if inCode {
out = append(out, lines[i])
i++
continue
}
if kind, pts, pwd, locked, ok := parseLegacyHideOpen(trimmed); ok {
bodyLines := make([]string, 0)
j := i + 1
innerCode := false
found := false
for j < len(lines) {
inner := strings.TrimSpace(lines[j])
if strings.HasPrefix(inner, "```") {
innerCode = !innerCode
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if innerCode {
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if inner == ":::" {
found = true
break
}
bodyLines = append(bodyLines, lines[j])
j++
}
out = append(out, openMarkerLine(kind, pts, pwd, locked))
out = append(out, bodyLines...)
if found {
out = append(out, "[/hide]")
i = j + 1
} else {
// 未闭合:仍写出已转换开标记与正文,避免丢内容
i = j
}
continue
}
if trimmed == ":::timeline" || strings.HasPrefix(trimmed, ":::timeline ") {
bodyLines := make([]string, 0)
j := i + 1
innerCode := false
found := false
for j < len(lines) {
inner := strings.TrimSpace(lines[j])
if strings.HasPrefix(inner, "```") {
innerCode = !innerCode
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if innerCode {
bodyLines = append(bodyLines, lines[j])
j++
continue
}
if inner == ":::" {
found = true
break
}
bodyLines = append(bodyLines, lines[j])
j++
}
out = append(out, "[timeline]")
out = append(out, bodyLines...)
if found {
out = append(out, "[/timeline]")
i = j + 1
} else {
i = j
}
continue
}
out = append(out, lines[i])
i++
}
return strings.Join(out, "\n")
}
// parseLegacyHideOpen 解析旧式 :::hide <kind> [arg] [locked]
func parseLegacyHideOpen(trimmed string) (kind string, points int, password string, locked bool, ok bool) {
if !strings.HasPrefix(trimmed, ":::hide") {
return "", 0, "", false, false
}
rest := strings.TrimSpace(trimmed[len(":::hide"):])
if rest == "" {
return "", 0, "", false, false
}
parts := strings.Fields(rest)
if len(parts) == 0 {
return "", 0, "", false, false
}
kind = parts[0]
switch kind {
case HideKindLogin, HideKindReply, HideKindPoints, HideKindPassword:
default:
return "", 0, "", false, false
}
idx := 1
if kind == HideKindPoints {
if idx >= len(parts) {
return "", 0, "", false, false
}
n, err := strconv.Atoi(parts[idx])
if err != nil {
return "", 0, "", false, false
}
points = n
idx++
} else if kind == HideKindPassword {
if idx < len(parts) && parts[idx] != "locked" {
password = parts[idx]
idx++
}
}
for ; idx < len(parts); idx++ {
if parts[idx] == "locked" {
locked = true
} else {
return "", 0, "", false, false
}
}
return kind, points, password, locked, true
}

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log"
"strings"
"github.com/freefire/jiang13-bbs/markdown"
"gorm.io/driver/postgres"
@@ -36,9 +37,14 @@ func InitDB(dsn string) error {
return fmt.Errorf("清理遗留 FK 约束失败: %w", err)
}
// 公告 pinned:已有行不能直接 ADD NOT NULL,先加列并回填 false
if err := prepareAnnouncementPinnedColumn(db); err != nil {
return fmt.Errorf("公告置顶列迁移失败: %w", err)
}
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
&PostPollVote{}, &PostLotteryEntry{},
@@ -46,10 +52,14 @@ func InitDB(dsn string) error {
return fmt.Errorf("自动迁移失败: %w", err)
}
// 旧帖整帖可见性 → 正文 :::hide 块(幂等)
// 旧帖整帖可见性 → 正文 [hide] 块(幂等)
if err := migratePostContentAccessToHideBlocks(db); err != nil {
return fmt.Errorf("正文隐藏块迁移失败: %w", err)
}
// 旧式 :::hide / :::timeline → 行级 BBCode(幂等)
if err := migrateLegacyDirectiveSyntax(db); err != nil {
return fmt.Errorf("短代码语法迁移失败: %w", err)
}
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
if err := backfillPointsFromCheckin(db); err != nil {
@@ -120,6 +130,28 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
return nil
}
// prepareAnnouncementPinnedColumn 存量公告加 pinned NOT NULL:先 DEFAULT false 再回填
func prepareAnnouncementPinnedColumn(db *gorm.DB) error {
var tableCount int64
if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'announcements'`).
Scan(&tableCount).Error; err != nil {
return err
}
if tableCount == 0 {
return nil
}
var hasCol int64
if err := db.Raw(`SELECT count(1) FROM information_schema.columns
WHERE table_name = 'announcements' AND column_name = 'pinned'`).
Scan(&hasCol).Error; err != nil {
return err
}
if hasCol > 0 {
return nil
}
return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error
}
// prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列,
// 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前:
// 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败)
@@ -372,7 +404,7 @@ func ensureDefaultChatMemberships(db *gorm.DB) error {
return nil
}
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 :::hide 块(幂等)。
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 [hide] 块(幂等)。
func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
var posts []Post
if err := db.Unscoped().
@@ -407,3 +439,98 @@ func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
}
return nil
}
// migrateLegacyDirectiveSyntax 将正文中的 :::hide / :::timeline 改写为 BBCode(幂等)。
func migrateLegacyDirectiveSyntax(db *gorm.DB) error {
type row struct {
ID uint
Content string
}
rewriteTable := func(table string, rows []row) (int, error) {
n := 0
for _, r := range rows {
if !strings.Contains(r.Content, ":::") {
continue
}
next := markdown.RewriteLegacyDirectives(r.Content)
if next == r.Content {
continue
}
if err := db.Table(table).Where("id = ?", r.ID).
Update("content", next).Error; err != nil {
return n, err
}
n++
}
return n, nil
}
var posts []row
if err := db.Model(&Post{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&posts).Error; err != nil {
return err
}
if n, err := rewriteTable("posts", posts); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 帖子短代码改写:%d 篇", n)
}
var comments []row
if err := db.Model(&Comment{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&comments).Error; err != nil {
return err
}
if n, err := rewriteTable("comments", comments); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 评论短代码改写:%d 条", n)
}
var anns []row
if err := db.Model(&Announcement{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&anns).Error; err != nil {
return err
}
if n, err := rewriteTable("announcements", anns); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 公告短代码改写:%d 条", n)
}
var pages []row
if err := db.Model(&SitePage{}).Unscoped().Select("id", "content").
Where("content LIKE ?", "%:::%").Find(&pages).Error; err != nil {
return err
}
if n, err := rewriteTable("site_pages", pages); err != nil {
return err
} else if n > 0 {
log.Printf("[model] 站点页短代码改写:%d 篇", n)
}
var hist []struct {
ID uint
OldContent string
}
if err := db.Model(&CommentEditHistory{}).Select("id", "old_content").
Where("old_content LIKE ?", "%:::%").Find(&hist).Error; err != nil {
return err
}
nHist := 0
for _, r := range hist {
next := markdown.RewriteLegacyDirectives(r.OldContent)
if next == r.OldContent {
continue
}
if err := db.Model(&CommentEditHistory{}).Where("id = ?", r.ID).
Update("old_content", next).Error; err != nil {
return err
}
nHist++
}
if nHist > 0 {
log.Printf("[model] 评论历史短代码改写:%d 条", nHist)
}
return nil
}

View File

@@ -80,7 +80,7 @@ func NormalizePostType(t string) string {
return PostTypeDiscussion
}
// 正文可见性(由正文 :::hide 块派生;mixed = 多种隐藏类型并存)
// 正文可见性(由正文 [hide] 块派生;mixed = 多种隐藏类型并存)
const (
ContentAccessPublic = "public" // 公开
ContentAccessLogin = "login" // 仅登录可见块
@@ -388,13 +388,30 @@ type Announcement struct {
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tag string `gorm:"size:32;not null;default:公告" json:"tag"`
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray/teal/cyan/pink/amber/indigo/rose
Published bool `gorm:"not null;index" json:"published"` // 显式写入 false;不可用 default:true,否则草稿零值会被 GORM 省略而落成已发布
Pinned bool `gorm:"not null;index" json:"pinned"` // 默认 false;写入须 Select,禁止 default:true
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SitePage 站点单页(功能介绍等;侧栏入口 + /p/:slug)
type SitePage struct {
ID uint `gorm:"primaryKey" json:"id"`
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Excerpt string `gorm:"size:300" json:"excerpt"`
Published bool `gorm:"not null;index" json:"published"`
ShowInSidebar bool `gorm:"not null;index" json:"show_in_sidebar"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
AllowComments bool `gorm:"not null" json:"allow_comments"` // 默认 false;本轮无评论 UI
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
type SiteSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`

View File

@@ -51,6 +51,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
overviewSvc := service.NewOverviewService(model.DB)
checkinSvc := service.NewCheckinService(model.DB)
announcementSvc := service.NewAnnouncementService(model.DB)
sitePageSvc := service.NewSitePageService(model.DB)
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")).WithSetting(settingSvc)
pointsSvc := service.NewPointsService(model.DB)
@@ -79,6 +80,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
OverviewSvc: overviewSvc,
Checkin: checkinSvc,
Announcement: announcementSvc,
SitePage: sitePageSvc,
Upload: uploadSvc,
PostFile: postFileSvc,
Points: pointsSvc,
@@ -129,6 +131,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.GET("/announcements", h.AnnouncementsList)
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
pubAPI.GET("/pages/:slug", h.SitePageDetail)
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
@@ -183,6 +186,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.GET("/my/media", h.MyMedia)
api.DELETE("/my/attachments/:id", h.DeleteAttachment)
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
api.POST("/timeline/from-git", middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit), h.TimelineFromGit)
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
{
@@ -247,9 +253,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
announceAPI.GET("/announcements", h.AdminListAnnouncements)
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
announceAPI.GET("/pages", h.AdminListSitePages)
announceAPI.POST("/pages", h.AdminCreateSitePage)
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
// 站点外观设置(超级管理员/站长)
// 站点外观设置(超级管理员/站长);含 timeline_git_import
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史

View File

@@ -4,6 +4,7 @@ import (
"errors"
"strings"
"github.com/freefire/jiang13-bbs/markdown"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
@@ -12,6 +13,8 @@ import (
var allowedTagColors = map[string]bool{
"blue": true, "green": true, "orange": true,
"red": true, "purple": true, "gray": true,
"teal": true, "cyan": true, "pink": true,
"amber": true, "indigo": true, "rose": true,
}
// AnnouncementService 站点公告服务
@@ -30,6 +33,7 @@ type AnnouncementInput struct {
Tag string `json:"tag"`
TagColor string `json:"tag_color"`
Published *bool `json:"published"`
Pinned *bool `json:"pinned"`
}
// normalize 清洗并校验入参,返回可直接落库的字段
@@ -45,6 +49,12 @@ func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
if in.Content == "" {
return errors.New("内容不能为空")
}
if err := markdown.ValidateHideContent(in.Content); err != nil {
return err
}
if err := rejectReplyHideBlocks(in.Content, ErrAnnouncementReplyHideNotAllowed); err != nil {
return err
}
in.Tag = strings.TrimSpace(in.Tag)
if in.Tag == "" {
@@ -68,7 +78,7 @@ func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
return nil
}
// applyTo 把入参写入模型(Published 为指针以区分"未传"与"显式 false")
// applyTo 把入参写入模型(Published/Pinned 为指针以区分"未传"与"显式 false")
func (in *AnnouncementInput) applyTo(a *model.Announcement) {
a.Title = in.Title
a.Content = in.Content
@@ -77,19 +87,46 @@ func (in *AnnouncementInput) applyTo(a *model.Announcement) {
if in.Published != nil {
a.Published = *in.Published
}
if in.Pinned != nil {
a.Pinned = *in.Pinned
}
}
// ListPublished 已发布公告(首页右栏 / 公开列表),按发布时间倒序
// 写入字段须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名
var announcementWriteFields = []string{
"Title", "Content", "Tag", "TagColor", "Published", "Pinned",
}
// ListPublished 已发布公告(首页右栏),置顶优先再按发布时间倒序
func (s *AnnouncementService) ListPublished(limit int) ([]model.Announcement, error) {
if limit <= 0 {
limit = 5
}
var list []model.Announcement
err := s.db.Where("published = ?", true).
Order("created_at DESC, id DESC").Limit(limit).Find(&list).Error
Order("pinned DESC, created_at DESC, id DESC").Limit(limit).Find(&list).Error
return list, err
}
// ListPublishedPage 已发布公告分页(全部公告页)
func (s *AnnouncementService) ListPublishedPage(page, size int) ([]model.Announcement, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
q := s.db.Model(&model.Announcement{}).Where("published = ?", true)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Announcement
err := q.Order("pinned DESC, created_at DESC, id DESC").
Offset((page - 1) * size).Limit(size).Find(&list).Error
return list, total, err
}
// GetPublished 取单条已发布公告(详情页);草稿/不存在返回 gorm.ErrRecordNotFound
func (s *AnnouncementService) GetPublished(id uint) (*model.Announcement, error) {
var a model.Announcement
@@ -100,10 +137,10 @@ func (s *AnnouncementService) GetPublished(id uint) (*model.Announcement, error)
return &a, nil
}
// ListAll 管理后台:含草稿,按更新时间倒序
// ListAll 管理后台:含草稿,置顶优先
func (s *AnnouncementService) ListAll() ([]model.Announcement, error) {
var list []model.Announcement
err := s.db.Order("created_at DESC, id DESC").Limit(100).Find(&list).Error
err := s.db.Order("pinned DESC, created_at DESC, id DESC").Limit(100).Find(&list).Error
return list, err
}
@@ -112,9 +149,9 @@ func (s *AnnouncementService) Create(in *AnnouncementInput) (*model.Announcement
if err := in.normalize(nil); err != nil {
return nil, err
}
a := &model.Announcement{Published: true}
a := &model.Announcement{Published: true, Pinned: false}
in.applyTo(a)
if err := s.db.Create(a).Error; err != nil {
if err := s.db.Select(announcementWriteFields).Create(a).Error; err != nil {
return nil, err
}
return a, nil
@@ -130,7 +167,27 @@ func (s *AnnouncementService) Update(id uint, in *AnnouncementInput) (*model.Ann
return nil, err
}
in.applyTo(&a)
if err := s.db.Save(&a).Error; err != nil {
if err := s.db.Model(&model.Announcement{}).Where("id = ?", a.ID).Updates(map[string]interface{}{
"title": a.Title,
"content": a.Content,
"tag": a.Tag,
"tag_color": a.TagColor,
"published": a.Published,
"pinned": a.Pinned,
}).Error; err != nil {
return nil, err
}
return &a, nil
}
// TogglePin 切换置顶状态
func (s *AnnouncementService) TogglePin(id uint) (*model.Announcement, error) {
var a model.Announcement
if err := s.db.First(&a, id).Error; err != nil {
return nil, err
}
a.Pinned = !a.Pinned
if err := s.db.Model(&a).Select("Pinned").Update("pinned", a.Pinned).Error; err != nil {
return nil, err
}
return &a, nil

View File

@@ -0,0 +1,37 @@
package service
import (
"errors"
"github.com/freefire/jiang13-bbs/markdown"
)
var (
// ErrAnnouncementReplyHideNotAllowed 公告不可评论,禁止回复可见块
ErrAnnouncementReplyHideNotAllowed = errors.New("公告不可评论,不能使用回复可见")
// ErrSitePageHideNotAllowed 站点单页正文禁止 [hide](本轮单页仍无评论/解锁)
ErrSitePageHideNotAllowed = errors.New("页面不支持隐藏内容")
)
// rejectHideBlocks 若正文含 [hide] 开标记(忽略代码围栏内伪语法)则返回指定错误。
func rejectHideBlocks(content string, deny error) error {
if markdown.HasHideBlocks(content) {
return deny
}
return nil
}
// rejectReplyHideBlocks 若正文含回复可见隐藏块则返回指定错误。
func rejectReplyHideBlocks(content string, deny error) error {
blocks, err := markdown.ParseHideBlocks(content)
if err != nil {
// 语法错误交给 ValidateHideContent;此处不阻断「无有效块」的脏文本
return nil
}
for _, b := range blocks {
if b.Kind == markdown.HideKindReply {
return deny
}
}
return nil
}

View File

@@ -51,6 +51,7 @@ type AnnouncementItem struct {
Title string `json:"title"`
Tag string `json:"tag"`
TagColor string `json:"tag_color"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
}
@@ -65,13 +66,15 @@ type NewUserItem struct {
// OverviewData 首页聚合数据
type OverviewData struct {
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Announcements []AnnouncementItem `json:"announcements"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
Stats OverviewStats `json:"stats"`
Hot []PostListItem `json:"hot"`
ActiveUsers []ActiveUser `json:"active_users"`
Boards []BoardCount `json:"boards"`
Announcements []AnnouncementItem `json:"announcements"`
AnnouncementsTotal int64 `json:"announcements_total"`
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
NewUsers []NewUserItem `json:"new_users"`
Checkin *CheckinStatus `json:"checkin,omitempty"`
}
const (
@@ -98,6 +101,7 @@ func (s *OverviewService) Get() (*OverviewData, error) {
ActiveUsers: []ActiveUser{},
Boards: []BoardCount{},
Announcements: []AnnouncementItem{},
SidebarPages: []SidebarPageItem{},
NewUsers: []NewUserItem{},
}
@@ -230,10 +234,14 @@ LIMIT 5`
data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]})
}
// 站点公告(已发布,最新 5 条)
// 站点公告(已发布,置顶优先,最新 5 条)+ 总数
if err := s.db.Model(&model.Announcement{}).Where("published = ?", true).
Count(&data.AnnouncementsTotal).Error; err != nil {
return nil, err
}
var anns []model.Announcement
if err := s.db.Where("published = ?", true).
Order("created_at DESC, id DESC").Limit(5).Find(&anns).Error; err != nil {
Order("pinned DESC, created_at DESC, id DESC").Limit(5).Find(&anns).Error; err != nil {
return nil, err
}
for _, a := range anns {
@@ -242,10 +250,25 @@ LIMIT 5`
Title: a.Title,
Tag: a.Tag,
TagColor: a.TagColor,
Pinned: a.Pinned,
CreatedAt: a.CreatedAt,
})
}
// 侧栏单页入口(已发布且勾选侧栏)
var pages []model.SitePage
if err := s.db.Where("published = ? AND show_in_sidebar = ?", true, true).
Order("sort_order ASC, id ASC").
Select("id", "slug", "title", "excerpt").
Find(&pages).Error; err != nil {
return nil, err
}
for _, p := range pages {
data.SidebarPages = append(data.SidebarPages, SidebarPageItem{
ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
})
}
// 最新注册成员
var newUsers []model.User
if err := s.db.Select("id, username, nickname, avatar, created_at").

View File

@@ -403,7 +403,7 @@ type PostDetail struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// CreatePostInput 发帖入参(content_access / access_points 由正文 :::hide 派生)
// CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生)
type CreatePostInput struct {
UserID uint
BoardID uint
@@ -517,7 +517,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
}
}
// sanitizePostContent 按读者能力对正文 :::hide 块脱敏;fullyLocked 表示无可见正文。
// sanitizePostContent 按读者能力对正文 [hide] 块脱敏;fullyLocked 表示无可见正文。
func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (content string, fullyLocked bool, hint string) {
caps := s.buildHideViewerCaps(post, viewerID, loadActor, pwdUnlocked)
sanitized, fully := markdown.SanitizeForViewer(post.Content, caps)

View File

@@ -70,6 +70,7 @@ const (
RateUpload = "upload" // 帖子插图等上传
RateInteract = "interact" // 投票/抽奖/解锁等互动
RateHidePassword = "hide_password" // 密码隐藏块尝试
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
)
// DefaultRateLimiter 创建默认速率限制器
@@ -83,5 +84,6 @@ func DefaultRateLimiter() *RateLimiter {
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
return rl
}

View File

@@ -0,0 +1,231 @@
package service
import (
"errors"
"regexp"
"strings"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
var sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
var sitePageWriteFields = []string{
"Slug", "Title", "Content", "Excerpt",
"Published", "ShowInSidebar", "SortOrder", "AllowComments",
}
// SitePageService 站点单页服务
type SitePageService struct {
db *gorm.DB
}
func NewSitePageService(db *gorm.DB) *SitePageService {
return &SitePageService{db: db}
}
// SitePageInput 创建/更新单页入参
type SitePageInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
Published *bool `json:"published"`
ShowInSidebar *bool `json:"show_in_sidebar"`
SortOrder *int `json:"sort_order"`
AllowComments *bool `json:"allow_comments"`
}
// SidebarPageItem 首页右栏「关于本站」入口
type SidebarPageItem struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
}
func (in *SitePageInput) normalize(existing *model.SitePage) error {
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
if in.Slug == "" {
return errors.New("slug 不能为空")
}
if len(in.Slug) > 64 {
return errors.New("slug 不能超过 64 字符")
}
if !sitePageSlugRe.MatchString(in.Slug) {
return errors.New("slug 仅允许小写字母、数字与连字符")
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
return errors.New("标题不能为空")
}
if utf8.RuneCountInString(in.Title) > 200 {
return errors.New("标题不能超过 200 字")
}
in.Content = strings.TrimSpace(in.Content)
if in.Content == "" {
return errors.New("内容不能为空")
}
if err := rejectHideBlocks(in.Content, ErrSitePageHideNotAllowed); err != nil {
return err
}
in.Excerpt = strings.TrimSpace(in.Excerpt)
if utf8.RuneCountInString(in.Excerpt) > 300 {
return errors.New("摘要不能超过 300 字")
}
_ = existing
return nil
}
func (in *SitePageInput) applyTo(p *model.SitePage) {
p.Slug = in.Slug
p.Title = in.Title
p.Content = in.Content
p.Excerpt = in.Excerpt
if in.Published != nil {
p.Published = *in.Published
}
if in.ShowInSidebar != nil {
p.ShowInSidebar = *in.ShowInSidebar
}
if in.SortOrder != nil {
p.SortOrder = *in.SortOrder
}
if in.AllowComments != nil {
p.AllowComments = *in.AllowComments
}
}
func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) {
q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug)
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
var n int64
if err := q.Count(&n).Error; err != nil {
return false, err
}
return n > 0, nil
}
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
func (s *SitePageService) ListSidebarPublished() ([]SidebarPageItem, error) {
var pages []model.SitePage
err := s.db.Where("published = ? AND show_in_sidebar = ?", true, true).
Order("sort_order ASC, id ASC").
Select("id", "slug", "title", "excerpt").
Find(&pages).Error
if err != nil {
return nil, err
}
out := make([]SidebarPageItem, 0, len(pages))
for _, p := range pages {
out = append(out, SidebarPageItem{
ID: p.ID, Slug: p.Slug, Title: p.Title, Excerpt: p.Excerpt,
})
}
return out, nil
}
// GetPublishedBySlug 公开详情(仅已发布)
func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
var p model.SitePage
err := s.db.Where("slug = ? AND published = ?", slug, true).First(&p).Error
if err != nil {
return nil, err
}
return &p, nil
}
// ListAll 管理后台全部单页
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
var list []model.SitePage
err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error
return list, err
}
// Get 管理后台按 ID 取单页
func (s *SitePageService) Get(id uint) (*model.SitePage, error) {
var p model.SitePage
if err := s.db.First(&p, id).Error; err != nil {
return nil, err
}
return &p, nil
}
// Create 新建单页
func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
if err := in.normalize(nil); err != nil {
return nil, err
}
taken, err := s.slugTaken(in.Slug, 0)
if err != nil {
return nil, err
}
if taken {
return nil, errors.New("slug 已被占用")
}
p := &model.SitePage{
Published: false,
ShowInSidebar: false,
AllowComments: false,
SortOrder: 0,
}
in.applyTo(p)
if err := s.db.Select(sitePageWriteFields).Create(p).Error; err != nil {
return nil, err
}
return p, nil
}
// Update 更新单页
func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, error) {
var p model.SitePage
if err := s.db.First(&p, id).Error; err != nil {
return nil, err
}
if err := in.normalize(&p); err != nil {
return nil, err
}
taken, err := s.slugTaken(in.Slug, id)
if err != nil {
return nil, err
}
if taken {
return nil, errors.New("slug 已被占用")
}
in.applyTo(&p)
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
"slug": p.Slug,
"title": p.Title,
"content": p.Content,
"excerpt": p.Excerpt,
"published": p.Published,
"show_in_sidebar": p.ShowInSidebar,
"sort_order": p.SortOrder,
"allow_comments": p.AllowComments,
}).Error; err != nil {
return nil, err
}
return &p, nil
}
// Delete 软删除单页
func (s *SitePageService) Delete(id uint) error {
result := s.db.Delete(&model.SitePage{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}

View File

@@ -0,0 +1,721 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const (
timelineGitHTTPTimeout = 8 * time.Second
timelineGitMaxBody = 1 << 20 // 1MB
timelineTitleMax = 200
timelineBodyMax = 2000
)
// TimelineGitItem 导入结果单项(已消毒)
type TimelineGitItem struct {
Date string `json:"date"`
Title string `json:"title"`
Body string `json:"body"`
SourceURL string `json:"source_url,omitempty"`
SHA string `json:"sha,omitempty"`
}
// TimelineGitImportResult 导入响应
type TimelineGitImportResult struct {
Items []TimelineGitItem `json:"items"`
Warning string `json:"warning,omitempty"`
Error string `json:"error,omitempty"`
}
var (
repoNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,100}$`)
shaHexRe = regexp.MustCompile(`^[0-9a-fA-F]{7,64}$`)
)
// ImportTimelineFromGit 按适配器解析 commits 页 URL 并拉取提交
func (s *SettingService) ImportTimelineFromGit(urls []string, followPages bool) (*TimelineGitImportResult, error) {
cfg, err := s.loadTimelineGitAdapter()
if err != nil {
return nil, err
}
cleanURLs := make([]string, 0, len(urls))
for _, u := range urls {
u = strings.TrimSpace(u)
if u != "" {
cleanURLs = append(cleanURLs, u)
}
}
if len(cleanURLs) == 0 {
return nil, errors.New("请提供至少一条 URL")
}
if len(cleanURLs) > 20 {
return nil, errors.New("一次最多 20 条 URL")
}
client := &http.Client{
Timeout: timelineGitHTTPTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("重定向过多")
}
if err := assertSafeHTTPSURL(req.URL); err != nil {
return err
}
return nil
},
}
seenSHA := map[string]bool{}
var items []TimelineGitItem
var failMsgs []string
truncated := false
for _, rawURL := range cleanURLs {
part, partTrunc, err := s.importOneGitURL(client, cfg, rawURL, followPages, seenSHA, cfg.MaxCommits-len(items))
if err != nil {
failMsgs = append(failMsgs, fmt.Sprintf("%s:%s", truncateTimelineStr(rawURL, 80), err.Error()))
continue
}
items = append(items, part...)
if partTrunc {
truncated = true
}
if len(items) >= cfg.MaxCommits {
truncated = true
break
}
}
out := &TimelineGitImportResult{Items: items}
if truncated {
out.Warning = fmt.Sprintf("已达上限(最多 %d 条),可再贴后续页 URL", cfg.MaxCommits)
}
if len(failMsgs) > 0 {
out.Error = strings.Join(failMsgs, ";")
}
if len(items) == 0 && out.Error == "" {
out.Error = "未能解析出提交"
}
return out, nil
}
func (s *SettingService) importOneGitURL(
client *http.Client,
cfg *TimelineGitAdapter,
rawURL string,
followPages bool,
seenSHA map[string]bool,
remain int,
) ([]TimelineGitItem, bool, error) {
if remain <= 0 {
return nil, true, nil
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, false, errors.New("URL 无效")
}
if err := assertSafeHTTPSURL(u); err != nil {
return nil, false, err
}
host := strings.ToLower(u.Hostname())
path := u.EscapedPath()
if path == "" {
path = "/"
}
src, caps, singleSHA, err := matchGitSource(cfg, host, path)
if err != nil {
return nil, false, err
}
if !repoNameRe.MatchString(caps["owner"]) || !repoNameRe.MatchString(caps["repo"]) {
return nil, false, errors.New("仓库名非法")
}
if ref, ok := caps["ref"]; ok && ref != "" {
if strings.ContainsAny(ref, " \t\n\r") || utf8.RuneCountInString(ref) > 200 {
return nil, false, errors.New("分支名非法")
}
}
startPage := 1
if p := u.Query().Get("page"); p != "" {
if n, e := strconv.Atoi(p); e == nil && n >= 1 {
startPage = n
}
}
if singleSHA != "" {
item, err := fetchSingleCommit(client, src, host, caps, singleSHA)
if err != nil {
return nil, false, err
}
if item.SHA != "" && seenSHA[item.SHA] {
return nil, false, nil
}
if item.SHA != "" {
seenSHA[item.SHA] = true
}
return []TimelineGitItem{*item}, false, nil
}
var out []TimelineGitItem
truncated := false
page := startPage
maxPages := 1
if followPages {
maxPages = cfg.MaxPages
}
pagesDone := 0
nextURL := ""
for pagesDone < maxPages && len(out) < remain {
var apiURL string
if nextURL != "" {
apiURL = nextURL
nextURL = ""
} else {
apiURL, err = expandAPITemplate(src.APIURL, host, caps, page)
if err != nil {
return out, truncated, err
}
q := url.Values{}
for k, v := range src.Query {
q.Set(k, expandTemplate(v, host, caps, page))
}
parsed, e := url.Parse(apiURL)
if e != nil {
return out, truncated, errors.New("API URL 无效")
}
if len(q) > 0 {
existing := parsed.Query()
for k, vs := range q {
existing.Set(k, vs[0])
}
parsed.RawQuery = existing.Encode()
}
apiURL = parsed.String()
}
parsedAPI, err := url.Parse(apiURL)
if err != nil {
return out, truncated, errors.New("API URL 无效")
}
if err := assertSafeHTTPSURL(parsedAPI); err != nil {
return out, truncated, err
}
headers := http.Header{}
for k, v := range src.Headers {
if allowedAdapterHeaders[strings.ToLower(k)] {
headers.Set(k, v)
}
}
if headers.Get("User-Agent") == "" {
headers.Set("User-Agent", "jiang13-bbs")
}
body, linkNext, status, err := httpGetLimited(client, parsedAPI.String(), headers)
if err != nil {
return out, truncated, err
}
if status == 404 || status == 401 || status == 403 {
return out, truncated, errors.New("无法读取该仓库(私有、不存在或无权访问)")
}
if status == 429 {
return out, truncated, errors.New("远端限流,请稍后再试")
}
if status < 200 || status >= 300 {
return out, truncated, fmt.Errorf("远端返回 %d", status)
}
pageItems, err := parseCommitListJSON(body, src, host)
if err != nil {
return out, truncated, err
}
if len(pageItems) == 0 {
break
}
for _, it := range pageItems {
if it.SHA != "" && seenSHA[it.SHA] {
continue
}
if it.SHA != "" {
seenSHA[it.SHA] = true
}
out = append(out, it)
if len(out) >= remain {
truncated = true
break
}
}
pagesDone++
if !followPages {
break
}
if src.Pagination == "link_header" && linkNext != "" {
nu, e := url.Parse(linkNext)
if e != nil || assertSafeHTTPSURL(nu) != nil {
break
}
nextURL = nu.String()
} else if src.Pagination == "query_page" {
page++
} else {
break
}
}
if pagesDone >= maxPages && followPages {
truncated = true
}
return out, truncated, nil
}
func matchGitSource(cfg *TimelineGitAdapter, host, path string) (*TimelineGitSourceCfg, map[string]string, string, error) {
for i := range cfg.Sources {
src := &cfg.Sources[i]
if src.Host != "*" && !strings.EqualFold(src.Host, host) {
continue
}
if src.Host == "*" && strings.EqualFold(host, "github.com") {
continue
}
if src.CommitPath != "" {
re, err := regexp.Compile(src.CommitPath)
if err == nil {
if m := re.FindStringSubmatch(path); m != nil {
caps := subexpMap(re, m)
sha := caps["sha"]
if !shaHexRe.MatchString(sha) {
return nil, nil, "", errors.New("提交哈希非法")
}
return src, caps, sha, nil
}
}
}
re, err := regexp.Compile(src.ListPath)
if err != nil {
continue
}
if m := re.FindStringSubmatch(path); m != nil {
return src, subexpMap(re, m), "", nil
}
}
return nil, nil, "", errors.New("地址不符或主机未配置")
}
func subexpMap(re *regexp.Regexp, m []string) map[string]string {
out := map[string]string{}
for i, name := range re.SubexpNames() {
if i == 0 || name == "" || i >= len(m) {
continue
}
out[name] = m[i]
}
return out
}
func expandTemplate(tpl, host string, caps map[string]string, page int) string {
r := strings.NewReplacer(
"{host}", host,
"{owner}", caps["owner"],
"{repo}", caps["repo"],
"{ref}", caps["ref"],
"{sha}", caps["sha"],
"{page}", strconv.Itoa(page),
)
return r.Replace(tpl)
}
func expandAPITemplate(tpl, host string, caps map[string]string, page int) (string, error) {
s := expandTemplate(tpl, host, caps, page)
u, err := url.Parse(s)
if err != nil {
return "", errors.New("API URL 无效")
}
if err := assertSafeHTTPSURL(u); err != nil {
return "", err
}
return u.String(), nil
}
func fetchSingleCommit(client *http.Client, src *TimelineGitSourceCfg, host string, caps map[string]string, sha string) (*TimelineGitItem, error) {
caps = copyCaps(caps)
caps["sha"] = sha
apiTpl := src.CommitAPI
if apiTpl == "" {
apiTpl = strings.TrimSuffix(src.APIURL, "/") + "/{sha}"
}
apiURL, err := expandAPITemplate(apiTpl, host, caps, 1)
if err != nil {
return nil, err
}
headers := http.Header{}
for k, v := range src.Headers {
if allowedAdapterHeaders[strings.ToLower(k)] {
headers.Set(k, v)
}
}
if headers.Get("User-Agent") == "" {
headers.Set("User-Agent", "jiang13-bbs")
}
body, _, status, err := httpGetLimited(client, apiURL, headers)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("远端返回 %d", status)
}
var obj any
if err := json.Unmarshal(body, &obj); err != nil {
return nil, errors.New("响应非 JSON")
}
item, ok := mapCommitObject(obj, src, host)
if !ok {
return nil, errors.New("无法解析提交")
}
return &item, nil
}
func copyCaps(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func parseCommitListJSON(body []byte, src *TimelineGitSourceCfg, host string) ([]TimelineGitItem, error) {
var root any
if err := json.Unmarshal(body, &root); err != nil {
return nil, errors.New("响应非 JSON")
}
arr, ok := root.([]any)
if !ok {
if m, isMap := root.(map[string]any); isMap {
if c, ok := m["commits"].([]any); ok {
arr = c
}
}
}
if arr == nil {
return nil, errors.New("响应不是提交列表")
}
var out []TimelineGitItem
for _, el := range arr {
item, ok := mapCommitObject(el, src, host)
if !ok {
continue
}
out = append(out, item)
}
return out, nil
}
func mapCommitObject(el any, src *TimelineGitSourceCfg, host string) (TimelineGitItem, bool) {
sha := jsonPathString(el, src.Item.SHA)
dateRaw := jsonPathString(el, src.Item.Date)
msg := jsonPathString(el, src.Item.Message)
srcURL := jsonPathString(el, src.Item.SourceURL)
if msg == "" && sha == "" {
return TimelineGitItem{}, false
}
sha = sanitizeSHA(sha)
title, body := splitCommitMessage(msg)
title = sanitizeTimelinePlain(title, timelineTitleMax)
body = sanitizeTimelinePlain(body, timelineBodyMax)
title = neutralizeDirectivePlain(title)
body = neutralizeDirectivePlain(body)
date := parseCommitDate(dateRaw)
srcURL = sanitizeSourceURL(srcURL, host)
return TimelineGitItem{
Date: date,
Title: title,
Body: body,
SourceURL: srcURL,
SHA: sha,
}, true
}
func jsonPathString(root any, path string) string {
if path == "" || root == nil {
return ""
}
cur := root
for _, part := range strings.Split(path, ".") {
m, ok := cur.(map[string]any)
if !ok {
return ""
}
cur, ok = m[part]
if !ok {
return ""
}
}
switch v := cur.(type) {
case string:
return v
case float64:
return strconv.FormatInt(int64(v), 10)
case json.Number:
return v.String()
default:
return ""
}
}
func splitCommitMessage(msg string) (title, body string) {
msg = strings.ReplaceAll(msg, "\r\n", "\n")
msg = strings.TrimSpace(msg)
if msg == "" {
return "提交", ""
}
parts := strings.SplitN(msg, "\n", 2)
title = strings.TrimSpace(parts[0])
if title == "" {
title = "提交"
}
if len(parts) > 1 {
body = stripCommitTrailers(strings.TrimSpace(parts[1]))
}
return title, body
}
// stripCommitTrailers 去掉 Co-authored-by 等尾部 trailer 块(及前导空行)。
func stripCommitTrailers(body string) string {
if body == "" {
return ""
}
lines := strings.Split(body, "\n")
// 从末尾向前:连续 trailer / 空行;遇到非 trailer 正文则停
end := len(lines)
for end > 0 {
t := strings.TrimSpace(lines[end-1])
if t == "" || isCommitTrailerLine(t) {
end--
continue
}
break
}
// 若尾部有 trailer,再去掉其前的空行分隔
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
end--
}
return strings.TrimSpace(strings.Join(lines[:end], "\n"))
}
func isCommitTrailerLine(t string) bool {
lower := strings.ToLower(t)
prefixes := []string{
"co-authored-by:",
"signed-off-by:",
"reviewed-by:",
"acked-by:",
"tested-by:",
"reported-by:",
"suggested-by:",
"helped-by:",
}
for _, p := range prefixes {
if strings.HasPrefix(lower, p) {
return true
}
}
return false
}
func sanitizeSHA(s string) string {
s = strings.TrimSpace(s)
if !shaHexRe.MatchString(s) {
return ""
}
if len(s) > 64 {
return s[:64]
}
return s
}
func sanitizeTimelinePlain(s string, max int) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == 0 || (r < 0x20 && r != '\n' && r != '\t') || r == 0x7f {
continue
}
if r >= 0x202A && r <= 0x202E {
continue
}
if r >= 0x2066 && r <= 0x2069 {
continue
}
if unicode.Is(unicode.Cs, r) {
continue
}
b.WriteRune(r)
}
out := b.String()
if utf8.RuneCountInString(out) > max {
runes := []rune(out)
out = string(runes[:max])
}
return out
}
func neutralizeDirectivePlain(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
t := strings.TrimSpace(line)
lower := strings.ToLower(t)
if lower == "[/hide]" || lower == "[/timeline]" || lower == "[timeline]" ||
strings.HasPrefix(lower, "[hide") || strings.HasPrefix(t, "<!-- git:") {
lines[i] = " " + line
}
}
return strings.Join(lines, "\n")
}
func parseCommitDate(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Now().UTC().Format("2006-01-02")
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.UTC().Format("2006-01-02")
}
}
return time.Now().UTC().Format("2006-01-02")
}
func sanitizeSourceURL(raw, allowedHost string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
if !strings.EqualFold(u.Scheme, "https") {
return ""
}
h := strings.ToLower(u.Hostname())
ah := strings.ToLower(allowedHost)
if h == ah || (ah == "github.com" && (h == "github.com" || h == "www.github.com")) {
return u.String()
}
if ah == "api.github.com" && (h == "github.com" || h == "www.github.com") {
return u.String()
}
return ""
}
func assertSafeHTTPSURL(u *url.URL) error {
if u == nil {
return errors.New("URL 无效")
}
if !strings.EqualFold(u.Scheme, "https") {
return errors.New("仅允许 https")
}
host := u.Hostname()
if host == "" {
return errors.New("主机为空")
}
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
return errors.New("禁止访问内网地址")
}
ips, err := net.LookupIP(host)
if err != nil {
return errors.New("无法解析主机")
}
if len(ips) == 0 {
return errors.New("无法解析主机")
}
for _, ip := range ips {
if !isPublicIP(ip) {
return errors.New("禁止访问内网地址")
}
}
return nil
}
func isPublicIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if ip4 := ip.To4(); ip4 != nil {
if ip4[0] == 169 && ip4[1] == 254 {
return false
}
}
return true
}
func httpGetLimited(client *http.Client, rawURL string, headers http.Header) ([]byte, string, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), timelineGitHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", 0, err
}
for k, vs := range headers {
for _, v := range vs {
req.Header.Add(k, v)
}
}
resp, err := client.Do(req)
if err != nil {
return nil, "", 0, errors.New("请求失败")
}
defer resp.Body.Close()
limited := io.LimitReader(resp.Body, timelineGitMaxBody+1)
body, err := io.ReadAll(limited)
if err != nil {
return nil, "", resp.StatusCode, errors.New("读取响应失败")
}
if len(body) > timelineGitMaxBody {
return nil, "", resp.StatusCode, errors.New("响应过大")
}
linkNext := parseLinkNext(resp.Header.Get("Link"))
return body, linkNext, resp.StatusCode, nil
}
func parseLinkNext(link string) string {
parts := strings.Split(link, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if !strings.Contains(p, `rel="next"`) && !strings.Contains(p, `rel=next`) {
continue
}
start := strings.Index(p, "<")
end := strings.Index(p, ">")
if start >= 0 && end > start {
return p[start+1 : end]
}
}
return ""
}
func truncateTimelineStr(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n]) + "…"
}

View File

@@ -0,0 +1,230 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"unicode/utf8"
)
// SettingKeyTimelineGitImport 提交导入适配器 JSON(不进公开 settings)
const SettingKeyTimelineGitImport = "timeline_git_import"
const (
maxAdapterJSONBytes = 64 * 1024
maxAdapterRegexLen = 512
maxAdapterSources = 20
)
// TimelineGitAdapter 声明式提交导入配置
type TimelineGitAdapter struct {
MaxPages int `json:"max_pages"`
MaxCommits int `json:"max_commits"`
Sources []TimelineGitSourceCfg `json:"sources"`
}
// TimelineGitSourceCfg 单个 Git 源
type TimelineGitSourceCfg struct {
ID string `json:"id"`
Host string `json:"host"` // 精确主机或 "*"
ListPath string `json:"list_path"`
CommitPath string `json:"commit_path,omitempty"` // 单条 commit 页
APIURL string `json:"api_url"`
CommitAPI string `json:"commit_api,omitempty"`
Query map[string]string `json:"query"`
Headers map[string]string `json:"headers"`
Pagination string `json:"pagination"` // link_header | query_page
Item TimelineGitItemMap `json:"item"`
}
// TimelineGitItemMap JSON 点路径映射
type TimelineGitItemMap struct {
SHA string `json:"sha"`
Date string `json:"date"`
Message string `json:"message"`
SourceURL string `json:"source_url"`
}
var allowedAdapterHeaders = map[string]bool{
"user-agent": true,
"accept": true,
"accept-language": true,
}
// DefaultTimelineGitAdapterJSON 内置 GitHub + Gitea 模板
func DefaultTimelineGitAdapterJSON() string {
return `{
"max_pages": 10,
"max_commits": 100,
"sources": [
{
"id": "github",
"host": "github.com",
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
"api_url": "https://api.github.com/repos/{owner}/{repo}/commits",
"commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}",
"query": { "sha": "{ref}", "per_page": "100", "page": "{page}" },
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" },
"pagination": "link_header",
"item": {
"sha": "sha",
"date": "commit.committer.date",
"message": "commit.message",
"source_url": "html_url"
}
},
{
"id": "gitea",
"host": "*",
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
"api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits",
"commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
"query": { "sha": "{ref}", "limit": "50", "page": "{page}" },
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" },
"pagination": "query_page",
"item": {
"sha": "sha",
"date": "created",
"message": "commit.message",
"source_url": "html_url"
}
}
]
}`
}
// ValidateTimelineGitAdapterJSON 校验并规范化适配器 JSON
func ValidateTimelineGitAdapterJSON(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("适配器不能为空")
}
if len(raw) > maxAdapterJSONBytes {
return "", errors.New("适配器过大")
}
var cfg TimelineGitAdapter
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return "", fmt.Errorf("JSON 无效: %w", err)
}
if err := validateTimelineGitAdapter(&cfg); err != nil {
return "", err
}
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
return string(out), nil
}
func validateTimelineGitAdapter(cfg *TimelineGitAdapter) error {
if cfg.MaxPages < 1 || cfg.MaxPages > 50 {
return errors.New("max_pages 须在 1–50")
}
if cfg.MaxCommits < 1 || cfg.MaxCommits > 500 {
return errors.New("max_commits 须在 1–500")
}
if len(cfg.Sources) == 0 {
return errors.New("至少配置一个 source")
}
if len(cfg.Sources) > maxAdapterSources {
return errors.New("sources 过多")
}
seen := map[string]bool{}
for i := range cfg.Sources {
src := &cfg.Sources[i]
src.ID = strings.TrimSpace(src.ID)
if src.ID == "" {
return fmt.Errorf("sources[%d].id 不能为空", i)
}
if seen[src.ID] {
return fmt.Errorf("重复的 source id: %s", src.ID)
}
seen[src.ID] = true
src.Host = strings.ToLower(strings.TrimSpace(src.Host))
if src.Host == "" {
return fmt.Errorf("%s.host 不能为空", src.ID)
}
if utf8.RuneCountInString(src.ListPath) > maxAdapterRegexLen {
return fmt.Errorf("%s.list_path 过长", src.ID)
}
if _, err := regexp.Compile(src.ListPath); err != nil {
return fmt.Errorf("%s.list_path 正则无效: %w", src.ID, err)
}
if src.CommitPath != "" {
if _, err := regexp.Compile(src.CommitPath); err != nil {
return fmt.Errorf("%s.commit_path 正则无效: %w", src.ID, err)
}
}
if strings.TrimSpace(src.APIURL) == "" {
return fmt.Errorf("%s.api_url 不能为空", src.ID)
}
if src.Pagination != "link_header" && src.Pagination != "query_page" {
return fmt.Errorf("%s.pagination 须为 link_header 或 query_page", src.ID)
}
for k := range src.Headers {
if !allowedAdapterHeaders[strings.ToLower(k)] {
return fmt.Errorf("%s.headers 不允许字段 %s", src.ID, k)
}
}
if src.Item.SHA == "" || src.Item.Date == "" || src.Item.Message == "" || src.Item.SourceURL == "" {
return fmt.Errorf("%s.item 须包含 sha/date/message/source_url", src.ID)
}
}
return nil
}
// TimelineGitAdapterJSON 读取站点适配器;缺行返回默认
func (s *SettingService) TimelineGitAdapterJSON() (string, error) {
v, found, err := s.getValue(SettingKeyTimelineGitImport)
if err != nil {
return "", err
}
if !found || strings.TrimSpace(v) == "" {
return DefaultTimelineGitAdapterJSON(), nil
}
return v, nil
}
// SetTimelineGitAdapterJSON 保存适配器(已校验)
func (s *SettingService) SetTimelineGitAdapterJSON(raw string) (string, error) {
normalized, err := ValidateTimelineGitAdapterJSON(raw)
if err != nil {
return "", err
}
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
return "", err
}
return normalized, nil
}
// ResetTimelineGitAdapter 恢复默认
func (s *SettingService) ResetTimelineGitAdapter() (string, error) {
def := DefaultTimelineGitAdapterJSON()
normalized, err := ValidateTimelineGitAdapterJSON(def)
if err != nil {
return "", err
}
if err := s.putValue(SettingKeyTimelineGitImport, normalized); err != nil {
return "", err
}
return normalized, nil
}
func (s *SettingService) loadTimelineGitAdapter() (*TimelineGitAdapter, error) {
raw, err := s.TimelineGitAdapterJSON()
if err != nil {
return nil, err
}
var cfg TimelineGitAdapter
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
}
if err := validateTimelineGitAdapter(&cfg); err != nil {
return nil, errors.New("适配器无效,请在站点设置中修复或恢复默认")
}
return &cfg, nil
}