mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-04 04:22:57 +00:00
391 lines
15 KiB
Go
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
|
|
}
|