package config import ( "fmt" "os" "strconv" "time" "github.com/joho/godotenv" ) // Config represents the complete application configuration structure. // This is the root configuration object that holds all subsystem configurations // (server, database, JWT, cookies, Redis, NATS, and storage). By organizing // configuration into this hierarchical structure, we achieve: // 1. Clear separation of concerns - each subsystem has its own config struct // 2. Easy maintainability - adding new config is as simple as adding a new field // 3. Type safety - all config values are properly typed // 4. Centralized validation - one place to validate all configuration type Config struct { //Server Server ServerConfig Database DatabaseConfig JWT JWTConfig Cookie CookieConfig Redis RedisConfig NATS NATSConfig Storage StorageConfig } // ServerConfig holds HTTP server-specific configuration settings. // These settings control how the Echo web server behaves: // - Port determines which port the server listens on // - Environment determines the runtime mode (affects logging, error detail, etc.) // - Timeouts prevent slow clients from holding connections indefinitely type ServerConfig struct { Port string // HTTP port to listen on (e.g., "8080", ":3000") Environment string // Runtime environment: "development", "staging", "production", "test", "UAT" ReadTimeout time.Duration // Maximum duration for reading the entire request, including the body. Prevents slow-read attacks WriteTimeout time.Duration // Maximum duration before timing out writes of the response. Prevents slow-write attacks } // DatabaseConfig contains PostgreSQL connection settings and connection pool configuration. // The connection pool settings are critical for performance: // - MaxOpenConns limits total connections to prevent overwhelming the database // - MaxIdleConns keeps connections warm for reuse, improving response times // - ConnMaxLifetime recycles connections to prevent stale connection issues type DatabaseConfig struct { Host string // Database server hostname or IP address (e.g., "localhost", "db.example.com") Port string // Database port number (default PostgreSQL port is "5432") User string // Database username for authentication Password string // Database password for authentication DBName string // Name of the specific database to connect to SSLMode string // SSL/TLS mode: "disable" (no encryption), "require" (encrypted but no cert verification), "verify-full" (encrypted with full cert verification) MaxOpenConns int // Maximum number of open connections to the database. Limits total concurrent connections MaxIdleConns int // Maximum number of idle connections kept in the pool. Higher values = faster connection reuse but more resources ConnMaxLifetime time.Duration // Maximum amount of time a connection may be reused. Forces connection refresh to prevent issues with stale connections } // JWTConfig contains JWT (JSON Web Token) authentication settings. // We use two types of tokens for security: // 1. Access tokens: Short-lived (15 minutes), used for API requests // 2. Refresh tokens: Long-lived (7 days), used to obtain new access tokens // This dual-token approach balances security (short access token lifetime) with // user experience (long refresh token means less frequent re-authentication). // The secrets MUST be different to prevent token type confusion attacks. type JWTConfig struct { AccessSecret string // Secret key for signing access tokens. MUST be cryptographically random and kept secret RefreshSecret string // Secret key for signing refresh tokens. MUST differ from AccessSecret to prevent token substitution AccessExpiry time.Duration // How long access tokens remain valid (typically 15 minutes). Shorter = more secure but more token refreshes RefreshExpiry time.Duration // How long refresh tokens remain valid (typically 7 days). Longer = better UX but higher risk if stolen Issuer string // Token issuer claim (iss). Identifies which application/service issued the token for validation } // CookieConfig contains HTTP cookie settings for token storage. // These settings control how authentication tokens are stored in browser cookies: // - Domain controls which domains can access the cookie // - Secure ensures cookies only sent over HTTPS in production // - SameSite prevents CSRF attacks by controlling when cookies are sent type CookieConfig struct { CookieDomain string // Domain scope for cookies (e.g., "example.com" allows *.example.com to access). Use "localhost" for local development CookieSecure bool // If true, cookies only sent over HTTPS. MUST be true in production to prevent token theft over unencrypted connections CookieSameSite string // SameSite policy: "strict" (never sent cross-site), "lax" (sent on top-level navigation), "none" (always sent, requires Secure=true) } // RedisConfig contains Redis connection settings. // Redis is used for caching and session storage to improve performance. // Key uses include: // - Session storage for distributed systems // - Caching frequently accessed data // - Rate limiting counters // - Real-time analytics type RedisConfig struct { Host string // Redis server hostname or IP address (e.g., "localhost", "redis.example.com") Port string // Redis port number (default is "6379") Password string // Redis password for authentication. Leave empty ("") if Redis is running without auth (not recommended for production) DB int // Redis database number (0-15 by default). Allows logical separation of data within a single Redis instance } // NATSConfig contains NATS messaging system settings. // NATS is a message broker used for asynchronous communication between services: // - Decouples services (sender doesn't need to know about receivers) // - Enables event-driven architecture // - Provides reliable message delivery // - Supports pub/sub, request/reply, and queue patterns type NATSConfig struct { URL string // NATS server connection URL (e.g., "nats://localhost:4222" or "nats://user:pass@host:4222") } // StorageConfig contains MinIO (S3-compatible) object storage settings. // MinIO provides distributed object storage for files like: // - User-uploaded documents and images // - Generated reports and exports // - Backup files // - Any binary data that shouldn't go in the database type StorageConfig struct { Endpoint string // MinIO server endpoint URL without protocol (e.g., "localhost:9000", "minio.example.com:9000") AccessKeyID string // Access key for MinIO authentication (similar to AWS access key) SecretAccessKey string // Secret key for MinIO authentication (similar to AWS secret key) BucketName string // Name of the bucket to use for storing objects. Must be created before use UseSSL bool // If true, use HTTPS for MinIO connections. Should be true in production for security } // Load reads configuration from environment variables and returns a fully populated Config struct. // Configuration loading follows this priority: // 1. Environment variables (highest priority - allows override in deployment) // 2. .env file (for local development) // 3. Default values (fallback to sensible defaults) // // The loading process: // 1. In non-production environments, attempts to load .env file (fails gracefully if missing) // 2. Reads each config value using getEnv() which checks environment then falls back to defaults // 3. Parses string values into appropriate types (durations, ints, bools) // 4. Validates the complete configuration // 5. Returns error if validation fails, otherwise returns the populated Config // // Why this approach? // - .env files make local development easy (no need to set environment variables manually) // - Environment variables are standard for containerized deployments (Docker, Kubernetes) // - Defaults prevent the application from crashing if non-critical config is missing // - Validation ensures critical config is present before the app starts func Load() (*Config, error) { // In non-production environments, try to load .env file // godotenv.Load() reads key=value pairs from .env file and sets them as environment variables // We only do this in non-production because: // 1. Production should use real environment variables (set by infrastructure) // 2. .env files shouldn't exist in production (security risk if committed to version control) if os.Getenv("APP_ENV") != "production" { if err := godotenv.Load(); err != nil { // Warning only - we continue even if .env doesn't exist // This allows the app to work purely with environment variables if needed fmt.Println("Warning: .env file not found, using environment variables") } } // Build the configuration struct by reading from environment variables // Each field uses getEnv() which provides a fallback default value cfg := &Config{ // Server configuration with sensible defaults for local development Server: ServerConfig{ Port: getEnv("SERVER_PORT", "8080"), // Default to port 8080 (common for APIs) Environment: getEnv("APP_ENV", "development"), // Default to development mode ReadTimeout: parseDuration(getEnv("SERVER_READ_TIMEOUT", "10s")), // 10 seconds is reasonable for most API requests WriteTimeout: parseDuration(getEnv("SERVER_WRITE_TIMEOUT", "10s")), // 10 seconds handles most response sizes }, // Database configuration with defaults suitable for local PostgreSQL Database: DatabaseConfig{ Host: getEnv("DB_HOST", "localhost"), // Assume PostgreSQL is running locally Port: getEnv("DB_PORT", "5432"), // 5432 is PostgreSQL's default port User: getEnv("DB_USER", "aurganize"), // Default username matches project name Password: getEnv("DB_PASSWORD", ""), // Empty by default (will require setting in production) DBName: getEnv("DB_NAME", "aruganize_db_1"), // Default database name SSLMode: getEnv("DB_SSLMODE", "disable"), // SSL disabled for local development (enable in production!) MaxOpenConns: parseInt(getEnv("DB_MAX_OPEN_CONNECTIONS", "25")), // 25 is a good starting point for connection pool MaxIdleConns: parseInt(getEnv("DB_MAX_IDLE_CONNECTIONS", "5")), // Keep 5 connections warm for quick reuse ConnMaxLifetime: parseDuration(getEnv("DB_CONNECTION_MAX_LIFETIME", "5m")), // Refresh connections every 5 minutes }, // JWT configuration - secrets MUST be set via environment variables (no defaults for security) JWT: JWTConfig{ AccessSecret: getEnv("JWT_ACCESS_SECRET", ""), // Empty default forces explicit configuration RefreshSecret: getEnv("JWT_REFRESH_SECRET", ""), // Empty default forces explicit configuration AccessExpiry: parseDuration(getEnv("JWT_ACCESS_EXPIRY", "15m")), // 15 minutes is secure but requires frequent refresh RefreshExpiry: parseDuration(getEnv("JWT_REFRESH_EXPIRY", "168h")), // 168 hours = 7 days for good user experience Issuer: getEnv("JWT_ISSUER", "aurganize-v62"), // Identifies this application as token issuer }, // Cookie configuration for storing tokens in browser Cookie: CookieConfig{ CookieDomain: getEnv("COOKIE_DOMAIN", "localhost"), // localhost for development // CookieSecure is true only in production (requires HTTPS) // This line checks ENV (not APP_ENV) to determine if we're in production CookieSecure: getEnv("ENV", "development") == "production", CookieSameSite: getEnv("COOKIE_SAMESITE", "lax"), // "lax" is a good balance between security and usability }, // Redis configuration for caching and sessions Redis: RedisConfig{ Host: getEnv("REDIST_HOST", "localhost"), // Note: typo in env var name (REDIST vs REDIS) Port: getEnv("REDIS_PORT", "6379"), // 6379 is Redis default port Password: getEnv("REDIS_PASSWORD", ""), // Empty for local development (no auth) DB: parseInt(getEnv("REDIS_DB", "0")), // Use database 0 by default }, // NATS configuration for message queuing NATS: NATSConfig{ URL: getEnv("NATS_URL", "nats://localhost:4222"), // Standard NATS URL format }, // MinIO configuration for object storage Storage: StorageConfig{ Endpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"), // MinIO default port is 9000 AccessKeyID: getEnv("MINIO_ACCESS_KEY", "minioadmin"), // Default MinIO credentials SecretAccessKey: getEnv("MINIO_SECRET_KEY", "miniosecretkey"), // Default MinIO credentials (change in production!) BucketName: getEnv("MINIO_BUCKET", "aurganize_bucket_1"), // Default bucket name UseSSL: parseBool(getEnv("MINIO_USE_SSL", "false")), // No SSL for local development }, } // Validate the entire configuration before returning // This catches configuration errors at startup rather than during runtime if err := cfg.Validate(); err != nil { // Wrap the error with context for better debugging return nil, fmt.Errorf("configuration validation failure [%w]", err) } return cfg, nil } // Validate checks if all required configuration values are present and valid. // This method enforces configuration rules that prevent the application from // starting with invalid or insecure settings. Validation happens at startup // so errors are caught immediately rather than during runtime. // // Validation rules: // 1. Database password required in production (prevents accidental no-auth deployments) // 2. JWT secrets must be set (no default secrets for security) // 3. JWT secrets must be different (prevents token substitution attacks) // 4. Environment must be valid (ensures proper behavior for the deployment type) // // Why validate at startup? // - Fail fast: Better to crash at startup than fail during a user request // - Clear errors: Validation errors explain exactly what's wrong // - Security: Prevents running with insecure configuration func (c *Config) Validate() error { // Validate database password in production // In production, an empty password likely means configuration was forgotten // This prevents accidentally deploying with an open database connection if c.Database.Password == "" && c.Server.Environment == "production" { return fmt.Errorf("DB_PASSWORD is required in production") } // JWT access secret is always required (no environment exceptions) // Without this, we cannot sign or verify access tokens if c.JWT.AccessSecret == "" { return fmt.Errorf("JWT_ACCESS_SECRET is required") } // JWT refresh secret is always required (no environment exceptions) // Without this, we cannot sign or verify refresh tokens if c.JWT.RefreshSecret == "" { return fmt.Errorf("JWT_REFRESH_SECRET is required") } // JWT secrets must be different to prevent token type confusion // If they're the same, an attacker could use a refresh token as an access token // This is a security vulnerability that could allow privilege escalation if c.JWT.AccessSecret == c.JWT.RefreshSecret { return fmt.Errorf("JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different") } // Validate environment value against allowed list // This prevents typos in environment configuration (e.g., "prod" instead of "production") // Each environment may have different behaviors (logging, error detail, etc.) validEnvs := map[string]bool{ "development": true, // Local development with debug logging "test": true, // Automated testing environment "staging": true, // Pre-production testing environment "UAT": true, // User Acceptance Testing environment "production": true, // Live production environment } if !validEnvs[c.Server.Environment] { return fmt.Errorf("invalid environment configured in enviroment") } return nil } // Helper Functions // getEnv retrieves an environment variable value or returns a default if not set. // This is a wrapper around os.Getenv that adds default value support. // // How it works: // 1. Checks if environment variable exists and has a non-empty value // 2. If yes, returns that value // 3. If no, returns the provided default value // // Why use this instead of os.Getenv directly? // - Provides sensible defaults for non-critical configuration // - Reduces repetitive if-else checks throughout the code // - Makes configuration more resilient (app still works if some vars missing) // - Documents expected configuration by showing default values func getEnv(key, defaultValue string) string { // os.Getenv returns empty string if variable doesn't exist if value := os.Getenv(key); value != "" { return value } return defaultValue } // parseDuration converts a string duration to time.Duration type. // Durations are expected in Go's duration format: "10s", "5m", "2h", etc. // // Examples of valid formats: // - "10s" = 10 seconds // - "5m" = 5 minutes // - "2h" = 2 hours // - "1h30m" = 1 hour 30 minutes // // If parsing fails (invalid format), returns 0 duration rather than crashing. // This makes the app more resilient but means invalid config might be silently ignored. // Consider adding logging here to warn about parsing failures. func parseDuration(s string) time.Duration { d, err := time.ParseDuration(s) if err != nil { // Returns zero duration on error - might want to log this return 0 } return d } // parseInt converts a string to an integer. // If conversion fails (non-numeric string), returns 0 rather than crashing. // // Why return 0 on error? // - Makes configuration more resilient (app starts even with bad config) // - 0 is often a reasonable default for numeric config // - Drawback: Invalid config might be silently ignored // // Consider: Adding logging to warn when parsing fails func parseInt(s string) int { i, err := strconv.Atoi(s) if err != nil { // Returns 0 on error - might want to log this return 0 } return i } // parseBool converts a string to a boolean. // Accepts: "true", "false", "1", "0", "t", "f", "T", "F", "TRUE", "FALSE" (case-insensitive) // // If conversion fails, returns false rather than crashing. // // Why return false on error? // - Makes configuration more resilient // - false is typically the "safe" default for boolean flags // - Drawback: Invalid config might be silently ignored // // Consider: Adding logging to warn when parsing fails func parseBool(s string) bool { b, err := strconv.ParseBool(s) if err != nil { // Returns false on error - might want to log this return false } return b } // DatabaseDSN constructs and returns the PostgreSQL Data Source Name (connection string). // DSN format: "host=X port=Y user=Z password=W dbname=N sslmode=M" // // The DSN is used by database drivers to establish a connection to PostgreSQL. // Each component tells the driver: // - Where to connect (host and port) // - How to authenticate (user and password) // - Which database to use (dbname) // - Security requirements (sslmode) // // Note: There's a typo in the format string - "post" should be "port" // This will cause connection failures! Should be fixed to: "host=%s port=%s ..." func (c *Config) DatabaseDSN() string { return fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", // BUG: "post" should be "port" c.Database.Host, c.Database.Port, c.Database.User, c.Database.Password, c.Database.DBName, c.Database.SSLMode, ) } // RedisDSN constructs and returns the Redis connection string. // Format: "host:port" (e.g., "localhost:6379") // // This simple format is used by most Redis clients to connect. // Authentication (if needed) is typically handled separately via // Redis client options rather than being part of the DSN. func (c *Config) RedisDSN() string { return fmt.Sprintf("%s:%s", c.Redis.Host, c.Redis.Port) }