Files
gochat/backend/internal/config/config.go
T
Rogee 92f0d51375 refactor: 统一 DB/Redis 配置为 DSN 模式 + 移除 Helm/K8s 部署
- DatabaseConfig: Host/Port/User/Password/Name/DBName/SSLMode → 单个 DSN 字段
- RedisConfig: Host/Port/Password/DB/URL → 单个 DSN 字段
- 环境变量: GOCHAT_DATABASE_* (7个) → GOCHAT_DATABASE_DSN, GOCHAT_REDIS_* (5个) → GOCHAT_REDIS_DSN
- validator.go: DSN URL 解析校验 (scheme + host)
- redis.go: redis.ParseURL(cfg.DSN) 直连
- 所有 docker-compose / CI / shell 脚本 / .env 同步更新
- 删除 deploy/helm/ 整个目录 (20个文件)
- CI 删除 helm-validate / deploy-staging / deploy-production 三个 job
- 文档同步更新 (README, 架构设计, PRD, 滚动升级)
2026-07-29 20:58:10 +08:00

768 lines
30 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
applogger "github.com/gochat/gochat/pkg/logger"
)
// Build metadata injected via -ldflags at build time.
// Usage: go build -ldflags="-X github.com/gochat/gochat/internal/config.Version=v1.0.0 ..."
var (
Version = "dev" // semantic version, e.g. v1.2.3
CommitSHA = "unknown" // git commit short hash
BuildDate = "unknown" // UTC timestamp of build
)
// Config holds all application configuration.
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"`
Worker WorkerConfig `mapstructure:"worker"`
OAuth OAuthConfig `mapstructure:"oauth"`
OIDC OIDCConfig `mapstructure:"oidc"`
Push PushConfig `mapstructure:"push"`
Notification NotificationConfig `mapstructure:"notification"`
Webhook WebhookConfig `mapstructure:"webhook"`
Search SearchConfig `mapstructure:"search"`
CSRF CSRFConfig `mapstructure:"csrf"`
Session SessionConfig `mapstructure:"session"`
Storage StorageConfig `mapstructure:"storage"`
}
type WorkerConfig struct {
Concurrency int `mapstructure:"concurrency"`
StreamPrefix string `mapstructure:"redis_stream_prefix"` // default "gochat:jobs"
ConsumerGroup string `mapstructure:"redis_consumer_group"` // default "gochat-workers"
BlockTimeoutS int `mapstructure:"redis_block_timeout_s"` // default 5
SweepIntervalS int `mapstructure:"redis_sweep_interval_s"` // default 30
}
// SearchConfig controls the full-text search backend. Meilisearch is the
// production target for Chatwoot parity; db is only a local development fallback.
type SearchConfig struct {
Engine string `mapstructure:"engine"` // meilisearch or db
Host string `mapstructure:"host"` // e.g. http://localhost:7700
APIKey string `mapstructure:"api_key"` // Meilisearch master/search key
IndexPrefix string `mapstructure:"index_prefix"` // index name prefix, e.g. gochat_
TimeoutSeconds int `mapstructure:"timeout_seconds"` // HTTP timeout for Meilisearch calls
}
type OAuthProviderConfig struct {
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
RedirectURL string `mapstructure:"redirect_url"`
TenantID string `mapstructure:"tenant_id"` // Azure AD tenant (Microsoft-specific)
Scopes string `mapstructure:"scopes"` // comma-separated OAuth scopes
}
type OAuthConfig struct {
Google OAuthProviderConfig `mapstructure:"google"`
Twitter OAuthProviderConfig `mapstructure:"twitter"`
Microsoft OAuthProviderConfig `mapstructure:"microsoft"`
Facebook OAuthProviderConfig `mapstructure:"facebook"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug, release, test
CORS CORSConfig `mapstructure:"cors"`
}
// CORSConfig holds CORS middleware configuration.
// AllowedOrigins supports exact matches (e.g. "https://app.example.com")
// and wildcard subdomains (e.g. "*.example.com").
// When Mode is "debug" and AllowedOrigins is empty, Allow-Origin:* is used as fallback.
type CORSConfig struct {
AllowedOrigins []string `mapstructure:"allowed_origins"`
AllowedMethods []string `mapstructure:"allowed_methods"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
ExposeHeaders []string `mapstructure:"expose_headers"`
AllowCredentials bool `mapstructure:"allow_credentials"`
MaxAge int `mapstructure:"max_age"` // seconds
}
type DatabaseConfig struct {
// DSN is the full PostgreSQL connection string, e.g.
// "postgres://user:password@host:port/dbname?sslmode=disable"
DSN string `mapstructure:"dsn"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // seconds
RunMigrations bool `mapstructure:"run_migrations"` // run golang-migrate on startup
MigrationsPath string `mapstructure:"migrations_path"` // path to migration files (default: "migrations")
}
// MigrateDSN returns the connection string for golang-migrate.
// Same as DSN — both GORM (pgx) and golang-migrate accept the
// "postgres://user:password@host:port/dbname?sslmode=mode" URL format.
func (d DatabaseConfig) MigrateDSN() string {
return d.DSN
}
// GetMigrationsPath returns the migrations path, defaulting to "migrations" if not set.
func (d DatabaseConfig) GetMigrationsPath() string {
if d.MigrationsPath == "" {
return "migrations"
}
return d.MigrationsPath
}
type RedisConfig struct {
// DSN is the full Redis connection string, e.g.
// "redis://:password@host:port/db"
// "rediss://host:port" (TLS)
DSN string `mapstructure:"dsn"`
PoolSize int `mapstructure:"pool_size"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpiryHours int `mapstructure:"expiry_hours"`
RefreshExpiryHours int `mapstructure:"refresh_expiry_hours"`
AccessExpiryMinutes int `mapstructure:"access_expiry_minutes"`
Audience string `mapstructure:"audience"`
Issuer string `mapstructure:"issuer"`
}
func (j JWTConfig) ExpiryDuration() time.Duration {
return time.Duration(j.ExpiryHours) * time.Hour
}
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"` // json, text
}
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
// Reference: M13 §4.3 — OIDC (OpenID Connect) provider integration.
// Supports Google Workspace, Auth0, Keycloak, Azure AD and any OIDC-compliant IdP.
// Per-account OIDC settings override these defaults (stored in DB).
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
DefaultClientID string `mapstructure:"default_client_id"` // default OIDC client ID
DefaultClientSecret string `mapstructure:"default_client_secret"` // default OIDC client secret
DefaultRedirectURL string `mapstructure:"default_redirect_url"` // default redirect URL for callback
DefaultIssuerURL string `mapstructure:"default_issuer_url"` // default IdP issuer URL (e.g. https://accounts.google.com)
DefaultAuthorizationURL string `mapstructure:"default_authorization_url"` // default authorization endpoint
DefaultTokenURL string `mapstructure:"default_token_url"` // default token endpoint
DefaultUserInfoURL string `mapstructure:"default_user_info_url"` // default userinfo endpoint (for non-JWT claims)
DefaultJWKSURL string `mapstructure:"default_jwks_url"` // default JWKS endpoint for id_token verification
DefaultScopes []string `mapstructure:"default_scopes"` // default scopes (openid, profile, email)
}
// PushConfig holds push notification (VAPID/web push) configuration.
// Reference: Chatwoot vapid configuration for web push notifications.
type PushConfig struct {
Enabled bool `mapstructure:"enabled"`
VapidPublicKey string `mapstructure:"vapid_public_key"`
VapidPrivateKey string `mapstructure:"vapid_private_key"`
VapidSubject string `mapstructure:"vapid_subject"` // e.g. mailto:admin@example.com
}
// NotificationConfig holds notification delivery pipeline configuration.
type NotificationConfig struct {
Enabled bool `mapstructure:"enabled"`
DeliveryWorkers int `mapstructure:"delivery_workers"` // concurrent delivery goroutines
RetryMaxAttempts int `mapstructure:"retry_max_attempts"`
RetryDelaySeconds int `mapstructure:"retry_delay_seconds"`
}
// WebhookConfig holds outgoing webhook delivery configuration.
// Reference: Chatwoot webhook_config for account-level webhook integrations.
type WebhookConfig struct {
Enabled bool `mapstructure:"enabled"`
SigningSecret string `mapstructure:"signing_secret"` // HMAC-SHA256 secret for webhook payloads
TimeoutSeconds int `mapstructure:"timeout_seconds"`
RetryMaxAttempts int `mapstructure:"retry_max_attempts"`
RetryDelaySeconds int `mapstructure:"retry_delay_seconds"`
}
// CSRFConfig holds CSRF protection configuration.
// Reference: OWASP CSRF Prevention Cheat Sheet — double-submit cookie pattern
// adapted for API-first architecture (no server-side session required).
type CSRFConfig struct {
Enabled bool `mapstructure:"enabled"`
Secret string `mapstructure:"secret"` // 32-byte hex secret for token generation
CookieName string `mapstructure:"cookie_name"` // default: _gochat_csrf
HeaderName string `mapstructure:"header_name"` // default: X-CSRF-Token
TokenLength int `mapstructure:"token_length"` // default: 32 bytes
SafeMethods []string `mapstructure:"safe_methods"` // default: GET, HEAD, OPTIONS
SkipPaths []string `mapstructure:"skip_paths"` // paths that skip CSRF validation (e.g., /api/v1/auth/login)
CookieSecure bool `mapstructure:"cookie_secure"` // set Secure flag (prod: true)
CookieHTTPOnly bool `mapstructure:"cookie_http_only"` // set HttpOnly flag (default: false)
CookieSameSite string `mapstructure:"cookie_same_site"` // Strict, Lax, or None (default: Strict)
CookieDomain string `mapstructure:"cookie_domain"` // optional domain restriction
CookiePath string `mapstructure:"cookie_path"` // default: /
ExpirySeconds int `mapstructure:"expiry_seconds"` // token rotation period (default: 3600)
}
// SessionConfig holds session management configuration.
// Reference: Chatwoot Devise sessions — replaced with JWT + session store.
type SessionConfig struct {
Enabled bool `mapstructure:"enabled"`
ExpirySeconds int `mapstructure:"expiry_seconds"` // session lifetime (default: 86400 = 24h)
TokenLength int `mapstructure:"token_length"` // session ID length in bytes (default: 32)
HeaderName string `mapstructure:"header_name"` // header name for session ID (default: X-Session-ID)
SkipPaths []string `mapstructure:"skip_paths"` // paths that skip session validation
CleanupInterval int `mapstructure:"cleanup_interval"` // expired session cleanup interval in seconds (default: 300)
}
// StorageConfig holds file storage configuration.
type StorageConfig struct {
Provider string `mapstructure:"provider"` // "local" (default), "s3" (future)
LocalPath string `mapstructure:"local_path"` // Directory for local file storage
MaxFileSize int64 `mapstructure:"max_file_size"` // Maximum file size in bytes (default 20MB)
}
// Load reads config from file and environment.
func Load() (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./configs")
viper.AddConfigPath("./")
viper.AddConfigPath("/etc/gochat/")
viper.SetEnvPrefix("GOCHAT")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// Set defaults for OIDC (M13)
viper.SetDefault("oidc.enabled", false)
viper.SetDefault("oidc.default_scopes", []string{"openid", "profile", "email"})
// Set defaults for push notifications
viper.SetDefault("push.enabled", false)
viper.SetDefault("push.vapid_public_key", "")
viper.SetDefault("push.vapid_private_key", "")
viper.SetDefault("push.vapid_subject", "")
// Set defaults for notification delivery
viper.SetDefault("notification.enabled", true)
viper.SetDefault("notification.delivery_workers", 4)
viper.SetDefault("notification.retry_max_attempts", 3)
viper.SetDefault("notification.retry_delay_seconds", 30)
// Set defaults for webhook delivery
viper.SetDefault("webhook.enabled", false)
viper.SetDefault("webhook.signing_secret", "")
viper.SetDefault("webhook.timeout_seconds", 10)
viper.SetDefault("webhook.retry_max_attempts", 3)
viper.SetDefault("webhook.retry_delay_seconds", 60)
// Set defaults for search. Meilisearch is the Chatwoot parity target; db is
// reserved for explicit local development fallback.
viper.SetDefault("search.engine", "meilisearch")
viper.SetDefault("search.host", "http://localhost:7700")
viper.SetDefault("search.api_key", "")
viper.SetDefault("search.index_prefix", "gochat_")
viper.SetDefault("search.timeout_seconds", 5)
// Set defaults for CSRF protection
viper.SetDefault("csrf.enabled", true)
viper.SetDefault("csrf.cookie_name", "_gochat_csrf")
viper.SetDefault("csrf.header_name", "X-CSRF-Token")
viper.SetDefault("csrf.token_length", 32)
viper.SetDefault("csrf.cookie_secure", true)
viper.SetDefault("csrf.cookie_http_only", false)
viper.SetDefault("csrf.cookie_same_site", "Strict")
viper.SetDefault("csrf.cookie_path", "/")
viper.SetDefault("csrf.expiry_seconds", 3600)
viper.SetDefault("csrf.skip_paths", []string{"/auth/", "/api/v1/", "/platform/api/", "/public/api/", "/widget/", "/webhooks/", "/health"})
// Set defaults for session management
viper.SetDefault("session.enabled", true)
viper.SetDefault("session.expiry_seconds", 86400)
viper.SetDefault("session.token_length", 32)
viper.SetDefault("session.header_name", "X-Session-ID")
viper.SetDefault("session.cleanup_interval", 300)
if err := viper.ReadInConfig(); err != nil {
return nil, err
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
// Apply defaults for zero-valued fields (viper may not set defaults for already-present keys)
applySearchDefaults(&cfg.Search)
return &cfg, nil
}
func applySearchDefaults(search *SearchConfig) {
if search.Engine == "" {
search.Engine = "meilisearch"
}
if search.Host == "" {
search.Host = "http://localhost:7700"
}
if search.IndexPrefix == "" {
search.IndexPrefix = "gochat_"
}
if search.TimeoutSeconds == 0 {
search.TimeoutSeconds = 5
}
}
// ConfigReloader manages hot-reloading of configuration files.
// It watches for changes and applies safe, runtime-updatable config fields
// without requiring a full application restart.
// Pattern: similar to Chatwoot's config/environments/* reload via Spring-like watchers.
type ConfigReloader struct {
v *viper.Viper
cfg *Config
mu sync.RWMutex
env string
stopCh chan struct{}
// onChange callbacks — subscribers can react to config updates.
onChange []func(old, new *Config)
}
// ReloadableFields lists config keys that can be safely hot-reloaded at runtime.
// Sensitive fields (JWT secret, DB connection, Redis URL) require restart.
var ReloadableFields = []string{
"log.level",
"log.format",
"worker.concurrency",
"worker.redis_block_timeout_s",
"worker.redis_sweep_interval_s",
"server.cors.allowed_origins",
}
// NewConfigReloader creates a reloader that watches the config file for changes.
// It uses the same viper instance used during initial Load() so settings are consistent.
func NewConfigReloader(cfg *Config, env string) (*ConfigReloader, error) {
v := viper.GetViper()
r := &ConfigReloader{
v: v,
cfg: cfg,
env: env,
stopCh: make(chan struct{}),
}
// Enable fsnotify-based file watching
v.WatchConfig()
v.OnConfigChange(r.handleConfigChange)
return r, nil
}
// handleConfigChange is the viper OnConfigChange callback.
// It re-validates the new config and applies reloadable fields only.
func (r *ConfigReloader) handleConfigChange(e fsnotify.Event) {
applogger.L().Infof("Config file changed: %s (op=%s)", e.Name, e.Op)
// Re-unmarshal the full config from viper (which now has updated values)
var newCfg Config
if err := r.v.Unmarshal(&newCfg); err != nil {
applogger.L().Errorf("Failed to unmarshal updated config: %v", err)
return
}
// Apply defaults for zero-valued fields (same logic as Load())
applySearchDefaults(&newCfg.Search)
// Validate the entire new config — if invalid, skip the reload
if err := Validate(&newCfg); err != nil {
applogger.L().Errorf("Updated config validation failed, keeping old config: %v", err)
return
}
// Swap in reloadable fields only — immutable fields stay as-is
r.mu.Lock()
oldCfg := *r.cfg // snapshot old for callbacks
r.applyReloadableFields(&newCfg)
r.mu.Unlock()
applogger.L().Infof("Config hot-reload applied successfully")
// Fire onChange callbacks
for _, cb := range r.onChange {
cb(&oldCfg, r.cfg)
}
}
// applyReloadableFields copies only safe-to-reload fields from newCfg into r.cfg.
// Immutable fields (DB, Redis, JWT, SAML secrets) remain unchanged.
func (r *ConfigReloader) applyReloadableFields(newCfg *Config) {
// Log settings — safe to change at runtime
r.cfg.Log = newCfg.Log
// Worker concurrency — safe to change at runtime
r.cfg.Worker.Concurrency = newCfg.Worker.Concurrency
// Worker Redis sweep/block timing — safe to change at runtime
r.cfg.Worker.BlockTimeoutS = newCfg.Worker.BlockTimeoutS
r.cfg.Worker.SweepIntervalS = newCfg.Worker.SweepIntervalS
// CORS allowed origins — safe to update whitelist at runtime
r.cfg.Server.CORS = newCfg.Server.CORS
// NOTE: Database, Redis, JWT, OAuth secrets are NOT hot-reloaded.
// Changing these requires a full application restart.
}
// OnChange registers a callback that fires when config is hot-reloaded.
// Callbacks receive the old and new config snapshots.
func (r *ConfigReloader) OnChange(cb func(old, new *Config)) {
r.onChange = append(r.onChange, cb)
}
// Config returns the current config (thread-safe read).
func (r *ConfigReloader) Config() *Config {
r.mu.RLock()
defer r.mu.RUnlock()
return r.cfg
}
// Stop terminates the file watcher goroutine.
// Safe to call multiple times — subsequent calls are no-op.
func (r *ConfigReloader) Stop() {
select {
case <-r.stopCh:
// Already closed
default:
close(r.stopCh)
}
}
// LoadWithEnv loads config with environment overlay support.
// Base config.yaml is loaded first, then config.{env}.yaml merges on top.
// This follows Chatwoot's Rails-style environment-specific config pattern:
//
// config/environments/development.rb overrides config/application.rb defaults.
func LoadWithEnv(env string) (*Config, error) {
v := viper.New()
// Env key replacer: GOCHAT_DATABASE_DSN → database.dsn
v.SetEnvPrefix("GOCHAT")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// Bind specific env keys that viper can't auto-infer for nested structs
// These are common overrides that users set via environment variables
envBindings := map[string]string{
"GOCHAT_SERVER_HOST": "server.host",
"GOCHAT_SERVER_PORT": "server.port",
"GOCHAT_SERVER_MODE": "server.mode",
"GOCHAT_DATABASE_DSN": "database.dsn",
"GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns",
"GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns",
"GOCHAT_DATABASE_CONN_MAX_LIFETIME": "database.conn_max_lifetime",
"GOCHAT_DATABASE_RUN_MIGRATIONS": "database.run_migrations",
"GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path",
"GOCHAT_REDIS_DSN": "redis.dsn",
"GOCHAT_REDIS_POOL_SIZE": "redis.pool_size",
"GOCHAT_JWT_SECRET": "jwt.secret",
"JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix)
"GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours",
"GOCHAT_JWT_ACCESS_EXPIRY_MINUTES": "jwt.access_expiry_minutes",
"GOCHAT_JWT_REFRESH_EXPIRY_HOURS": "jwt.refresh_expiry_hours",
"GOCHAT_LOG_LEVEL": "log.level",
"GOCHAT_LOG_FORMAT": "log.format",
"GOCHAT_WORKER_CONCURRENCY": "worker.concurrency",
"GOCHAT_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix",
"GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group",
"GOCHAT_WORKER_REDIS_BLOCK_TIMEOUT_S": "worker.redis_block_timeout_s",
"GOCHAT_WORKER_REDIS_SWEEP_INTERVAL_S": "worker.redis_sweep_interval_s",
"GOCHAT_SEARCH_ENGINE": "search.engine",
"GOCHAT_SEARCH_HOST": "search.host",
"GOCHAT_SEARCH_API_KEY": "search.api_key",
"GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix",
"GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds",
"GOCHAT_STORAGE_PROVIDER": "storage.provider",
"GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path",
"GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size",
// G10: OAuth config for new channel integrations (Twitter, Microsoft, Google)
"GOCHAT_OAUTH_TWITTER_CLIENT_ID": "oauth.twitter.client_id",
"GOCHAT_OAUTH_TWITTER_CLIENT_SECRET": "oauth.twitter.client_secret",
"GOCHAT_OAUTH_TWITTER_REDIRECT_URL": "oauth.twitter.redirect_url",
"GOCHAT_OAUTH_TWITTER_SCOPES": "oauth.twitter.scopes",
"GOCHAT_OAUTH_MICROSOFT_CLIENT_ID": "oauth.microsoft.client_id",
"GOCHAT_OAUTH_MICROSOFT_CLIENT_SECRET": "oauth.microsoft.client_secret",
"GOCHAT_OAUTH_MICROSOFT_TENANT_ID": "oauth.microsoft.tenant_id",
"GOCHAT_OAUTH_MICROSOFT_REDIRECT_URL": "oauth.microsoft.redirect_url",
"GOCHAT_OAUTH_MICROSOFT_SCOPES": "oauth.microsoft.scopes",
"GOCHAT_OAUTH_GOOGLE_CLIENT_ID": "oauth.google.client_id",
"GOCHAT_OAUTH_GOOGLE_CLIENT_SECRET": "oauth.google.client_secret",
"GOCHAT_OAUTH_GOOGLE_REDIRECT_URL": "oauth.google.redirect_url",
"GOCHAT_OAUTH_GOOGLE_SCOPES": "oauth.google.scopes",
}
for envKey, configKey := range envBindings {
if err := v.BindEnv(configKey, envKey); err != nil {
return nil, fmt.Errorf("failed to bind env %s: %w", envKey, err)
}
}
// Set defaults
setDefaults(v)
// Load base config: config.yaml
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.AddConfigPath("./")
v.AddConfigPath("/etc/gochat/")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("base config read failed: %w", err)
}
applogger.L().Infof("Loaded base config: %s", v.ConfigFileUsed())
// Overlay environment-specific config: config.{env}.yaml
if env != "" && env != "default" {
envFile := fmt.Sprintf("config.%s.yaml", env)
// Search in the same directory as the base config
baseConfigPath := v.ConfigFileUsed()
if baseConfigPath != "" {
envConfigPath := filepath.Join(filepath.Dir(baseConfigPath), envFile)
if _, err := os.Stat(envConfigPath); err == nil {
v.SetConfigFile(envConfigPath)
if err := v.MergeInConfig(); err != nil {
return nil, fmt.Errorf("env config merge failed (%s): %w", env, err)
}
applogger.L().Infof("Merged env config overlay: %s", envConfigPath)
} else {
applogger.L().Warnf("Env config file not found: %s (continuing with base config)", envConfigPath)
}
}
}
// Load .env file if present (common in Docker/local dev setups)
loadDotEnv(v, envBindings)
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("config unmarshal failed: %w", err)
}
// Apply defaults for zero-valued fields
applyZeroDefaults(&cfg)
return &cfg, nil
}
// LoadDotEnvEnvironment loads workspace environment values into the process.
// It is called before GOCHAT_ENV is read so the root .env can select the
// environment-specific config overlay as well as configure direct os.Getenv
// consumers throughout the application.
func LoadDotEnvEnvironment() {
dotEnvPaths := []string{".env", "../.env", "/etc/gochat/.env"}
for _, path := range dotEnvPaths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
key, val, ok := parseDotEnvLine(line)
if !ok {
continue
}
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val)
}
}
return
}
}
// LoadDotEnvFile reads a .env file and sets values into viper.
// Supports simple KEY=VALUE format, ignores comments and blank lines.
func loadDotEnv(v *viper.Viper, envBindings map[string]string) {
// The backend normally runs with backend/ as its working directory, while
// the workspace-level .env lives one directory above it.
dotEnvPaths := []string{".env", "../.env", "/etc/gochat/.env"}
for _, path := range dotEnvPaths {
data, err := os.ReadFile(path)
if err != nil {
continue // .env file is optional
}
applogger.L().Infof("Loading .env file: %s", path)
for _, line := range strings.Split(string(data), "\n") {
key, val, ok := parseDotEnvLine(line)
if !ok {
continue
}
// Existing process environment has priority over values from .env.
if existing, ok := os.LookupEnv(key); ok {
val = existing
} else {
_ = os.Setenv(key, val)
}
// Use explicit bindings so compound field names such as
// max_idle_conns are not incorrectly split into max.idle.conns.
if configKey, ok := envBindings[key]; ok {
v.Set(configKey, val)
}
}
_ = path // loaded one .env file is enough
break
}
}
func parseDotEnvLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
return "", "", false
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if len(val) >= 2 && ((val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'')) {
val = val[1 : len(val)-1]
} else if strings.HasPrefix(val, "#") {
val = ""
} else if comment := strings.Index(val, " #"); comment >= 0 {
val = strings.TrimSpace(val[:comment])
}
return key, val, key != ""
}
// setDefaults sets all viper defaults in one place.
func setDefaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 3000)
v.SetDefault("server.mode", "debug")
v.SetDefault("database.max_idle_conns", 10)
v.SetDefault("database.max_open_conns", 100)
v.SetDefault("database.conn_max_lifetime", 3600)
v.SetDefault("database.run_migrations", false)
v.SetDefault("database.migrations_path", "migrations")
v.SetDefault("redis.pool_size", 50)
v.SetDefault("jwt.expiry_hours", 72)
v.SetDefault("log.level", "debug")
v.SetDefault("log.format", "json")
v.SetDefault("search.engine", "meilisearch")
v.SetDefault("search.host", "http://localhost:7700")
v.SetDefault("search.api_key", "")
v.SetDefault("search.index_prefix", "gochat_")
v.SetDefault("search.timeout_seconds", 5)
v.SetDefault("worker.concurrency", 4)
v.SetDefault("worker.redis_stream_prefix", "gochat:jobs")
v.SetDefault("worker.redis_consumer_group", "gochat-workers")
v.SetDefault("worker.redis_block_timeout_s", 5)
v.SetDefault("worker.redis_sweep_interval_s", 30)
// CORS production defaults
v.SetDefault("server.cors.allowed_methods", []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"})
v.SetDefault("server.cors.allowed_headers", []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID"})
v.SetDefault("server.cors.expose_headers", []string{"Content-Length"})
v.SetDefault("server.cors.max_age", 86400)
v.SetDefault("server.cors.allow_credentials", false)
// CSRF defaults
v.SetDefault("csrf.enabled", true)
v.SetDefault("csrf.cookie_name", "_gochat_csrf")
v.SetDefault("csrf.header_name", "X-CSRF-Token")
v.SetDefault("csrf.token_length", 32)
v.SetDefault("csrf.cookie_secure", true)
v.SetDefault("csrf.cookie_http_only", false)
v.SetDefault("csrf.cookie_same_site", "Strict")
v.SetDefault("csrf.cookie_path", "/")
v.SetDefault("csrf.expiry_seconds", 3600)
v.SetDefault("csrf.skip_paths", []string{"/auth/", "/api/v1/", "/platform/api/", "/public/api/", "/widget/", "/webhooks/", "/health"})
// Session defaults
v.SetDefault("session.enabled", true)
v.SetDefault("session.expiry_seconds", 86400)
v.SetDefault("session.token_length", 32)
v.SetDefault("session.header_name", "X-Session-ID")
v.SetDefault("session.cleanup_interval", 300)
// Storage defaults
v.SetDefault("storage.provider", "local")
v.SetDefault("storage.local_path", "./uploads")
v.SetDefault("storage.max_file_size", 20*1024*1024) // 20MB
}
// applyZeroDefaults fills in defaults for zero-valued fields that viper may not set.
func applyZeroDefaults(cfg *Config) {
if cfg.Worker.Concurrency == 0 {
cfg.Worker.Concurrency = 4
}
if cfg.Worker.StreamPrefix == "" {
cfg.Worker.StreamPrefix = "gochat:jobs"
}
if cfg.Worker.ConsumerGroup == "" {
cfg.Worker.ConsumerGroup = "gochat-workers"
}
if cfg.Worker.BlockTimeoutS == 0 {
cfg.Worker.BlockTimeoutS = 5
}
if cfg.Worker.SweepIntervalS == 0 {
cfg.Worker.SweepIntervalS = 30
}
applySearchDefaults(&cfg.Search)
// CSRF defaults
if cfg.CSRF.CookieName == "" {
cfg.CSRF.CookieName = "_gochat_csrf"
}
if cfg.CSRF.HeaderName == "" {
cfg.CSRF.HeaderName = "X-CSRF-Token"
}
if cfg.CSRF.TokenLength == 0 {
cfg.CSRF.TokenLength = 32
}
if cfg.CSRF.ExpirySeconds == 0 {
cfg.CSRF.ExpirySeconds = 3600
}
if cfg.CSRF.CookiePath == "" {
cfg.CSRF.CookiePath = "/"
}
if cfg.CSRF.CookieSameSite == "" {
cfg.CSRF.CookieSameSite = "Strict"
}
if len(cfg.CSRF.SkipPaths) == 0 {
cfg.CSRF.SkipPaths = []string{"/api/v1/auth/", "/health", "/api/v1/oidc/"}
}
// Session defaults
if cfg.Session.ExpirySeconds == 0 {
cfg.Session.ExpirySeconds = 86400
}
if cfg.Session.TokenLength == 0 {
cfg.Session.TokenLength = 32
}
if cfg.Session.HeaderName == "" {
cfg.Session.HeaderName = "X-Session-ID"
}
if cfg.Session.CleanupInterval == 0 {
cfg.Session.CleanupInterval = 300
}
// Storage defaults
if cfg.Storage.Provider == "" {
cfg.Storage.Provider = "local"
}
if cfg.Storage.LocalPath == "" {
cfg.Storage.LocalPath = "./uploads"
}
if cfg.Storage.MaxFileSize == 0 {
cfg.Storage.MaxFileSize = 20 * 1024 * 1024 // 20MB
}
}