Files
jiang13-bbs/backend/service/setting.go
freefire 5f7193042f feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 04:16:32 +08:00

853 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 站点设置键
const (
// SettingKeyAccent 站点主题色(浅色基准,#rrggbb);为空表示使用内置默认色
SettingKeyAccent = "theme_accent"
// SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
SettingKeyTrustReviewedPublish = "trust_reviewed_publish"
SettingKeySiteName = "site_name"
SettingKeySiteDescription = "site_description"
SettingKeyAllowRegister = "allow_register"
// SettingKeyAllowComments 全站开放评论;缺行视为开启(默认开)
SettingKeyAllowComments = "allow_comments"
// SettingKeyAllowMessages 全站开放私聊/群聊;缺行视为开启(默认开)
SettingKeyAllowMessages = "allow_messages"
SettingKeyPostCooldownHours = "post_cooldown_hours"
// SettingKeyCodeBlockAutoFold 代码块自动折叠;缺行视为开启
SettingKeyCodeBlockAutoFold = "code_block_auto_fold"
// SettingKeyCodeBlockFoldLines 超过该行数则折叠;0=无预览;缺行=5
SettingKeyCodeBlockFoldLines = "code_block_fold_lines"
// SettingKeyUIAnimations 界面动画总开关;缺行视为开启
SettingKeyUIAnimations = "ui_animations"
// SettingKeyAnimCodeFold 代码折叠高度/跟滚动画;缺行视为开启
SettingKeyAnimCodeFold = "anim_code_fold"
// SettingKeyAnimSmoothScroll 平滑滚动;缺行视为开启
SettingKeyAnimSmoothScroll = "anim_smooth_scroll"
// SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启
SettingKeyAnimChrome = "anim_chrome"
// SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制)
SettingKeyPostLinkNewTab = "post_link_new_tab"
// SettingKeyAttachmentExtLimit 是否限制附件扩展名;缺行视为开启
SettingKeyAttachmentExtLimit = "attachment_ext_limit"
// SettingKeyAttachmentExts 允许的扩展名(逗号分隔,无点);缺行=默认列表
SettingKeyAttachmentExts = "attachment_exts"
// SettingKeyAttachmentMaxMB 附件单文件上限(MB);缺行=20
SettingKeyAttachmentMaxMB = "attachment_max_mb"
// SettingKeyAttachmentMaxCount 每帖附件个数上限;缺行=10
SettingKeyAttachmentMaxCount = "attachment_max_count"
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
SettingKeyImageMaxMB = "image_max_mb"
// SettingKeyCustomCSS 全站自定义 CSS(外观页注入,空=无)
SettingKeyCustomCSS = "custom_css"
// SettingKeyCustomJS 全站自定义 JS(外观页注入,空=无)
SettingKeyCustomJS = "custom_js"
)
const (
DefaultSiteName = "姜十三论坛"
DefaultSiteDescription = "姜十三论坛 - 技术分享与讨论社区"
DefaultCooldownHours = 24
DefaultCodeFoldLines = 5
MaxSiteNameRunes = 32
MaxSiteDescRunes = 160
MaxCooldownHours = 168
MinCodeFoldLines = 0
MaxCodeFoldLines = 100
DefaultAttachmentMaxMB = 20
DefaultAttachmentMaxCount = 10
DefaultImageMaxMB = 5
MinAttachmentMaxMB = 1
MinImageMaxMB = 1
MinAttachmentMaxCount = 1
MaxAttachmentMaxCount = 20
MaxAttachmentExtCount = 80
MaxCustomCSSRunes = 48000
MaxCustomJSRunes = 48000
)
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
var DefaultAttachmentExts = []string{
"pdf", "txt", "md", "csv", "json",
"doc", "docx", "xls", "xlsx", "ppt", "pptx",
"zip", "7z", "rar", "tar", "gz", "tgz",
"jpg", "jpeg", "png", "webp", "gif",
"mp3", "wav", "ogg", "mp4", "webm",
"exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll",
"html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh",
}
var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
// ErrInvalidAccent 主题色格式非法
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
// ErrInvalidSiteSetting 站点设置字段校验失败
var ErrInvalidSiteSetting = errors.New("站点设置参数无效")
var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
type PublicSiteSettings struct {
Accent string `json:"accent"`
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
AllowRegister bool `json:"allow_register"`
AllowComments bool `json:"allow_comments"`
AllowMessages bool `json:"allow_messages"`
PostCooldownHours int `json:"post_cooldown_hours"`
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
CodeBlockFoldLines int `json:"code_block_fold_lines"`
UIAnimations bool `json:"ui_animations"`
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
AttachmentExtLimit bool `json:"attachment_ext_limit"`
AttachmentExts []string `json:"attachment_exts"`
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 站点级键值设置
type SettingService struct {
db *gorm.DB
}
func NewSettingService(db *gorm.DB) *SettingService {
return &SettingService{db: db}
}
func (s *SettingService) getValue(key string) (string, bool, error) {
var st model.SiteSetting
err := s.db.Where("key = ?", key).First(&st).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return st.Value, true, nil
}
func (s *SettingService) putValue(key, value string) error {
st := model.SiteSetting{Key: key, Value: value}
return s.db.Save(&st).Error
}
func (s *SettingService) deleteKey(key string) error {
return s.db.Where("key = ?", key).Delete(&model.SiteSetting{}).Error
}
// 缺行或空值视为 on;仅显式 false/0/off/no 为关。不用 GORM bool default:true,避免零值省略。
func parseBoolDefaultTrue(v string, found bool) bool {
if !found {
return true
}
switch strings.TrimSpace(strings.ToLower(v)) {
case "false", "0", "off", "no":
return false
default:
return true
}
}
func (s *SettingService) setBoolDefaultTrue(key string, on bool) error {
if on {
return s.deleteKey(key)
}
return s.putValue(key, "false")
}
// Public 返回解析后的公开站点设置(缺省已填默认值)
func (s *SettingService) Public() (PublicSiteSettings, error) {
out := PublicSiteSettings{
SiteName: DefaultSiteName,
SiteDescription: DefaultSiteDescription,
TrustReviewedPublish: true,
AllowRegister: true,
AllowComments: true,
AllowMessages: true,
PostCooldownHours: DefaultCooldownHours,
CodeBlockAutoFold: true,
CodeBlockFoldLines: DefaultCodeFoldLines,
UIAnimations: true,
AnimCodeFold: true,
AnimSmoothScroll: true,
AnimChrome: true,
PostLinkNewTab: true,
AttachmentExtLimit: true,
AttachmentExts: append([]string(nil), DefaultAttachmentExts...),
AttachmentMaxMB: DefaultAttachmentMaxMB,
AttachmentMaxCount: DefaultAttachmentMaxCount,
ImageMaxMB: DefaultImageMaxMB,
}
accent, err := s.AccentColor()
if err != nil {
return out, err
}
out.Accent = accent
trust, err := s.TrustReviewedPublish()
if err != nil {
return out, err
}
out.TrustReviewedPublish = trust
name, found, err := s.getValue(SettingKeySiteName)
if err != nil {
return out, err
}
if found {
name = strings.TrimSpace(name)
if name != "" {
out.SiteName = name
}
}
desc, found, err := s.getValue(SettingKeySiteDescription)
if err != nil {
return out, err
}
if found {
out.SiteDescription = strings.TrimSpace(desc)
}
reg, found, err := s.getValue(SettingKeyAllowRegister)
if err != nil {
return out, err
}
out.AllowRegister = parseBoolDefaultTrue(reg, found)
comments, err := s.AllowComments()
if err != nil {
return out, err
}
out.AllowComments = comments
messages, err := s.AllowMessages()
if err != nil {
return out, err
}
out.AllowMessages = messages
hours, err := s.PostCooldownHours()
if err != nil {
return out, err
}
out.PostCooldownHours = hours
fold, err := s.CodeBlockAutoFold()
if err != nil {
return out, err
}
out.CodeBlockAutoFold = fold
foldLines, err := s.CodeBlockFoldLines()
if err != nil {
return out, err
}
out.CodeBlockFoldLines = foldLines
uiAnim, err := s.UIAnimations()
if err != nil {
return out, err
}
out.UIAnimations = uiAnim
animFold, err := s.AnimCodeFold()
if err != nil {
return out, err
}
out.AnimCodeFold = animFold
animScroll, err := s.AnimSmoothScroll()
if err != nil {
return out, err
}
out.AnimSmoothScroll = animScroll
animChrome, err := s.AnimChrome()
if err != nil {
return out, err
}
out.AnimChrome = animChrome
linkNewTab, err := s.PostLinkNewTab()
if err != nil {
return out, err
}
out.PostLinkNewTab = linkNewTab
extLimit, err := s.AttachmentExtLimit()
if err != nil {
return out, err
}
out.AttachmentExtLimit = extLimit
exts, err := s.AttachmentExts()
if err != nil {
return out, err
}
out.AttachmentExts = exts
attMB, err := s.AttachmentMaxMB()
if err != nil {
return out, err
}
out.AttachmentMaxMB = attMB
attCount, err := s.AttachmentMaxCount()
if err != nil {
return out, err
}
out.AttachmentMaxCount = attCount
imgMB, err := s.ImageMaxMB()
if err != nil {
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
}
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
func (s *SettingService) AccentColor() (string, error) {
v, found, err := s.getValue(SettingKeyAccent)
if err != nil || !found {
return "", err
}
return strings.ToLower(v), nil
}
// SetAccent 校验并保存主题色;hex 传空串表示恢复默认(删除该键)
func (s *SettingService) SetAccent(hex string) error {
hex = strings.TrimSpace(strings.ToLower(hex))
if hex == "" {
return s.deleteKey(SettingKeyAccent)
}
if !accentHexRe.MatchString(hex) {
return ErrInvalidAccent
}
return s.putValue(SettingKeyAccent, hex)
}
// TrustReviewedPublish 是否对「已有过审内容」的用户免审。
func (s *SettingService) TrustReviewedPublish() (bool, error) {
v, found, err := s.getValue(SettingKeyTrustReviewedPublish)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
// SetTrustReviewedPublish 持久化免审开关;开启时删除键以保持「缺行=默认开」语义
func (s *SettingService) SetTrustReviewedPublish(on bool) error {
return s.setBoolDefaultTrue(SettingKeyTrustReviewedPublish, on)
}
func (s *SettingService) SetSiteName(name string) error {
name = strings.TrimSpace(name)
if name == "" || name == DefaultSiteName {
return s.deleteKey(SettingKeySiteName)
}
if utf8.RuneCountInString(name) > MaxSiteNameRunes {
return ErrInvalidSiteSetting
}
return s.putValue(SettingKeySiteName, name)
}
func (s *SettingService) SetSiteDescription(desc string) error {
desc = strings.TrimSpace(desc)
if utf8.RuneCountInString(desc) > MaxSiteDescRunes {
return ErrInvalidSiteSetting
}
if desc == "" || desc == DefaultSiteDescription {
return s.deleteKey(SettingKeySiteDescription)
}
return s.putValue(SettingKeySiteDescription, desc)
}
func (s *SettingService) AllowRegister() (bool, error) {
v, found, err := s.getValue(SettingKeyAllowRegister)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetAllowRegister(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAllowRegister, on)
}
// AllowComments 是否允许全站发表评论/回复。缺行视为开启。
func (s *SettingService) AllowComments() (bool, error) {
v, found, err := s.getValue(SettingKeyAllowComments)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
// SetAllowComments 持久化评论开关;开启时删键保持「缺行=默认开」。
func (s *SettingService) SetAllowComments(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAllowComments, on)
}
// AllowMessages 是否允许全站私聊/群聊。缺行视为开启。
func (s *SettingService) AllowMessages() (bool, error) {
v, found, err := s.getValue(SettingKeyAllowMessages)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
// SetAllowMessages 持久化消息开关;开启时删键保持「缺行=默认开」。
func (s *SettingService) SetAllowMessages(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAllowMessages, on)
}
// PostCooldownHours 新用户发帖冷静期(小时)。缺行=24;0 表示关闭。
func (s *SettingService) PostCooldownHours() (int, error) {
v, found, err := s.getValue(SettingKeyPostCooldownHours)
if err != nil {
return DefaultCooldownHours, err
}
if !found {
return DefaultCooldownHours, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return DefaultCooldownHours, nil
}
if n > MaxCooldownHours {
return MaxCooldownHours, nil
}
return n, nil
}
func (s *SettingService) SetPostCooldownHours(hours int) error {
if hours < 0 || hours > MaxCooldownHours {
return ErrInvalidSiteSetting
}
if hours == DefaultCooldownHours {
return s.deleteKey(SettingKeyPostCooldownHours)
}
return s.putValue(SettingKeyPostCooldownHours, strconv.Itoa(hours))
}
// CodeBlockAutoFold 是否对超过阈值行数的 Markdown 代码块默认折叠。缺行视为开启。
func (s *SettingService) CodeBlockAutoFold() (bool, error) {
v, found, err := s.getValue(SettingKeyCodeBlockAutoFold)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
// SetCodeBlockAutoFold 持久化代码折叠开关;开启时删键保持「缺行=默认开」。
func (s *SettingService) SetCodeBlockAutoFold(on bool) error {
return s.setBoolDefaultTrue(SettingKeyCodeBlockAutoFold, on)
}
// CodeBlockFoldLines 超过该行数则折叠(折叠预览约同高;0=无预览)。缺行=5。
func (s *SettingService) CodeBlockFoldLines() (int, error) {
v, found, err := s.getValue(SettingKeyCodeBlockFoldLines)
if err != nil {
return DefaultCodeFoldLines, err
}
if !found {
return DefaultCodeFoldLines, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinCodeFoldLines {
return DefaultCodeFoldLines, nil
}
if n > MaxCodeFoldLines {
return MaxCodeFoldLines, nil
}
return n, nil
}
func (s *SettingService) SetCodeBlockFoldLines(lines int) error {
if lines < MinCodeFoldLines || lines > MaxCodeFoldLines {
return ErrInvalidSiteSetting
}
if lines == DefaultCodeFoldLines {
return s.deleteKey(SettingKeyCodeBlockFoldLines)
}
return s.putValue(SettingKeyCodeBlockFoldLines, strconv.Itoa(lines))
}
// UIAnimations 界面动画总开关。缺行视为开启;关闭后全站减少动态效果。
func (s *SettingService) UIAnimations() (bool, error) {
v, found, err := s.getValue(SettingKeyUIAnimations)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetUIAnimations(on bool) error {
return s.setBoolDefaultTrue(SettingKeyUIAnimations, on)
}
// AnimCodeFold 代码折叠动画子开关(总开关开启时生效)。
func (s *SettingService) AnimCodeFold() (bool, error) {
v, found, err := s.getValue(SettingKeyAnimCodeFold)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetAnimCodeFold(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAnimCodeFold, on)
}
// AnimSmoothScroll 平滑滚动子开关(总开关开启时生效)。
func (s *SettingService) AnimSmoothScroll() (bool, error) {
v, found, err := s.getValue(SettingKeyAnimSmoothScroll)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetAnimSmoothScroll(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAnimSmoothScroll, on)
}
// AnimChrome 装饰入场子开关(列表/Toast/点赞等;总开关开启时生效)。
func (s *SettingService) AnimChrome() (bool, error) {
v, found, err := s.getValue(SettingKeyAnimChrome)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetAnimChrome(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAnimChrome, on)
}
// PostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开。缺行视为开启。
func (s *SettingService) PostLinkNewTab() (bool, error) {
v, found, err := s.getValue(SettingKeyPostLinkNewTab)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetPostLinkNewTab(on bool) error {
return s.setBoolDefaultTrue(SettingKeyPostLinkNewTab, on)
}
// AttachmentExtLimit 是否限制附件扩展名。缺行视为开启。
func (s *SettingService) AttachmentExtLimit() (bool, error) {
v, found, err := s.getValue(SettingKeyAttachmentExtLimit)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetAttachmentExtLimit(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAttachmentExtLimit, on)
}
// NormalizeAttachmentExts 规范化扩展名列表:去点、小写、去重、校验格式。
func NormalizeAttachmentExts(raw []string) ([]string, error) {
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
e := strings.TrimSpace(strings.ToLower(item))
e = strings.TrimPrefix(e, ".")
if e == "" {
continue
}
if !attachmentExtRe.MatchString(e) {
return nil, ErrInvalidSiteSetting
}
if _, ok := seen[e]; ok {
continue
}
seen[e] = struct{}{}
out = append(out, e)
if len(out) > MaxAttachmentExtCount {
return nil, ErrInvalidSiteSetting
}
}
return out, nil
}
func parseExtList(v string) []string {
parts := strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == ' ' || r == ';' || r == '\n' || r == '\t'
})
out, err := NormalizeAttachmentExts(parts)
if err != nil || len(out) == 0 {
return append([]string(nil), DefaultAttachmentExts...)
}
return out
}
func extsEqualDefault(exts []string) bool {
if len(exts) != len(DefaultAttachmentExts) {
return false
}
for i := range exts {
if exts[i] != DefaultAttachmentExts[i] {
return false
}
}
return true
}
// AttachmentExts 允许的扩展名(无点)。缺行=默认列表;显式空串=空列表。
func (s *SettingService) AttachmentExts() ([]string, error) {
v, found, err := s.getValue(SettingKeyAttachmentExts)
if err != nil {
return append([]string(nil), DefaultAttachmentExts...), err
}
if !found {
return append([]string(nil), DefaultAttachmentExts...), nil
}
if strings.TrimSpace(v) == "" {
return []string{}, nil
}
return parseExtList(v), nil
}
// SetAttachmentExts 保存扩展名列表;与默认完全一致时删键。空列表写入空串(限制开启时表示禁止上传)。
func (s *SettingService) SetAttachmentExts(exts []string) error {
norm, err := NormalizeAttachmentExts(exts)
if err != nil {
return err
}
if len(norm) == 0 {
return s.putValue(SettingKeyAttachmentExts, "")
}
if extsEqualDefault(norm) {
return s.deleteKey(SettingKeyAttachmentExts)
}
return s.putValue(SettingKeyAttachmentExts, strings.Join(norm, ","))
}
// AttachmentExtAllowed 当前设置下扩展名(含点或不含点)是否允许上传。
func (s *SettingService) AttachmentExtAllowed(ext string) (bool, error) {
limit, err := s.AttachmentExtLimit()
if err != nil {
return false, err
}
if !limit {
return true, nil
}
e := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
if e == "" {
return false, nil
}
list, err := s.AttachmentExts()
if err != nil {
return false, err
}
for _, x := range list {
if x == e {
return true, nil
}
}
return false, nil
}
// AttachmentMaxMB 附件单文件上限(MB)。缺行=20;最小 1,无硬顶。
func (s *SettingService) AttachmentMaxMB() (int, error) {
v, found, err := s.getValue(SettingKeyAttachmentMaxMB)
if err != nil {
return DefaultAttachmentMaxMB, err
}
if !found {
return DefaultAttachmentMaxMB, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinAttachmentMaxMB {
return DefaultAttachmentMaxMB, nil
}
return n, nil
}
func (s *SettingService) SetAttachmentMaxMB(mb int) error {
if mb < MinAttachmentMaxMB {
return ErrInvalidSiteSetting
}
if mb == DefaultAttachmentMaxMB {
return s.deleteKey(SettingKeyAttachmentMaxMB)
}
return s.putValue(SettingKeyAttachmentMaxMB, strconv.Itoa(mb))
}
// AttachmentMaxBytes 附件字节上限(按当前 MB 设置)。
func (s *SettingService) AttachmentMaxBytes() (int64, error) {
mb, err := s.AttachmentMaxMB()
if err != nil {
return int64(DefaultAttachmentMaxMB) << 20, err
}
return int64(mb) << 20, nil
}
// AttachmentMaxCount 每帖/草稿附件个数上限。缺行=10;范围 1–20。
func (s *SettingService) AttachmentMaxCount() (int, error) {
v, found, err := s.getValue(SettingKeyAttachmentMaxCount)
if err != nil {
return DefaultAttachmentMaxCount, err
}
if !found {
return DefaultAttachmentMaxCount, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinAttachmentMaxCount {
return DefaultAttachmentMaxCount, nil
}
if n > MaxAttachmentMaxCount {
return MaxAttachmentMaxCount, nil
}
return n, nil
}
func (s *SettingService) SetAttachmentMaxCount(n int) error {
if n < MinAttachmentMaxCount || n > MaxAttachmentMaxCount {
return ErrInvalidSiteSetting
}
if n == DefaultAttachmentMaxCount {
return s.deleteKey(SettingKeyAttachmentMaxCount)
}
return s.putValue(SettingKeyAttachmentMaxCount, strconv.Itoa(n))
}
// ImageMaxMB 正文插图上限(MB)。缺行=5;最小 1,无硬顶。
func (s *SettingService) ImageMaxMB() (int, error) {
v, found, err := s.getValue(SettingKeyImageMaxMB)
if err != nil {
return DefaultImageMaxMB, err
}
if !found {
return DefaultImageMaxMB, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < MinImageMaxMB {
return DefaultImageMaxMB, nil
}
return n, nil
}
func (s *SettingService) SetImageMaxMB(mb int) error {
if mb < MinImageMaxMB {
return ErrInvalidSiteSetting
}
if mb == DefaultImageMaxMB {
return s.deleteKey(SettingKeyImageMaxMB)
}
return s.putValue(SettingKeyImageMaxMB, strconv.Itoa(mb))
}
// ImageMaxBytes 插图像素字节上限。
func (s *SettingService) ImageMaxBytes() (int64, error) {
mb, err := s.ImageMaxMB()
if err != nil {
return int64(DefaultImageMaxMB) << 20, err
}
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)
}