作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
841 lines
25 KiB
Go
841 lines
25 KiB
Go
package service
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
|
||
"git.iioio.com/freefire/jiang13-forum/model"
|
||
)
|
||
|
||
// 论坛设置键名
|
||
const (
|
||
SettingPostEditWindowHours = "post_edit_window_hours"
|
||
|
||
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"
|
||
|
||
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
|
||
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
|
||
|
||
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"
|
||
// 遗留单客户端字段(仅用于迁移到 oauth_clients)
|
||
SettingOAuthClientID = "oauth_client_id"
|
||
SettingOAuthClientSecret = "oauth_client_secret"
|
||
SettingOAuthRedirectURIs = "oauth_redirect_uris"
|
||
|
||
SettingGiteaSyncEnabled = "gitea_sync_enabled"
|
||
SettingGiteaBaseURL = "gitea_base_url"
|
||
SettingGiteaToken = "gitea_token"
|
||
SettingGiteaSyncIntervalMin = "gitea_sync_interval_min"
|
||
|
||
SettingSiteName = "site_name"
|
||
SettingSiteNameEN = "site_name_en"
|
||
SettingSiteSlogan = "site_slogan"
|
||
SettingSiteLogoMark = "site_logo_mark"
|
||
SettingSiteLogo = "site_logo"
|
||
SettingSiteFavicon = "site_favicon"
|
||
|
||
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
||
pageSizeAPIMax = 100
|
||
)
|
||
|
||
// ForumLimits 论坛可配置限制(API 传输结构)
|
||
type ForumLimits struct {
|
||
PostEditWindowHours int `json:"post_edit_window_hours"`
|
||
|
||
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"`
|
||
|
||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||
}
|
||
|
||
// 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"`
|
||
|
||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||
}
|
||
|
||
type settingDef struct {
|
||
key string
|
||
defaultVal string
|
||
min int
|
||
max int // 0 表示不限制上限
|
||
}
|
||
|
||
var forumSettingDefs = []settingDef{
|
||
{SettingPostEditWindowHours, "24", 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},
|
||
|
||
{SettingOpenPostsInNewTab, "1", 0, 1},
|
||
{SettingOpenContentLinksInNewTab, "1", 0, 1},
|
||
}
|
||
|
||
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",
|
||
SettingOAuthClientID: "",
|
||
SettingOAuthClientSecret: "",
|
||
SettingOAuthRedirectURIs: "",
|
||
}
|
||
|
||
var giteaSettingDefaults = map[string]string{
|
||
SettingGiteaSyncEnabled: "0",
|
||
SettingGiteaBaseURL: "",
|
||
SettingGiteaToken: "",
|
||
SettingGiteaSyncIntervalMin: "60",
|
||
}
|
||
|
||
var siteBrandingDefaults = map[string]string{
|
||
SettingSiteName: "姜十三论坛",
|
||
SettingSiteNameEN: "Jiang13 Forum",
|
||
SettingSiteSlogan: "拾三一隅,自在交流",
|
||
SettingSiteLogoMark: "姜",
|
||
SettingSiteLogo: "",
|
||
SettingSiteFavicon: "",
|
||
}
|
||
|
||
// SiteBranding 站点品牌配置(名称、Logo、Favicon 等)
|
||
type SiteBranding struct {
|
||
Name string `json:"name"`
|
||
NameEN string `json:"name_en"`
|
||
Slogan string `json:"slogan"`
|
||
LogoMark string `json:"logo_mark"`
|
||
Logo string `json:"logo"`
|
||
Favicon string `json:"favicon"`
|
||
}
|
||
|
||
// GiteaSyncConfig Gitea 仓库同步配置
|
||
type GiteaSyncConfig struct {
|
||
Enabled bool `json:"enabled"`
|
||
BaseURL string `json:"base_url"`
|
||
Token string `json:"token,omitempty"` // 更新时传入;回显时为空
|
||
HasToken bool `json:"has_token"`
|
||
SyncIntervalMin int `json:"sync_interval_min"`
|
||
Ready bool `json:"ready"`
|
||
RepoCount int64 `json:"repo_count"`
|
||
}
|
||
|
||
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
|
||
type OIDCConfig struct {
|
||
Enabled bool `json:"enabled"`
|
||
RootURL string `json:"root_url"`
|
||
Ready bool `json:"ready"`
|
||
DiscoveryURL string `json:"discovery_url,omitempty"`
|
||
AuthorizeURL string `json:"authorize_url,omitempty"`
|
||
LogoutURL string `json:"logout_url,omitempty"`
|
||
GroupClaim string `json:"group_claim"`
|
||
AdminGroup string `json:"admin_group"`
|
||
UserGroup string `json:"user_group"`
|
||
ClientCount int64 `json:"client_count"`
|
||
}
|
||
|
||
// ForumSettingsService 论坛全局设置
|
||
type ForumSettingsService struct {
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
func NewForumSettingsService() *ForumSettingsService {
|
||
s := &ForumSettingsService{}
|
||
s.ensureDefaults()
|
||
return s
|
||
}
|
||
|
||
func (s *ForumSettingsService) ensureDefaults() {
|
||
for _, def := range forumSettingDefs {
|
||
var count int64
|
||
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 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 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})
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
return ForumLimits{
|
||
PostEditWindowHours: s.PostEditWindowHours(),
|
||
|
||
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(),
|
||
|
||
OpenPostsInNewTab: s.OpenPostsInNewTab(),
|
||
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
|
||
}
|
||
}
|
||
|
||
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,
|
||
|
||
OpenPostsInNewTab: limits.OpenPostsInNewTab,
|
||
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
|
||
}
|
||
}
|
||
|
||
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||
updates := map[string]int{
|
||
SettingPostEditWindowHours: in.PostEditWindowHours,
|
||
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,
|
||
}
|
||
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
|
||
}
|
||
}
|
||
boolUpdates := map[string]bool{
|
||
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
|
||
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
|
||
}
|
||
for key, on := range boolUpdates {
|
||
v := "0"
|
||
if on {
|
||
v = "1"
|
||
}
|
||
if err := s.setString(key, v); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *ForumSettingsService) PostEditWindowHours() int {
|
||
return s.getInt(SettingPostEditWindowHours, 24)
|
||
}
|
||
|
||
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) OpenPostsInNewTab() bool {
|
||
return s.getString(SettingOpenPostsInNewTab, "1") == "1"
|
||
}
|
||
|
||
func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
|
||
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// SeedOIDCFromINI 若库中尚未配置,则用 app.ini 种子一次(便于迁移)
|
||
func (s *ForumSettingsService) SeedOIDCFromINI(rootURL, clientID, clientSecret, redirectURIsCSV string) {
|
||
rootURL = normalizeRootURL(rootURL)
|
||
clientID = strings.TrimSpace(clientID)
|
||
clientSecret = strings.TrimSpace(clientSecret)
|
||
uris := normalizeRedirectURIs(redirectURIsCSV)
|
||
if rootURL == "" && clientID == "" && clientSecret == "" && uris == "" {
|
||
return
|
||
}
|
||
if s.getString(SettingOIDCRootURL, "") == "" && rootURL != "" {
|
||
_ = s.setString(SettingOIDCRootURL, rootURL)
|
||
}
|
||
if s.getString(SettingOAuthClientID, "") == "" && clientID != "" {
|
||
_ = s.setString(SettingOAuthClientID, clientID)
|
||
}
|
||
if s.getString(SettingOAuthClientSecret, "") == "" && clientSecret != "" {
|
||
_ = s.setString(SettingOAuthClientSecret, clientSecret)
|
||
}
|
||
if s.getString(SettingOAuthRedirectURIs, "") == "" && uris != "" {
|
||
_ = s.setString(SettingOAuthRedirectURIs, uris)
|
||
}
|
||
s.MigrateLegacyOIDCClient()
|
||
if s.getString(SettingOIDCEnabled, "0") == "0" &&
|
||
s.getString(SettingOIDCRootURL, "") != "" &&
|
||
CountEnabledOAuthClients() > 0 {
|
||
_ = s.setString(SettingOIDCEnabled, "1")
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// SeedGiteaFromINI 若库中尚未配置,则用 app.ini 种子一次
|
||
func (s *ForumSettingsService) SeedGiteaFromINI(baseURL, token string, enabled bool) {
|
||
baseURL = normalizeRootURL(baseURL)
|
||
token = strings.TrimSpace(token)
|
||
if baseURL == "" && token == "" && !enabled {
|
||
return
|
||
}
|
||
if s.getString(SettingGiteaBaseURL, "") == "" && baseURL != "" {
|
||
_ = s.setString(SettingGiteaBaseURL, baseURL)
|
||
}
|
||
if s.getString(SettingGiteaToken, "") == "" && token != "" {
|
||
_ = s.setString(SettingGiteaToken, token)
|
||
}
|
||
if enabled && s.getString(SettingGiteaSyncEnabled, "0") == "0" &&
|
||
s.getString(SettingGiteaBaseURL, "") != "" &&
|
||
s.getString(SettingGiteaToken, "") != "" {
|
||
_ = s.setString(SettingGiteaSyncEnabled, "1")
|
||
}
|
||
}
|
||
|
||
// 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])
|
||
}
|
||
return SiteBranding{
|
||
Name: name,
|
||
NameEN: strings.TrimSpace(s.getString(SettingSiteNameEN, siteBrandingDefaults[SettingSiteNameEN])),
|
||
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
|
||
LogoMark: mark,
|
||
Logo: strings.TrimSpace(s.getString(SettingSiteLogo, "")),
|
||
Favicon: strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
|
||
}
|
||
}
|
||
|
||
// 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])
|
||
}
|
||
nameEN := strings.TrimSpace(in.NameEN)
|
||
if len([]rune(nameEN)) > 64 {
|
||
return ErrInvalidSetting
|
||
}
|
||
slogan := strings.TrimSpace(in.Slogan)
|
||
if len([]rune(slogan)) > 200 {
|
||
return ErrInvalidSetting
|
||
}
|
||
updates := map[string]string{
|
||
SettingSiteName: name,
|
||
SettingSiteNameEN: nameEN,
|
||
SettingSiteSlogan: slogan,
|
||
SettingSiteLogoMark: mark,
|
||
}
|
||
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))
|
||
}
|
||
|
||
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
|
||
}
|