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[^/]+)/(?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" } } ] }` } // 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 }