Files
jiang13-bbs/backend/service/timeline_git_adapter.go
freefire a83276060f feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:06 +08:00

231 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}