feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
37
backend/service/content_hide.go
Normal file
37
backend/service/content_hide.go
Normal 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
|
||||
}
|
||||
@@ -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").
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
231
backend/service/site_page.go
Normal file
231
backend/service/site_page.go
Normal 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
|
||||
}
|
||||
721
backend/service/timeline_git.go
Normal file
721
backend/service/timeline_git.go
Normal 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]) + "…"
|
||||
}
|
||||
230
backend/service/timeline_git_adapter.go
Normal file
230
backend/service/timeline_git_adapter.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user