setting_public.go 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "encoding/json"
  7. "fmt"
  8. "log/slog"
  9. "net/url"
  10. "strconv"
  11. "strings"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  13. )
  14. func normalizeLoginAgreementMode(raw string) string {
  15. switch strings.ToLower(strings.TrimSpace(raw)) {
  16. case "checkbox":
  17. return "checkbox"
  18. default:
  19. return defaultLoginAgreementMode
  20. }
  21. }
  22. func defaultLoginAgreementDocuments() []LoginAgreementDocument {
  23. return []LoginAgreementDocument{
  24. {
  25. ID: "terms",
  26. Title: "服务条款",
  27. ContentMD: "",
  28. },
  29. {
  30. ID: "usage-policy",
  31. Title: "使用政策",
  32. ContentMD: "",
  33. },
  34. {
  35. ID: "supported-regions",
  36. Title: "支持的国家和地区",
  37. ContentMD: "",
  38. },
  39. {
  40. ID: "service-specific-terms",
  41. Title: "服务特定条款",
  42. ContentMD: "",
  43. },
  44. }
  45. }
  46. func normalizeLoginAgreementDocumentID(raw string) string {
  47. raw = strings.ToLower(strings.TrimSpace(raw))
  48. var b strings.Builder
  49. lastSeparator := false
  50. for _, r := range raw {
  51. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
  52. _, _ = b.WriteRune(r)
  53. lastSeparator = false
  54. continue
  55. }
  56. if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' {
  57. if !lastSeparator && b.Len() > 0 {
  58. if r == '_' {
  59. _, _ = b.WriteRune('_')
  60. } else {
  61. _, _ = b.WriteRune('-')
  62. }
  63. lastSeparator = true
  64. }
  65. }
  66. }
  67. return strings.Trim(b.String(), "-_")
  68. }
  69. func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument {
  70. normalized := make([]LoginAgreementDocument, 0, len(docs))
  71. seen := make(map[string]int, len(docs))
  72. for i, doc := range docs {
  73. title := strings.TrimSpace(doc.Title)
  74. content := strings.TrimSpace(doc.ContentMD)
  75. if title == "" && content == "" {
  76. continue
  77. }
  78. id := normalizeLoginAgreementDocumentID(doc.ID)
  79. if id == "" {
  80. sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content)))
  81. id = hex.EncodeToString(sum[:])[:12]
  82. }
  83. baseID := id
  84. for suffix := 2; seen[id] > 0; suffix++ {
  85. id = fmt.Sprintf("%s-%d", baseID, suffix)
  86. }
  87. seen[id]++
  88. normalized = append(normalized, LoginAgreementDocument{
  89. ID: id,
  90. Title: title,
  91. ContentMD: content,
  92. })
  93. }
  94. return normalized
  95. }
  96. func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument {
  97. raw = strings.TrimSpace(raw)
  98. if raw == "" {
  99. return defaultLoginAgreementDocuments()
  100. }
  101. var docs []LoginAgreementDocument
  102. if err := json.Unmarshal([]byte(raw), &docs); err != nil {
  103. return defaultLoginAgreementDocuments()
  104. }
  105. docs = normalizeLoginAgreementDocuments(docs)
  106. if len(docs) == 0 {
  107. return defaultLoginAgreementDocuments()
  108. }
  109. return docs
  110. }
  111. func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) {
  112. normalized := normalizeLoginAgreementDocuments(docs)
  113. if len(normalized) == 0 {
  114. normalized = defaultLoginAgreementDocuments()
  115. }
  116. b, err := json.Marshal(normalized)
  117. if err != nil {
  118. return "", fmt.Errorf("marshal login agreement documents: %w", err)
  119. }
  120. return string(b), nil
  121. }
  122. func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string {
  123. normalized := normalizeLoginAgreementDocuments(docs)
  124. payload, err := json.Marshal(struct {
  125. UpdatedAt string `json:"updated_at"`
  126. Documents []LoginAgreementDocument `json:"documents"`
  127. }{
  128. UpdatedAt: strings.TrimSpace(updatedAt),
  129. Documents: normalized,
  130. })
  131. if err != nil {
  132. payload = []byte(strings.TrimSpace(updatedAt))
  133. }
  134. sum := sha256.Sum256(payload)
  135. return hex.EncodeToString(sum[:])[:16]
  136. }
  137. // GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件)
  138. func (s *SettingService) GetFrontendURL(ctx context.Context) string {
  139. val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL)
  140. if err == nil && strings.TrimSpace(val) != "" {
  141. return strings.TrimSpace(val)
  142. }
  143. return s.cfg.Server.FrontendURL
  144. }
  145. // GetPublicSettings 获取公开设置(无需登录)
  146. func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
  147. keys := []string{
  148. SettingKeyRegistrationEnabled,
  149. SettingKeyEmailVerifyEnabled,
  150. SettingKeyForceEmailOnThirdPartySignup,
  151. SettingKeyRegistrationEmailSuffixWhitelist,
  152. SettingKeyRegistrationEmailDomainQuotaEnabled,
  153. SettingKeyPromoCodeEnabled,
  154. SettingKeyPasswordResetEnabled,
  155. SettingKeyInvitationCodeEnabled,
  156. SettingKeyTotpEnabled,
  157. SettingKeyPasskeyEnabled,
  158. SettingKeyLoginAgreementEnabled,
  159. SettingKeyLoginAgreementMode,
  160. SettingKeyLoginAgreementUpdatedAt,
  161. SettingKeyLoginAgreementDocuments,
  162. SettingKeyTurnstileEnabled,
  163. SettingKeyTurnstileSiteKey,
  164. SettingKeyTencentCaptchaEnabled,
  165. SettingKeyTencentCaptchaAppID,
  166. SettingKeyTencentCaptchaRegion,
  167. SettingKeyAliyunCaptchaEnabled,
  168. SettingKeyAliyunCaptchaSceneID,
  169. SettingKeyAliyunCaptchaPrefix,
  170. SettingKeyAliyunCaptchaRegion,
  171. SettingKeyAPIKeyACLTrustForwardedIP,
  172. SettingKeySiteName,
  173. SettingKeySiteLogo,
  174. SettingKeySiteSubtitle,
  175. SettingKeyAPIBaseURL,
  176. SettingKeyContactInfo,
  177. SettingKeyDocURL,
  178. SettingKeyHomeContent,
  179. SettingKeyCompactHomeEnabled,
  180. SettingKeyHideCcsImportButton,
  181. SettingKeyPurchaseSubscriptionEnabled,
  182. SettingKeyPurchaseSubscriptionURL,
  183. SettingKeyTableDefaultPageSize,
  184. SettingKeyTablePageSizeOptions,
  185. SettingKeyCustomMenuItems,
  186. SettingKeyCustomEndpoints,
  187. SettingKeyLinuxDoConnectEnabled,
  188. SettingKeyDingTalkConnectEnabled,
  189. SettingKeyWeChatConnectEnabled,
  190. SettingKeyWeChatConnectAppID,
  191. SettingKeyWeChatConnectAppSecret,
  192. SettingKeyWeChatConnectOpenAppID,
  193. SettingKeyWeChatConnectOpenAppSecret,
  194. SettingKeyWeChatConnectMPAppID,
  195. SettingKeyWeChatConnectMPAppSecret,
  196. SettingKeyWeChatConnectMobileAppID,
  197. SettingKeyWeChatConnectMobileAppSecret,
  198. SettingKeyWeChatConnectOpenEnabled,
  199. SettingKeyWeChatConnectMPEnabled,
  200. SettingKeyWeChatConnectMobileEnabled,
  201. SettingKeyWeChatConnectMode,
  202. SettingKeyWeChatConnectScopes,
  203. SettingKeyWeChatConnectRedirectURL,
  204. SettingKeyWeChatConnectFrontendRedirectURL,
  205. SettingKeyBackendModeEnabled,
  206. SettingPaymentEnabled,
  207. SettingKeyOIDCConnectEnabled,
  208. SettingKeyOIDCConnectProviderName,
  209. SettingKeyGitHubOAuthEnabled,
  210. SettingKeyGitHubOAuthClientID,
  211. SettingKeyGitHubOAuthClientSecret,
  212. SettingKeyGoogleOAuthEnabled,
  213. SettingKeyGoogleOAuthClientID,
  214. SettingKeyGoogleOAuthClientSecret,
  215. SettingKeyBalanceLowNotifyEnabled,
  216. SettingKeyBalanceLowNotifyThreshold,
  217. SettingKeyBalanceLowNotifyRechargeURL,
  218. SettingKeyAccountQuotaNotifyEnabled,
  219. SettingKeyChannelMonitorEnabled,
  220. SettingKeyChannelMonitorMode,
  221. SettingKeyChannelMonitorDefaultIntervalSeconds,
  222. SettingKeyChannelMonitorHideThroughput,
  223. SettingKeyChannelMonitorShowQuota,
  224. SettingKeyChannelMonitorHideUserRanking,
  225. SettingKeyAvailableChannelsEnabled,
  226. SettingKeyModelPlazaEnabled,
  227. SettingKeyModelPlazaRequireAuth,
  228. SettingKeyPluginManagementEnabled,
  229. SettingKeyAffiliateEnabled,
  230. SettingKeyRiskControlEnabled,
  231. SettingKeyAllowUserViewErrorRequests,
  232. }
  233. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  234. if err != nil {
  235. return nil, fmt.Errorf("get public settings: %w", err)
  236. }
  237. linuxDoEnabled := false
  238. if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
  239. linuxDoEnabled = raw == "true"
  240. } else {
  241. linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
  242. }
  243. dingTalkEnabled := false
  244. if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
  245. dingTalkEnabled = raw == "true"
  246. } else {
  247. dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled
  248. }
  249. oidcEnabled := false
  250. if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
  251. oidcEnabled = raw == "true"
  252. } else {
  253. oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled
  254. }
  255. oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName])
  256. if oidcProviderName == "" && s.cfg != nil {
  257. oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName)
  258. }
  259. if oidcProviderName == "" {
  260. oidcProviderName = "OIDC"
  261. }
  262. gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
  263. googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
  264. weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
  265. // Password reset requires email verification to be enabled
  266. emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
  267. passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
  268. registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
  269. settings[SettingKeyRegistrationEmailSuffixWhitelist],
  270. )
  271. tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences(
  272. settings[SettingKeyTableDefaultPageSize],
  273. settings[SettingKeyTablePageSizeOptions],
  274. )
  275. loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
  276. loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
  277. if loginAgreementUpdatedAt == "" {
  278. loginAgreementUpdatedAt = defaultLoginAgreementDate
  279. }
  280. var balanceLowNotifyThreshold float64
  281. if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
  282. balanceLowNotifyThreshold = v
  283. }
  284. return &PublicSettings{
  285. RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
  286. EmailVerifyEnabled: emailVerifyEnabled,
  287. ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
  288. RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
  289. RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true",
  290. PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
  291. PasswordResetEnabled: passwordResetEnabled,
  292. InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
  293. TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
  294. PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings),
  295. LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
  296. LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
  297. LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
  298. LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
  299. LoginAgreementDocuments: loginAgreementDocuments,
  300. TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
  301. TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
  302. TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true",
  303. TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID],
  304. TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
  305. AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
  306. AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
  307. AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
  308. AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]),
  309. SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
  310. SiteLogo: settings[SettingKeySiteLogo],
  311. SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
  312. APIBaseURL: settings[SettingKeyAPIBaseURL],
  313. ContactInfo: settings[SettingKeyContactInfo],
  314. DocURL: settings[SettingKeyDocURL],
  315. HomeContent: settings[SettingKeyHomeContent],
  316. CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
  317. HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
  318. PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
  319. PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
  320. TableDefaultPageSize: tableDefaultPageSize,
  321. TablePageSizeOptions: tablePageSizeOptions,
  322. CustomMenuItems: settings[SettingKeyCustomMenuItems],
  323. CustomEndpoints: settings[SettingKeyCustomEndpoints],
  324. LinuxDoOAuthEnabled: linuxDoEnabled,
  325. DingTalkOAuthEnabled: dingTalkEnabled,
  326. WeChatOAuthEnabled: weChatEnabled,
  327. WeChatOAuthOpenEnabled: weChatOpenEnabled,
  328. WeChatOAuthMPEnabled: weChatMPEnabled,
  329. WeChatOAuthMobileEnabled: weChatMobileEnabled,
  330. BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
  331. PaymentEnabled: settings[SettingPaymentEnabled] == "true",
  332. OIDCOAuthEnabled: oidcEnabled,
  333. OIDCOAuthProviderName: oidcProviderName,
  334. GitHubOAuthEnabled: gitHubEnabled,
  335. GoogleOAuthEnabled: googleEnabled,
  336. BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
  337. AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
  338. BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
  339. BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
  340. ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
  341. ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]),
  342. ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
  343. ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]),
  344. ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true",
  345. ChannelMonitorHideUserRanking: isTrueSettingValue(settings[SettingKeyChannelMonitorHideUserRanking]),
  346. AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
  347. ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
  348. ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
  349. PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true",
  350. AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
  351. RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
  352. AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true",
  353. }, nil
  354. }
  355. // channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
  356. // (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
  357. const (
  358. channelMonitorIntervalMin = 15
  359. channelMonitorIntervalMax = 3600
  360. channelMonitorIntervalFallback = 60
  361. defaultChannelMonitorMode = ChannelMonitorModeV1
  362. )
  363. // normalizeChannelMonitorMode accepts only v1/v2; empty/invalid → v1 (safe default).
  364. func normalizeChannelMonitorMode(raw string) string {
  365. switch strings.ToLower(strings.TrimSpace(raw)) {
  366. case ChannelMonitorModeV1, "":
  367. return ChannelMonitorModeV1
  368. case ChannelMonitorModeV2:
  369. return ChannelMonitorModeV2
  370. default:
  371. return defaultChannelMonitorMode
  372. }
  373. }
  374. // parseChannelMonitorInterval parses the stored string and clamps to [15, 3600].
  375. // Empty / invalid input falls back to channelMonitorIntervalFallback.
  376. func parseChannelMonitorInterval(raw string) int {
  377. v, err := strconv.Atoi(strings.TrimSpace(raw))
  378. if err != nil {
  379. return channelMonitorIntervalFallback
  380. }
  381. return clampChannelMonitorInterval(v)
  382. }
  383. // clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided".
  384. func clampChannelMonitorInterval(v int) int {
  385. if v <= 0 {
  386. return 0
  387. }
  388. if v < channelMonitorIntervalMin {
  389. return channelMonitorIntervalMin
  390. }
  391. if v > channelMonitorIntervalMax {
  392. return channelMonitorIntervalMax
  393. }
  394. return v
  395. }
  396. // ChannelMonitorRuntime is the lightweight view of the channel monitor feature
  397. // consumed by the runner, V2 aggregator, and user-facing handlers.
  398. type ChannelMonitorRuntime struct {
  399. Enabled bool
  400. Mode string // ChannelMonitorModeV1 or ChannelMonitorModeV2
  401. DefaultIntervalSeconds int
  402. // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals.
  403. HideThroughput bool
  404. // ShowQuota: when true, user-facing monitor views keep the quota/balance
  405. // snapshots; otherwise the user handler strips them server-side.
  406. // Parsed fail-closed (only literal "true" enables). Admin always sees them.
  407. ShowQuota bool
  408. // HideUserRanking: when true, user-facing V2 views hide the user ranking tab
  409. // and the /users payload. Parsed fail-open (only literal "true" hides it).
  410. HideUserRanking bool
  411. }
  412. // ActiveProbesAllowed reports whether V1 active provider probes may run.
  413. func (r ChannelMonitorRuntime) ActiveProbesAllowed() bool {
  414. return r.Enabled && r.Mode == ChannelMonitorModeV1
  415. }
  416. // PassiveAggregationAllowed reports whether V2 passive aggregation may run.
  417. func (r ChannelMonitorRuntime) PassiveAggregationAllowed() bool {
  418. return r.Enabled && r.Mode == ChannelMonitorModeV2
  419. }
  420. // GetChannelMonitorRuntime reads the channel monitor feature flags directly from
  421. // the settings store. Fail-open: on error returns Enabled=true, Mode=v1, default interval.
  422. func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime {
  423. if s == nil || s.settingRepo == nil {
  424. return ChannelMonitorRuntime{
  425. Enabled: true,
  426. Mode: defaultChannelMonitorMode,
  427. DefaultIntervalSeconds: channelMonitorIntervalFallback,
  428. HideThroughput: true,
  429. }
  430. }
  431. vals, err := s.settingRepo.GetMultiple(ctx, []string{
  432. SettingKeyChannelMonitorEnabled,
  433. SettingKeyChannelMonitorMode,
  434. SettingKeyChannelMonitorDefaultIntervalSeconds,
  435. SettingKeyChannelMonitorHideThroughput,
  436. SettingKeyChannelMonitorShowQuota,
  437. SettingKeyChannelMonitorHideUserRanking,
  438. })
  439. if err != nil {
  440. return ChannelMonitorRuntime{
  441. Enabled: true,
  442. Mode: defaultChannelMonitorMode,
  443. DefaultIntervalSeconds: channelMonitorIntervalFallback,
  444. HideThroughput: true,
  445. }
  446. }
  447. return ChannelMonitorRuntime{
  448. Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]),
  449. Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]),
  450. DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
  451. HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]),
  452. ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true",
  453. HideUserRanking: isTrueSettingValue(vals[SettingKeyChannelMonitorHideUserRanking]),
  454. }
  455. }
  456. // AvailableChannelsRuntime is the lightweight view of the available-channels feature
  457. // switch consumed by the user-facing handler.
  458. type AvailableChannelsRuntime struct {
  459. Enabled bool
  460. }
  461. // GetAvailableChannelsRuntime reads the available-channels feature switch directly
  462. // from the settings store. Fail-closed: on error returns Enabled=false, matching
  463. // the opt-in default (unknown ↔ disabled).
  464. func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime {
  465. vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled})
  466. if err != nil {
  467. return AvailableChannelsRuntime{Enabled: false}
  468. }
  469. return AvailableChannelsRuntime{
  470. Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true",
  471. }
  472. }
  473. // ModelPlazaRuntime is the lightweight view of the model-plaza feature consumed
  474. // by the public plaza handler.
  475. type ModelPlazaRuntime struct {
  476. Enabled bool
  477. RequireAuth bool
  478. Description string
  479. }
  480. // GetModelPlazaRuntime reads the model-plaza feature switches directly from the
  481. // settings store. Fail-closed: on error returns Enabled=false, matching the
  482. // opt-in default (unknown ↔ disabled).
  483. func (s *SettingService) GetModelPlazaRuntime(ctx context.Context) ModelPlazaRuntime {
  484. vals, err := s.settingRepo.GetMultiple(ctx, []string{
  485. SettingKeyModelPlazaEnabled,
  486. SettingKeyModelPlazaRequireAuth,
  487. SettingKeyModelPlazaDescription,
  488. })
  489. if err != nil {
  490. return ModelPlazaRuntime{Enabled: false}
  491. }
  492. return ModelPlazaRuntime{
  493. Enabled: vals[SettingKeyModelPlazaEnabled] == "true",
  494. RequireAuth: vals[SettingKeyModelPlazaRequireAuth] == "true",
  495. Description: vals[SettingKeyModelPlazaDescription],
  496. }
  497. }
  498. // IsUserErrorViewAllowed reads the user-facing error-requests visibility switch
  499. // directly from the settings store. Fail-closed: on error returns false (opt-in default).
  500. func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool {
  501. vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests})
  502. if err != nil {
  503. slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err)
  504. return false
  505. }
  506. return vals[SettingKeyAllowUserViewErrorRequests] == "true"
  507. }
  508. // PublicSettingsInjectionPayload is the JSON shape embedded into HTML as
  509. // `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site
  510. // config before the first XHR finishes.
  511. //
  512. // INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings.
  513. // If you forget a feature-flag field here, the frontend's
  514. // `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the
  515. // async `/api/v1/settings/public` call returns — which causes opt-in menus
  516. // (strict `=== true`) to flicker off/on. See
  517. // frontend/src/utils/featureFlags.ts for the matching registry.
  518. //
  519. // A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
  520. // drift automatically (see setting_service_injection_test.go).
  521. type PublicSettingsInjectionPayload struct {
  522. RegistrationEnabled bool `json:"registration_enabled"`
  523. EmailVerifyEnabled bool `json:"email_verify_enabled"`
  524. RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
  525. RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"`
  526. PromoCodeEnabled bool `json:"promo_code_enabled"`
  527. PasswordResetEnabled bool `json:"password_reset_enabled"`
  528. InvitationCodeEnabled bool `json:"invitation_code_enabled"`
  529. TotpEnabled bool `json:"totp_enabled"`
  530. PasskeyEnabled bool `json:"passkey_enabled"`
  531. LoginAgreementEnabled bool `json:"login_agreement_enabled"`
  532. LoginAgreementMode string `json:"login_agreement_mode"`
  533. LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
  534. LoginAgreementRevision string `json:"login_agreement_revision"`
  535. LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
  536. TurnstileEnabled bool `json:"turnstile_enabled"`
  537. TurnstileSiteKey string `json:"turnstile_site_key"`
  538. TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
  539. TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
  540. TencentCaptchaRegion string `json:"tencent_captcha_region"`
  541. AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
  542. AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
  543. AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
  544. AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
  545. SiteName string `json:"site_name"`
  546. SiteLogo string `json:"site_logo"`
  547. SiteSubtitle string `json:"site_subtitle"`
  548. APIBaseURL string `json:"api_base_url"`
  549. ContactInfo string `json:"contact_info"`
  550. DocURL string `json:"doc_url"`
  551. HomeContent string `json:"home_content"`
  552. CompactHomeEnabled bool `json:"compact_home_enabled"`
  553. HideCcsImportButton bool `json:"hide_ccs_import_button"`
  554. PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
  555. PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
  556. TableDefaultPageSize int `json:"table_default_page_size"`
  557. TablePageSizeOptions []int `json:"table_page_size_options"`
  558. CustomMenuItems json.RawMessage `json:"custom_menu_items"`
  559. CustomEndpoints json.RawMessage `json:"custom_endpoints"`
  560. LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
  561. DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
  562. WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
  563. WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
  564. WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
  565. WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
  566. OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
  567. OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
  568. GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
  569. GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
  570. BackendModeEnabled bool `json:"backend_mode_enabled"`
  571. PaymentEnabled bool `json:"payment_enabled"`
  572. Version string `json:"version"`
  573. // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用
  574. ServerTimezone string `json:"server_timezone"`
  575. ServerUTCOffset string `json:"server_utc_offset"`
  576. BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
  577. AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
  578. BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
  579. BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
  580. // Feature flags — MUST match the opt-in/opt-out registry in
  581. // frontend/src/utils/featureFlags.ts. Missing a field here is the bug
  582. // that hid the "可用渠道" menu on page refresh.
  583. ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
  584. ChannelMonitorMode string `json:"channel_monitor_mode"`
  585. ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
  586. // ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM
  587. // without waiting for API redaction alone (defense in depth).
  588. ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
  589. // ChannelMonitorShowQuota gates the user-facing quota/balance display on
  590. // monitors; fail-closed (absent/false = hidden). Admin UI always shows it.
  591. // ChannelMonitorHideUserRanking hides the user ranking tab and /users payload
  592. // from non-admin channel-monitor v2 viewers; default false (visible).
  593. ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
  594. ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
  595. AvailableChannelsEnabled bool `json:"available_channels_enabled"`
  596. ModelPlazaEnabled bool `json:"model_plaza_enabled"`
  597. ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
  598. PluginManagementEnabled bool `json:"plugin_management_enabled"`
  599. AffiliateEnabled bool `json:"affiliate_enabled"`
  600. RiskControlEnabled bool `json:"risk_control_enabled"`
  601. AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
  602. }
  603. // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
  604. // This implements the web.PublicSettingsProvider interface.
  605. func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
  606. settings, err := s.GetPublicSettings(ctx)
  607. if err != nil {
  608. return nil, err
  609. }
  610. return &PublicSettingsInjectionPayload{
  611. RegistrationEnabled: settings.RegistrationEnabled,
  612. EmailVerifyEnabled: settings.EmailVerifyEnabled,
  613. RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
  614. RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
  615. PromoCodeEnabled: settings.PromoCodeEnabled,
  616. PasswordResetEnabled: settings.PasswordResetEnabled,
  617. InvitationCodeEnabled: settings.InvitationCodeEnabled,
  618. TotpEnabled: settings.TotpEnabled,
  619. PasskeyEnabled: settings.PasskeyEnabled,
  620. LoginAgreementEnabled: settings.LoginAgreementEnabled,
  621. LoginAgreementMode: settings.LoginAgreementMode,
  622. LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
  623. LoginAgreementRevision: settings.LoginAgreementRevision,
  624. LoginAgreementDocuments: settings.LoginAgreementDocuments,
  625. TurnstileEnabled: settings.TurnstileEnabled,
  626. TurnstileSiteKey: settings.TurnstileSiteKey,
  627. TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
  628. TencentCaptchaAppID: settings.TencentCaptchaAppID,
  629. TencentCaptchaRegion: settings.TencentCaptchaRegion,
  630. AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
  631. AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
  632. AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
  633. AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
  634. SiteName: settings.SiteName,
  635. SiteLogo: settings.SiteLogo,
  636. SiteSubtitle: settings.SiteSubtitle,
  637. APIBaseURL: settings.APIBaseURL,
  638. ContactInfo: settings.ContactInfo,
  639. DocURL: settings.DocURL,
  640. HomeContent: settings.HomeContent,
  641. CompactHomeEnabled: settings.CompactHomeEnabled,
  642. HideCcsImportButton: settings.HideCcsImportButton,
  643. PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
  644. PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
  645. TableDefaultPageSize: settings.TableDefaultPageSize,
  646. TablePageSizeOptions: settings.TablePageSizeOptions,
  647. CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
  648. CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
  649. LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
  650. DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
  651. WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
  652. WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
  653. WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
  654. WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
  655. OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
  656. OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
  657. GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
  658. GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
  659. BackendModeEnabled: settings.BackendModeEnabled,
  660. PaymentEnabled: settings.PaymentEnabled,
  661. Version: s.version,
  662. ServerTimezone: timezone.Name(),
  663. ServerUTCOffset: timezone.UTCOffset(),
  664. BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
  665. AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
  666. BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
  667. BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
  668. ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
  669. ChannelMonitorMode: settings.ChannelMonitorMode,
  670. ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
  671. ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
  672. ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
  673. ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
  674. AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
  675. ModelPlazaEnabled: settings.ModelPlazaEnabled,
  676. ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
  677. PluginManagementEnabled: settings.PluginManagementEnabled,
  678. AffiliateEnabled: settings.AffiliateEnabled,
  679. RiskControlEnabled: settings.RiskControlEnabled,
  680. AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
  681. }, nil
  682. }
  683. // filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
  684. // array string, returning only items with visibility != "admin".
  685. func filterUserVisibleMenuItems(raw string) json.RawMessage {
  686. raw = strings.TrimSpace(raw)
  687. if raw == "" || raw == "[]" {
  688. return json.RawMessage("[]")
  689. }
  690. var items []struct {
  691. Visibility string `json:"visibility"`
  692. }
  693. if err := json.Unmarshal([]byte(raw), &items); err != nil {
  694. return json.RawMessage("[]")
  695. }
  696. // Parse full items to preserve all fields
  697. var fullItems []json.RawMessage
  698. if err := json.Unmarshal([]byte(raw), &fullItems); err != nil {
  699. return json.RawMessage("[]")
  700. }
  701. var filtered []json.RawMessage
  702. for i, item := range items {
  703. if item.Visibility != "admin" {
  704. filtered = append(filtered, fullItems[i])
  705. }
  706. }
  707. if len(filtered) == 0 {
  708. return json.RawMessage("[]")
  709. }
  710. result, err := json.Marshal(filtered)
  711. if err != nil {
  712. return json.RawMessage("[]")
  713. }
  714. return result
  715. }
  716. // safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
  717. func safeRawJSONArray(raw string) json.RawMessage {
  718. raw = strings.TrimSpace(raw)
  719. if raw == "" {
  720. return json.RawMessage("[]")
  721. }
  722. if json.Valid([]byte(raw)) {
  723. return json.RawMessage(raw)
  724. }
  725. return json.RawMessage("[]")
  726. }
  727. // GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL,
  728. // purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
  729. func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
  730. settings, err := s.GetPublicSettings(ctx)
  731. if err != nil {
  732. return nil, err
  733. }
  734. seen := make(map[string]struct{})
  735. var origins []string
  736. addOrigin := func(rawURL string) {
  737. if origin := extractOriginFromURL(rawURL); origin != "" {
  738. if _, ok := seen[origin]; !ok {
  739. seen[origin] = struct{}{}
  740. origins = append(origins, origin)
  741. }
  742. }
  743. }
  744. // home content URL (when home_content is set to a URL for iframe embedding)
  745. addOrigin(settings.HomeContent)
  746. // purchase subscription URL
  747. if settings.PurchaseSubscriptionEnabled {
  748. addOrigin(settings.PurchaseSubscriptionURL)
  749. }
  750. // all custom menu items (including admin-only, since CSP must allow all iframes)
  751. for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) {
  752. addOrigin(item)
  753. }
  754. return origins, nil
  755. }
  756. // extractOriginFromURL returns the scheme+host origin from rawURL.
  757. // Only http and https schemes are accepted.
  758. func extractOriginFromURL(rawURL string) string {
  759. rawURL = strings.TrimSpace(rawURL)
  760. if rawURL == "" {
  761. return ""
  762. }
  763. u, err := url.Parse(rawURL)
  764. if err != nil || u.Host == "" {
  765. return ""
  766. }
  767. if u.Scheme != "http" && u.Scheme != "https" {
  768. return ""
  769. }
  770. return u.Scheme + "://" + u.Host
  771. }
  772. // parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
  773. func parseCustomMenuItemURLs(raw string) []string {
  774. raw = strings.TrimSpace(raw)
  775. if raw == "" || raw == "[]" {
  776. return nil
  777. }
  778. var items []struct {
  779. URL string `json:"url"`
  780. }
  781. if err := json.Unmarshal([]byte(raw), &items); err != nil {
  782. return nil
  783. }
  784. urls := make([]string, 0, len(items))
  785. for _, item := range items {
  786. if item.URL != "" {
  787. urls = append(urls, item.URL)
  788. }
  789. }
  790. return urls
  791. }