Files
Containr/app/backend/internal/config/config.go
T
2026-04-10 12:02:36 +02:00

391 lines
15 KiB
Go

package config
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Config represents the application configuration
type Config struct {
// Server Configuration
Port int `env:"PORT" default:"8080"`
Host string `env:"HOST" default:"localhost"`
ReadTimeout time.Duration `env:"READ_TIMEOUT" default:"30s"`
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" default:"30s"`
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"60s"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" default:"30s"`
// Database Configuration
DatabaseURL string `env:"DATABASE_URL" default:"postgres://user:password@localhost/containr?sslmode=disable"`
RedisURL string `env:"REDIS_URL" default:"redis://localhost:6379"`
SQLitePath string `env:"SQLITE_PATH" default:"./data/apwhy.db"`
MaxConnections int `env:"MAX_CONNECTIONS" default:"25"`
MaxIdleConnections int `env:"MAX_IDLE_CONNECTIONS" default:"5"`
ConnMaxLifetime time.Duration `env:"CONN_MAX_LIFETIME" default:"5m"`
ConnMaxIdleTime time.Duration `env:"CONN_MAX_IDLE_TIME" default:"5m"`
AutoMigrate bool `env:"AUTO_MIGRATE" default:"true"`
MigrationLockTimeout time.Duration `env:"MIGRATION_LOCK_TIMEOUT" default:"2m"`
SeedDataOnStart bool `env:"SEED_DATA_ON_START" default:"false"`
// Security Configuration
JWTSecret string `env:"JWT_SECRET" default:"your-secret-key-change-in-production"`
AuthPort int `env:"AUTH_PORT" default:"3001"`
BetterAuthEnabled bool `env:"BETTER_AUTH_ENABLED" default:"true"`
BetterAuthSecret string `env:"BETTER_AUTH_SECRET" default:"PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN"`
BetterAuthProxyURL string `env:"BETTER_AUTH_PROXY_URL" default:"http://127.0.0.1:3001"`
BetterAuthInternalURL string `env:"BETTER_AUTH_INTERNAL_URL" default:"http://127.0.0.1:3001/internal/session"`
BetterAuthInternalToken string `env:"BETTER_AUTH_INTERNAL_TOKEN" default:""`
BetterAuthNodeBinary string `env:"BETTER_AUTH_NODE_BINARY" default:"node"`
BetterAuthEntrypoint string `env:"BETTER_AUTH_ENTRYPOINT" default:"auth/src/server.js"`
BetterAuthStartupTimeout time.Duration `env:"BETTER_AUTH_STARTUP_TIMEOUT" default:"20s"`
SessionAccessCookie string `env:"SESSION_ACCESS_COOKIE" default:"access_token"`
SessionRefreshCookie string `env:"SESSION_REFRESH_COOKIE" default:"refresh_token"`
AccessTokenTTL time.Duration `env:"ACCESS_TOKEN_TTL" default:"15m"`
RefreshTokenTTL time.Duration `env:"REFRESH_TOKEN_TTL" default:"7d"`
BcryptCost int `env:"BCRYPT_COST" default:"12"`
// CORS Configuration
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
CORSMethods []string `env:"CORS_METHODS" default:"GET,POST,PUT,PATCH,DELETE,OPTIONS"`
CORSHeaders []string `env:"CORS_HEADERS" default:"Origin,Content-Type,Accept,Authorization"`
CORSCredentials bool `env:"CORS_CREDENTIALS" default:"true"`
// API Gateway Configuration
APIKeyHeader string `env:"API_KEY_HEADER" default:"X-API-Key"`
ServiceTokenHeader string `env:"SERVICE_TOKEN_HEADER" default:"X-Service-Token"`
AllowRootRoutePrefix bool `env:"ALLOW_ROOT_ROUTE_PREFIX" default:"false"`
DefaultServiceTimeout time.Duration `env:"DEFAULT_SERVICE_TIMEOUT" default:"30s"`
// Rate Limiting Configuration
FreeRPM int `env:"FREE_RPM" default:"60"`
ProRPM int `env:"PRO_RPM" default:"600"`
BusinessRPM int `env:"BUSINESS_RPM" default:"3000"`
FreeMonthlyQuota int `env:"FREE_MONTHLY_QUOTA" default:"1000"`
ProMonthlyQuota int `env:"PRO_MONTHLY_QUOTA" default:"50000"`
BusinessMonthlyQuota int `env:"BUSINESS_MONTHLY_QUOTA" default:"300000"`
// Cookie Configuration
CookieSecure bool `env:"COOKIE_SECURE" default:"false"`
CookieDomain string `env:"COOKIE_DOMAIN" default:""`
CookiePath string `env:"COOKIE_PATH" default:"/"`
CookieSameSite string `env:"COOKIE_SAME_SITE" default:"lax"`
// Analytics Configuration
UmamiBaseURL string `env:"UMAMI_BASE_URL" default:""`
UmamiAPIKey string `env:"UMAMI_API_KEY" default:""`
UmamiWebsiteID string `env:"UMAMI_WEBSITE_ID" default:""`
// Logging Configuration
LogLevel string `env:"LOG_LEVEL" default:"info"`
LogFormat string `env:"LOG_FORMAT" default:"json"`
LogOutput string `env:"LOG_OUTPUT" default:"stdout"`
// Development/Debug Configuration
Debug bool `env:"DEBUG" default:"false"`
TrustedProxyCIDR string `env:"TRUSTED_PROXY_CIDR" default:""`
MaxRequestBody int64 `env:"MAX_REQUEST_BODY_BYTES" default:"10485760"`
// Dashboard UI Configuration
DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"`
}
// Environment represents the application environment
type Environment string
const (
Development Environment = "development"
Production Environment = "production"
Staging Environment = "staging"
Test Environment = "test"
)
// Load loads configuration from environment variables with defaults
func Load() *Config {
cfg := &Config{}
// Use reflection or manual field setting for simplicity
cfg.Port = getenvInt("PORT", 8080)
cfg.Host = getenv("HOST", "localhost")
cfg.ReadTimeout = getenvDuration("READ_TIMEOUT", 30*time.Second)
cfg.WriteTimeout = getenvDuration("WRITE_TIMEOUT", 30*time.Second)
cfg.IdleTimeout = getenvDuration("IDLE_TIMEOUT", 60*time.Second)
cfg.ShutdownTimeout = getenvDuration("SHUTDOWN_TIMEOUT", 30*time.Second)
cfg.DatabaseURL = encodeDatabasePassword(getenv("DATABASE_URL", "postgres://user:password@localhost/containr?sslmode=disable"))
cfg.RedisURL = encodeRedisPassword(getenv("REDIS_URL", "redis://localhost:6379"))
cfg.SQLitePath = getenv("SQLITE_PATH", "./data/apwhy.db")
cfg.MaxConnections = getenvInt("MAX_CONNECTIONS", 25)
cfg.MaxIdleConnections = getenvInt("MAX_IDLE_CONNECTIONS", 5)
cfg.ConnMaxLifetime = getenvDuration("CONN_MAX_LIFETIME", 5*time.Minute)
cfg.ConnMaxIdleTime = getenvDuration("CONN_MAX_IDLE_TIME", 5*time.Minute)
cfg.AutoMigrate = getenvBool("AUTO_MIGRATE", true)
cfg.MigrationLockTimeout = getenvDuration("MIGRATION_LOCK_TIMEOUT", 2*time.Minute)
cfg.SeedDataOnStart = getenvBool("SEED_DATA_ON_START", false)
cfg.JWTSecret = getenv("JWT_SECRET", "your-secret-key-change-in-production")
cfg.AuthPort = getenvInt("AUTH_PORT", 3001)
cfg.BetterAuthEnabled = getenvBool("BETTER_AUTH_ENABLED", true)
cfg.BetterAuthSecret = getenv("BETTER_AUTH_SECRET", "PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN")
cfg.BetterAuthProxyURL = getenv("BETTER_AUTH_PROXY_URL", "http://127.0.0.1:3001")
cfg.BetterAuthInternalURL = getenv("BETTER_AUTH_INTERNAL_URL", "http://127.0.0.1:3001/internal/session")
cfg.BetterAuthInternalToken = getenv("BETTER_AUTH_INTERNAL_TOKEN", "")
cfg.BetterAuthNodeBinary = getenv("BETTER_AUTH_NODE_BINARY", "node")
cfg.BetterAuthEntrypoint = getenv("BETTER_AUTH_ENTRYPOINT", "auth/src/server.js")
cfg.BetterAuthStartupTimeout = getenvDuration("BETTER_AUTH_STARTUP_TIMEOUT", 20*time.Second)
cfg.SessionAccessCookie = getenv("SESSION_ACCESS_COOKIE", "access_token")
cfg.SessionRefreshCookie = getenv("SESSION_REFRESH_COOKIE", "refresh_token")
cfg.AccessTokenTTL = getenvDuration("ACCESS_TOKEN_TTL", 15*time.Minute)
cfg.RefreshTokenTTL = getenvDuration("REFRESH_TOKEN_TTL", 7*24*time.Hour)
cfg.BcryptCost = getenvInt("BCRYPT_COST", 12)
cfg.CORSOrigins = getenvSliceWithAliases([]string{"CORS_ORIGINS", "CORS_ALLOWED_ORIGINS"}, []string{"*"})
cfg.CORSMethods = getenvSlice("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
cfg.CORSHeaders = getenvSlice("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization"})
cfg.CORSCredentials = getenvBool("CORS_CREDENTIALS", true)
cfg.APIKeyHeader = getenv("API_KEY_HEADER", "X-API-Key")
cfg.ServiceTokenHeader = getenv("SERVICE_TOKEN_HEADER", "X-Service-Token")
cfg.AllowRootRoutePrefix = getenvBool("ALLOW_ROOT_ROUTE_PREFIX", false)
cfg.DefaultServiceTimeout = getenvDuration("DEFAULT_SERVICE_TIMEOUT", 30*time.Second)
cfg.FreeRPM = getenvInt("FREE_RPM", 60)
cfg.ProRPM = getenvInt("PRO_RPM", 600)
cfg.BusinessRPM = getenvInt("BUSINESS_RPM", 3000)
cfg.FreeMonthlyQuota = getenvInt("FREE_MONTHLY_QUOTA", 1000)
cfg.ProMonthlyQuota = getenvInt("PRO_MONTHLY_QUOTA", 50000)
cfg.BusinessMonthlyQuota = getenvInt("BUSINESS_MONTHLY_QUOTA", 300000)
cfg.CookieSecure = getenvBool("COOKIE_SECURE", false)
cfg.CookieDomain = getenv("COOKIE_DOMAIN", "")
cfg.CookiePath = getenv("COOKIE_PATH", "/")
cfg.CookieSameSite = getenv("COOKIE_SAME_SITE", "lax")
cfg.UmamiBaseURL = getenv("UMAMI_BASE_URL", "")
cfg.UmamiAPIKey = getenv("UMAMI_API_KEY", "")
cfg.UmamiWebsiteID = getenv("UMAMI_WEBSITE_ID", "")
cfg.LogLevel = getenv("LOG_LEVEL", "info")
cfg.LogFormat = getenv("LOG_FORMAT", "json")
cfg.LogOutput = getenv("LOG_OUTPUT", "stdout")
cfg.Debug = getenvBool("DEBUG", false)
cfg.TrustedProxyCIDR = getenv("TRUSTED_PROXY_CIDR", "")
cfg.MaxRequestBody = getenvInt64("MAX_REQUEST_BODY_BYTES", 10*1024*1024)
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
// Validate configuration
if err := cfg.Validate(); err != nil {
panic(fmt.Sprintf("Invalid configuration: %v", err))
}
return cfg
}
// Validate validates the configuration
func (c *Config) Validate() error {
if c.JWTSecret == "your-secret-key-change-in-production" && c.GetEnvironment() == Production {
return fmt.Errorf("JWT_SECRET must be set in production")
}
if c.DatabaseURL == "" {
return fmt.Errorf("DATABASE_URL is required")
}
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("PORT must be between 1 and 65535")
}
if c.BcryptCost < 10 || c.BcryptCost > 31 {
return fmt.Errorf("BCRYPT_COST must be between 10 and 31")
}
if c.GetEnvironment() == Production {
if len(strings.TrimSpace(c.JWTSecret)) < 32 {
return fmt.Errorf("JWT_SECRET must be at least 32 characters in production")
}
if c.SeedDataOnStart {
return fmt.Errorf("SEED_DATA_ON_START must be false in production")
}
if !c.CookieSecure {
return fmt.Errorf("COOKIE_SECURE must be true in production")
}
if c.BetterAuthEnabled {
if len(strings.TrimSpace(c.BetterAuthSecret)) < 32 ||
c.BetterAuthSecret == "PLACEHOLDER_BETTER_AUTH_SECRET_CHANGE_ME_32CHARS_MIN" {
return fmt.Errorf("BETTER_AUTH_SECRET must be at least 32 characters in production")
}
if strings.TrimSpace(c.BetterAuthInternalToken) == "" ||
c.BetterAuthInternalToken == "PLACEHOLDER_INTERNAL_AUTH_TOKEN" {
return fmt.Errorf("BETTER_AUTH_INTERNAL_TOKEN must be set in production")
}
}
nonEmptyOrigins := 0
for _, origin := range c.CORSOrigins {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
nonEmptyOrigins++
if trimmed == "*" && c.CORSCredentials {
return fmt.Errorf("CORS_ORIGINS cannot include '*' when CORS_CREDENTIALS=true in production")
}
}
if nonEmptyOrigins == 0 {
return fmt.Errorf("CORS_ORIGINS must include at least one explicit origin in production")
}
}
return nil
}
// GetEnvironment determines the current environment
func (c *Config) GetEnvironment() Environment {
env := strings.ToLower(getenv("ENVIRONMENT", "development"))
switch env {
case "production":
return Production
case "staging":
return Staging
case "test":
return Test
default:
return Development
}
}
// IsProduction returns true if running in production
func (c *Config) IsProduction() bool {
return c.GetEnvironment() == Production
}
// IsDevelopment returns true if running in development
func (c *Config) IsDevelopment() bool {
return c.GetEnvironment() == Development
}
// Helper functions for environment variable parsing
func getenv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getenvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
func getenvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
if boolValue, err := strconv.ParseBool(value); err == nil {
return boolValue
}
}
return defaultValue
}
func getenvDuration(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
}
return defaultValue
}
func getenvInt64(key string, defaultValue int64) int64 {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.ParseInt(value, 10, 64); err == nil {
return intValue
}
}
return defaultValue
}
func getenvSlice(key string, defaultValue []string) []string {
if value := os.Getenv(key); value != "" {
return strings.Split(value, ",")
}
return defaultValue
}
func getenvSliceWithAliases(keys []string, defaultValue []string) []string {
for _, key := range keys {
if value := os.Getenv(key); value != "" {
return strings.Split(value, ",")
}
}
return defaultValue
}
// encodeDatabasePassword URL-encodes the password in a database connection string
func encodeDatabasePassword(databaseURL string) string {
// Find the password part between :@ and encode it
atIndex := strings.LastIndex(databaseURL, "@")
if atIndex == -1 {
return databaseURL // No @ found, return as is
}
beforeAt := databaseURL[:atIndex]
afterAt := databaseURL[atIndex:]
// Find the last : before the @ to locate the password
lastColonIndex := strings.LastIndex(beforeAt, ":")
if lastColonIndex == -1 {
return databaseURL // No : found, return as is
}
userPart := beforeAt[:lastColonIndex]
password := beforeAt[lastColonIndex+1:]
// URL encode the password
encodedPassword := url.QueryEscape(password)
// Reconstruct the URL
return userPart + ":" + encodedPassword + afterAt
}
// encodeRedisPassword URL-encodes the password in a Redis connection string.
func encodeRedisPassword(redisURL string) string {
// Find the host separator (@). Use the last one so passwords containing @ are preserved.
atIndex := strings.LastIndex(redisURL, "@")
if atIndex == -1 {
return redisURL // No auth section found
}
beforeAt := redisURL[:atIndex]
afterAt := redisURL[atIndex:]
// Find the last : before @ to locate the password.
lastColonIndex := strings.LastIndex(beforeAt, ":")
if lastColonIndex == -1 {
return redisURL // No password separator found
}
password := beforeAt[lastColonIndex+1:]
if password == "" {
return redisURL
}
decoded, err := url.QueryUnescape(password)
if err == nil {
password = decoded
}
encodedPassword := url.QueryEscape(password)
return beforeAt[:lastColonIndex+1] + encodedPassword + afterAt
}