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

313 lines
10 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"
"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 <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,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 / 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")
}
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
}