diff --git a/backend/handler/announcement.go b/backend/handler/announcement.go index 6d17a40..89503c6 100644 --- a/backend/handler/announcement.go +++ b/backend/handler/announcement.go @@ -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) diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 5300723..6d45199 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -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 diff --git a/backend/handler/overview.go b/backend/handler/overview.go index 949f7eb..39b5f8c 100644 --- a/backend/handler/overview.go +++ b/backend/handler/overview.go @@ -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, }) } diff --git a/backend/handler/setting.go b/backend/handler/setting.go index 45adb08..a6243d6 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -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) } diff --git a/backend/handler/site_page.go b/backend/handler/site_page.go new file mode 100644 index 0000000..9bb1aa6 --- /dev/null +++ b/backend/handler/site_page.go @@ -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}) +} diff --git a/backend/handler/timeline.go b/backend/handler/timeline.go new file mode 100644 index 0000000..0dcbe47 --- /dev/null +++ b/backend/handler/timeline.go @@ -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) +} diff --git a/backend/markdown/hide.go b/backend/markdown/hide.go index 047c86c..8d704e6 100644 --- a/backend/markdown/hide.go +++ b/backend/markdown/hide.go @@ -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 …];闭标记:整行 [/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 [points|password] [locked] +// parseOpenMarker 解析 [hide [=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 [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): diff --git a/backend/markdown/hide_test.go b/backend/markdown/hide_test.go index 56cd28a..2d0c4e3 100644 --- a/backend/markdown/hide_test.go +++ b/backend/markdown/hide_test.go @@ -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) + } +} diff --git a/backend/markdown/legacy_rewrite.go b/backend/markdown/legacy_rewrite.go new file mode 100644 index 0000000..95aa077 --- /dev/null +++ b/backend/markdown/legacy_rewrite.go @@ -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 [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 +} diff --git a/backend/model/db.go b/backend/model/db.go index 275ce84..800b13f 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -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 +} diff --git a/backend/model/models.go b/backend/model/models.go index cd77e29..e23f72d 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -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"` diff --git a/backend/router/router.go b/backend/router/router.go index eea5757..b0a49d5 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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) // 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史 diff --git a/backend/service/announcement.go b/backend/service/announcement.go index 771221b..b9987e6 100644 --- a/backend/service/announcement.go +++ b/backend/service/announcement.go @@ -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 diff --git a/backend/service/content_hide.go b/backend/service/content_hide.go new file mode 100644 index 0000000..e79e0c5 --- /dev/null +++ b/backend/service/content_hide.go @@ -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 +} diff --git a/backend/service/overview.go b/backend/service/overview.go index 68cec43..8efe559 100644 --- a/backend/service/overview.go +++ b/backend/service/overview.go @@ -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"). diff --git a/backend/service/post.go b/backend/service/post.go index 1452ae8..89d0240 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -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) diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go index a60c466..2d7f204 100644 --- a/backend/service/ratelimit.go +++ b/backend/service/ratelimit.go @@ -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 } diff --git a/backend/service/site_page.go b/backend/service/site_page.go new file mode 100644 index 0000000..26a83ba --- /dev/null +++ b/backend/service/site_page.go @@ -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 +} diff --git a/backend/service/timeline_git.go b/backend/service/timeline_git.go new file mode 100644 index 0000000..854fd25 --- /dev/null +++ b/backend/service/timeline_git.go @@ -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, "\s*$/i; +const HEADING_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/; + +/** 去掉控制字符与 RTL 覆盖,截断长度 */ +export function sanitizeTimelineText(raw: string, max: number): string { + let s = raw + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") + .replace(/[\u202A-\u202E\u2066-\u2069]/g, ""); + if ([...s].length > max) { + s = [...s].slice(0, max).join(""); + } + return s; +} + +function isDirectiveLine(t: string): boolean { + const lower = t.toLowerCase(); + return ( + lower === "[/hide]" || + lower === "[/timeline]" || + lower === "[timeline]" || + lower.startsWith("[hide") || + t.startsWith("`); + } + return lines.join("\n"); + }); + return `[timeline]\n${parts.join("\n\n")}\n[/timeline]`; +} + +export function groupTimelineByDate(items: TimelineItem[]): TimelineDayGroup[] { + const map = new Map(); + const order: string[] = []; + for (const it of items) { + const d = it.date || "未知日期"; + if (!map.has(d)) { + map.set(d, []); + order.push(d); + } + map.get(d)!.push(it); + } + order.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); + return order.map((date) => ({ date, items: map.get(date)! })); +} + +export function isLikelyMergeCommit(title: string): boolean { + return /^merge\b/i.test(title.trim()) || /^合并\b/.test(title.trim()); +}