Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
894 lines
36 KiB
Go
894 lines
36 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"`
|
|
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"`
|
|
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
|
|
}
|
|
|
|
// 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{"/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)
|
|
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_DATABASE_RUN_MIGRATIONS": "database.run_migrations",
|
|
"GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path",
|
|
"GOCHAT_REDIS_URL": "redis.url",
|
|
"GOCHAT_REDIS_HOST": "redis.host",
|
|
"GOCHAT_REDIS_PORT": "redis.port",
|
|
"GOCHAT_REDIS_PASSWORD": "redis.password",
|
|
"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_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",
|
|
"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)
|
|
|
|
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("redis.pool_size", 50)
|
|
|
|
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{"/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.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
|
|
}
|
|
}
|