405 lines
20 KiB
Go
405 lines
20 KiB
Go
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)
|
|
}
|