首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
231 lines
7.2 KiB
Go
231 lines
7.2 KiB
Go
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
|
||
}
|