feat(search): add meilisearch engine foundation

This commit is contained in:
2026-06-04 19:56:29 +08:00
parent db0bd5b0f6
commit 3fc4859e20
13 changed files with 1515 additions and 289 deletions
+190 -140
View File
@@ -17,57 +17,68 @@ import (
// 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
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"`
CSRF CSRFConfig `mapstructure:"csrf"`
Session SessionConfig `mapstructure:"session"`
Storage StorageConfig `mapstructure:"storage"`
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
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"`
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
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug, release, test
CORS CORSConfig `mapstructure:"cors"`
}
@@ -95,8 +106,8 @@ type DatabaseConfig struct {
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")
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 {
@@ -136,12 +147,12 @@ type RedisConfig struct {
}
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"`
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 {
@@ -157,17 +168,17 @@ type LogConfig struct {
// 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
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.
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
@@ -192,8 +203,8 @@ func (c CaptainConfig) LLMConfig() LLMConfig {
// 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
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
@@ -204,23 +215,23 @@ type LLMConfig struct {
// 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
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
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
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.
@@ -235,18 +246,18 @@ func (c SAMLConfig) ClockDriftDuration() time.Duration {
// 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
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)
SyncInterval int `mapstructure:"sync_interval"` // group sync interval in seconds (default: 3600)
}
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
@@ -254,43 +265,43 @@ type LDAPConfig struct {
// 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)
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"`
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
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"`
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"`
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.
@@ -298,35 +309,35 @@ type WebhookConfig struct {
// 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)
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)
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)
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
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)
}
@@ -339,6 +350,7 @@ func Load() (*Config, error) {
viper.AddConfigPath("/etc/gochat/")
viper.SetEnvPrefix("GOCHAT")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// Set defaults for rate limiting
@@ -387,6 +399,14 @@ func Load() (*Config, error) {
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")
@@ -422,10 +442,26 @@ func Load() (*Config, error) {
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.
@@ -494,6 +530,7 @@ func (r *ConfigReloader) handleConfigChange(e fsnotify.Event) {
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 {
@@ -567,7 +604,8 @@ func (r *ConfigReloader) Stop() {
// 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.
//
// config/environments/development.rb overrides config/application.rb defaults.
func LoadWithEnv(env string) (*Config, error) {
v := viper.New()
@@ -579,44 +617,49 @@ func LoadWithEnv(env string) (*Config, error) {
// 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_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_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",
"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 {
@@ -735,6 +778,12 @@ func setDefaults(v *viper.Viper) {
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
@@ -787,6 +836,7 @@ func applyZeroDefaults(cfg *Config) {
if cfg.Worker.Concurrency == 0 {
cfg.Worker.Concurrency = 4
}
applySearchDefaults(&cfg.Search)
// CSRF defaults
if cfg.CSRF.CookieName == "" {
cfg.CSRF.CookieName = "_gochat_csrf"
@@ -832,4 +882,4 @@ func applyZeroDefaults(cfg *Config) {
if cfg.Storage.MaxFileSize == 0 {
cfg.Storage.MaxFileSize = 20 * 1024 * 1024 // 20MB
}
}
}
+45 -11
View File
@@ -26,11 +26,12 @@ func TestValidate_ValidConfig(t *testing.T) {
ExpiryHours: 24,
RefreshExpiryHours: 168,
},
Log: LogConfig{Level: "info", Format: "json"},
Captain: CaptainConfig{Enabled: false},
Worker: WorkerConfig{Concurrency: 4},
OAuth: OAuthConfig{},
Log: LogConfig{Level: "info", Format: "json"},
Captain: CaptainConfig{Enabled: false},
Worker: WorkerConfig{Concurrency: 4},
OAuth: OAuthConfig{},
RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60},
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", IndexPrefix: "gochat_", TimeoutSeconds: 5},
}
err := Validate(cfg)
@@ -39,10 +40,10 @@ func TestValidate_ValidConfig(t *testing.T) {
func TestValidate_InvalidPort(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 0, Mode: "debug"},
Server: ServerConfig{Host: "localhost", Port: 0, Mode: "debug"},
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
Redis: RedisConfig{URL: "redis://localhost:6379"},
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
Redis: RedisConfig{URL: "redis://localhost:6379"},
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
}
err := Validate(cfg)
@@ -52,10 +53,10 @@ func TestValidate_InvalidPort(t *testing.T) {
func TestValidate_InvalidMode(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "invalid"},
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "invalid"},
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
Redis: RedisConfig{URL: "redis://localhost:6379"},
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
Redis: RedisConfig{URL: "redis://localhost:6379"},
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
}
err := Validate(cfg)
@@ -156,6 +157,39 @@ func TestValidate_InvalidWorkerConcurrency(t *testing.T) {
assert.Contains(t, err.Error(), "worker concurrency")
}
func TestValidate_SearchMeilisearchRequiresValidHost(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
Redis: RedisConfig{URL: "redis://localhost:6379"},
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1},
RateLimit: RateLimitConfig{RequestsPerMinute: 100, WindowSeconds: 60},
Search: SearchConfig{Engine: "meilisearch", Host: "not a url", TimeoutSeconds: 5},
}
err := Validate(cfg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid search.host")
}
func TestValidate_SearchDBFallbackAllowed(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
Redis: RedisConfig{URL: "redis://localhost:6379"},
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1},
RateLimit: RateLimitConfig{RequestsPerMinute: 100, WindowSeconds: 60},
Search: SearchConfig{Engine: "db"},
}
err := Validate(cfg)
assert.NoError(t, err)
}
func TestDatabaseConfig_DSN(t *testing.T) {
cfg := DatabaseConfig{
Host: "localhost",
@@ -184,4 +218,4 @@ func TestServerConfig_Address(t *testing.T) {
cfg := ServerConfig{Host: "0.0.0.0", Port: 3000}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
assert.Equal(t, "0.0.0.0:3000", addr)
}
}
+25 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
)
@@ -70,6 +71,28 @@ func Validate(cfg *Config) error {
return fmt.Errorf("rate_limit.window_seconds must be >= 1")
}
// Search validation. Meilisearch is the production parity engine; db remains
// available only as an explicit development fallback.
engine := strings.ToLower(cfg.Search.Engine)
if engine == "" {
engine = "meilisearch"
}
if engine != "meilisearch" && engine != "db" {
return fmt.Errorf("invalid search engine: %s (must be meilisearch or db)", cfg.Search.Engine)
}
if engine == "meilisearch" {
if cfg.Search.Host == "" {
return fmt.Errorf("search.host is required when search.engine=meilisearch")
}
searchURL, err := url.Parse(cfg.Search.Host)
if err != nil || searchURL.Scheme == "" || searchURL.Host == "" {
return fmt.Errorf("invalid search.host: %s", cfg.Search.Host)
}
}
if cfg.Search.TimeoutSeconds < 0 {
return fmt.Errorf("search.timeout_seconds must be >= 0")
}
return nil
}
@@ -78,12 +101,12 @@ func ParseStatementTimeout(timeout string) (time.Duration, error) {
if timeout == "" {
return 14 * time.Second, nil
}
// Handle plain seconds (e.g., "14s")
if secs, err := strconv.Atoi(timeout); err == nil {
return time.Duration(secs) * time.Second, nil
}
// Handle Go duration format (e.g., "14s", "500ms")
d, err := time.ParseDuration(timeout)
if err != nil {