feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板

后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 04:16:32 +08:00
parent fa713bfc3f
commit 5f7193042f
41 changed files with 2297 additions and 873 deletions

View File

@@ -41,6 +41,8 @@ type updateSettingsRequest struct {
AttachmentMaxMB *int `json:"attachment_max_mb"`
AttachmentMaxCount *int `json:"attachment_max_count"`
ImageMaxMB *int `json:"image_max_mb"`
CustomCSS *string `json:"custom_css"`
CustomJS *string `json:"custom_js"`
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
}
@@ -66,6 +68,8 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"attachment_max_mb": saved.AttachmentMaxMB,
"attachment_max_count": saved.AttachmentMaxCount,
"image_max_mb": saved.ImageMaxMB,
"custom_css": saved.CustomCSS,
"custom_js": saved.CustomJS,
}
}
@@ -76,7 +80,8 @@ func (req *updateSettingsRequest) hasAny() bool {
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.TimelineGitImport != nil
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.CustomCSS != nil ||
req.CustomJS != nil || req.TimelineGitImport != nil
}
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
@@ -264,6 +269,26 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.CustomCSS != nil {
if err := h.Setting.SetCustomCSS(*req.CustomCSS); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 CSS 过长或含非法闭合标签"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.CustomJS != nil {
if err := h.Setting.SetCustomJS(*req.CustomJS); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 JS 过长或含非法闭合标签"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
var savedAdapter string
if req.TimelineGitImport != nil {

View File

@@ -0,0 +1,19 @@
package service
import "testing"
func TestSanitizeCustomSnippet(t *testing.T) {
got, err := sanitizeCustomSnippet(" body{color:red} ", "</style", MaxCustomCSSRunes)
if err != nil || got != "body{color:red}" {
t.Fatalf("trim got %q err=%v", got, err)
}
if _, err = sanitizeCustomSnippet("a</style>b", "</style", MaxCustomCSSRunes); err == nil {
t.Fatal("css closer should fail")
}
if _, err = sanitizeCustomSnippet("a</SCRIPT>b", "</script", MaxCustomJSRunes); err == nil {
t.Fatal("js closer should fail")
}
if _, err = sanitizeCustomSnippet("ok", "</script", MaxCustomJSRunes); err != nil {
t.Fatalf("valid js: %v", err)
}
}

View File

@@ -49,6 +49,10 @@ const (
SettingKeyAttachmentMaxCount = "attachment_max_count"
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
SettingKeyImageMaxMB = "image_max_mb"
// SettingKeyCustomCSS 全站自定义 CSS(外观页注入,空=无)
SettingKeyCustomCSS = "custom_css"
// SettingKeyCustomJS 全站自定义 JS(外观页注入,空=无)
SettingKeyCustomJS = "custom_js"
)
const (
@@ -70,6 +74,9 @@ const (
MinAttachmentMaxCount = 1
MaxAttachmentMaxCount = 20
MaxAttachmentExtCount = 80
MaxCustomCSSRunes = 48000
MaxCustomJSRunes = 48000
)
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -115,6 +122,8 @@ type PublicSiteSettings struct {
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
CustomCSS string `json:"custom_css"`
CustomJS string `json:"custom_js"`
}
// SettingService 站点级键值设置
@@ -316,6 +325,18 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
return out, err
}
out.ImageMaxMB = imgMB
css, err := s.CustomCSS()
if err != nil {
return out, err
}
out.CustomCSS = css
js, err := s.CustomJS()
if err != nil {
return out, err
}
out.CustomJS = js
return out, nil
}
@@ -767,3 +788,65 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
}
return int64(mb) << 20, nil
}
func sanitizeCustomSnippet(s, closer string, maxRunes int) (string, error) {
s = strings.TrimSpace(s)
if strings.ContainsRune(s, 0) {
return "", ErrInvalidSiteSetting
}
if utf8.RuneCountInString(s) > maxRunes {
return "", ErrInvalidSiteSetting
}
if closer != "" && strings.Contains(strings.ToLower(s), closer) {
return "", ErrInvalidSiteSetting
}
return s, nil
}
// CustomCSS 全站自定义 CSS。缺行/空=无。
func (s *SettingService) CustomCSS() (string, error) {
v, found, err := s.getValue(SettingKeyCustomCSS)
if err != nil || !found {
return "", err
}
out, nerr := sanitizeCustomSnippet(v, "</style", MaxCustomCSSRunes)
if nerr != nil {
return "", nil
}
return out, nil
}
func (s *SettingService) SetCustomCSS(css string) error {
normalized, err := sanitizeCustomSnippet(css, "</style", MaxCustomCSSRunes)
if err != nil {
return err
}
if normalized == "" {
return s.deleteKey(SettingKeyCustomCSS)
}
return s.putValue(SettingKeyCustomCSS, normalized)
}
// CustomJS 全站自定义 JS。缺行/空=无。
func (s *SettingService) CustomJS() (string, error) {
v, found, err := s.getValue(SettingKeyCustomJS)
if err != nil || !found {
return "", err
}
out, nerr := sanitizeCustomSnippet(v, "</script", MaxCustomJSRunes)
if nerr != nil {
return "", nil
}
return out, nil
}
func (s *SettingService) SetCustomJS(js string) error {
normalized, err := sanitizeCustomSnippet(js, "</script", MaxCustomJSRunes)
if err != nil {
return err
}
if normalized == "" {
return s.deleteKey(SettingKeyCustomJS)
}
return s.putValue(SettingKeyCustomJS, normalized)
}