package config import ( "fmt" "os" "strings" "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 Mongo struct { URI string Database string } Auth struct { AccessSecret string AccessExpire int64 RefreshSecret string RefreshExpire int64 } // 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"` } // Mail — 驗證碼/重設密碼寄信(SMTP;空 Host 則只打 log + 可回 mock_code) Mail struct { Sender string `json:",optional"` // e.g. "Harbor Desk " 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 origin(Threads OAuth redirect_uri 必須 https 公開網址) // 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port // 例:https://threads-tool-dev.30cm.net PublicAPIBase string `json:",optional"` // Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg Brand struct { Name string `json:",optional,default=Harbor Desk"` LogoURL string `json:",optional"` // absolute URL Copyright string `json:",optional,default=© Harbor Desk"` } `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 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 := 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("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("WORKER_ID"); v != "" { c.Worker.ID = v } if v := os.Getenv("PORT"); v != "" { var p int for _, ch := range v { if ch < '0' || ch > '9' { return } p = p*10 + int(ch-'0') } if p > 0 { c.Port = p } } // Mail / SMTP(MAIL_* 優先;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") } 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") } return nil } func invalidSecret(value string) bool { value = strings.TrimSpace(value) return len(value) < 32 || strings.Contains(value, "REPLACE_WITH") || strings.Contains(value, "CHANGE_ME") } 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 }