package service import ( "encoding/json" "errors" "fmt" "regexp" "strings" "unicode/utf8" ) const ( 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[^/]+)/(?P[^/]+)/commits/(?P[^/]+)/?$", "commit_path": "^/(?P[^/]+)/(?P[^/]+)/commit/(?P[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[^/]+)/(?P[^/]+)/commits/branch/(?P.+)/?$", "commit_path": "^/(?P[^/]+)/(?P[^/]+)/commit/(?P[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" } } ] }` } 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 } func (s *SettingService) loadTimelineGitAdapter() (*TimelineGitAdapter, error) { raw := DefaultTimelineGitAdapterJSON() 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 }