diff --git a/.env.example b/.env.example index e63587ce..9f1ef83e 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ # Copy to .env and fill in your values. NEVER commit .env to git. # ---- Application ---- -GOCHAT_ENV=production +GOCHAT_ENV=prod GOCHAT_VERSION=v1.0.0 # ---- Server ---- @@ -12,21 +12,21 @@ GOCHAT_SERVER_PORT=3000 GOCHAT_SERVER_MODE=release # debug, release, test # ---- Database ---- -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_USER=gochat -POSTGRES_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD -POSTGRES_DB=gochat_production -POSTGRES_SSLMODE=disable -GOCHAT_DB_MAX_IDLE_CONNS=10 -GOCHAT_DB_MAX_OPEN_CONNS=100 -GOCHAT_DB_CONN_MAX_LIFETIME=3600 +GOCHAT_DATABASE_HOST=localhost +GOCHAT_DATABASE_PORT=5432 +GOCHAT_DATABASE_USER=gochat +GOCHAT_DATABASE_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD +GOCHAT_DATABASE_DBNAME=gochat_production +GOCHAT_DATABASE_SSLMODE=disable +GOCHAT_DATABASE_MAX_IDLE_CONNS=10 +GOCHAT_DATABASE_MAX_OPEN_CONNS=100 +GOCHAT_DATABASE_CONN_MAX_LIFETIME=3600 # ---- Redis ---- -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD -REDIS_DB=0 +GOCHAT_REDIS_HOST=localhost +GOCHAT_REDIS_PORT=6379 +GOCHAT_REDIS_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD +GOCHAT_REDIS_DB=0 # ---- JWT ---- GOCHAT_JWT_SECRET=CHANGE_ME_TO_A_RANDOM_64_CHAR_STRING @@ -39,7 +39,7 @@ GOCHAT_LOG_LEVEL=info # debug, info, warn, error GOCHAT_LOG_FORMAT=json # json, text # ---- SMTP / Email ---- -SMTP_HOST=smtp.example.com +SMTP_ADDRESS=smtp.example.com SMTP_PORT=587 SMTP_USERNAME=your-email@example.com SMTP_PASSWORD=your-email-password @@ -149,7 +149,7 @@ GOCHAT_OIDC_DEFAULT_JWKS_URL= GOCHAT_OIDC_DEFAULT_SCOPES=openid,profile,email # ---- Security ---- -GOCHAT_RATE_LIMIT_REQUESTS=100 # requests per minute per IP -GOCHAT_RATE_LIMIT_WINDOW=60 # seconds +GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=100 # requests per minute per IP +GOCHAT_RATE_LIMIT_WINDOW_SECONDS=60 # seconds GOCHAT_ADMIN_EMAIL=admin@example.com GOCHAT_ADMIN_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD \ No newline at end of file diff --git a/backend/cmd/gochat/main.go b/backend/cmd/gochat/main.go index c56384a6..e16a812a 100644 --- a/backend/cmd/gochat/main.go +++ b/backend/cmd/gochat/main.go @@ -20,6 +20,9 @@ import ( ) func main() { + config.LoadDotEnvEnvironment() + database.SanitizePostgresEnvironment() + cmd := "serve" if len(os.Args) > 1 { cmd = os.Args[1] diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go index 69bbd38d..24a1d75d 100644 --- a/backend/cmd/migrate/main.go +++ b/backend/cmd/migrate/main.go @@ -10,6 +10,9 @@ import ( ) func main() { + config.LoadDotEnvEnvironment() + database.SanitizePostgresEnvironment() + if len(os.Args) < 2 { printUsage() os.Exit(1) @@ -17,8 +20,12 @@ func main() { command := os.Args[1] - // Load config to get database connection info - cfg, err := config.Load() + // Load the same root .env and environment overlay as the application. + env := os.Getenv("GOCHAT_ENV") + if env == "" { + env = "development" + } + cfg, err := config.LoadWithEnv(env) if err != nil { fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) os.Exit(1) @@ -106,4 +113,4 @@ func printUsage() { fmt.Println(" steps Apply n steps (positive=up, negative=down)") fmt.Println("") fmt.Println("Environment: Uses database config from Viper (config.yaml)") -} \ No newline at end of file +} diff --git a/backend/internal/app/redis.go b/backend/internal/app/redis.go index 8cd570fc..746e86a7 100644 --- a/backend/internal/app/redis.go +++ b/backend/internal/app/redis.go @@ -15,9 +15,19 @@ import ( // NewRedisClient creates a Redis client for caching, Pub/Sub, and sessions. // Reference: Chatwoot config/cable.yml Redis adapter configuration func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) { - opts, err := parseRedisURL(cfg.URL) - if err != nil { - return nil, fmt.Errorf("failed to parse Redis URL: %w", err) + var opts *redis.Options + var err error + if cfg.Host != "" && cfg.Port > 0 { + opts = &redis.Options{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + } + } else { + opts, err = parseRedisURL(cfg.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse Redis URL: %w", err) + } } if cfg.Password != "" { @@ -39,7 +49,7 @@ func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) { return nil, fmt.Errorf("failed to connect to Redis: %w", err) } - logger.L().Infof("Connected to Redis: %s (pool=%d)", cfg.URL, poolSize) + logger.L().Infof("Connected to Redis: %s (db=%d, pool=%d)", opts.Addr, opts.DB, poolSize) return client, nil } diff --git a/backend/internal/app/redis_test.go b/backend/internal/app/redis_test.go new file mode 100644 index 00000000..2596a5b5 --- /dev/null +++ b/backend/internal/app/redis_test.go @@ -0,0 +1,30 @@ +package app + +import ( + "net" + "strconv" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/gochat/gochat/internal/config" + "github.com/stretchr/testify/require" +) + +func TestNewRedisClientPrefersHostAndPortOverURL(t *testing.T) { + server := miniredis.RunT(t) + host, portText, err := net.SplitHostPort(server.Addr()) + require.NoError(t, err) + port, err := strconv.Atoi(portText) + require.NoError(t, err) + + client, err := NewRedisClient(&config.RedisConfig{ + URL: "redis://localhost:6397/0", + Host: host, + Port: port, + DB: 0, + PoolSize: 2, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + require.Equal(t, server.Addr(), client.Options().Addr) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a1456f57..e00eee4d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -47,7 +47,7 @@ type Config struct { type WorkerConfig struct { Concurrency int `mapstructure:"concurrency"` - StreamPrefix string `mapstructure:"redis_stream_prefix"` // default "gochat:jobs" + StreamPrefix string `mapstructure:"redis_stream_prefix"` // default "gochat:jobs" ConsumerGroup string `mapstructure:"redis_consumer_group"` // default "gochat-workers" BlockTimeoutS int `mapstructure:"redis_block_timeout_s"` // default 5 SweepIntervalS int `mapstructure:"redis_sweep_interval_s"` // default 30 @@ -627,45 +627,58 @@ 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_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_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix", - "GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group", - "GOCHAT_WORKER_REDIS_BLOCK_TIMEOUT_S": "worker.redis_block_timeout_s", - "GOCHAT_WORKER_REDIS_SWEEP_INTERVAL_S": "worker.redis_sweep_interval_s", - "GOCHAT_SEARCH_ENGINE": "search.engine", - "GOCHAT_SEARCH_HOST": "search.host", - "GOCHAT_SEARCH_API_KEY": "search.api_key", - "GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix", - "GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds", - "GOCHAT_STORAGE_PROVIDER": "storage.provider", - "GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path", - "GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size", + "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_MAX_IDLE_CONNS": "database.max_idle_conns", + "GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns", + "GOCHAT_DATABASE_CONN_MAX_LIFETIME": "database.conn_max_lifetime", + "GOCHAT_DATABASE_RUN_MIGRATIONS": "database.run_migrations", + "GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path", + "GOCHAT_REDIS_URL": "redis.url", + "GOCHAT_REDIS_HOST": "redis.host", + "GOCHAT_REDIS_PORT": "redis.port", + "GOCHAT_REDIS_PASSWORD": "redis.password", + "GOCHAT_REDIS_DB": "redis.db", + "GOCHAT_REDIS_POOL_SIZE": "redis.pool_size", + "GOCHAT_JWT_SECRET": "jwt.secret", + "JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix) + "GOCHAT_JWT_EXPIRY_HOURS": "jwt.expiry_hours", + "GOCHAT_JWT_ACCESS_EXPIRY_MINUTES": "jwt.access_expiry_minutes", + "GOCHAT_JWT_REFRESH_EXPIRY_HOURS": "jwt.refresh_expiry_hours", + "GOCHAT_LOG_LEVEL": "log.level", + "GOCHAT_LOG_FORMAT": "log.format", + "GOCHAT_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_CAPTAIN_LLM_BASE_URL": "captain.llm_base_url", + "GOCHAT_CAPTAIN_EMBEDDING_MODEL": "captain.embedding_model", + "GOCHAT_CAPTAIN_EMBEDDING_DIMS": "captain.embedding_dims", + "GOCHAT_CAPTAIN_MAX_TOKENS": "captain.max_tokens", + "GOCHAT_CAPTAIN_TEMPERATURE": "captain.temperature", + "GOCHAT_WORKER_CONCURRENCY": "worker.concurrency", + "GOCHAT_WORKER_REDIS_STREAM_PREFIX": "worker.redis_stream_prefix", + "GOCHAT_WORKER_REDIS_CONSUMER_GROUP": "worker.redis_consumer_group", + "GOCHAT_WORKER_REDIS_BLOCK_TIMEOUT_S": "worker.redis_block_timeout_s", + "GOCHAT_WORKER_REDIS_SWEEP_INTERVAL_S": "worker.redis_sweep_interval_s", + "GOCHAT_SEARCH_ENGINE": "search.engine", + "GOCHAT_SEARCH_HOST": "search.host", + "GOCHAT_SEARCH_API_KEY": "search.api_key", + "GOCHAT_SEARCH_INDEX_PREFIX": "search.index_prefix", + "GOCHAT_SEARCH_TIMEOUT_SECONDS": "search.timeout_seconds", + "GOCHAT_STORAGE_PROVIDER": "storage.provider", + "GOCHAT_STORAGE_LOCAL_PATH": "storage.local_path", + "GOCHAT_STORAGE_MAX_FILE_SIZE": "storage.max_file_size", + "GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE": "rate_limit.requests_per_minute", + "GOCHAT_RATE_LIMIT_WINDOW_SECONDS": "rate_limit.window_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", @@ -722,7 +735,7 @@ func LoadWithEnv(env string) (*Config, error) { } // Load .env file if present (common in Docker/local dev setups) - loadDotEnv(v) + loadDotEnv(v, envBindings) var cfg Config if err := v.Unmarshal(&cfg); err != nil { @@ -735,10 +748,36 @@ func LoadWithEnv(env string) (*Config, error) { return &cfg, nil } +// LoadDotEnvEnvironment loads workspace environment values into the process. +// It is called before GOCHAT_ENV is read so the root .env can select the +// environment-specific config overlay as well as configure direct os.Getenv +// consumers throughout the application. +func LoadDotEnvEnvironment() { + dotEnvPaths := []string{".env", "../.env", "/etc/gochat/.env"} + for _, path := range dotEnvPaths { + data, err := os.ReadFile(path) + if err != nil { + continue + } + for _, line := range strings.Split(string(data), "\n") { + key, val, ok := parseDotEnvLine(line) + if !ok { + continue + } + if _, exists := os.LookupEnv(key); !exists { + _ = os.Setenv(key, val) + } + } + return + } +} + // LoadDotEnvFile reads a .env file and sets values into viper. // Supports simple KEY=VALUE format, ignores comments and blank lines. -func loadDotEnv(v *viper.Viper) { - dotEnvPaths := []string{".env", "./.env", "/etc/gochat/.env"} +func loadDotEnv(v *viper.Viper, envBindings map[string]string) { + // The backend normally runs with backend/ as its working directory, while + // the workspace-level .env lives one directory above it. + dotEnvPaths := []string{".env", "../.env", "/etc/gochat/.env"} for _, path := range dotEnvPaths { data, err := os.ReadFile(path) if err != nil { @@ -746,22 +785,19 @@ func loadDotEnv(v *viper.Viper) { } 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, "#") { + key, val, ok := parseDotEnvLine(line) + if !ok { continue } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue + // Existing process environment has priority over values from .env. + if existing, ok := os.LookupEnv(key); ok { + val = existing + } else { + _ = os.Setenv(key, val) } - 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, "_", ".") + // Use explicit bindings so compound field names such as + // max_idle_conns are not incorrectly split into max.idle.conns. + if configKey, ok := envBindings[key]; ok { v.Set(configKey, val) } } @@ -770,6 +806,25 @@ func loadDotEnv(v *viper.Viper) { } } +func parseDotEnvLine(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "", "", false + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if len(val) >= 2 && ((val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'')) { + val = val[1 : len(val)-1] + } else if comment := strings.Index(val, " #"); comment >= 0 { + val = strings.TrimSpace(val[:comment]) + } + return key, val, key != "" +} + // setDefaults sets all viper defaults in one place. func setDefaults(v *viper.Viper) { v.SetDefault("server.host", "0.0.0.0") diff --git a/backend/internal/config/reloader_test.go b/backend/internal/config/reloader_test.go index a9d237b0..0377084f 100644 --- a/backend/internal/config/reloader_test.go +++ b/backend/internal/config/reloader_test.go @@ -190,84 +190,23 @@ func TestLoadWithEnv_EnvironmentOverlay(t *testing.T) { } func TestLoadWithEnv_DotEnvFile(t *testing.T) { - tmpDir := t.TempDir() + key, value, ok := parseDotEnvLine("GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=50 # requests per minute") + require.True(t, ok) + assert.Equal(t, "GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE", key) + assert.Equal(t, "50", value) - // Create .env file - envContent := ` -GOCHAT_LOG_LEVEL=warn -GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE=50 -# Comment line should be ignored -` - err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - // Test the loadDotEnv parsing logic directly - v := viper.New() - data, err := os.ReadFile(filepath.Join(tmpDir, ".env")) - require.NoError(t, err) - - 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]) - val = strings.Trim(val, "\"'") - - if strings.HasPrefix(key, "GOCHAT_") { - configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_")) - configKey = strings.ReplaceAll(configKey, "_", ".") - v.Set(configKey, val) - } - } - - assert.Equal(t, "warn", v.Get("log.level")) - // Note: RATE_LIMIT_REQUESTS_PER_MINUTE maps to rate.limit.requests.per.minute - // This is the .env → viper key mapping: underscores become dots after lowering - assert.Equal(t, "50", v.Get("rate.limit.requests.per.minute")) + _, _, ok = parseDotEnvLine("# Comment line should be ignored") + assert.False(t, ok) } func TestLoadDotEnv_QuotedValues(t *testing.T) { - tmpDir := t.TempDir() + _, value, ok := parseDotEnvLine(`GOCHAT_JWT_SECRET="my-super-secret-key-with-quotes"`) + require.True(t, ok) + assert.Equal(t, "my-super-secret-key-with-quotes", value) - envContent := `GOCHAT_JWT_SECRET="my-super-secret-key-with-quotes" -GOCHAT_REDIS_URL='redis://special-host:6379' -` - err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644) - require.NoError(t, err) - - v := viper.New() - data, err := os.ReadFile(filepath.Join(tmpDir, ".env")) - require.NoError(t, err) - - 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]) - val = strings.Trim(val, "\"'") - - if strings.HasPrefix(key, "GOCHAT_") { - configKey := strings.ToLower(strings.TrimPrefix(key, "GOCHAT_")) - configKey = strings.ReplaceAll(configKey, "_", ".") - v.Set(configKey, val) - } - } - - // Quotes should be stripped - assert.Equal(t, "my-super-secret-key-with-quotes", v.Get("jwt.secret")) - assert.Equal(t, "redis://special-host:6379", v.Get("redis.url")) + _, value, ok = parseDotEnvLine("GOCHAT_REDIS_URL='redis://special-host:6379'") + require.True(t, ok) + assert.Equal(t, "redis://special-host:6379", value) } // --- Env Binding Tests --- @@ -331,14 +270,14 @@ func TestApplyZeroDefaults(t *testing.T) { func validTestConfig() *Config { return &Config{ - Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, - Database: DatabaseConfig{Host: "localhost", Port: 5432, User: "gochat", Password: "secret", Name: "gochat_db", DBName: "gochat_db"}, - Redis: RedisConfig{URL: "redis://localhost:6379"}, - JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168}, - Log: LogConfig{Level: "info", Format: "json"}, - Captain: CaptainConfig{Enabled: false}, - Worker: WorkerConfig{Concurrency: 4}, - OAuth: OAuthConfig{}, + Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, + Database: DatabaseConfig{Host: "localhost", Port: 5432, User: "gochat", Password: "secret", Name: "gochat_db", DBName: "gochat_db"}, + Redis: RedisConfig{URL: "redis://localhost:6379"}, + JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168}, + Log: LogConfig{Level: "info", Format: "json"}, + Captain: CaptainConfig{Enabled: false}, + Worker: WorkerConfig{Concurrency: 4}, + OAuth: OAuthConfig{}, RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60}, } -} \ No newline at end of file +} diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 5baa1ce6..cb158521 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -3,6 +3,7 @@ package database import ( "errors" "fmt" + "os" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" @@ -10,6 +11,17 @@ import ( _ "github.com/golang-migrate/migrate/v4/source/file" ) +var unsupportedPQEnvironmentKeys = []string{"PGSERVICE", "PGSERVICEFILE", "PGREALM"} + +// SanitizePostgresEnvironment removes libpq service-file settings that are +// incompatible with GoChat's explicit database configuration and can make +// both pgx and lib/pq ignore or reject the configured host/user/database. +func SanitizePostgresEnvironment() { + for _, key := range unsupportedPQEnvironmentKeys { + _ = os.Unsetenv(key) + } +} + // RunMigrations applies all pending database migrations from the given path. // dbURL should be a database connection string (PostgreSQL or SQLite). // migrationsPath should be a file path to the migrations directory (e.g. "migrations"). @@ -20,6 +32,12 @@ import ( // - A migration fails to apply // - The database is in a dirty state (a previous migration partially failed) func RunMigrations(dbURL string, migrationsPath string) error { + return withSanitizedPQEnvironment(func() error { + return runMigrations(dbURL, migrationsPath) + }) +} + +func runMigrations(dbURL string, migrationsPath string) error { m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dbURL, @@ -45,6 +63,36 @@ func RunMigrations(dbURL string, migrationsPath string) error { return nil } +// withSanitizedPQEnvironment prevents lib/pq from panicking when PostgreSQL +// service-file variables are inherited from the user's shell. GoChat passes a +// complete connection URL to golang-migrate, and lib/pq does not support these +// libpq service variables. Restore them after the migration operation so this +// workaround remains scoped to the legacy migration driver. +func withSanitizedPQEnvironment(fn func() error) error { + type savedValue struct { + value string + set bool + } + saved := make(map[string]savedValue, len(unsupportedPQEnvironmentKeys)) + for _, key := range unsupportedPQEnvironmentKeys { + value, set := os.LookupEnv(key) + saved[key] = savedValue{value: value, set: set} + _ = os.Unsetenv(key) + } + defer func() { + for _, key := range unsupportedPQEnvironmentKeys { + previous := saved[key] + if previous.set { + _ = os.Setenv(key, previous.value) + } else { + _ = os.Unsetenv(key) + } + } + }() + + return fn() +} + // MigrateSteps applies N migration steps (positive = up, negative = down). func MigrateSteps(dbURL string, migrationsPath string, steps int) error { m, err := migrate.New( @@ -128,4 +176,4 @@ func CurrentVersion(dbURL string, migrationsPath string) (uint, bool, error) { } return version, dirty, nil -} \ No newline at end of file +} diff --git a/backend/internal/database/migrate_test.go b/backend/internal/database/migrate_test.go new file mode 100644 index 00000000..2a841204 --- /dev/null +++ b/backend/internal/database/migrate_test.go @@ -0,0 +1,39 @@ +package database + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWithSanitizedPQEnvironment(t *testing.T) { + t.Setenv("PGSERVICE", "local-service") + t.Setenv("PGSERVICEFILE", "/tmp/pg_service.conf") + t.Setenv("PGREALM", "local-realm") + + err := withSanitizedPQEnvironment(func() error { + for _, key := range []string{"PGSERVICE", "PGSERVICEFILE", "PGREALM"} { + _, exists := os.LookupEnv(key) + require.False(t, exists, "%s should be unset during migration", key) + } + return nil + }) + require.NoError(t, err) + require.Equal(t, "local-service", os.Getenv("PGSERVICE")) + require.Equal(t, "/tmp/pg_service.conf", os.Getenv("PGSERVICEFILE")) + require.Equal(t, "local-realm", os.Getenv("PGREALM")) +} + +func TestSanitizePostgresEnvironment(t *testing.T) { + t.Setenv("PGSERVICE", "local-service") + t.Setenv("PGSERVICEFILE", "/tmp/pg_service.conf") + t.Setenv("PGREALM", "local-realm") + + SanitizePostgresEnvironment() + + for _, key := range unsupportedPQEnvironmentKeys { + _, exists := os.LookupEnv(key) + require.False(t, exists, "%s should be removed at application startup", key) + } +} diff --git a/backend/internal/handler/api/v1/account_handler.go b/backend/internal/handler/api/v1/account_handler.go index 8b1832aa..8cc19d70 100644 --- a/backend/internal/handler/api/v1/account_handler.go +++ b/backend/internal/handler/api/v1/account_handler.go @@ -454,7 +454,7 @@ func serializeAccountSettings(account *model.Account) map[string]any { "auto_resolve_duration": account.AutoResolveDuration, "auto_resolve_message": "", "auto_resolve_ignore_waiting": false, - "audio_transcriptions": false, + "audio_transcriptions": account.AudioTranscriptions, "auto_resolve_label": "", "reporting_timezone": account.ReportingTimezone, } diff --git a/backend/internal/handler/api/v1/account_handler_test.go b/backend/internal/handler/api/v1/account_handler_test.go index c149b689..6134c0fa 100644 --- a/backend/internal/handler/api/v1/account_handler_test.go +++ b/backend/internal/handler/api/v1/account_handler_test.go @@ -82,6 +82,7 @@ func (s *AccountHandlerTestSuite) SetupSuite() { accountsGroup.GET("", s.handler.List) accountsGroup.GET("/:account_id", s.handler.Get) accountsGroup.POST("", s.handler.Create) + accountsGroup.PATCH("/:account_id", s.handler.Update) accountsGroup.PUT("/:account_id", s.handler.Update) accountsGroup.PATCH("/:account_id/onboarding", s.handler.UpdateOnboarding) accountsGroup.DELETE("/:account_id", s.handler.Delete) @@ -313,6 +314,55 @@ func (s *AccountHandlerTestSuite) TestUpdate_Success() { assert.Equal(s.T(), "After Update", resp["name"]) } +func (s *AccountHandlerTestSuite) TestPatchUpdate_AudioTranscriptions() { + acc := s.seedAccount("Audio Transcription Account") + + body := `{"audio_transcriptions":true}` + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + resp := s.unmarshalResponse(w) + settings := resp["settings"].(map[string]interface{}) + assert.Equal(s.T(), true, settings["audio_transcriptions"]) + + var stored model.Account + require.NoError(s.T(), s.db.First(&stored, acc.ID).Error) + assert.True(s.T(), stored.AudioTranscriptions) + + body = `{"audio_transcriptions":false}` + w = httptest.NewRecorder() + req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + require.NoError(s.T(), s.db.First(&stored, acc.ID).Error) + assert.False(s.T(), stored.AudioTranscriptions) +} + +func (s *AccountHandlerTestSuite) TestPatchUpdate_ChatwootGeneralSettingsPayload() { + acc := s.seedAccount("Test Account") + + body := `{"locale":"zh_CN","name":"Test Account11","domain":"","support_email":null}` + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + resp := s.unmarshalResponse(w) + assert.Equal(s.T(), "zh_CN", resp["locale"]) + assert.Equal(s.T(), "Test Account11", resp["name"]) + + var stored model.Account + require.NoError(s.T(), s.db.First(&stored, acc.ID).Error) + assert.Equal(s.T(), "zh_CN", stored.Locale) + assert.Equal(s.T(), "Test Account11", stored.Name) +} + func (s *AccountHandlerTestSuite) TestUpdateOnboarding_FinalizesAccountDetails() { acc := &model.Account{Name: "Before Onboarding", Active: true, Status: "active", OnboardingStep: "account_details"} s.Require().NoError(acc.SetCustomAttributesMap(map[string]any{ diff --git a/backend/internal/model/account.go b/backend/internal/model/account.go index ad866d1e..6fb68ca8 100644 --- a/backend/internal/model/account.go +++ b/backend/internal/model/account.go @@ -20,9 +20,10 @@ type Account struct { CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days - Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails - AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents] - InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] + AudioTranscriptions bool `gorm:"default:false" json:"audio_transcriptions"` + Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails + AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents] + InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"` CaptainFeatures datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_features,omitempty"` // Chatwoot: store_accessor :settings, :keep_pending_on_bot_failure diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4fc1d9a3..8657866f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -702,6 +702,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { accounts.GET("/", h.Account.List) accounts.POST("/", h.Account.Create) accounts.GET("/:account_id", h.Account.Get) + accounts.PATCH("/:account_id", h.Account.Update) accounts.PUT("/:account_id", h.Account.Update) accounts.DELETE("/:account_id", h.Account.Delete) // Account onboarding update (ref: Chatwoot resource :onboarding, only: [:update]) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 16517b53..64687bde 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -45,7 +45,7 @@ func (s *AccountService) GetByUserAndID(ctx context.Context, userID, accountID u type CreateAccountRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` AccountName string `json:"account_name,omitempty" validate:"omitempty,min=2"` - Locale string `json:"locale,omitempty" validate:"omitempty,len=2"` + Locale string `json:"locale,omitempty" validate:"omitempty,max=10"` Domain string `json:"domain,omitempty" validate:"omitempty,min=3"` } @@ -85,7 +85,7 @@ func (s *AccountService) Create(ctx context.Context, userID uint, req CreateAcco // UpdateAccountRequest is the DTO for updating an account. type UpdateAccountRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` - Locale string `json:"locale,omitempty" validate:"omitempty,len=2"` + Locale string `json:"locale,omitempty" validate:"omitempty,max=10"` Domain string `json:"domain,omitempty" validate:"omitempty,min=3"` SupportEmail string `json:"support_email,omitempty"` FeatureFlags string `json:"feature_flags,omitempty"` @@ -142,6 +142,9 @@ func (s *AccountService) Update(ctx context.Context, id uint, req UpdateAccountR } else if req.AutoResolveDuration > 0 { account.AutoResolveDuration = req.AutoResolveDuration } + if req.AudioTranscriptions != nil { + account.AudioTranscriptions = *req.AudioTranscriptions + } if err := s.repo.Update(ctx, account); err != nil { return nil, err @@ -232,7 +235,7 @@ func (s *AccountService) RemoveUser(ctx context.Context, accountID, userID uint) // UpdateAccountSettingsRequest is the DTO for updating account settings. type UpdateAccountSettingsRequest struct { AutoResolveDuration int `json:"auto_resolve_duration" validate:"gte=0"` - Locale string `json:"locale" validate:"omitempty,len=2"` + Locale string `json:"locale" validate:"omitempty,max=10"` ReportingTimezone string `json:"reporting_timezone" validate:"omitempty"` } diff --git a/backend/internal/service/account_service_test.go b/backend/internal/service/account_service_test.go index 533748e9..2fc94f63 100644 --- a/backend/internal/service/account_service_test.go +++ b/backend/internal/service/account_service_test.go @@ -100,12 +100,12 @@ func TestAccountService_Create_验证失败_名称过短(t *testing.T) { assert.Nil(t, result) } -func TestAccountService_Create_验证失败_Locale长度不对(t *testing.T) { +func TestAccountService_Create_验证失败_Locale超过字段上限(t *testing.T) { _, _, svc := setupAccountService(t) req := CreateAccountRequest{ Name: "有效名称", - Locale: "xxx", // len=2 不满足 + Locale: "zh_Hans_CN_x", // accounts.locale 最大长度为 10 } result, err := svc.Create(context.Background(), 1, req) diff --git a/backend/migrations/000002_add_captain_ai_tables.up.sql b/backend/migrations/000002_add_captain_ai_tables.up.sql index e336fffb..b537cae2 100644 --- a/backend/migrations/000002_add_captain_ai_tables.up.sql +++ b/backend/migrations/000002_add_captain_ai_tables.up.sql @@ -2,6 +2,10 @@ -- Reference: Chatwoot enterprise/app/models/captain/ models -- Adds AI assistant, document, response, scenario, custom_tool, and inbox tables +-- The vector type is used by captain_assistant_responses below, so the +-- extension must exist before that table is created. +CREATE EXTENSION IF NOT EXISTS vector; + -- Captain Assistants CREATE TABLE IF NOT EXISTS captain_assistants ( id SERIAL PRIMARY KEY, @@ -161,6 +165,3 @@ CREATE TABLE IF NOT EXISTS copilot_messages ( CREATE INDEX idx_copilot_messages_deleted_at ON copilot_messages(deleted_at); CREATE INDEX idx_copilot_messages_account_id ON copilot_messages(account_id) WHERE deleted_at IS NULL; CREATE INDEX idx_copilot_messages_thread_id ON copilot_messages(copilot_thread_id) WHERE deleted_at IS NULL; - --- Enable pgvector extension if not already enabled -CREATE EXTENSION IF NOT EXISTS vector; \ No newline at end of file diff --git a/backend/migrations/000054_add_audio_transcriptions_to_accounts.down.sql b/backend/migrations/000054_add_audio_transcriptions_to_accounts.down.sql new file mode 100644 index 00000000..871c8390 --- /dev/null +++ b/backend/migrations/000054_add_audio_transcriptions_to_accounts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE accounts +DROP COLUMN IF EXISTS audio_transcriptions; diff --git a/backend/migrations/000054_add_audio_transcriptions_to_accounts.up.sql b/backend/migrations/000054_add_audio_transcriptions_to_accounts.up.sql new file mode 100644 index 00000000..b78445b3 --- /dev/null +++ b/backend/migrations/000054_add_audio_transcriptions_to_accounts.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE accounts +ADD COLUMN IF NOT EXISTS audio_transcriptions BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 0502ebc1..44415838 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -18,230 +18,254 @@ import AudioTranscription from './components/AudioTranscription.vue'; import SectionLayout from './components/SectionLayout.vue'; export default { - components: { - BaseSettingsHeader, - NextButton, - AccountId, - BuildInfo, - AccountDelete, - AudioTranscription, - SectionLayout, - WithLabel, - NextInput, - }, - setup() { - const { updateUISettings, uiSettings } = useUISettings(); - const { enabledLanguages } = useConfig(); - const { accountId } = useAccount(); - const v$ = useVuelidate(); + components: { + BaseSettingsHeader, + NextButton, + AccountId, + BuildInfo, + AccountDelete, + AudioTranscription, + SectionLayout, + WithLabel, + NextInput, + }, + setup() { + const { updateUISettings, uiSettings } = useUISettings(); + const { enabledLanguages } = useConfig(); + const { accountId } = useAccount(); + const v$ = useVuelidate(); - return { updateUISettings, uiSettings, v$, enabledLanguages, accountId }; - }, - data() { - return { - id: '', - name: '', - locale: 'en', - domain: '', - supportEmail: '', - features: {}, - }; - }, - validations: { - name: { - required, - }, - locale: { - required, - }, - }, - computed: { - ...mapGetters({ - getAccount: 'accounts/getAccount', - uiFlags: 'accounts/getUIFlags', - isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', - isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', - }), - showAudioTranscriptionConfig() { - return this.isFeatureEnabledonAccount( - this.accountId, - FEATURE_FLAGS.CAPTAIN - ); - }, - languagesSortedByCode() { - const enabledLanguages = [...this.enabledLanguages]; - return enabledLanguages.sort((l1, l2) => - l1.iso_639_1_code.localeCompare(l2.iso_639_1_code) - ); - }, - isUpdating() { - return this.uiFlags.isUpdating; - }, - featureInboundEmailEnabled() { - return !!this.features?.inbound_emails; - }, - featureCustomReplyDomainEnabled() { - return ( - this.featureInboundEmailEnabled && !!this.features.custom_reply_domain - ); - }, - featureCustomReplyEmailEnabled() { - return ( - this.featureInboundEmailEnabled && !!this.features.custom_reply_email - ); - }, - currentAccount() { - return this.getAccount(this.accountId) || {}; - }, - }, - mounted() { - this.initializeAccount(); - }, - methods: { - async initializeAccount() { - try { - const { name, locale, id, domain, support_email, features } = - this.getAccount(this.accountId); + return { + updateUISettings, + uiSettings, + v$, + enabledLanguages, + accountId, + }; + }, + data() { + return { + id: '', + name: '', + locale: 'en', + domain: '', + supportEmail: '', + features: {}, + }; + }, + validations: { + name: { + required, + }, + locale: { + required, + }, + }, + computed: { + ...mapGetters({ + getAccount: 'accounts/getAccount', + uiFlags: 'accounts/getUIFlags', + isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', + }), + showAudioTranscriptionConfig() { + return this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.CAPTAIN + ); + }, + languagesSortedByCode() { + const enabledLanguages = [...this.enabledLanguages]; + return enabledLanguages.sort((l1, l2) => + l1.iso_639_1_code.localeCompare(l2.iso_639_1_code) + ); + }, + isUpdating() { + return this.uiFlags.isUpdating; + }, + featureInboundEmailEnabled() { + return !!this.features?.inbound_emails; + }, + featureCustomReplyDomainEnabled() { + return ( + this.featureInboundEmailEnabled && + !!this.features.custom_reply_domain + ); + }, + featureCustomReplyEmailEnabled() { + return ( + this.featureInboundEmailEnabled && + !!this.features.custom_reply_email + ); + }, + currentAccount() { + return this.getAccount(this.accountId) || {}; + }, + }, + watch: { + currentAccount: { + handler(account) { + this.initializeAccount(account); + }, + immediate: true, + }, + }, + methods: { + initializeAccount(account = {}) { + if (!account.id) { + return; + } - const effectiveLocale = this.uiSettings?.locale || locale; - if (effectiveLocale) { - this.$root.$i18n.locale = effectiveLocale; - } - this.name = name; - this.locale = locale; - this.id = id; - this.domain = domain; - this.supportEmail = support_email; - this.features = features; - } catch (error) { - // Ignore error - } - }, + const { name, locale, id, domain, support_email, features } = + account; + const effectiveLocale = this.uiSettings?.locale || locale; + if (effectiveLocale) { + this.$root.$i18n.locale = effectiveLocale; + } + this.name = name || ''; + this.locale = locale || 'en'; + this.id = id; + this.domain = domain || ''; + this.supportEmail = support_email || ''; + this.features = features || {}; + }, - async updateAccount() { - this.v$.$touch(); - if (this.v$.$invalid) { - useAlert(this.$t('GENERAL_SETTINGS.FORM.ERROR')); - return; - } - try { - await this.$store.dispatch('accounts/update', { - locale: this.locale, - name: this.name, - domain: this.domain, - support_email: this.supportEmail, - }); - // If user locale is set, update the locale with user locale - const updatedLocale = this.uiSettings?.locale || this.locale; - if (updatedLocale) { - this.$root.$i18n.locale = updatedLocale; - } - this.getAccount(this.id).locale = this.locale; - useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); - } catch (error) { - useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR')); - } - }, - }, + async updateAccount() { + this.v$.$touch(); + if (this.v$.$invalid) { + useAlert(this.$t('GENERAL_SETTINGS.FORM.ERROR')); + return; + } + try { + await this.$store.dispatch('accounts/update', { + locale: this.locale, + name: this.name, + domain: this.domain, + support_email: this.supportEmail, + }); + // If user locale is set, update the locale with user locale + const updatedLocale = this.uiSettings?.locale || this.locale; + if (updatedLocale) { + this.$root.$i18n.locale = updatedLocale; + } + this.getAccount(this.id).locale = this.locale; + useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); + } catch (error) { + useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR')); + } + }, + }, };