feat: 站点单页、时间线导入与行级 BBCode 短代码

首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 19:52:06 +08:00
parent 2aacf99a49
commit a83276060f
52 changed files with 5073 additions and 648 deletions

View 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
}