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

459 lines
16 KiB
Go
Raw Permalink Normal View History

2026-07-10 12:54:45 +00:00
package config
import (
2026-07-13 08:59:13 +00:00
"fmt"
2026-07-15 15:23:59 +00:00
"net/url"
2026-07-10 12:54:45 +00:00
"os"
"strings"
2026-07-15 15:23:59 +00:00
"apps/backend/internal/lib/secretbox"
2026-07-10 12:54:45 +00:00
"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
2026-07-15 15:23:59 +00:00
Redis struct {
Namespace string `json:",optional,default=haixun:dev:v1"`
AIModelCacheFingerprintSecret string `json:",optional"`
}
2026-07-10 12:54:45 +00:00
Mongo struct {
URI string
Database string
}
Auth struct {
AccessSecret string
AccessExpire int64
RefreshSecret string
RefreshExpire int64
}
2026-07-15 15:23:59 +00:00
// MemberSettingsEncryption is dedicated to member BYOK secrets. Never reuse
// Auth or Scout secrets here.
MemberSettingsEncryption struct {
CurrentKeyID string `json:",optional"`
Key string `json:",optional"`
}
2026-07-13 08:59:13 +00:00
// CORSAllowedOrigins is an explicit browser-origin allowlist.
CORSAllowedOrigins []string `json:",optional"`
Platform struct {
2026-07-13 01:15:30 +00:00
// 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"`
2026-07-10 12:54:45 +00:00
ExaKey string `json:",optional"`
ThreadsAppId string `json:",optional"`
ThreadsAppSecret string `json:",optional"`
}
2026-07-13 08:59:13 +00:00
// 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"`
}
2026-07-10 12:54:45 +00:00
Worker struct {
ID string `json:",default=worker-1"`
PollIntervalMs int `json:",default=2000"`
}
2026-07-13 08:59:13 +00:00
// Seed is used only by cmd/seeder; gateway and worker never read or log it.
Seed struct {
AdminPassword string `json:",optional"`
}
2026-07-10 12:54:45 +00:00
// Bcrypt cost (stand-alone Bcrypt.Cost)
Bcrypt struct {
Cost int `json:",default=10"`
}
2026-07-13 08:59:13 +00:00
// OAuth third-party. Empty provider config disables that provider.
2026-07-10 12:54:45 +00:00
OAuth struct {
GoogleClientID string `json:",optional"`
LineClientID string `json:",optional"`
LineClientSecret string `json:",optional"`
LineRedirectURI string `json:",optional"`
}
2026-07-15 15:23:59 +00:00
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"`
}
2026-07-10 12:54:45 +00:00
// 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"`
2026-07-13 01:15:30 +00:00
// 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"`
2026-07-10 12:54:45 +00:00
// Brand — 郵件 chromehermesLogoURL 空則用 PublicWebBase + /brand-mark.jpg
Brand struct {
2026-07-15 15:23:59 +00:00
Name string `json:",optional"`
2026-07-10 12:54:45 +00:00
LogoURL string `json:",optional"` // absolute URL
2026-07-15 15:23:59 +00:00
Copyright string `json:",optional"`
2026-07-10 12:54:45 +00:00
} `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() {
2026-07-15 15:23:59 +00:00
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"
}
2026-07-10 12:54:45 +00:00
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
}
}
2026-07-15 15:23:59 +00:00
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
}
2026-07-10 12:54:45 +00:00
if v := os.Getenv("AUTH_ACCESS_SECRET"); v != "" {
c.Auth.AccessSecret = v
}
if v := os.Getenv("AUTH_REFRESH_SECRET"); v != "" {
c.Auth.RefreshSecret = v
}
2026-07-15 15:23:59 +00:00
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
}
2026-07-10 12:54:45 +00:00
if v := os.Getenv("PLATFORM_AI_KEY"); v != "" {
c.Platform.AIKey = v
}
2026-07-13 01:15:30 +00:00
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
}
2026-07-10 12:54:45 +00:00
if v := os.Getenv("PLATFORM_EXA_KEY"); v != "" {
c.Platform.ExaKey = v
}
2026-07-13 01:15:30 +00:00
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
}
2026-07-15 15:23:59 +00:00
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
}
2026-07-10 12:54:45 +00:00
if v := os.Getenv("WORKER_ID"); v != "" {
c.Worker.ID = v
}
if v := os.Getenv("PORT"); v != "" {
var p int
2026-07-15 15:23:59 +00:00
valid := true
2026-07-10 12:54:45 +00:00
for _, ch := range v {
if ch < '0' || ch > '9' {
2026-07-15 15:23:59 +00:00
valid = false
break
2026-07-10 12:54:45 +00:00
}
p = p*10 + int(ch-'0')
}
2026-07-15 15:23:59 +00:00
if valid && p > 0 {
2026-07-10 12:54:45 +00:00
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, "/")
}
2026-07-13 01:15:30 +00:00
if v := firstEnv("PUBLIC_API_BASE", "HAIXUN_PUBLIC_API_BASE"); v != "" {
c.PublicAPIBase = strings.TrimRight(v, "/")
}
2026-07-13 08:59:13 +00:00
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
c.CORSAllowedOrigins = appendUnique(c.CORSAllowedOrigins, splitCSV(v))
}
2026-07-10 12:54:45 +00:00
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
}
}
2026-07-13 08:59:13 +00:00
// 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")
}
2026-07-15 15:23:59 +00:00
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")
}
2026-07-13 08:59:13 +00:00
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")
}
2026-07-15 15:23:59 +00:00
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
}
2026-07-13 08:59:13 +00:00
return nil
}
2026-07-15 15:23:59 +00:00
// NewMemberSettingsCodec constructs the dedicated BYOK-at-rest codec.
func (c Config) NewMemberSettingsCodec() (*secretbox.Codec, error) {
return secretbox.New(c.MemberSettingsEncryption.CurrentKeyID, c.MemberSettingsEncryption.Key)
}
2026-07-13 08:59:13 +00:00
func invalidSecret(value string) bool {
value = strings.TrimSpace(value)
return len(value) < 32 || strings.Contains(value, "REPLACE_WITH") || strings.Contains(value, "CHANGE_ME")
}
2026-07-15 15:23:59 +00:00
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
}
2026-07-10 12:54:45 +00:00
func firstEnv(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}
2026-07-13 08:59:13 +00:00
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
}
2026-07-10 12:54:45 +00:00
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
}