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
830 lines
36 KiB
Go
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
|
|
}
|