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"` Captain CaptainConfig `mapstructure:"captain"` Worker WorkerConfig `mapstructure:"worker"` OAuth OAuthConfig `mapstructure:"oauth"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` SAML SAMLConfig `mapstructure:"saml"` LDAP LDAPConfig `mapstructure:"ldap"` 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"` } // 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"` GitHub OAuthProviderConfig `mapstructure:"github"` 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 { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` Name string `mapstructure:"name"` DBName string `mapstructure:"dbname"` SSLMode string `mapstructure:"sslmode"` 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") } func (d DatabaseConfig) DSN() string { dbname := d.DBName if dbname == "" { dbname = d.Name } return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", d.Host, d.Port, d.User, d.Password, dbname, d.SSLMode) } // MigrateDSN returns a PostgreSQL connection URL in the format // expected by golang-migrate: postgres://user:password@host:port/dbname?sslmode=mode func (d DatabaseConfig) MigrateDSN() string { dbname := d.DBName if dbname == "" { dbname = d.Name } return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", d.User, d.Password, d.Host, d.Port, dbname, d.SSLMode) } // 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 { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Password string `mapstructure:"password"` DB int `mapstructure:"db"` URL string `mapstructure:"url"` } 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 } // RateLimitConfig holds rate limiting configuration. // Uses Redis sliding window counter for production, with in-memory fallback. // Reference: Chatwoot's Rack::Attack throttle configuration. type RateLimitConfig struct { Enabled bool `mapstructure:"enabled"` // enable/disable rate limiting RequestsPerMinute int `mapstructure:"requests_per_minute"` // max requests per client per window WindowSeconds int `mapstructure:"window_seconds"` // sliding window duration in seconds } // CaptainConfig holds Captain AI and Copilot feature configuration. // Reference: Chatwoot config/features.yml + ENV variables for Captain type CaptainConfig struct { Enabled bool `mapstructure:"enabled"` LLMProvider string `mapstructure:"llm_provider"` // openai, azure, custom LLMModel string `mapstructure:"llm_model"` // gpt-4o, gpt-3.5-turbo, etc. LLMAPIKey string `mapstructure:"llm_api_key"` LLMBaseURL string `mapstructure:"llm_base_url"` // custom endpoint EmbeddingModel string `mapstructure:"embedding_model"` // text-embedding-3-small EmbeddingDims int `mapstructure:"embedding_dims"` // 1536 MaxTokens int `mapstructure:"max_tokens"` // default max_tokens for responses Temperature float64 `mapstructure:"temperature"` // default temperature } // LLMConfig returns a structured LLM config derived from CaptainConfig, // suitable for constructing LLM providers. func (c CaptainConfig) LLMConfig() LLMConfig { return LLMConfig{ Provider: c.LLMProvider, BaseURL: c.LLMBaseURL, APIKey: c.LLMAPIKey, Model: c.LLMModel, EmbedModel: c.EmbeddingModel, MaxTokens: c.MaxTokens, Temperature: c.Temperature, } } // LLMConfig holds LLM provider configuration in a provider-friendly format. type LLMConfig struct { Provider string `yaml:"provider"` // openai, azure, custom BaseURL string `yaml:"base_url"` // https://api.openai.com/v1 or custom APIKey string `yaml:"api_key"` Model string `yaml:"model"` // gpt-4, gpt-3.5-turbo, etc. EmbedModel string `yaml:"embed_model"` // text-embedding-3-small MaxTokens int `yaml:"max_tokens"` // default 4096 Temperature float64 `yaml:"temperature"` // default 0.7 } // SAMLConfig holds SAML 2.0 Service Provider configuration. // Reference: P2E §1.6 — SAML SP integration for enterprise SSO. type SAMLConfig struct { Enabled bool `mapstructure:"enabled"` IdPMetadataURL string `mapstructure:"idp_metadata_url"` // URL to fetch IdP metadata XML IdPMetadataXML string `mapstructure:"idp_metadata_xml"` // Inline IdP metadata XML (alternative to URL) SPEntityID string `mapstructure:"sp_entity_id"` // Our SP entity ID ACSURL string `mapstructure:"acs_url"` // Assertion Consumer Service URL SPPrivateKey string `mapstructure:"sp_private_key"` // PEM-encoded SP private key SPCertificate string `mapstructure:"sp_certificate"` // PEM-encoded SP certificate AttributeMap SAMLAttributeMap `mapstructure:"attribute_map"` // SAML attribute → GoChat field mapping ClockDriftTolerance int `mapstructure:"clock_drift_tolerance"` // seconds of allowed clock drift } // SAMLAttributeMap maps SAML assertion attributes to GoChat user fields. type SAMLAttributeMap struct { Email string `mapstructure:"email"` // SAML attribute name for email DisplayName string `mapstructure:"display_name"` // SAML attribute name for display name FirstName string `mapstructure:"first_name"` // SAML attribute name for first name LastName string `mapstructure:"last_name"` // SAML attribute name for last name } // ClockDriftDuration returns clock drift tolerance as a time.Duration. func (c SAMLConfig) ClockDriftDuration() time.Duration { if c.ClockDriftTolerance <= 0 { return 180 * time.Second // default 3 minutes } return time.Duration(c.ClockDriftTolerance) * time.Second } // LDAPConfig holds LDAP authentication configuration. // Reference: M13 §4.4 — LDAP/Active Directory integration for enterprise authentication. // Per-account LDAP settings override these defaults (stored in DB). type LDAPConfig struct { Enabled bool `mapstructure:"enabled"` DefaultHost string `mapstructure:"default_host"` // default LDAP server host (e.g. ldap.example.com) DefaultPort int `mapstructure:"default_port"` // default port (389 for LDAP, 636 for LDAPS) DefaultUseTLS bool `mapstructure:"default_use_tls"` // use StartTLS on LDAP connection DefaultBaseDN string `mapstructure:"default_base_dn"` // default search base DN (e.g. dc=example,dc=com) DefaultBindDN string `mapstructure:"default_bind_dn"` // default bind DN for service account DefaultBindPassword string `mapstructure:"default_bind_password"` // default bind password DefaultUserFilter string `mapstructure:"default_user_filter"` // default LDAP user search filter DefaultEmailAttribute string `mapstructure:"default_email_attribute"` // default email attribute (mail) DefaultNameAttribute string `mapstructure:"default_name_attribute"` // default name attribute (cn) DefaultGroupAttribute string `mapstructure:"default_group_attribute"` // default group attribute (memberOf) SyncInterval int `mapstructure:"sync_interval"` // group sync interval in seconds (default: 3600) } // 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 rate limiting viper.SetDefault("rate_limit.enabled", true) viper.SetDefault("rate_limit.requests_per_minute", 100) viper.SetDefault("rate_limit.window_seconds", 60) // Set defaults for SAML viper.SetDefault("saml.enabled", false) viper.SetDefault("saml.clock_drift_tolerance", 180) viper.SetDefault("saml.attribute_map.email", "email") viper.SetDefault("saml.attribute_map.display_name", "displayName") viper.SetDefault("saml.attribute_map.first_name", "firstName") viper.SetDefault("saml.attribute_map.last_name", "lastName") // Set defaults for LDAP (M13) viper.SetDefault("ldap.enabled", false) viper.SetDefault("ldap.default_port", 389) viper.SetDefault("ldap.default_use_tls", false) viper.SetDefault("ldap.default_user_filter", "(objectClass=person)") viper.SetDefault("ldap.default_email_attribute", "mail") viper.SetDefault("ldap.default_name_attribute", "cn") viper.SetDefault("ldap.default_group_attribute", "memberOf") viper.SetDefault("ldap.sync_interval", 3600) // 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{"/api/v1/auth/", "/health", "/api/v1/saml/"}) // 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) if cfg.RateLimit.RequestsPerMinute == 0 { cfg.RateLimit.RequestsPerMinute = 100 } if cfg.RateLimit.WindowSeconds == 0 { cfg.RateLimit.WindowSeconds = 60 } 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", "rate_limit.enabled", "rate_limit.requests_per_minute", "rate_limit.window_seconds", "captain.enabled", "captain.llm_model", "captain.max_tokens", "captain.temperature", "worker.concurrency", "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()) if newCfg.RateLimit.RequestsPerMinute == 0 { newCfg.RateLimit.RequestsPerMinute = 100 } if newCfg.RateLimit.WindowSeconds == 0 { newCfg.RateLimit.WindowSeconds = 60 } 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 // Rate limit settings — safe to change at runtime r.cfg.RateLimit = newCfg.RateLimit // Captain AI settings — can toggle on/off, change model params r.cfg.Captain.Enabled = newCfg.Captain.Enabled r.cfg.Captain.LLMModel = newCfg.Captain.LLMModel r.cfg.Captain.MaxTokens = newCfg.Captain.MaxTokens r.cfg.Captain.Temperature = newCfg.Captain.Temperature // Worker concurrency — safe to change at runtime r.cfg.Worker.Concurrency = newCfg.Worker.Concurrency // CORS allowed origins — safe to update whitelist at runtime r.cfg.Server.CORS = newCfg.Server.CORS // NOTE: Database, Redis, JWT, SAML, 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_HOST → database.host 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_HOST": "database.host", "GOCHAT_DATABASE_PORT": "database.port", "GOCHAT_DATABASE_USER": "database.user", "GOCHAT_DATABASE_PASSWORD": "database.password", "GOCHAT_DATABASE_NAME": "database.name", "GOCHAT_DATABASE_DBNAME": "database.dbname", "GOCHAT_DATABASE_SSLMODE": "database.sslmode", "GOCHAT_REDIS_URL": "redis.url", "GOCHAT_REDIS_HOST": "redis.host", "GOCHAT_REDIS_PORT": "redis.port", "GOCHAT_REDIS_PASSWORD": "redis.password", "GOCHAT_JWT_SECRET": "jwt.secret", "JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix) "GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours", "GOCHAT_LOG_LEVEL": "log.level", "GOCHAT_LOG_FORMAT": "log.format", "GOCHAT_CAPTAIN_ENABLED": "captain.enabled", "GOCHAT_CAPTAIN_LLM_PROVIDER": "captain.llm_provider", "GOCHAT_CAPTAIN_LLM_MODEL": "captain.llm_model", "GOCHAT_CAPTAIN_LLM_API_KEY": "captain.llm_api_key", "GOCHAT_WORKER_CONCURRENCY": "worker.concurrency", "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", // 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) 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 } // 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) { 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") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) val := strings.TrimSpace(parts[1]) // Strip surrounding quotes val = strings.Trim(val, "\"'") // Map GOCHAT_ prefixed keys to viper config keys if strings.HasPrefix(key, "GOCHAT_") { configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_")) configKey = strings.ReplaceAll(configKey, "_", ".") v.Set(configKey, val) } } _ = path // loaded one .env file is enough break } } // 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.host", "localhost") v.SetDefault("database.port", 5432) v.SetDefault("database.sslmode", "disable") 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.host", "localhost") v.SetDefault("redis.port", 6379) v.SetDefault("redis.db", 0) v.SetDefault("jwt.expiry_hours", 72) v.SetDefault("log.level", "debug") v.SetDefault("log.format", "json") v.SetDefault("rate_limit.enabled", true) v.SetDefault("rate_limit.requests_per_minute", 100) v.SetDefault("rate_limit.window_seconds", 60) 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) // 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) v.SetDefault("saml.enabled", false) v.SetDefault("saml.clock_drift_tolerance", 180) v.SetDefault("saml.attribute_map.email", "email") v.SetDefault("saml.attribute_map.display_name", "displayName") v.SetDefault("saml.attribute_map.first_name", "firstName") v.SetDefault("saml.attribute_map.last_name", "lastName") // 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{"/api/v1/auth/", "/health", "/api/v1/saml/"}) // 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.RateLimit.RequestsPerMinute == 0 { cfg.RateLimit.RequestsPerMinute = 100 } if cfg.RateLimit.WindowSeconds == 0 { cfg.RateLimit.WindowSeconds = 60 } if cfg.Worker.Concurrency == 0 { cfg.Worker.Concurrency = 4 } 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/saml/", "/api/v1/ldap/", "/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 } }