Files
jiang13-forum/service/settings.go
freefire 429956c594 feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 07:55:45 +08:00

1896 lines
60 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"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"
// 伪静态键名见 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"
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"`
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"
AsideWidgetShowcase = "showcase"
)
var asideWidgetDefaultOrder = []string{
AsideWidgetTagCloud,
AsideWidgetRecentComments,
AsideWidgetRecentUsers,
AsideWidgetFriendLinks,
AsideWidgetShowcase,
}
// 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"`
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",
}
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(),
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,
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
}
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
}
// 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
}