thread-master/apps/backend/internal/config/config.go

459 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"fmt"
"net/url"
"os"
"strings"
"apps/backend/internal/lib/secretbox"
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/rest"
)
// Config — go-zero 風格RestConf + CacheRedis + Mongo URI.
type Config struct {
rest.RestConf
// CacheRedis is go-zero cache.CacheConf (feeds monc Redis 快取).
CacheRedis cache.CacheConf
Redis struct {
Namespace string `json:",optional,default=haixun:dev:v1"`
AIModelCacheFingerprintSecret string `json:",optional"`
}
Mongo struct {
URI string
Database string
}
Auth struct {
AccessSecret string
AccessExpire int64
RefreshSecret string
RefreshExpire int64
}
// MemberSettingsEncryption is dedicated to member BYOK secrets. Never reuse
// Auth or Scout secrets here.
MemberSettingsEncryption struct {
CurrentKeyID string `json:",optional"`
Key string `json:",optional"`
}
// CORSAllowedOrigins is an explicit browser-origin allowlist.
CORSAllowedOrigins []string `json:",optional"`
Platform struct {
// AIKey — legacy alias for XAIKey
AIKey string `json:",optional"`
// XAIKey — platform BYOK for xAI (api.x.ai)
XAIKey string `json:",optional"`
// OpenCodeKey — platform BYOK for OpenCode Go (opencode.ai/zen/go)
OpenCodeKey string `json:",optional"`
ExaKey string `json:",optional"`
ThreadsAppId string `json:",optional"`
ThreadsAppSecret string `json:",optional"`
}
// Scout keeps browser-session credentials separate from JWT and public APIs.
Scout struct {
SessionSecret string `json:",optional"`
CrawlerEndpoint string `json:",optional"`
CrawlerToken string `json:",optional"`
}
Worker struct {
ID string `json:",default=worker-1"`
PollIntervalMs int `json:",default=2000"`
}
// Seed is used only by cmd/seeder; gateway and worker never read or log it.
Seed struct {
AdminPassword string `json:",optional"`
}
// Bcrypt cost (stand-alone Bcrypt.Cost)
Bcrypt struct {
Cost int `json:",default=10"`
}
// OAuth third-party. Empty provider config disables that provider.
OAuth struct {
GoogleClientID string `json:",optional"`
LineClientID string `json:",optional"`
LineClientSecret string `json:",optional"`
LineRedirectURI string `json:",optional"`
}
Stripe struct {
Enabled bool `json:",optional"`
SecretKey string `json:",optional"`
WebhookSecret string `json:",optional"`
StarterPriceID string `json:",optional"`
ProPriceID string `json:",optional"`
SuccessURL string `json:",optional"`
CancelURL string `json:",optional"`
PortalReturnURL string `json:",optional"`
}
// Mail — 驗證碼重設密碼寄信SMTP空 Host 則只打 log + 可回 mock_code
Mail struct {
Sender string `json:",optional"` // e.g. "Harbor Desk <noreply@example.com>"
SMTP struct {
Host string `json:",optional"`
Port int `json:",default=587"`
User string `json:",optional"`
Password string `json:",optional"`
} `json:",optional"`
// DevExposeCode: API 回 mock_code本機沒信箱時用。正式請 false。
DevExposeCode bool `json:",default=true"`
} `json:",optional"`
// PublicWebBase — 前端 origin用於重設密碼信內連結logo勿尾斜線
// 例http://127.0.0.1:5173 或 https://threads-tool-dev.30cm.net
PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"`
// PublicAPIBase — 對外 API originThreads OAuth redirect_uri 必須 https 公開網址)
// 空則:若 PublicWebBase 為 https 則同 host否則回落 http://127.0.0.1:Port
// 例https://threads-tool-dev.30cm.net
PublicAPIBase string `json:",optional"`
// Brand — 郵件 chromehermesLogoURL 空則用 PublicWebBase + /brand-mark.jpg
Brand struct {
Name string `json:",optional"`
LogoURL string `json:",optional"` // absolute URL
Copyright string `json:",optional"`
} `json:",optional"`
// ObjectStorage — 頭像等二進位;**僅 MinIO / S3**(不落本機磁碟)
ObjectStorage struct {
Endpoint string `json:",optional"` // 必填e.g. http://127.0.0.1:9000
Region string `json:",optional,default=us-east-1"`
Bucket string `json:",optional,default=haixun-assets"`
AccessKey string `json:",optional"`
SecretKey string `json:",optional"`
UsePathStyle bool `json:",optional,default=true"`
PublicBaseURL string `json:",optional"` // 公開讀取前綴,勿尾斜線
} `json:",optional"`
}
// ApplyEnv overlays secrets from environment.
func (c *Config) ApplyEnv() {
if strings.TrimSpace(c.Redis.Namespace) == "" {
c.Redis.Namespace = "haixun:dev:v1"
}
if c.Brand.Name == "" {
c.Brand.Name = "Harbor Desk"
}
if c.Brand.Copyright == "" {
c.Brand.Copyright = "© Harbor Desk"
}
if v := os.Getenv("MONGO_URI"); v != "" {
c.Mongo.URI = v
}
if v := os.Getenv("MONGO_DATABASE"); v != "" {
c.Mongo.Database = v
}
if v := os.Getenv("REDIS_HOST"); v != "" && len(c.CacheRedis) > 0 {
c.CacheRedis[0].RedisConf.Host = v
}
if len(c.CacheRedis) > 0 {
// monc 快取 Redis 帳密(本機 infra 常開 requirepass
if v := os.Getenv("REDIS_PASSWORD"); v != "" {
c.CacheRedis[0].RedisConf.Pass = v
} else if v := os.Getenv("HAIXUN_REDIS_PASSWORD"); v != "" {
c.CacheRedis[0].RedisConf.Pass = v
}
if v := os.Getenv("HAIXUN_REDIS_ADDR"); v != "" {
c.CacheRedis[0].RedisConf.Host = v
}
}
if v := firstEnv("REDIS_NAMESPACE", "HAIXUN_REDIS_NAMESPACE"); v != "" {
c.Redis.Namespace = strings.Trim(v, ":")
}
if v := firstEnv("AI_MODEL_CACHE_FINGERPRINT_SECRET", "HAIXUN_AI_MODEL_CACHE_FINGERPRINT_SECRET"); v != "" {
c.Redis.AIModelCacheFingerprintSecret = v
}
if v := os.Getenv("AUTH_ACCESS_SECRET"); v != "" {
c.Auth.AccessSecret = v
}
if v := os.Getenv("AUTH_REFRESH_SECRET"); v != "" {
c.Auth.RefreshSecret = v
}
if v := os.Getenv("STRIPE_ENABLED"); v != "" {
c.Stripe.Enabled = v == "1" || strings.EqualFold(v, "true")
}
if v := os.Getenv("STRIPE_SECRET_KEY"); v != "" {
c.Stripe.SecretKey = v
}
if v := os.Getenv("STRIPE_WEBHOOK_SECRET"); v != "" {
c.Stripe.WebhookSecret = v
}
if v := os.Getenv("STRIPE_STARTER_PRICE_ID"); v != "" {
c.Stripe.StarterPriceID = v
}
if v := os.Getenv("STRIPE_PRO_PRICE_ID"); v != "" {
c.Stripe.ProPriceID = v
}
if v := os.Getenv("STRIPE_SUCCESS_URL"); v != "" {
c.Stripe.SuccessURL = v
}
if v := os.Getenv("STRIPE_CANCEL_URL"); v != "" {
c.Stripe.CancelURL = v
}
if v := os.Getenv("STRIPE_PORTAL_RETURN_URL"); v != "" {
c.Stripe.PortalReturnURL = v
}
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY_ID"); v != "" {
c.MemberSettingsEncryption.CurrentKeyID = v
}
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY"); v != "" {
c.MemberSettingsEncryption.Key = v
}
if v := os.Getenv("PLATFORM_AI_KEY"); v != "" {
c.Platform.AIKey = v
}
if v := firstEnv("PLATFORM_XAI_KEY", "XAI_API_KEY"); v != "" {
c.Platform.XAIKey = v
}
if v := firstEnv("PLATFORM_OPENCODE_KEY", "OPENCODE_API_KEY", "OPENCODE_GO_API_KEY"); v != "" {
c.Platform.OpenCodeKey = v
}
// legacy AIKey → XAI when XAI empty
if c.Platform.XAIKey == "" && c.Platform.AIKey != "" {
c.Platform.XAIKey = c.Platform.AIKey
}
if v := os.Getenv("PLATFORM_EXA_KEY"); v != "" {
c.Platform.ExaKey = v
}
if v := firstEnv("THREADS_APP_ID", "HAIXUN_THREADS_APP_ID"); v != "" {
c.Platform.ThreadsAppId = v
}
if v := firstEnv("THREADS_APP_SECRET", "HAIXUN_THREADS_APP_SECRET"); v != "" {
c.Platform.ThreadsAppSecret = v
}
if v := os.Getenv("SCOUT_SESSION_SECRET"); v != "" {
c.Scout.SessionSecret = v
}
if v := os.Getenv("SCOUT_CRAWLER_ENDPOINT"); v != "" {
c.Scout.CrawlerEndpoint = v
}
if v := os.Getenv("SCOUT_CRAWLER_TOKEN"); v != "" {
c.Scout.CrawlerToken = v
}
if v := os.Getenv("SEED_ADMIN_PASSWORD"); v != "" {
c.Seed.AdminPassword = v
}
if v := os.Getenv("WORKER_ID"); v != "" {
c.Worker.ID = v
}
if v := os.Getenv("PORT"); v != "" {
var p int
valid := true
for _, ch := range v {
if ch < '0' || ch > '9' {
valid = false
break
}
p = p*10 + int(ch-'0')
}
if valid && p > 0 {
c.Port = p
}
}
// Mail / SMTPMAIL_* 優先HAIXUN_SMTP_* 與 old/infra 相容)
if v := firstEnv("MAIL_SENDER", "HAIXUN_SMTP_FROM"); v != "" {
c.Mail.Sender = v
}
if v := firstEnv("MAIL_SMTP_HOST", "HAIXUN_SMTP_HOST"); v != "" {
c.Mail.SMTP.Host = v
}
if v := firstEnv("MAIL_SMTP_USER", "HAIXUN_SMTP_USERNAME"); v != "" {
c.Mail.SMTP.User = v
}
if v := firstEnv("MAIL_SMTP_PASSWORD", "HAIXUN_SMTP_PASSWORD"); v != "" {
c.Mail.SMTP.Password = v
}
if v := firstEnv("MAIL_SMTP_PORT", "HAIXUN_SMTP_PORT"); v != "" {
if p := parsePort(v); p > 0 {
c.Mail.SMTP.Port = p
}
}
if v := os.Getenv("MAIL_DEV_EXPOSE_CODE"); v == "0" || v == "false" || v == "FALSE" {
c.Mail.DevExposeCode = false
} else if v == "1" || v == "true" || v == "TRUE" {
c.Mail.DevExposeCode = true
}
if v := firstEnv("PUBLIC_WEB_BASE", "HAIXUN_PUBLIC_WEB_BASE"); v != "" {
c.PublicWebBase = strings.TrimRight(v, "/")
}
if v := firstEnv("PUBLIC_API_BASE", "HAIXUN_PUBLIC_API_BASE"); v != "" {
c.PublicAPIBase = strings.TrimRight(v, "/")
}
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
c.CORSAllowedOrigins = appendUnique(c.CORSAllowedOrigins, splitCSV(v))
}
if v := os.Getenv("MAIL_BRAND_NAME"); v != "" {
c.Brand.Name = v
}
if v := firstEnv("MAIL_LOGO_URL", "HAIXUN_MAIL_LOGO_URL"); v != "" {
c.Brand.LogoURL = v
}
if v := os.Getenv("MAIL_COPYRIGHT"); v != "" {
c.Brand.Copyright = v
}
// Object storage (HAIXUN_STORAGE_S3_* 與 old/infra 相容)
if v := firstEnv("OBJECT_STORAGE_ENDPOINT", "HAIXUN_STORAGE_S3_ENDPOINT"); v != "" {
c.ObjectStorage.Endpoint = v
}
if v := firstEnv("OBJECT_STORAGE_REGION", "HAIXUN_STORAGE_S3_REGION"); v != "" {
c.ObjectStorage.Region = v
}
if v := firstEnv("OBJECT_STORAGE_BUCKET", "HAIXUN_STORAGE_S3_BUCKET"); v != "" {
c.ObjectStorage.Bucket = v
}
if v := firstEnv("OBJECT_STORAGE_ACCESS_KEY", "HAIXUN_STORAGE_S3_ACCESS_KEY", "MINIO_ROOT_USER"); v != "" {
c.ObjectStorage.AccessKey = v
}
if v := firstEnv("OBJECT_STORAGE_SECRET_KEY", "HAIXUN_STORAGE_S3_SECRET_KEY", "MINIO_ROOT_PASSWORD"); v != "" {
c.ObjectStorage.SecretKey = v
}
if v := firstEnv("OBJECT_STORAGE_PUBLIC_BASE_URL", "HAIXUN_STORAGE_S3_PUBLIC_BASE_URL"); v != "" {
c.ObjectStorage.PublicBaseURL = strings.TrimRight(v, "/")
}
if v := os.Getenv("HAIXUN_STORAGE_S3_USE_PATH_STYLE"); v == "0" || v == "false" {
c.ObjectStorage.UsePathStyle = false
} else if v == "1" || v == "true" {
c.ObjectStorage.UsePathStyle = true
}
}
// ValidateProductionDependencies prevents a successful-looking deployment that
// silently falls back to test credentials or fake external integrations.
func (c Config) ValidateProductionDependencies() error {
if invalidSecret(c.Auth.AccessSecret) {
return fmt.Errorf("AUTH_ACCESS_SECRET must be at least 32 bytes")
}
if invalidSecret(c.Auth.RefreshSecret) {
return fmt.Errorf("AUTH_REFRESH_SECRET must be at least 32 bytes")
}
if c.Auth.AccessSecret == c.Auth.RefreshSecret {
return fmt.Errorf("AUTH_ACCESS_SECRET and AUTH_REFRESH_SECRET must differ")
}
fingerprintSecret := strings.TrimSpace(c.Redis.AIModelCacheFingerprintSecret)
if invalidSecret(fingerprintSecret) {
return fmt.Errorf("AI_MODEL_CACHE_FINGERPRINT_SECRET must be at least 32 bytes")
}
if fingerprintSecret == strings.TrimSpace(c.Auth.AccessSecret) ||
fingerprintSecret == strings.TrimSpace(c.Auth.RefreshSecret) ||
fingerprintSecret == strings.TrimSpace(c.Scout.SessionSecret) ||
fingerprintSecret == strings.TrimSpace(c.MemberSettingsEncryption.Key) {
return fmt.Errorf("AI model cache fingerprint secret must be dedicated")
}
if namespace := strings.TrimSpace(c.Redis.Namespace); namespace == "" || strings.ContainsAny(namespace, "{} \t\r\n") {
return fmt.Errorf("Redis.Namespace must be non-empty and must not contain whitespace or braces")
}
if _, err := c.NewMemberSettingsCodec(); err != nil {
return fmt.Errorf("member settings encryption: %w", err)
}
encryptionKey := strings.TrimSpace(c.MemberSettingsEncryption.Key)
if encryptionKey == strings.TrimSpace(c.Auth.AccessSecret) ||
encryptionKey == strings.TrimSpace(c.Auth.RefreshSecret) ||
encryptionKey == strings.TrimSpace(c.Scout.SessionSecret) {
return fmt.Errorf("member settings encryption key must not reuse Auth or Scout secrets")
}
if len(c.CacheRedis) == 0 || strings.TrimSpace(c.CacheRedis[0].Host) == "" {
return fmt.Errorf("CacheRedis is required")
}
if strings.TrimSpace(c.Mongo.URI) == "" || strings.TrimSpace(c.Mongo.Database) == "" {
return fmt.Errorf("Mongo.URI and Mongo.Database are required")
}
if len(c.CORSAllowedOrigins) == 0 {
return fmt.Errorf("CORSAllowedOrigins is required")
}
if err := validateStripe(c.Stripe.Enabled, c.Stripe.SecretKey, c.Stripe.WebhookSecret, c.Stripe.StarterPriceID, c.Stripe.ProPriceID, c.Stripe.SuccessURL, c.Stripe.CancelURL, c.Stripe.PortalReturnURL); err != nil {
return err
}
return nil
}
// NewMemberSettingsCodec constructs the dedicated BYOK-at-rest codec.
func (c Config) NewMemberSettingsCodec() (*secretbox.Codec, error) {
return secretbox.New(c.MemberSettingsEncryption.CurrentKeyID, c.MemberSettingsEncryption.Key)
}
func invalidSecret(value string) bool {
value = strings.TrimSpace(value)
return len(value) < 32 || strings.Contains(value, "REPLACE_WITH") || strings.Contains(value, "CHANGE_ME")
}
func validateStripe(enabled bool, secretKey, webhookSecret, starterPriceID, proPriceID, successURL, cancelURL, portalReturnURL string) error {
if !enabled {
return nil
}
if !strings.HasPrefix(strings.TrimSpace(secretKey), "sk_") {
return fmt.Errorf("STRIPE_SECRET_KEY is required when Stripe is enabled")
}
if !strings.HasPrefix(strings.TrimSpace(webhookSecret), "whsec_") {
return fmt.Errorf("STRIPE_WEBHOOK_SECRET is required when Stripe is enabled")
}
starterPriceID = strings.TrimSpace(starterPriceID)
proPriceID = strings.TrimSpace(proPriceID)
if !strings.HasPrefix(starterPriceID, "price_") || !strings.HasPrefix(proPriceID, "price_") {
return fmt.Errorf("STRIPE_STARTER_PRICE_ID and STRIPE_PRO_PRICE_ID are required when Stripe is enabled")
}
if starterPriceID == proPriceID {
return fmt.Errorf("Stripe Starter and Pro Price IDs must differ")
}
if !strings.Contains(successURL, "{CHECKOUT_ID}") {
return fmt.Errorf("STRIPE_SUCCESS_URL must contain {CHECKOUT_ID}")
}
for name, value := range map[string]string{
"STRIPE_SUCCESS_URL": successURL,
"STRIPE_CANCEL_URL": cancelURL,
"STRIPE_PORTAL_RETURN_URL": portalReturnURL,
} {
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return fmt.Errorf("%s must be an absolute HTTPS URL", name)
}
}
return nil
}
func firstEnv(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}
func appendUnique(existing, values []string) []string {
seen := make(map[string]struct{}, len(existing)+len(values))
out := make([]string, 0, len(existing)+len(values))
for _, value := range append(existing, values...) {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func splitCSV(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
out = append(out, strings.TrimRight(part, "/"))
}
}
return out
}
func parsePort(v string) int {
var p int
for _, ch := range v {
if ch < '0' || ch > '9' {
return 0
}
p = p*10 + int(ch-'0')
}
return p
}