Files
jiang13-forum/services/settings.go
freefire fde5f628ec feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings;
落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 05:44:16 +08:00

1526 lines
47 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 services
import (
"encoding/json"
"errors"
"net/url"
"strconv"
"strings"
"sync"
"git.iioio.com/freefire/jiang13-forum/models"
)
// ForumSettingsService 读写 forum_settings全部为运行时热更新改后无需重启进程
// 需重启的项仅在 CLI/Env端口、DATA、DB_*)与 data 下密钥文件,见 docs/rebuild-spec/07-config-ops.md。
// 论坛设置键名
const (
SettingPostEditWindowHours = "post_edit_window_hours"
SettingCommentEditWindowMinutes = "comment_edit_window_minutes"
SettingRateLimitPost = "rate_limit_post"
SettingRateLimitComment = "rate_limit_comment"
SettingRateLimitRegister = "rate_limit_register"
SettingRateLimitLogin = "rate_limit_login"
SettingRateLimitWindow = "rate_limit_window_sec"
SettingPostTitleMax = "post_title_max"
SettingPostTagsMax = "post_tags_max"
SettingPostContentMax = "post_content_max"
SettingCommentMax = "comment_max"
SettingSearchKeywordMin = "search_keyword_min"
SettingSearchKeywordMax = "search_keyword_max"
SettingPageSizeDefault = "page_size_default"
SettingPasswordMinLen = "password_min_len"
SettingAvatarMaxMB = "avatar_max_mb"
SettingSignatureMax = "signature_max"
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
SettingAsideShowTagCloud = "aside_show_tag_cloud"
SettingAsideShowRecentComments = "aside_show_recent_comments"
SettingAsideShowFriendLinks = "aside_show_friend_links"
SettingAsideWidgets = "aside_widgets"
SettingNavShowFriendLinks = "nav_show_friend_links"
SettingFooterShowFriendLinks = "footer_show_friend_links"
SettingFeedListStyle = "feed_list_style"
// 伪静态键名见 permalink.goSettingPermalinkEnabled / SettingPermalinkExt
SettingSMTPEnabled = "smtp_enabled"
SettingSMTPHost = "smtp_host"
SettingSMTPPort = "smtp_port"
SettingSMTPUsername = "smtp_username"
SettingSMTPPassword = "smtp_password"
SettingSMTPFrom = "smtp_from"
SettingSMTPFromName = "smtp_from_name"
SettingSMTPEncryption = "smtp_encryption"
SettingOIDCEnabled = "oidc_enabled"
SettingOIDCRootURL = "oidc_root_url"
SettingOIDCGroupClaim = "oidc_group_claim"
SettingOIDCAdminGroup = "oidc_admin_group"
SettingOIDCUserGroup = "oidc_user_group"
// Gitea 同步键保留兼容,产品能力已后置(见 02-features.md §K
SettingGiteaSyncEnabled = "gitea_sync_enabled"
SettingGiteaBaseURL = "gitea_base_url"
SettingGiteaToken = "gitea_token"
SettingGiteaSyncIntervalMin = "gitea_sync_interval_min"
SettingStorageType = "storage_type"
SettingStorageEndpoint = "storage_endpoint"
SettingStorageRegion = "storage_region"
SettingStorageBucket = "storage_bucket"
SettingStorageAccessKey = "storage_access_key"
SettingStorageSecretKey = "storage_secret_key"
SettingStoragePublicBaseURL = "storage_public_base_url"
SettingStoragePrefix = "storage_prefix"
SettingStorageForcePathStyle = "storage_force_path_style"
SettingStorageImageDelivery = "storage_image_delivery"
SettingSiteName = "site_name"
SettingSiteSlogan = "site_slogan"
SettingSiteDescription = "site_description"
SettingSiteKeywords = "site_keywords"
SettingSiteLogoMark = "site_logo_mark"
SettingSiteLogo = "site_logo"
SettingSiteFavicon = "site_favicon"
SettingSiteOGImage = "site_og_image"
SettingSiteICPBeian = "site_icp_beian"
SettingSiteICPBeianURL = "site_icp_beian_url"
SettingSiteFriendLinks = "site_friend_links"
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
SettingFilterWords = "filter_words"
SettingOIDCRSAPrivatePEM = "oidc_rsa_private_pem"
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size非后台可配项
pageSizeAPIMax = 100
)
// ForumLimits 论坛可配置限制API 传输结构)
type ForumLimits struct {
PostEditWindowHours int `json:"post_edit_window_hours"`
CommentEditWindowMinutes int `json:"comment_edit_window_minutes"`
RateLimitPost int `json:"rate_limit_post"`
RateLimitComment int `json:"rate_limit_comment"`
RateLimitRegister int `json:"rate_limit_register"`
RateLimitLogin int `json:"rate_limit_login"`
RateLimitWindowSec int `json:"rate_limit_window_sec"`
PostTitleMax int `json:"post_title_max"`
PostTagsMax int `json:"post_tags_max"`
PostContentMax int `json:"post_content_max"`
CommentMax int `json:"comment_max"`
SearchKeywordMin int `json:"search_keyword_min"`
SearchKeywordMax int `json:"search_keyword_max"`
PageSizeDefault int `json:"page_size_default"`
PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"`
SignatureMax int `json:"signature_max"`
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
// AsideWidget 右侧栏可选组件
type AsideWidget struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
const (
AsideWidgetTagCloud = "tag_cloud"
AsideWidgetRecentComments = "recent_comments"
AsideWidgetRecentUsers = "recent_users"
AsideWidgetFriendLinks = "friend_links"
)
var asideWidgetDefaultOrder = []string{
AsideWidgetTagCloud,
AsideWidgetRecentComments,
AsideWidgetRecentUsers,
AsideWidgetFriendLinks,
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
PostTagsMax int `json:"post_tags_max"`
PostContentMax int `json:"post_content_max"`
CommentMax int `json:"comment_max"`
SearchKeywordMin int `json:"search_keyword_min"`
SearchKeywordMax int `json:"search_keyword_max"`
PageSizeDefault int `json:"page_size_default"`
PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"`
SignatureMax int `json:"signature_max"`
CommentEditWindowMinutes int `json:"comment_edit_window_minutes"`
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
type settingDef struct {
key string
defaultVal string
min int
max int // 0 表示不限制上限
}
var forumSettingDefs = []settingDef{
{SettingPostEditWindowHours, "24", 0, 0},
{SettingCommentEditWindowMinutes, "3", 0, 0},
{SettingRateLimitPost, "10", 1, 1000},
{SettingRateLimitComment, "10", 1, 1000},
{SettingRateLimitRegister, "10", 1, 1000},
{SettingRateLimitLogin, "10", 1, 1000},
{SettingRateLimitWindow, "60", 10, 3600},
{SettingPostTitleMax, "128", 1, 512},
{SettingPostTagsMax, "256", 0, 512},
{SettingPostContentMax, "50000", 0, 0},
{SettingCommentMax, "5000", 1, 50000},
{SettingSearchKeywordMin, "1", 0, 100},
{SettingSearchKeywordMax, "50", 1, 200},
{SettingPageSizeDefault, "30", 1, pageSizeAPIMax},
{SettingPasswordMinLen, "6", 4, 128},
{SettingAvatarMaxMB, "2", 1, 20},
{SettingSignatureMax, "200", 0, 512},
{SettingOpenPostsInNewTab, "1", 0, 1},
{SettingOpenContentLinksInNewTab, "1", 0, 1},
}
var feedSettingDefaults = map[string]string{
SettingFeedListStyle: "title",
}
var asideSettingDefaults = map[string]string{
SettingAsideShowTagCloud: "0",
SettingAsideShowRecentComments: "0",
SettingAsideShowFriendLinks: "1",
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
}
var mailSettingDefaults = map[string]string{
SettingSMTPEnabled: "0",
SettingSMTPHost: "",
SettingSMTPPort: "465",
SettingSMTPUsername: "",
SettingSMTPPassword: "",
SettingSMTPFrom: "",
SettingSMTPFromName: "姜十三论坛",
SettingSMTPEncryption: "ssl",
}
var oidcSettingDefaults = map[string]string{
SettingOIDCEnabled: "0",
SettingOIDCRootURL: "",
SettingOIDCGroupClaim: "groups",
SettingOIDCAdminGroup: "gitea-admin",
SettingOIDCUserGroup: "gitea-users",
}
var giteaSettingDefaults = map[string]string{
SettingGiteaSyncEnabled: "0",
SettingGiteaBaseURL: "",
SettingGiteaToken: "",
SettingGiteaSyncIntervalMin: "60",
}
var storageSettingDefaults = map[string]string{
SettingStorageType: "local",
SettingStorageEndpoint: "",
SettingStorageRegion: "us-east-1",
SettingStorageBucket: "",
SettingStorageAccessKey: "",
SettingStorageSecretKey: "",
SettingStoragePublicBaseURL: "",
SettingStoragePrefix: "",
SettingStorageForcePathStyle: "1",
SettingStorageImageDelivery: ImageDeliveryWebP,
}
var friendLinkSettingDefaults = map[string]string{
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
SettingNavShowFriendLinks: "1",
SettingFooterShowFriendLinks: "1",
}
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流",
SettingSiteDescription: "",
SettingSiteKeywords: "",
SettingSiteLogoMark: "姜",
SettingSiteLogo: "",
SettingSiteFavicon: "",
SettingSiteOGImage: "",
SettingSiteICPBeian: "",
SettingSiteICPBeianURL: "https://beian.miit.gov.cn/",
SettingSiteFriendLinks: "[]",
}
const (
maxFriendLinks = 20
maxFriendLinkName = 32
maxFriendLinkURL = 512
maxICPBeianLen = 64
maxICPBeianURLLen = 512
maxSiteDescriptionLen = 500
maxSiteKeywordsLen = 200
maxSiteKeywordItems = 20
defaultICPBeianURL = "https://beian.miit.gov.cn/"
)
// FriendLink 友情链接
type FriendLink struct {
Name string `json:"name"`
URL string `json:"url"`
Logo string `json:"logo,omitempty"`
}
// SiteBranding 站点品牌配置名称、Logo、Favicon、页脚等
type SiteBranding struct {
Name string `json:"name"`
Slogan string `json:"slogan"`
Description string `json:"description"` // 站点简介SEO / 首页可见)
Keywords string `json:"keywords"` // SEO keywords逗号分隔
LogoMark string `json:"logo_mark"`
Logo string `json:"logo"`
Favicon string `json:"favicon"`
OGImage string `json:"og_image"` // 默认社交分享图Open Graph
ICPBeian string `json:"icp_beian"`
ICPBeianURL string `json:"icp_beian_url"`
FriendLinks []FriendLink `json:"friend_links"`
SiteURL string `json:"site_url,omitempty" gorm:"-"` // 公开站点根 URL仅 API 填充
}
// DocumentTitle 浏览器标签标题:站点名 - 副标题(标语)
func (b SiteBranding) DocumentTitle() string {
name := strings.TrimSpace(b.Name)
subtitle := strings.TrimSpace(b.Slogan)
if subtitle != "" {
return name + " - " + subtitle
}
return name
}
// MetaDescription 用于 meta description优先简介其次标语
func (b SiteBranding) MetaDescription() string {
if d := strings.TrimSpace(b.Description); d != "" {
return d
}
return strings.TrimSpace(b.Slogan)
}
// MetaKeywords 用于 meta keywords
func (b SiteBranding) MetaKeywords() string {
return strings.TrimSpace(b.Keywords)
}
// DefaultShareImage 默认社交预览图:专用 OG 图 → Logo → Favicon
func (b SiteBranding) DefaultShareImage() string {
for _, u := range []string{b.OGImage, b.Logo, b.Favicon} {
if s := strings.TrimSpace(u); s != "" {
return s
}
}
return ""
}
// GiteaSyncConfig Gitea 仓库同步配置
type GiteaSyncConfig struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
Token string `json:"token,omitempty"` // 更新时传入;回显时为空
HasToken bool `json:"has_token"`
SyncIntervalMin int `json:"sync_interval_min"`
Ready bool `json:"ready"`
RepoCount int64 `json:"repo_count"`
}
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients
type OIDCConfig struct {
Enabled bool `json:"enabled"`
RootURL string `json:"root_url"`
Ready bool `json:"ready"`
DiscoveryURL string `json:"discovery_url,omitempty"`
AuthorizeURL string `json:"authorize_url,omitempty"`
LogoutURL string `json:"logout_url,omitempty"`
GroupClaim string `json:"group_claim"`
AdminGroup string `json:"admin_group"`
UserGroup string `json:"user_group"`
ClientCount int64 `json:"client_count"`
}
// ForumSettingsService 论坛全局设置
type ForumSettingsService struct {
mu sync.RWMutex
}
func NewForumSettingsService() *ForumSettingsService {
s := &ForumSettingsService{}
s.ensureDefaults()
return s
}
func (s *ForumSettingsService) ensureDefaults() {
for _, def := range forumSettingDefs {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: def.key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: def.key, Value: def.defaultVal})
}
}
for key, val := range feedSettingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range asideSettingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range mailSettingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range oidcSettingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range giteaSettingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range storageSettingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range siteBrandingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
for key, val := range friendLinkSettingDefaults {
var count int64
models.DB.Model(&models.ForumSetting{}).Where(&models.ForumSetting{Key: key}).Count(&count)
if count == 0 {
models.DB.Create(&models.ForumSetting{Key: key, Value: val})
}
}
}
func (s *ForumSettingsService) getString(key, fallback string) string {
var setting models.ForumSetting
if err := models.DB.First(&setting, &models.ForumSetting{Key: key}).Error; err != nil {
return fallback
}
return setting.Value
}
func (s *ForumSettingsService) setString(key, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
return models.DB.Save(&models.ForumSetting{Key: key, Value: value}).Error
}
func (s *ForumSettingsService) getInt(key string, fallback int) int {
var setting models.ForumSetting
if err := models.DB.First(&setting, &models.ForumSetting{Key: key}).Error; err != nil {
return fallback
}
v, err := strconv.Atoi(setting.Value)
if err != nil {
return fallback
}
return v
}
func (s *ForumSettingsService) setInt(key string, value int) error {
for _, def := range forumSettingDefs {
if def.key != key {
continue
}
if value < def.min {
return ErrInvalidSetting
}
if def.max > 0 && value > def.max {
return ErrInvalidSetting
}
s.mu.Lock()
defer s.mu.Unlock()
return models.DB.Save(&models.ForumSetting{Key: key, Value: strconv.Itoa(value)}).Error
}
return ErrInvalidSetting
}
func (s *ForumSettingsService) Limits() ForumLimits {
permalink := s.Permalink()
widgets := s.AsideWidgets()
bools := asideBoolsFromWidgets(widgets)
return ForumLimits{
PostEditWindowHours: s.PostEditWindowHours(),
CommentEditWindowMinutes: s.CommentEditWindowMinutes(),
RateLimitPost: s.RateLimitFor("post"),
RateLimitComment: s.RateLimitFor("comment"),
RateLimitRegister: s.RateLimitFor("register"),
RateLimitLogin: s.RateLimitFor("login"),
RateLimitWindowSec: s.RateLimitWindowSec(),
PostTitleMax: s.PostTitleMax(),
PostTagsMax: s.PostTagsMax(),
PostContentMax: s.PostContentMax(),
CommentMax: s.CommentMax(),
SearchKeywordMin: s.SearchKeywordMin(),
SearchKeywordMax: s.SearchKeywordMax(),
PageSizeDefault: s.PageSizeDefault(),
PasswordMinLen: s.PasswordMinLen(),
AvatarMaxMB: s.AvatarMaxMB(),
SignatureMax: s.SignatureMax(),
OpenPostsInNewTab: s.OpenPostsInNewTab(),
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
AsideShowTagCloud: bools.tagCloud,
AsideShowRecentComments: bools.recentComments,
AsideShowFriendLinks: bools.friendLinks,
AsideWidgets: widgets,
NavShowFriendLinks: s.NavShowFriendLinks(),
FooterShowFriendLinks: s.FooterShowFriendLinks(),
FeedListStyle: s.FeedListStyle(),
PermalinkEnabled: permalink.Enabled,
PermalinkExt: permalink.Ext,
}
}
func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
limits := s.Limits()
return ForumLimitsPublic{
PostTitleMax: limits.PostTitleMax,
PostTagsMax: limits.PostTagsMax,
PostContentMax: limits.PostContentMax,
CommentMax: limits.CommentMax,
SearchKeywordMin: limits.SearchKeywordMin,
SearchKeywordMax: limits.SearchKeywordMax,
PageSizeDefault: limits.PageSizeDefault,
PasswordMinLen: limits.PasswordMinLen,
AvatarMaxMB: limits.AvatarMaxMB,
SignatureMax: limits.SignatureMax,
CommentEditWindowMinutes: limits.CommentEditWindowMinutes,
OpenPostsInNewTab: limits.OpenPostsInNewTab,
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
AsideShowTagCloud: limits.AsideShowTagCloud,
AsideShowRecentComments: limits.AsideShowRecentComments,
AsideShowFriendLinks: limits.AsideShowFriendLinks,
AsideWidgets: limits.AsideWidgets,
NavShowFriendLinks: limits.NavShowFriendLinks,
FooterShowFriendLinks: limits.FooterShowFriendLinks,
FeedListStyle: limits.FeedListStyle,
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
}
}
// UpdateRateLimits 仅更新基础限流Admin SSR 子集,不影响其它 limits
func (s *ForumSettingsService) UpdateRateLimits(post, comment, register, login, windowSec int) error {
updates := map[string]int{
SettingRateLimitPost: post,
SettingRateLimitComment: comment,
SettingRateLimitRegister: register,
SettingRateLimitLogin: login,
SettingRateLimitWindow: windowSec,
}
for key, val := range updates {
if err := s.setInt(key, val); err != nil {
return err
}
}
return nil
}
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours,
SettingCommentEditWindowMinutes: in.CommentEditWindowMinutes,
SettingRateLimitPost: in.RateLimitPost,
SettingRateLimitComment: in.RateLimitComment,
SettingRateLimitRegister: in.RateLimitRegister,
SettingRateLimitLogin: in.RateLimitLogin,
SettingRateLimitWindow: in.RateLimitWindowSec,
SettingPostTitleMax: in.PostTitleMax,
SettingPostTagsMax: in.PostTagsMax,
SettingPostContentMax: in.PostContentMax,
SettingCommentMax: in.CommentMax,
SettingSearchKeywordMin: in.SearchKeywordMin,
SettingSearchKeywordMax: in.SearchKeywordMax,
SettingPageSizeDefault: in.PageSizeDefault,
SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB,
SettingSignatureMax: in.SignatureMax,
}
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
return ErrInvalidSetting
}
for key, val := range updates {
if err := s.setInt(key, val); err != nil {
return err
}
}
widgets := NormalizeAsideWidgets(in.AsideWidgets)
if len(widgets) == 0 {
widgets = asideWidgetsFromBools(in.AsideShowTagCloud, in.AsideShowRecentComments, in.AsideShowFriendLinks)
}
bools := asideBoolsFromWidgets(widgets)
boolUpdates := map[string]bool{
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
SettingAsideShowTagCloud: bools.tagCloud,
SettingAsideShowRecentComments: bools.recentComments,
SettingAsideShowFriendLinks: bools.friendLinks,
SettingNavShowFriendLinks: in.NavShowFriendLinks,
SettingFooterShowFriendLinks: in.FooterShowFriendLinks,
SettingPermalinkEnabled: in.PermalinkEnabled,
}
for key, on := range boolUpdates {
v := "0"
if on {
v = "1"
}
if err := s.setString(key, v); err != nil {
return err
}
}
widgetsJSON, err := json.Marshal(widgets)
if err != nil {
return err
}
if err := s.setString(SettingAsideWidgets, string(widgetsJSON)); err != nil {
return err
}
ext, ok := NormalizePermalinkExt(in.PermalinkExt)
if !ok {
return ErrInvalidSetting
}
if err := s.setString(SettingPermalinkExt, ext); err != nil {
return err
}
style, ok := NormalizeFeedListStyle(in.FeedListStyle)
if !ok {
return ErrInvalidSetting
}
if err := s.setString(SettingFeedListStyle, style); err != nil {
return err
}
return nil
}
func (s *ForumSettingsService) PostEditWindowHours() int {
return s.getInt(SettingPostEditWindowHours, 24)
}
func (s *ForumSettingsService) CommentEditWindowMinutes() int {
return s.getInt(SettingCommentEditWindowMinutes, 3)
}
func (s *ForumSettingsService) RateLimitFor(action string) int {
switch action {
case "post":
return s.getInt(SettingRateLimitPost, 10)
case "comment":
return s.getInt(SettingRateLimitComment, 10)
case "register":
return s.getInt(SettingRateLimitRegister, 10)
case "login", "admin_login":
return s.getInt(SettingRateLimitLogin, 10)
default:
return 10
}
}
func (s *ForumSettingsService) RateLimitWindowSec() int {
return s.getInt(SettingRateLimitWindow, 60)
}
func (s *ForumSettingsService) PostTitleMax() int { return s.getInt(SettingPostTitleMax, 128) }
func (s *ForumSettingsService) PostTagsMax() int { return s.getInt(SettingPostTagsMax, 256) }
func (s *ForumSettingsService) PostContentMax() int { return s.getInt(SettingPostContentMax, 50000) }
func (s *ForumSettingsService) CommentMax() int { return s.getInt(SettingCommentMax, 5000) }
func (s *ForumSettingsService) SearchKeywordMin() int { return s.getInt(SettingSearchKeywordMin, 1) }
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }
func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
func (s *ForumSettingsService) SignatureMax() int { return s.getInt(SettingSignatureMax, 200) }
func (s *ForumSettingsService) OpenPostsInNewTab() bool {
return s.getString(SettingOpenPostsInNewTab, "1") == "1"
}
func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
}
// FriendLinkReciprocalCheckEnabled 是否启用友链回链检测;缺省为关闭
func (s *ForumSettingsService) FriendLinkReciprocalCheckEnabled() bool {
return s.getString(SettingFriendLinkReciprocalCheck, "0") == "1"
}
// NavShowFriendLinks 左侧栏「站点」是否展示友链入口;缺省开启
func (s *ForumSettingsService) NavShowFriendLinks() bool {
return s.getString(SettingNavShowFriendLinks, "1") == "1"
}
// FooterShowFriendLinks 页脚是否展示友链入口;缺省开启
func (s *ForumSettingsService) FooterShowFriendLinks() bool {
return s.getString(SettingFooterShowFriendLinks, "1") == "1"
}
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
v := "0"
if enabled {
v = "1"
}
return s.setString(SettingFriendLinkReciprocalCheck, v)
}
func (s *ForumSettingsService) SetNavShowFriendLinks(enabled bool) error {
v := "0"
if enabled {
v = "1"
}
return s.setString(SettingNavShowFriendLinks, v)
}
func (s *ForumSettingsService) SetFooterShowFriendLinks(enabled bool) error {
v := "0"
if enabled {
v = "1"
}
return s.setString(SettingFooterShowFriendLinks, v)
}
// SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步)
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
widgets := s.AsideWidgets()
found := false
for i := range widgets {
if widgets[i].ID == AsideWidgetFriendLinks {
widgets[i].Enabled = enabled
found = true
break
}
}
if !found {
widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled})
}
widgets = NormalizeAsideWidgets(widgets)
bools := asideBoolsFromWidgets(widgets)
payload, err := json.Marshal(widgets)
if err != nil {
return err
}
if err := s.setString(SettingAsideWidgets, string(payload)); err != nil {
return err
}
v := "0"
if bools.friendLinks {
v = "1"
}
return s.setString(SettingAsideShowFriendLinks, v)
}
type asideWidgetBools struct {
tagCloud bool
recentComments bool
friendLinks bool
}
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
return []AsideWidget{
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
{ID: AsideWidgetRecentComments, Enabled: recentComments},
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
}
}
func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
out := asideWidgetBools{}
for _, w := range widgets {
switch w.ID {
case AsideWidgetTagCloud:
out.tagCloud = w.Enabled
case AsideWidgetRecentComments:
out.recentComments = w.Enabled
case AsideWidgetFriendLinks:
out.friendLinks = w.Enabled
}
}
return out
}
func isValidAsideWidgetID(id string) bool {
switch id {
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks:
return true
default:
return false
}
}
// NormalizeAsideWidgets 校验并补全右侧栏组件列表(顺序保留,缺失项按默认顺序追加)
func NormalizeAsideWidgets(in []AsideWidget) []AsideWidget {
seen := make(map[string]bool, len(asideWidgetDefaultOrder))
out := make([]AsideWidget, 0, len(asideWidgetDefaultOrder))
for _, w := range in {
id := strings.TrimSpace(w.ID)
if !isValidAsideWidgetID(id) || seen[id] {
continue
}
seen[id] = true
out = append(out, AsideWidget{ID: id, Enabled: w.Enabled})
}
for _, id := range asideWidgetDefaultOrder {
if seen[id] {
continue
}
out = append(out, AsideWidget{ID: id, Enabled: false})
}
return out
}
func (s *ForumSettingsService) AsideWidgets() []AsideWidget {
raw := strings.TrimSpace(s.getString(SettingAsideWidgets, ""))
if raw != "" {
var widgets []AsideWidget
if err := json.Unmarshal([]byte(raw), &widgets); err == nil {
normalized := NormalizeAsideWidgets(widgets)
if len(normalized) > 0 {
return normalized
}
}
}
return asideWidgetsFromBools(s.AsideShowTagCloud(), s.AsideShowRecentComments(), s.AsideShowFriendLinks())
}
func (s *ForumSettingsService) AsideShowTagCloud() bool {
return s.getString(SettingAsideShowTagCloud, "0") == "1"
}
func (s *ForumSettingsService) AsideShowRecentComments() bool {
return s.getString(SettingAsideShowRecentComments, "0") == "1"
}
func (s *ForumSettingsService) AsideShowFriendLinks() bool {
return s.getString(SettingAsideShowFriendLinks, "1") == "1"
}
// NormalizeFeedListStyle 校验首页列表样式
func NormalizeFeedListStyle(v string) (string, bool) {
switch strings.TrimSpace(strings.ToLower(v)) {
case "title", "":
return "title", true
case "excerpt":
return "excerpt", true
case "thumbnail":
return "thumbnail", true
default:
return "", false
}
}
func (s *ForumSettingsService) FeedListStyle() string {
v, ok := NormalizeFeedListStyle(s.getString(SettingFeedListStyle, feedSettingDefaults[SettingFeedListStyle]))
if !ok {
return "title"
}
return v
}
// MailConfig 读取 SMTP 配置(密码不回显明文)
func (s *ForumSettingsService) MailConfig() MailConfig {
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
if port <= 0 {
port = 465
}
password := s.getString(SettingSMTPPassword, "")
return MailConfig{
Enabled: s.getString(SettingSMTPEnabled, "0") == "1",
Host: s.getString(SettingSMTPHost, ""),
Port: port,
Username: s.getString(SettingSMTPUsername, ""),
Password: password,
From: s.getString(SettingSMTPFrom, ""),
FromName: s.getString(SettingSMTPFromName, "姜十三论坛"),
Encryption: normalizeEncryption(s.getString(SettingSMTPEncryption, "ssl")),
HasPassword: password != "",
}
}
// MailConfigPublic 管理端回显(不含密码明文)
func (s *ForumSettingsService) MailConfigPublic() MailConfig {
cfg := s.MailConfig()
cfg.Password = ""
return cfg
}
// MailReady 邮件服务是否可用于发信
func (s *ForumSettingsService) MailReady() bool {
cfg := s.MailConfig()
return cfg.Enabled &&
strings.TrimSpace(cfg.Host) != "" &&
cfg.Port > 0 &&
strings.TrimSpace(cfg.From) != "" &&
strings.TrimSpace(cfg.Username) != "" &&
cfg.HasPassword
}
// UpdateMailConfig 更新 SMTP 配置;密码为空表示保持原值
func (s *ForumSettingsService) UpdateMailConfig(in MailConfig) error {
enc := normalizeEncryption(in.Encryption)
if enc != "none" && enc != "starttls" && enc != "ssl" {
return ErrInvalidSetting
}
port := in.Port
if port <= 0 {
port = 465
}
enabled := "0"
if in.Enabled {
enabled = "1"
}
updates := map[string]string{
SettingSMTPEnabled: enabled,
SettingSMTPHost: strings.TrimSpace(in.Host),
SettingSMTPPort: strconv.Itoa(port),
SettingSMTPUsername: strings.TrimSpace(in.Username),
SettingSMTPFrom: strings.TrimSpace(in.From),
SettingSMTPFromName: strings.TrimSpace(in.FromName),
SettingSMTPEncryption: enc,
}
if strings.TrimSpace(in.Password) != "" {
updates[SettingSMTPPassword] = in.Password
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// OIDCConfig 读取 OIDC 全局配置
func (s *ForumSettingsService) OIDCConfig() OIDCConfig {
root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
clientCount := CountEnabledOAuthClients()
cfg := OIDCConfig{
Enabled: s.getString(SettingOIDCEnabled, "0") == "1",
RootURL: root,
GroupClaim: strings.TrimSpace(s.getString(SettingOIDCGroupClaim, "groups")),
AdminGroup: strings.TrimSpace(s.getString(SettingOIDCAdminGroup, "gitea-admin")),
UserGroup: strings.TrimSpace(s.getString(SettingOIDCUserGroup, "gitea-users")),
ClientCount: clientCount,
}
if cfg.GroupClaim == "" {
cfg.GroupClaim = "groups"
}
cfg.Ready = cfg.Enabled && cfg.RootURL != "" && clientCount > 0
if root != "" {
cfg.DiscoveryURL = root + "/.well-known/openid-configuration"
cfg.AuthorizeURL = root + "/oauth/authorize"
cfg.LogoutURL = root + "/oauth/logout"
}
return cfg
}
// OIDCConfigPublic 管理端回显
func (s *ForumSettingsService) OIDCConfigPublic() OIDCConfig {
return s.OIDCConfig()
}
// UpdateOIDCConfig 更新 OIDC 全局配置(不含 OAuth 应用凭证)
func (s *ForumSettingsService) UpdateOIDCConfig(in OIDCConfig) error {
root := normalizeRootURL(in.RootURL)
if root != "" && !strings.HasPrefix(root, "http://") && !strings.HasPrefix(root, "https://") {
return ErrInvalidSetting
}
enabled := "0"
if in.Enabled {
enabled = "1"
}
groupClaim := strings.TrimSpace(in.GroupClaim)
if groupClaim == "" {
groupClaim = "groups"
}
adminGroup := strings.TrimSpace(in.AdminGroup)
userGroup := strings.TrimSpace(in.UserGroup)
updates := map[string]string{
SettingOIDCEnabled: enabled,
SettingOIDCRootURL: root,
SettingOIDCGroupClaim: groupClaim,
SettingOIDCAdminGroup: adminGroup,
SettingOIDCUserGroup: userGroup,
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// GiteaSyncConfig 读取 Gitea 同步配置(含 Token 明文,供服务内部使用)
func (s *ForumSettingsService) GiteaSyncConfig() GiteaSyncConfig {
interval, _ := strconv.Atoi(s.getString(SettingGiteaSyncIntervalMin, "60"))
if interval < 5 {
interval = 5
}
if interval > 24*60 {
interval = 24 * 60
}
token := s.getString(SettingGiteaToken, "")
base := normalizeRootURL(s.getString(SettingGiteaBaseURL, ""))
cfg := GiteaSyncConfig{
Enabled: s.getString(SettingGiteaSyncEnabled, "0") == "1",
BaseURL: base,
Token: token,
HasToken: token != "",
SyncIntervalMin: interval,
}
cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken
var n int64
models.DB.Model(&models.GiteaRepo{}).Where("private = ?", false).Count(&n)
cfg.RepoCount = n
return cfg
}
// GiteaSyncConfigPublic 管理端回显(不含 Token 明文)
func (s *ForumSettingsService) GiteaSyncConfigPublic() GiteaSyncConfig {
cfg := s.GiteaSyncConfig()
cfg.Token = ""
return cfg
}
// UpdateGiteaSyncConfig 更新同步配置Token 为空表示保持原值
func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
base := normalizeRootURL(in.BaseURL)
if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
return ErrInvalidSetting
}
interval := in.SyncIntervalMin
if interval <= 0 {
interval = 60
}
if interval < 5 {
interval = 5
}
if interval > 24*60 {
interval = 24 * 60
}
enabled := "0"
if in.Enabled {
enabled = "1"
}
updates := map[string]string{
SettingGiteaSyncEnabled: enabled,
SettingGiteaBaseURL: base,
SettingGiteaSyncIntervalMin: strconv.Itoa(interval),
}
if strings.TrimSpace(in.Token) != "" {
updates[SettingGiteaToken] = strings.TrimSpace(in.Token)
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
func (s *ForumSettingsService) StorageConfig() StorageConfig {
secret := s.getString(SettingStorageSecretKey, "")
region := strings.TrimSpace(s.getString(SettingStorageRegion, "us-east-1"))
if region == "" {
region = "us-east-1"
}
cfg := StorageConfig{
Type: normalizeStorageType(s.getString(SettingStorageType, "local")),
Endpoint: strings.TrimSpace(s.getString(SettingStorageEndpoint, "")),
Region: region,
Bucket: strings.TrimSpace(s.getString(SettingStorageBucket, "")),
AccessKey: strings.TrimSpace(s.getString(SettingStorageAccessKey, "")),
SecretKey: secret,
PublicBaseURL: normalizeRootURL(s.getString(SettingStoragePublicBaseURL, "")),
Prefix: normalizeObjectPrefix(s.getString(SettingStoragePrefix, "")),
ForcePathStyle: s.getString(SettingStorageForcePathStyle, "1") == "1",
HasSecretKey: secret != "",
ImageDelivery: normalizeImageDelivery(s.getString(SettingStorageImageDelivery, ImageDeliveryWebP)),
}
cfg.Ready = cfg.Type == "local" || (cfg.Endpoint != "" && cfg.Bucket != "" &&
cfg.AccessKey != "" && cfg.HasSecretKey && cfg.PublicBaseURL != "")
return cfg
}
// ImageDelivery 图片展示方案webp | original
func (s *ForumSettingsService) ImageDelivery() string {
return normalizeImageDelivery(s.getString(SettingStorageImageDelivery, ImageDeliveryWebP))
}
// StorageConfigPublic 管理端回显(不含 Secret Key 明文)
func (s *ForumSettingsService) StorageConfigPublic() StorageConfig {
cfg := s.StorageConfig()
cfg.SecretKey = ""
return cfg
}
// UpdateStorageConfig 更新存储配置Secret Key 为空表示保持原值
func (s *ForumSettingsService) UpdateStorageConfig(in StorageConfig) error {
typ := normalizeStorageType(in.Type)
if typ != "local" && typ != "s3" {
return ErrInvalidSetting
}
endpoint := strings.TrimSpace(in.Endpoint)
region := strings.TrimSpace(in.Region)
if region == "" {
region = "us-east-1"
}
bucket := strings.TrimSpace(in.Bucket)
accessKey := strings.TrimSpace(in.AccessKey)
publicBase := normalizeRootURL(in.PublicBaseURL)
prefix := normalizeObjectPrefix(in.Prefix)
forcePath := "0"
if in.ForcePathStyle {
forcePath = "1"
}
if typ == "s3" {
if endpoint == "" || bucket == "" || publicBase == "" {
return errors.New("启用 S3 时须填写 Endpoint、Bucket 与公开访问地址")
}
if _, _, err := parseS3Endpoint(endpoint); err != nil {
return err
}
if !strings.HasPrefix(publicBase, "http://") && !strings.HasPrefix(publicBase, "https://") {
return errors.New("公开访问地址须以 http:// 或 https:// 开头")
}
existingSecret := s.getString(SettingStorageSecretKey, "")
secret := strings.TrimSpace(in.SecretKey)
if accessKey == "" {
accessKey = strings.TrimSpace(s.getString(SettingStorageAccessKey, ""))
}
if secret == "" {
secret = existingSecret
}
if accessKey == "" || secret == "" {
return errors.New("启用 S3 时须填写 Access Key 与 Secret Key")
}
}
delivery := normalizeImageDelivery(in.ImageDelivery)
updates := map[string]string{
SettingStorageType: typ,
SettingStorageEndpoint: endpoint,
SettingStorageRegion: region,
SettingStorageBucket: bucket,
SettingStorageAccessKey: accessKey,
SettingStoragePublicBaseURL: publicBase,
SettingStoragePrefix: prefix,
SettingStorageForcePathStyle: forcePath,
SettingStorageImageDelivery: delivery,
}
if strings.TrimSpace(in.SecretKey) != "" {
updates[SettingStorageSecretKey] = strings.TrimSpace(in.SecretKey)
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// SiteBranding 读取站点品牌配置
func (s *ForumSettingsService) SiteBranding() SiteBranding {
name := strings.TrimSpace(s.getString(SettingSiteName, siteBrandingDefaults[SettingSiteName]))
if name == "" {
name = siteBrandingDefaults[SettingSiteName]
}
mark := strings.TrimSpace(s.getString(SettingSiteLogoMark, siteBrandingDefaults[SettingSiteLogoMark]))
if mark == "" {
mark = siteBrandingDefaults[SettingSiteLogoMark]
}
// 字标取首个字符(支持中文)
runes := []rune(mark)
if len(runes) > 1 {
mark = string(runes[0])
}
links := parseFriendLinksJSON(s.getString(SettingSiteFriendLinks, "[]"))
links = EnrichFriendLinksLogos(links)
if err := s.maybePersistEnrichedFriendLinks(links); err != nil {
// 回填失败不阻断读取
_ = err
}
return SiteBranding{
Name: name,
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
Description: strings.TrimSpace(s.getString(SettingSiteDescription, "")),
Keywords: strings.TrimSpace(s.getString(SettingSiteKeywords, "")),
LogoMark: mark,
Logo: strings.TrimSpace(s.getString(SettingSiteLogo, "")),
Favicon: strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
OGImage: strings.TrimSpace(s.getString(SettingSiteOGImage, "")),
ICPBeian: strings.TrimSpace(s.getString(SettingSiteICPBeian, "")),
ICPBeianURL: strings.TrimSpace(s.getString(SettingSiteICPBeianURL, defaultICPBeianURL)),
FriendLinks: links,
}
}
// UpdateSiteBranding 更新品牌文案与页脚信息Logo/Favicon URL 由上传接口单独写入
func (s *ForumSettingsService) UpdateSiteBranding(in SiteBranding) error {
name := strings.TrimSpace(in.Name)
if name == "" {
return ErrInvalidSetting
}
if len([]rune(name)) > 64 {
return ErrInvalidSetting
}
mark := strings.TrimSpace(in.LogoMark)
if mark == "" {
mark = siteBrandingDefaults[SettingSiteLogoMark]
}
runes := []rune(mark)
if len(runes) > 1 {
mark = string(runes[0])
}
slogan := strings.TrimSpace(in.Slogan)
if len([]rune(slogan)) > 200 {
return ErrInvalidSetting
}
description := strings.TrimSpace(in.Description)
if len([]rune(description)) > maxSiteDescriptionLen {
return ErrInvalidSetting
}
keywords, err := normalizeSiteKeywords(in.Keywords)
if err != nil {
return err
}
icp := strings.TrimSpace(in.ICPBeian)
if len([]rune(icp)) > maxICPBeianLen {
return ErrInvalidSetting
}
icpURL, err := normalizeOptionalHTTPURL(in.ICPBeianURL, defaultICPBeianURL, maxICPBeianURLLen)
if err != nil {
return err
}
links, err := normalizeFriendLinks(in.FriendLinks)
if err != nil {
return err
}
linksJSON, err := json.Marshal(links)
if err != nil {
return err
}
updates := map[string]string{
SettingSiteName: name,
SettingSiteSlogan: slogan,
SettingSiteDescription: description,
SettingSiteKeywords: keywords,
SettingSiteLogoMark: mark,
SettingSiteICPBeian: icp,
SettingSiteICPBeianURL: icpURL,
SettingSiteFriendLinks: string(linksJSON),
}
for key, val := range updates {
if err := s.setString(key, val); err != nil {
return err
}
}
return nil
}
// SetSiteLogo 写入 Logo URL空串表示清除
func (s *ForumSettingsService) SetSiteLogo(url string) error {
return s.setString(SettingSiteLogo, strings.TrimSpace(url))
}
// SetSiteFavicon 写入 Favicon URL空串表示清除
func (s *ForumSettingsService) SetSiteFavicon(url string) error {
return s.setString(SettingSiteFavicon, strings.TrimSpace(url))
}
// SetSiteOGImage 写入默认社交分享图 URL空串表示清除
func (s *ForumSettingsService) SetSiteOGImage(url string) error {
return s.setString(SettingSiteOGImage, strings.TrimSpace(url))
}
// normalizeSiteKeywords 统一中英文分隔符,去重并限制数量/长度
func normalizeSiteKeywords(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
if len([]rune(raw)) > maxSiteKeywordsLen {
return "", ErrInvalidSetting
}
raw = strings.NewReplacer("", ",", "、", ",", ";", ",", "", ",").Replace(raw)
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
if len(out) > maxSiteKeywordItems {
return "", ErrInvalidSetting
}
return strings.Join(out, ","), nil
}
// JoinSEOKeywords 合并页面级与站点级关键词(逗号分隔)
func JoinSEOKeywords(parts ...string) string {
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
for _, p := range strings.Split(strings.NewReplacer("", ",", "、", ",").Replace(part), ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
}
return strings.Join(out, ",")
}
// normalizeOptionalHTTPURL 空值回落到 defaultURL非空须为 http(s)
func normalizeOptionalHTTPURL(raw, defaultURL string, maxLen int) (string, error) {
href := strings.TrimSpace(raw)
if href == "" {
return defaultURL, nil
}
if len(href) > maxLen {
return "", ErrInvalidSetting
}
u, err := url.Parse(href)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", ErrInvalidSetting
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", ErrInvalidSetting
}
return href, nil
}
func parseFriendLinksJSON(raw string) []FriendLink {
raw = strings.TrimSpace(raw)
if raw == "" {
return []FriendLink{}
}
var links []FriendLink
if err := json.Unmarshal([]byte(raw), &links); err != nil {
return []FriendLink{}
}
out, err := normalizeFriendLinks(links)
if err != nil {
return []FriendLink{}
}
return out
}
func normalizeFriendLinks(in []FriendLink) ([]FriendLink, error) {
if len(in) > maxFriendLinks {
return nil, ErrInvalidSetting
}
out := make([]FriendLink, 0, len(in))
for _, item := range in {
name := strings.TrimSpace(item.Name)
href := strings.TrimSpace(item.URL)
if name == "" && href == "" {
continue
}
if name == "" || href == "" {
return nil, ErrInvalidSetting
}
if len([]rune(name)) > maxFriendLinkName || len(href) > maxFriendLinkURL {
return nil, ErrInvalidSetting
}
u, err := url.Parse(href)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, ErrInvalidSetting
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return nil, ErrInvalidSetting
}
out = append(out, FriendLink{Name: name, URL: href, Logo: normalizeFriendLinkLogoOptional(item.Logo)})
}
if out == nil {
out = []FriendLink{}
}
return out, nil
}
func normalizeRootURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
func normalizeRedirectURIs(raw string) string {
parts := splitRedirectURIs(raw)
return strings.Join(parts, ",")
}
func splitRedirectURIs(raw string) []string {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\n", ",")
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
// NormalizeSearchKeyword 校验并规范化搜索关键词,空字符串表示无搜索
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
kw := trimRunes(keyword)
if kw == "" {
return "", nil
}
minLen := s.SearchKeywordMin()
maxLen := s.SearchKeywordMax()
runeLen := runeLen(kw)
if minLen > 0 && runeLen < minLen {
return "", ErrSearchKeywordTooShort
}
if maxLen > 0 && runeLen > maxLen {
return "", ErrSearchKeywordTooLong
}
return kw, nil
}
// NormalizePageSize 规范化分页大小
func (s *ForumSettingsService) NormalizePageSize(size int) int {
if size < 1 {
return s.PageSizeDefault()
}
if size > pageSizeAPIMax {
return pageSizeAPIMax
}
return size
}
// ValidateTextLength 校验文本长度max=0 表示不限
func (s *ForumSettingsService) ValidateTextLength(text string, max int, tooLongErr error) error {
if max <= 0 {
return nil
}
if runeLen(text) > max {
return tooLongErr
}
return nil
}