package service import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log/slog" "net/url" "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" ) func normalizeLoginAgreementMode(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "checkbox": return "checkbox" default: return defaultLoginAgreementMode } } func defaultLoginAgreementDocuments() []LoginAgreementDocument { return []LoginAgreementDocument{ { ID: "terms", Title: "服务条款", ContentMD: "", }, { ID: "usage-policy", Title: "使用政策", ContentMD: "", }, { ID: "supported-regions", Title: "支持的国家和地区", ContentMD: "", }, { ID: "service-specific-terms", Title: "服务特定条款", ContentMD: "", }, } } func normalizeLoginAgreementDocumentID(raw string) string { raw = strings.ToLower(strings.TrimSpace(raw)) var b strings.Builder lastSeparator := false for _, r := range raw { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { _, _ = b.WriteRune(r) lastSeparator = false continue } if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' { if !lastSeparator && b.Len() > 0 { if r == '_' { _, _ = b.WriteRune('_') } else { _, _ = b.WriteRune('-') } lastSeparator = true } } } return strings.Trim(b.String(), "-_") } func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument { normalized := make([]LoginAgreementDocument, 0, len(docs)) seen := make(map[string]int, len(docs)) for i, doc := range docs { title := strings.TrimSpace(doc.Title) content := strings.TrimSpace(doc.ContentMD) if title == "" && content == "" { continue } id := normalizeLoginAgreementDocumentID(doc.ID) if id == "" { sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content))) id = hex.EncodeToString(sum[:])[:12] } baseID := id for suffix := 2; seen[id] > 0; suffix++ { id = fmt.Sprintf("%s-%d", baseID, suffix) } seen[id]++ normalized = append(normalized, LoginAgreementDocument{ ID: id, Title: title, ContentMD: content, }) } return normalized } func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument { raw = strings.TrimSpace(raw) if raw == "" { return defaultLoginAgreementDocuments() } var docs []LoginAgreementDocument if err := json.Unmarshal([]byte(raw), &docs); err != nil { return defaultLoginAgreementDocuments() } docs = normalizeLoginAgreementDocuments(docs) if len(docs) == 0 { return defaultLoginAgreementDocuments() } return docs } func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) { normalized := normalizeLoginAgreementDocuments(docs) if len(normalized) == 0 { normalized = defaultLoginAgreementDocuments() } b, err := json.Marshal(normalized) if err != nil { return "", fmt.Errorf("marshal login agreement documents: %w", err) } return string(b), nil } func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string { normalized := normalizeLoginAgreementDocuments(docs) payload, err := json.Marshal(struct { UpdatedAt string `json:"updated_at"` Documents []LoginAgreementDocument `json:"documents"` }{ UpdatedAt: strings.TrimSpace(updatedAt), Documents: normalized, }) if err != nil { payload = []byte(strings.TrimSpace(updatedAt)) } sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:])[:16] } // GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件) func (s *SettingService) GetFrontendURL(ctx context.Context) string { val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL) if err == nil && strings.TrimSpace(val) != "" { return strings.TrimSpace(val) } return s.cfg.Server.FrontendURL } // GetPublicSettings 获取公开设置(无需登录) func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) { keys := []string{ SettingKeyRegistrationEnabled, SettingKeyEmailVerifyEnabled, SettingKeyForceEmailOnThirdPartySignup, SettingKeyRegistrationEmailSuffixWhitelist, SettingKeyRegistrationEmailDomainQuotaEnabled, SettingKeyPromoCodeEnabled, SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, SettingKeyTotpEnabled, SettingKeyPasskeyEnabled, SettingKeyLoginAgreementEnabled, SettingKeyLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt, SettingKeyLoginAgreementDocuments, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, SettingKeyTencentCaptchaEnabled, SettingKeyTencentCaptchaAppID, SettingKeyTencentCaptchaRegion, SettingKeyAliyunCaptchaEnabled, SettingKeyAliyunCaptchaSceneID, SettingKeyAliyunCaptchaPrefix, SettingKeyAliyunCaptchaRegion, SettingKeyAPIKeyACLTrustForwardedIP, SettingKeySiteName, SettingKeySiteLogo, SettingKeySiteSubtitle, SettingKeyAPIBaseURL, SettingKeyContactInfo, SettingKeyDocURL, SettingKeyHomeContent, SettingKeyCompactHomeEnabled, SettingKeyHideCcsImportButton, SettingKeyPurchaseSubscriptionEnabled, SettingKeyPurchaseSubscriptionURL, SettingKeyTableDefaultPageSize, SettingKeyTablePageSizeOptions, SettingKeyCustomMenuItems, SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, SettingKeyDingTalkConnectEnabled, SettingKeyWeChatConnectEnabled, SettingKeyWeChatConnectAppID, SettingKeyWeChatConnectAppSecret, SettingKeyWeChatConnectOpenAppID, SettingKeyWeChatConnectOpenAppSecret, SettingKeyWeChatConnectMPAppID, SettingKeyWeChatConnectMPAppSecret, SettingKeyWeChatConnectMobileAppID, SettingKeyWeChatConnectMobileAppSecret, SettingKeyWeChatConnectOpenEnabled, SettingKeyWeChatConnectMPEnabled, SettingKeyWeChatConnectMobileEnabled, SettingKeyWeChatConnectMode, SettingKeyWeChatConnectScopes, SettingKeyWeChatConnectRedirectURL, SettingKeyWeChatConnectFrontendRedirectURL, SettingKeyBackendModeEnabled, SettingPaymentEnabled, SettingKeyOIDCConnectEnabled, SettingKeyOIDCConnectProviderName, SettingKeyGitHubOAuthEnabled, SettingKeyGitHubOAuthClientID, SettingKeyGitHubOAuthClientSecret, SettingKeyGoogleOAuthEnabled, SettingKeyGoogleOAuthClientID, SettingKeyGoogleOAuthClientSecret, SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL, SettingKeyAccountQuotaNotifyEnabled, SettingKeyChannelMonitorEnabled, SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, SettingKeyChannelMonitorShowQuota, SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, SettingKeyAffiliateEnabled, SettingKeyRiskControlEnabled, SettingKeyAllowUserViewErrorRequests, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return nil, fmt.Errorf("get public settings: %w", err) } linuxDoEnabled := false if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { linuxDoEnabled = raw == "true" } else { linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled } dingTalkEnabled := false if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { dingTalkEnabled = raw == "true" } else { dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled } oidcEnabled := false if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { oidcEnabled = raw == "true" } else { oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled } oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName]) if oidcProviderName == "" && s.cfg != nil { oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName) } if oidcProviderName == "" { oidcProviderName = "OIDC" } gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github") googleEnabled := s.emailOAuthPublicEnabled(settings, "google") weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings) // Password reset requires email verification to be enabled emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist( settings[SettingKeyRegistrationEmailSuffixWhitelist], ) tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences( settings[SettingKeyTableDefaultPageSize], settings[SettingKeyTablePageSizeOptions], ) loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) if loginAgreementUpdatedAt == "" { loginAgreementUpdatedAt = defaultLoginAgreementDate } var balanceLowNotifyThreshold float64 if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { balanceLowNotifyThreshold = v } return &PublicSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true", PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), LoginAgreementUpdatedAt: loginAgreementUpdatedAt, LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), LoginAgreementDocuments: loginAgreementDocuments, TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]), AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], DocURL: settings[SettingKeyDocURL], HomeContent: settings[SettingKeyHomeContent], CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), TableDefaultPageSize: tableDefaultPageSize, TablePageSizeOptions: tablePageSizeOptions, CustomMenuItems: settings[SettingKeyCustomMenuItems], CustomEndpoints: settings[SettingKeyCustomEndpoints], LinuxDoOAuthEnabled: linuxDoEnabled, DingTalkOAuthEnabled: dingTalkEnabled, WeChatOAuthEnabled: weChatEnabled, WeChatOAuthOpenEnabled: weChatOpenEnabled, WeChatOAuthMPEnabled: weChatMPEnabled, WeChatOAuthMobileEnabled: weChatMobileEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", PaymentEnabled: settings[SettingPaymentEnabled] == "true", OIDCOAuthEnabled: oidcEnabled, OIDCOAuthProviderName: oidcProviderName, GitHubOAuthEnabled: gitHubEnabled, GoogleOAuthEnabled: googleEnabled, BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", BalanceLowNotifyThreshold: balanceLowNotifyThreshold, BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]), ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true", AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true", ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true", AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true", }, nil } // channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval // (mirrors the monitor-level constraint but lives here so setting_service stays decoupled). const ( channelMonitorIntervalMin = 15 channelMonitorIntervalMax = 3600 channelMonitorIntervalFallback = 60 defaultChannelMonitorMode = ChannelMonitorModeV1 ) // normalizeChannelMonitorMode accepts only v1/v2; empty/invalid → v1 (safe default). func normalizeChannelMonitorMode(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case ChannelMonitorModeV1, "": return ChannelMonitorModeV1 case ChannelMonitorModeV2: return ChannelMonitorModeV2 default: return defaultChannelMonitorMode } } // parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. // Empty / invalid input falls back to channelMonitorIntervalFallback. func parseChannelMonitorInterval(raw string) int { v, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil { return channelMonitorIntervalFallback } return clampChannelMonitorInterval(v) } // clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided". func clampChannelMonitorInterval(v int) int { if v <= 0 { return 0 } if v < channelMonitorIntervalMin { return channelMonitorIntervalMin } if v > channelMonitorIntervalMax { return channelMonitorIntervalMax } return v } // ChannelMonitorRuntime is the lightweight view of the channel monitor feature // consumed by the runner, V2 aggregator, and user-facing handlers. type ChannelMonitorRuntime struct { Enabled bool Mode string // ChannelMonitorModeV1 or ChannelMonitorModeV2 DefaultIntervalSeconds int // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals. HideThroughput bool // ShowQuota: when true, user-facing monitor views keep the quota/balance // snapshots; otherwise the user handler strips them server-side. // Parsed fail-closed (only literal "true" enables). Admin always sees them. ShowQuota bool } // ActiveProbesAllowed reports whether V1 active provider probes may run. func (r ChannelMonitorRuntime) ActiveProbesAllowed() bool { return r.Enabled && r.Mode == ChannelMonitorModeV1 } // PassiveAggregationAllowed reports whether V2 passive aggregation may run. func (r ChannelMonitorRuntime) PassiveAggregationAllowed() bool { return r.Enabled && r.Mode == ChannelMonitorModeV2 } // GetChannelMonitorRuntime reads the channel monitor feature flags directly from // the settings store. Fail-open: on error returns Enabled=true, Mode=v1, default interval. func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { if s == nil || s.settingRepo == nil { return ChannelMonitorRuntime{ Enabled: true, Mode: defaultChannelMonitorMode, DefaultIntervalSeconds: channelMonitorIntervalFallback, HideThroughput: true, } } vals, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyChannelMonitorEnabled, SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, SettingKeyChannelMonitorShowQuota, }) if err != nil { return ChannelMonitorRuntime{ Enabled: true, Mode: defaultChannelMonitorMode, DefaultIntervalSeconds: channelMonitorIntervalFallback, HideThroughput: true, } } return ChannelMonitorRuntime{ Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]), DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]), ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true", } } // AvailableChannelsRuntime is the lightweight view of the available-channels feature // switch consumed by the user-facing handler. type AvailableChannelsRuntime struct { Enabled bool } // GetAvailableChannelsRuntime reads the available-channels feature switch directly // from the settings store. Fail-closed: on error returns Enabled=false, matching // the opt-in default (unknown ↔ disabled). func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime { vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled}) if err != nil { return AvailableChannelsRuntime{Enabled: false} } return AvailableChannelsRuntime{ Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true", } } // ModelPlazaRuntime is the lightweight view of the model-plaza feature consumed // by the public plaza handler. type ModelPlazaRuntime struct { Enabled bool RequireAuth bool Description string } // GetModelPlazaRuntime reads the model-plaza feature switches directly from the // settings store. Fail-closed: on error returns Enabled=false, matching the // opt-in default (unknown ↔ disabled). func (s *SettingService) GetModelPlazaRuntime(ctx context.Context) ModelPlazaRuntime { vals, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, SettingKeyModelPlazaDescription, }) if err != nil { return ModelPlazaRuntime{Enabled: false} } return ModelPlazaRuntime{ Enabled: vals[SettingKeyModelPlazaEnabled] == "true", RequireAuth: vals[SettingKeyModelPlazaRequireAuth] == "true", Description: vals[SettingKeyModelPlazaDescription], } } // IsUserErrorViewAllowed reads the user-facing error-requests visibility switch // directly from the settings store. Fail-closed: on error returns false (opt-in default). func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests}) if err != nil { slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err) return false } return vals[SettingKeyAllowUserViewErrorRequests] == "true" } // PublicSettingsInjectionPayload is the JSON shape embedded into HTML as // `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site // config before the first XHR finishes. // // INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings. // If you forget a feature-flag field here, the frontend's // `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the // async `/api/v1/settings/public` call returns — which causes opt-in menus // (strict `=== true`) to flicker off/on. See // frontend/src/utils/featureFlags.ts for the matching registry. // // A unit test diffs this struct's JSON keys against dto.PublicSettings to catch // drift automatically (see setting_service_injection_test.go). type PublicSettingsInjectionPayload struct { RegistrationEnabled bool `json:"registration_enabled"` EmailVerifyEnabled bool `json:"email_verify_enabled"` RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` PromoCodeEnabled bool `json:"promo_code_enabled"` PasswordResetEnabled bool `json:"password_reset_enabled"` InvitationCodeEnabled bool `json:"invitation_code_enabled"` TotpEnabled bool `json:"totp_enabled"` PasskeyEnabled bool `json:"passkey_enabled"` LoginAgreementEnabled bool `json:"login_agreement_enabled"` LoginAgreementMode string `json:"login_agreement_mode"` LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` LoginAgreementRevision string `json:"login_agreement_revision"` LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key"` TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` TencentCaptchaAppID string `json:"tencent_captcha_app_id"` TencentCaptchaRegion string `json:"tencent_captcha_region"` AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` AliyunCaptchaRegion string `json:"aliyun_captcha_region"` SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` DocURL string `json:"doc_url"` HomeContent string `json:"home_content"` CompactHomeEnabled bool `json:"compact_home_enabled"` HideCcsImportButton bool `json:"hide_ccs_import_button"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"` TableDefaultPageSize int `json:"table_default_page_size"` TablePageSizeOptions []int `json:"table_page_size_options"` CustomMenuItems json.RawMessage `json:"custom_menu_items"` CustomEndpoints json.RawMessage `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` GitHubOAuthEnabled bool `json:"github_oauth_enabled"` GoogleOAuthEnabled bool `json:"google_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 ServerTimezone string `json:"server_timezone"` ServerUTCOffset string `json:"server_utc_offset"` BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` // Feature flags — MUST match the opt-in/opt-out registry in // frontend/src/utils/featureFlags.ts. Missing a field here is the bug // that hid the "可用渠道" menu on page refresh. ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorMode string `json:"channel_monitor_mode"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` // ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM // without waiting for API redaction alone (defense in depth). ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` // ChannelMonitorShowQuota gates the user-facing quota/balance display on // monitors; fail-closed (absent/false = hidden). Admin UI always shows it. ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` AffiliateEnabled bool `json:"affiliate_enabled"` RiskControlEnabled bool `json:"risk_control_enabled"` AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. // This implements the web.PublicSettingsProvider interface. func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) { settings, err := s.GetPublicSettings(ctx) if err != nil { return nil, err } return &PublicSettingsInjectionPayload{ RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, PasskeyEnabled: settings.PasskeyEnabled, LoginAgreementEnabled: settings.LoginAgreementEnabled, LoginAgreementMode: settings.LoginAgreementMode, LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, LoginAgreementRevision: settings.LoginAgreementRevision, LoginAgreementDocuments: settings.LoginAgreementDocuments, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, TencentCaptchaEnabled: settings.TencentCaptchaEnabled, TencentCaptchaAppID: settings.TencentCaptchaAppID, TencentCaptchaRegion: settings.TencentCaptchaRegion, AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, AliyunCaptchaRegion: settings.AliyunCaptchaRegion, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, APIBaseURL: settings.APIBaseURL, ContactInfo: settings.ContactInfo, DocURL: settings.DocURL, HomeContent: settings.HomeContent, CompactHomeEnabled: settings.CompactHomeEnabled, HideCcsImportButton: settings.HideCcsImportButton, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, TableDefaultPageSize: settings.TableDefaultPageSize, TablePageSizeOptions: settings.TablePageSizeOptions, CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthProviderName: settings.OIDCOAuthProviderName, GitHubOAuthEnabled: settings.GitHubOAuthEnabled, GoogleOAuthEnabled: settings.GoogleOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, Version: s.version, ServerTimezone: timezone.Name(), ServerUTCOffset: timezone.UTCOffset(), BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, }, nil } // filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON // array string, returning only items with visibility != "admin". func filterUserVisibleMenuItems(raw string) json.RawMessage { raw = strings.TrimSpace(raw) if raw == "" || raw == "[]" { return json.RawMessage("[]") } var items []struct { Visibility string `json:"visibility"` } if err := json.Unmarshal([]byte(raw), &items); err != nil { return json.RawMessage("[]") } // Parse full items to preserve all fields var fullItems []json.RawMessage if err := json.Unmarshal([]byte(raw), &fullItems); err != nil { return json.RawMessage("[]") } var filtered []json.RawMessage for i, item := range items { if item.Visibility != "admin" { filtered = append(filtered, fullItems[i]) } } if len(filtered) == 0 { return json.RawMessage("[]") } result, err := json.Marshal(filtered) if err != nil { return json.RawMessage("[]") } return result } // safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". func safeRawJSONArray(raw string) json.RawMessage { raw = strings.TrimSpace(raw) if raw == "" { return json.RawMessage("[]") } if json.Valid([]byte(raw)) { return json.RawMessage(raw) } return json.RawMessage("[]") } // GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL, // purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { settings, err := s.GetPublicSettings(ctx) if err != nil { return nil, err } seen := make(map[string]struct{}) var origins []string addOrigin := func(rawURL string) { if origin := extractOriginFromURL(rawURL); origin != "" { if _, ok := seen[origin]; !ok { seen[origin] = struct{}{} origins = append(origins, origin) } } } // home content URL (when home_content is set to a URL for iframe embedding) addOrigin(settings.HomeContent) // purchase subscription URL if settings.PurchaseSubscriptionEnabled { addOrigin(settings.PurchaseSubscriptionURL) } // all custom menu items (including admin-only, since CSP must allow all iframes) for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) { addOrigin(item) } return origins, nil } // extractOriginFromURL returns the scheme+host origin from rawURL. // Only http and https schemes are accepted. func extractOriginFromURL(rawURL string) string { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return "" } u, err := url.Parse(rawURL) if err != nil || u.Host == "" { return "" } if u.Scheme != "http" && u.Scheme != "https" { return "" } return u.Scheme + "://" + u.Host } // parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items. func parseCustomMenuItemURLs(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" || raw == "[]" { return nil } var items []struct { URL string `json:"url"` } if err := json.Unmarshal([]byte(raw), &items); err != nil { return nil } urls := make([]string, 0, len(items)) for _, item := range items { if item.URL != "" { urls = append(urls, item.URL) } } return urls }