package service import ( "encoding/json" "errors" "net" "net/url" "strconv" "strings" "sync" "git.iioio.com/freefire/jiang13-forum/model" ) // 论坛设置键名 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" SettingAsideShowShowcase = "aside_show_showcase" SettingAsideWidgets = "aside_widgets" SettingNavShowFriendLinks = "nav_show_friend_links" SettingFooterShowFriendLinks = "footer_show_friend_links" SettingNavShowShowcase = "nav_show_showcase" SettingFooterShowShowcase = "footer_show_showcase" SettingFeedListStyle = "feed_list_style" SettingFeedSortTabs = "feed_sort_tabs" // 伪静态键名见 permalink.go:SettingPermalinkEnabled / 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" 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" SettingCommunityReportEnabled = "community_report_enabled" SettingCommunityHubEnabled = "community_hub_enabled" // 遗留键,不再作为开关来源 SettingCommunityInstanceID = "community_instance_id" SettingCommunityHubURL = "community_hub_url" SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_URL) SettingMonitorEnabled = "monitor_enabled" SettingMonitorRetention = "monitor_retention_days" SettingMonitorAccessLogRetention = "monitor_access_log_retention_days" SettingMonitorExclude = "monitor_exclude_json" SettingMonitorTrustProxy = "monitor_trust_proxy" // DefaultCommunityHubURL 官方演示站(社区枢纽默认地址) DefaultCommunityHubURL = "https://bbs.iioio.com" // DefaultMonitorExcludeJSON 默认排除路径/后缀(JSON 数组) DefaultMonitorExcludeJSON = `["/admin","/api/admin/","/api/me","/api/site-branding","/health","/uploads/","/media/","/static/","/spa/","/api/admin/monitor","/api/monitor/pageview","/favicon.ico",".js",".css",".map",".woff",".woff2",".ttf",".png",".jpg",".jpeg",".gif",".webp",".avif",".svg",".ico"]` // 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"` AsideShowShowcase bool `json:"aside_show_showcase"` AsideWidgets []AsideWidget `json:"aside_widgets"` NavShowFriendLinks bool `json:"nav_show_friend_links"` FooterShowFriendLinks bool `json:"footer_show_friend_links"` NavShowShowcase bool `json:"nav_show_showcase"` FooterShowShowcase bool `json:"footer_show_showcase"` FeedListStyle string `json:"feed_list_style"` FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"` PermalinkEnabled bool `json:"permalink_enabled"` PermalinkExt string `json:"permalink_ext"` } // AsideWidget 右侧栏可选组件 type AsideWidget struct { ID string `json:"id"` Enabled bool `json:"enabled"` } // FeedSortTab 首页 Feed 排序标签(名称 / 顺序 / 启停) type FeedSortTab struct { ID string `json:"id"` // reply | latest | hot Label string `json:"label"` // 展示名 Enabled bool `json:"enabled"` } const ( AsideWidgetTagCloud = "tag_cloud" AsideWidgetRecentComments = "recent_comments" AsideWidgetRecentUsers = "recent_users" AsideWidgetFriendLinks = "friend_links" AsideWidgetShowcase = "showcase" FeedSortReply = "reply" FeedSortLatest = "latest" FeedSortHot = "hot" // 仅推荐帖(featured) ) var asideWidgetDefaultOrder = []string{ AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase, } var feedSortTabDefaultOrder = []string{ FeedSortReply, FeedSortLatest, FeedSortHot, } var feedSortTabDefaultLabels = map[string]string{ FeedSortReply: "新评论", FeedSortLatest: "新帖子", FeedSortHot: "推荐帖", } // 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"` AsideShowShowcase bool `json:"aside_show_showcase"` AsideWidgets []AsideWidget `json:"aside_widgets"` NavShowFriendLinks bool `json:"nav_show_friend_links"` FooterShowFriendLinks bool `json:"footer_show_friend_links"` NavShowShowcase bool `json:"nav_show_showcase"` FooterShowShowcase bool `json:"footer_show_showcase"` FeedListStyle string `json:"feed_list_style"` FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"` PermalinkEnabled bool `json:"permalink_enabled"` PermalinkExt string `json:"permalink_ext"` // MonitorPageview 是否接受前台路由 pageview 信标(与 monitor_enabled 同步) MonitorPageview bool `json:"monitor_pageview"` } 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", SettingFeedSortTabs: `[{"id":"reply","label":"新评论","enabled":true},{"id":"latest","label":"新帖子","enabled":true},{"id":"hot","label":"推荐帖","enabled":true}]`, } var asideSettingDefaults = map[string]string{ SettingAsideShowTagCloud: "0", SettingAsideShowRecentComments: "0", SettingAsideShowFriendLinks: "1", SettingAsideShowShowcase: "0", SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true},{"id":"showcase","enabled":false}]`, } 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", SettingNavShowShowcase: "0", SettingFooterShowShowcase: "0", } var communitySettingDefaults = map[string]string{ SettingCommunityReportEnabled: "0", SettingCommunityHubEnabled: "0", SettingCommunityInstanceID: "", SettingCommunityHubURL: DefaultCommunityHubURL, SettingCommunitySiteURL: "", } var monitorSettingDefaults = map[string]string{ SettingMonitorEnabled: "0", SettingMonitorRetention: "30", SettingMonitorAccessLogRetention: "7", SettingMonitorExclude: DefaultMonitorExcludeJSON, SettingMonitorTrustProxy: "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"` } // CommunityConfig 社区上报配置(HubEnabled 只读:运维开关或官网域名) type CommunityConfig struct { ReportEnabled bool `json:"report_enabled"` HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量 / 官网 Host HubURL string `json:"hub_url"` SiteURL string `json:"site_url"` // 上报用的本站公开地址 InstanceID string `json:"instance_id"` } // MonitorConfig 网站监控采集设置 type MonitorConfig struct { Enabled bool `json:"enabled"` RetentionDays int `json:"retention_days"` // page_views 保留 AccessLogRetentionDays int `json:"access_log_retention_days"` // JSONL 请求日志保留 ExcludeRules []string `json:"exclude_rules"` DefaultExcludeRules []string `json:"default_exclude_rules"` // 只读:恢复推荐规则 TrustProxy bool `json:"trust_proxy"` AccessLogDir string `json:"access_log_dir"` IP2LocationV4Path string `json:"ip2location_v4_path"` IP2LocationV6Path string `json:"ip2location_v6_path"` IP2LocationV4Available bool `json:"ip2location_v4_available"` IP2LocationV6Available bool `json:"ip2location_v6_available"` GeoIPAvailable bool `json:"geoip_available"` GeoIPCountryPath string `json:"geoip_country_path"` GeoIPASNPath string `json:"geoip_asn_path"` GeoIPCountryAvailable bool `json:"geoip_country_available"` GeoIPASNAvailable bool `json:"geoip_asn_available"` } // 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 communityHubEnabled bool // 运维配置注入,非后台可改 } func NewForumSettingsService() *ForumSettingsService { s := &ForumSettingsService{} s.ensureDefaults() return s } // SetCommunityHubEnabled 由启动配置注入是否作为社区枢纽 func (s *ForumSettingsService) SetCommunityHubEnabled(enabled bool) { s.mu.Lock() s.communityHubEnabled = enabled s.mu.Unlock() } func (s *ForumSettingsService) ensureDefaults() { for _, def := range forumSettingDefs { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", def.key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal}) } } for key, val := range feedSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range asideSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range mailSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range oidcSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range giteaSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range storageSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range siteBrandingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range friendLinkSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range communitySettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } for key, val := range monitorSettingDefaults { var count int64 model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) if count == 0 { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } } func (s *ForumSettingsService) getString(key, fallback string) string { var setting model.ForumSetting if err := model.DB.First(&setting, "`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 model.DB.Save(&model.ForumSetting{Key: key, Value: value}).Error } func (s *ForumSettingsService) getInt(key string, fallback int) int { var setting model.ForumSetting if err := model.DB.First(&setting, "`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 model.DB.Save(&model.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, AsideShowShowcase: bools.showcase, AsideWidgets: widgets, NavShowFriendLinks: s.NavShowFriendLinks(), FooterShowFriendLinks: s.FooterShowFriendLinks(), NavShowShowcase: s.NavShowShowcase(), FooterShowShowcase: s.FooterShowShowcase(), FeedListStyle: s.FeedListStyle(), FeedSortTabs: s.FeedSortTabs(), 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, AsideShowShowcase: limits.AsideShowShowcase, AsideWidgets: limits.AsideWidgets, NavShowFriendLinks: limits.NavShowFriendLinks, FooterShowFriendLinks: limits.FooterShowFriendLinks, NavShowShowcase: limits.NavShowShowcase, FooterShowShowcase: limits.FooterShowShowcase, FeedListStyle: limits.FeedListStyle, FeedSortTabs: limits.FeedSortTabs, PermalinkEnabled: limits.PermalinkEnabled, PermalinkExt: limits.PermalinkExt, MonitorPageview: s.MonitorEnabled(), } } 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, SettingAsideShowShowcase: bools.showcase, SettingNavShowFriendLinks: in.NavShowFriendLinks, SettingFooterShowFriendLinks: in.FooterShowFriendLinks, SettingNavShowShowcase: in.NavShowShowcase, SettingFooterShowShowcase: in.FooterShowShowcase, 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 } tabs := NormalizeFeedSortTabs(in.FeedSortTabs) tabsJSON, err := json.Marshal(tabs) if err != nil { return err } if err := s.setString(SettingFeedSortTabs, string(tabsJSON)); 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" } // NavShowShowcase 左侧栏「站点」是否展示开源展柜入口;缺省关闭 func (s *ForumSettingsService) NavShowShowcase() bool { return s.getString(SettingNavShowShowcase, "0") == "1" } // FooterShowShowcase 页脚是否展示开源展柜入口;缺省关闭 func (s *ForumSettingsService) FooterShowShowcase() bool { return s.getString(SettingFooterShowShowcase, "0") == "1" } // AsideShowShowcase 右侧栏是否展示开源展柜组件 func (s *ForumSettingsService) AsideShowShowcase() bool { for _, w := range s.AsideWidgets() { if w.ID == AsideWidgetShowcase { return w.Enabled } } return s.getString(SettingAsideShowShowcase, "0") == "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) } func (s *ForumSettingsService) SetNavShowShowcase(enabled bool) error { v := "0" if enabled { v = "1" } return s.setString(SettingNavShowShowcase, v) } func (s *ForumSettingsService) SetFooterShowShowcase(enabled bool) error { v := "0" if enabled { v = "1" } return s.setString(SettingFooterShowShowcase, v) } // SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步) func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error { return s.setAsideWidgetEnabled(AsideWidgetFriendLinks, SettingAsideShowFriendLinks, enabled) } // SetAsideShowcaseEnabled 更新右侧栏开源展柜组件开关(与 aside_widgets 同步) func (s *ForumSettingsService) SetAsideShowcaseEnabled(enabled bool) error { return s.setAsideWidgetEnabled(AsideWidgetShowcase, SettingAsideShowShowcase, enabled) } func (s *ForumSettingsService) setAsideWidgetEnabled(widgetID, boolSettingKey string, enabled bool) error { widgets := s.AsideWidgets() found := false for i := range widgets { if widgets[i].ID == widgetID { widgets[i].Enabled = enabled found = true break } } if !found { widgets = append(widgets, AsideWidget{ID: widgetID, 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" switch widgetID { case AsideWidgetFriendLinks: if bools.friendLinks { v = "1" } case AsideWidgetShowcase: if bools.showcase { v = "1" } default: if enabled { v = "1" } } return s.setString(boolSettingKey, v) } type asideWidgetBools struct { tagCloud bool recentComments bool friendLinks bool showcase bool } func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget { return []AsideWidget{ {ID: AsideWidgetTagCloud, Enabled: tagCloud}, {ID: AsideWidgetRecentComments, Enabled: recentComments}, {ID: AsideWidgetFriendLinks, Enabled: friendLinks}, {ID: AsideWidgetShowcase, Enabled: false}, } } 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 case AsideWidgetShowcase: out.showcase = w.Enabled } } return out } func isValidAsideWidgetID(id string) bool { switch id { case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase: 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 } func isValidFeedSortTabID(id string) bool { switch id { case FeedSortReply, FeedSortLatest, FeedSortHot: return true default: return false } } // NormalizeFeedSortTabs 校验并补全 Feed 排序标签(顺序保留,至少一项启用) func NormalizeFeedSortTabs(in []FeedSortTab) []FeedSortTab { seen := make(map[string]bool, len(feedSortTabDefaultOrder)) out := make([]FeedSortTab, 0, len(feedSortTabDefaultOrder)) for _, t := range in { id := strings.TrimSpace(t.ID) if !isValidFeedSortTabID(id) || seen[id] { continue } seen[id] = true label := strings.TrimSpace(t.Label) if label == "" { label = feedSortTabDefaultLabels[id] } out = append(out, FeedSortTab{ID: id, Label: label, Enabled: t.Enabled}) } for _, id := range feedSortTabDefaultOrder { if seen[id] { continue } out = append(out, FeedSortTab{ ID: id, Label: feedSortTabDefaultLabels[id], Enabled: true, }) } hasEnabled := false for _, t := range out { if t.Enabled { hasEnabled = true break } } if !hasEnabled && len(out) > 0 { out[0].Enabled = true } return out } func (s *ForumSettingsService) FeedSortTabs() []FeedSortTab { raw := strings.TrimSpace(s.getString(SettingFeedSortTabs, "")) if raw == "" { raw = feedSettingDefaults[SettingFeedSortTabs] } var tabs []FeedSortTab if raw != "" { _ = json.Unmarshal([]byte(raw), &tabs) } return NormalizeFeedSortTabs(tabs) } // DefaultFeedSort 返回配置中第一个启用的排序键 func (s *ForumSettingsService) DefaultFeedSort() string { for _, t := range s.FeedSortTabs() { if t.Enabled { return t.ID } } return FeedSortReply } // 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 model.DB.Model(&model.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 } // CommunityConfig 读取社区上报配置(无请求上下文;官网需靠已存 ROOT_URL / site_url) func (s *ForumSettingsService) CommunityConfig() CommunityConfig { return s.CommunityConfigForRequest("") } // CommunityConfigForRequest 读取社区配置;requestHint 可为 Origin / 公开 URL,用于识别官网 Host func (s *ForumSettingsService) CommunityConfigForRequest(requestHint string) CommunityConfig { return CommunityConfig{ ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1", HubEnabled: s.CommunityHubEnabled(requestHint), HubURL: DefaultCommunityHubURL, SiteURL: s.CommunitySiteURL(requestHint), InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")), } } // CommunityHubEnabled 是否作为社区枢纽:运维开关,或本站即为官方演示站域名 func (s *ForumSettingsService) CommunityHubEnabled(requestHint string) bool { s.mu.RLock() ops := s.communityHubEnabled s.mu.RUnlock() if ops { return true } official := officialCommunityHubHost() if official == "" { return false } for _, cand := range []string{ s.getString(SettingOIDCRootURL, ""), s.getString(SettingCommunitySiteURL, ""), requestHint, } { if hostFromURLOrHost(cand) == official { return true } } return false } // officialCommunityHubHost 官方枢纽规范化主机名(如 bbs.iioio.com) func officialCommunityHubHost() string { return hostFromURLOrHost(DefaultCommunityHubURL) } // hostFromURLOrHost 从 URL 或裸 Host 提取规范化主机名(小写、去 www、去端口) func hostFromURLOrHost(raw string) string { raw = strings.TrimSpace(strings.ToLower(raw)) if raw == "" { return "" } if strings.Contains(raw, "://") { u, err := url.Parse(raw) if err != nil || u.Host == "" { return "" } raw = u.Host } else if i := strings.IndexAny(raw, "/?"); i >= 0 { raw = raw[:i] } if raw == "" { return "" } if h, _, err := net.SplitHostPort(raw); err == nil { raw = h } return strings.TrimPrefix(raw, "www.") } // CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string { if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" { return strings.TrimRight(u, "/") } return s.SitePublicBaseURL(requestOrigin) } // EnsureCommunitySiteURL 在开启上报时确保有可用的本站公开地址;origin 可来自当前管理请求 func (s *ForumSettingsService) EnsureCommunitySiteURL(requestOrigin string) (string, error) { if u := s.CommunitySiteURL(requestOrigin); u != "" { // 若仅靠 Origin 推断,持久化以便后台 ticker 使用 if normalizeRootURL(s.getString(SettingCommunitySiteURL, "")) == "" && normalizeRootURL(s.getString(SettingOIDCRootURL, "")) == "" { if err := s.setString(SettingCommunitySiteURL, u); err != nil { return "", err } } return u, nil } return "", errors.New("无法确定本站公开地址:请先在 OIDC 设置中填写 ROOT_URL,或通过浏览器管理端开启上报") } // EnsureCommunityInstanceID 确保本机有稳定的匿名实例 ID func (s *ForumSettingsService) EnsureCommunityInstanceID() (string, error) { id := strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")) if id != "" { return id, nil } id = newCommunityInstanceID() if err := s.setString(SettingCommunityInstanceID, id); err != nil { return "", err } return id, nil } // UpdateCommunityConfig 仅更新上报开关;忽略客户端传入的 hub_url / site_url func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasReportEnabled bool, err error) { wasReportEnabled = s.getString(SettingCommunityReportEnabled, "0") == "1" report := "0" if in.ReportEnabled { report = "1" } if err := s.setString(SettingCommunityReportEnabled, report); err != nil { return wasReportEnabled, err } if in.ReportEnabled { if _, err := s.EnsureCommunityInstanceID(); err != nil { return wasReportEnabled, err } } return wasReportEnabled, nil } // DefaultMonitorExcludeRules 导出推荐排除规则(供设置页「恢复默认」) func DefaultMonitorExcludeRules() []string { return parseMonitorExcludeJSON(DefaultMonitorExcludeJSON) } func normalizeMonitorRetentionDays(v int) int { if v < 1 { return 1 } if v > 365 { return 365 } return v } // MonitorConfig 读取网站监控设置 func (s *ForumSettingsService) MonitorConfig() MonitorConfig { retention, _ := strconv.Atoi(s.getString(SettingMonitorRetention, "30")) accessRetention, _ := strconv.Atoi(s.getString(SettingMonitorAccessLogRetention, "7")) return MonitorConfig{ Enabled: s.getString(SettingMonitorEnabled, "0") == "1", RetentionDays: normalizeMonitorRetentionDays(retention), AccessLogRetentionDays: normalizeMonitorRetentionDays(accessRetention), ExcludeRules: parseMonitorExcludeJSON(s.getString(SettingMonitorExclude, DefaultMonitorExcludeJSON)), DefaultExcludeRules: DefaultMonitorExcludeRules(), TrustProxy: s.getString(SettingMonitorTrustProxy, "1") == "1", } } // MonitorEnabled 采集是否开启 func (s *ForumSettingsService) MonitorEnabled() bool { return s.getString(SettingMonitorEnabled, "0") == "1" } // UpdateMonitorConfig 更新网站监控设置 func (s *ForumSettingsService) UpdateMonitorConfig(in MonitorConfig) error { enabled := "0" if in.Enabled { enabled = "1" } retention := normalizeMonitorRetentionDays(in.RetentionDays) accessRetention := normalizeMonitorRetentionDays(in.AccessLogRetentionDays) trust := "0" if in.TrustProxy { trust = "1" } rules := in.ExcludeRules if rules == nil { rules = parseMonitorExcludeJSON(DefaultMonitorExcludeJSON) } raw, err := json.Marshal(normalizeMonitorExclude(rules)) if err != nil { return err } updates := map[string]string{ SettingMonitorEnabled: enabled, SettingMonitorRetention: strconv.Itoa(retention), SettingMonitorAccessLogRetention: strconv.Itoa(accessRetention), SettingMonitorExclude: string(raw), SettingMonitorTrustProxy: trust, } for key, val := range updates { if err := s.setString(key, val); err != nil { return err } } return nil } func parseMonitorExcludeJSON(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { raw = DefaultMonitorExcludeJSON } var rules []string if err := json.Unmarshal([]byte(raw), &rules); err != nil { _ = json.Unmarshal([]byte(DefaultMonitorExcludeJSON), &rules) } return normalizeMonitorExclude(rules) } func normalizeMonitorExclude(rules []string) []string { seen := make(map[string]struct{}, len(rules)) out := make([]string, 0, len(rules)) for _, r := range rules { r = strings.TrimSpace(r) if r == "" { continue } if _, ok := seen[r]; ok { continue } seen[r] = struct{}{} out = append(out, r) } return out } // 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 }