Files
sub2api/backend/internal/service/setting_public.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

830 lines
36 KiB
Go

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
}