feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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