package config import ( "fmt" "net/url" "os" "strconv" "strings" "time" "github.com/spf13/viper" ) // Config holds all configuration for the application type Config struct { Server ServerConfig Database DatabaseConfig Redis RedisConfig Email EmailConfig Push PushConfig Worker WorkerConfig Security SecurityConfig Storage StorageConfig AppleAuth AppleAuthConfig GoogleAuth GoogleAuthConfig AppleIAP AppleIAPConfig GoogleIAP GoogleIAPConfig } type ServerConfig struct { Port int Debug bool AllowedHosts []string CorsAllowedOrigins []string // Comma-separated origins for CORS (production only; debug uses wildcard) Timezone string StaticDir string // Directory for static landing page files BaseURL string // Public base URL for email tracking links (e.g., https://casera.app) } type DatabaseConfig struct { Host string Port int User string Password string Database string SSLMode string MaxOpenConns int MaxIdleConns int MaxLifetime time.Duration } type RedisConfig struct { URL string Password string DB int } type EmailConfig struct { Host string Port int User string Password string From string UseTLS bool } type PushConfig struct { // APNs (iOS) - uses github.com/sideshow/apns2 for direct APNs communication APNSKeyPath string APNSKeyID string APNSTeamID string APNSTopic string APNSSandbox bool APNSProduction bool // If true, use production APNs; if false, use sandbox // FCM (Android) - uses direct HTTP to FCM legacy API FCMServerKey string } type AppleAuthConfig struct { ClientID string // Bundle ID (e.g., com.tt.casera.CaseraDev) TeamID string // Apple Developer Team ID } type GoogleAuthConfig struct { ClientID string // Web client ID for token verification AndroidClientID string // Android client ID (optional, for audience verification) IOSClientID string // iOS client ID (optional, for audience verification) } // AppleIAPConfig holds Apple App Store Server API configuration type AppleIAPConfig struct { KeyPath string // Path to .p8 private key file KeyID string // Key ID from App Store Connect IssuerID string // Issuer ID from App Store Connect BundleID string // App bundle ID (e.g., com.tt.casera) Sandbox bool // Use sandbox environment for testing } // GoogleIAPConfig holds Google Play Developer API configuration type GoogleIAPConfig struct { ServiceAccountPath string // Path to service account JSON file PackageName string // Android package name (e.g., com.tt.casera) } type WorkerConfig struct { // Scheduled job times (UTC) TaskReminderHour int OverdueReminderHour int DailyNotifHour int } type SecurityConfig struct { SecretKey string TokenCacheTTL time.Duration PasswordResetExpiry time.Duration ConfirmationExpiry time.Duration MaxPasswordResetRate int // per hour } // StorageConfig holds file storage settings type StorageConfig struct { UploadDir string // Directory to store uploaded files BaseURL string // Public URL prefix for serving files (e.g., "/uploads") MaxFileSize int64 // Max file size in bytes (default 10MB) AllowedTypes string // Comma-separated MIME types } var cfg *Config // Load reads configuration from environment variables func Load() (*Config, error) { viper.SetEnvPrefix("") viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // Set defaults setDefaults() // Parse DATABASE_URL if set (Dokku-style) dbConfig := DatabaseConfig{ Host: viper.GetString("DB_HOST"), Port: viper.GetInt("DB_PORT"), User: viper.GetString("POSTGRES_USER"), Password: viper.GetString("POSTGRES_PASSWORD"), Database: viper.GetString("POSTGRES_DB"), SSLMode: viper.GetString("DB_SSLMODE"), MaxOpenConns: viper.GetInt("DB_MAX_OPEN_CONNS"), MaxIdleConns: viper.GetInt("DB_MAX_IDLE_CONNS"), MaxLifetime: viper.GetDuration("DB_MAX_LIFETIME"), } // Override with DATABASE_URL if present if databaseURL := viper.GetString("DATABASE_URL"); databaseURL != "" { parsed, err := parseDatabaseURL(databaseURL) if err == nil { dbConfig.Host = parsed.Host dbConfig.Port = parsed.Port dbConfig.User = parsed.User dbConfig.Password = parsed.Password dbConfig.Database = parsed.Database if parsed.SSLMode != "" { dbConfig.SSLMode = parsed.SSLMode } } } cfg = &Config{ Server: ServerConfig{ Port: viper.GetInt("PORT"), Debug: viper.GetBool("DEBUG"), AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","), CorsAllowedOrigins: parseCorsOrigins(viper.GetString("CORS_ALLOWED_ORIGINS")), Timezone: viper.GetString("TIMEZONE"), StaticDir: viper.GetString("STATIC_DIR"), BaseURL: viper.GetString("BASE_URL"), }, Database: dbConfig, Redis: RedisConfig{ URL: viper.GetString("REDIS_URL"), Password: viper.GetString("REDIS_PASSWORD"), DB: viper.GetInt("REDIS_DB"), }, Email: EmailConfig{ Host: viper.GetString("EMAIL_HOST"), Port: viper.GetInt("EMAIL_PORT"), User: viper.GetString("EMAIL_HOST_USER"), Password: viper.GetString("EMAIL_HOST_PASSWORD"), From: viper.GetString("DEFAULT_FROM_EMAIL"), UseTLS: viper.GetBool("EMAIL_USE_TLS"), }, Push: PushConfig{ APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"), APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"), APNSTeamID: viper.GetString("APNS_TEAM_ID"), APNSTopic: viper.GetString("APNS_TOPIC"), APNSSandbox: viper.GetBool("APNS_USE_SANDBOX"), APNSProduction: viper.GetBool("APNS_PRODUCTION"), FCMServerKey: viper.GetString("FCM_SERVER_KEY"), }, Worker: WorkerConfig{ TaskReminderHour: viper.GetInt("TASK_REMINDER_HOUR"), OverdueReminderHour: viper.GetInt("OVERDUE_REMINDER_HOUR"), DailyNotifHour: viper.GetInt("DAILY_DIGEST_HOUR"), }, Security: SecurityConfig{ SecretKey: viper.GetString("SECRET_KEY"), TokenCacheTTL: 5 * time.Minute, PasswordResetExpiry: 15 * time.Minute, ConfirmationExpiry: 24 * time.Hour, MaxPasswordResetRate: 3, }, Storage: StorageConfig{ UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"), BaseURL: viper.GetString("STORAGE_BASE_URL"), MaxFileSize: viper.GetInt64("STORAGE_MAX_FILE_SIZE"), AllowedTypes: viper.GetString("STORAGE_ALLOWED_TYPES"), }, AppleAuth: AppleAuthConfig{ ClientID: viper.GetString("APPLE_CLIENT_ID"), TeamID: viper.GetString("APPLE_TEAM_ID"), }, GoogleAuth: GoogleAuthConfig{ ClientID: viper.GetString("GOOGLE_CLIENT_ID"), AndroidClientID: viper.GetString("GOOGLE_ANDROID_CLIENT_ID"), IOSClientID: viper.GetString("GOOGLE_IOS_CLIENT_ID"), }, AppleIAP: AppleIAPConfig{ KeyPath: viper.GetString("APPLE_IAP_KEY_PATH"), KeyID: viper.GetString("APPLE_IAP_KEY_ID"), IssuerID: viper.GetString("APPLE_IAP_ISSUER_ID"), BundleID: viper.GetString("APPLE_IAP_BUNDLE_ID"), Sandbox: viper.GetBool("APPLE_IAP_SANDBOX"), }, GoogleIAP: GoogleIAPConfig{ ServiceAccountPath: viper.GetString("GOOGLE_IAP_SERVICE_ACCOUNT_PATH"), PackageName: viper.GetString("GOOGLE_IAP_PACKAGE_NAME"), }, } // Validate required fields if err := validate(cfg); err != nil { return nil, err } return cfg, nil } // Get returns the current configuration func Get() *Config { return cfg } func setDefaults() { // Server defaults viper.SetDefault("PORT", 8000) viper.SetDefault("DEBUG", false) viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1") viper.SetDefault("TIMEZONE", "UTC") viper.SetDefault("STATIC_DIR", "/app/static") viper.SetDefault("BASE_URL", "https://casera.app") // Database defaults viper.SetDefault("DB_HOST", "localhost") viper.SetDefault("DB_PORT", 5432) viper.SetDefault("POSTGRES_USER", "postgres") viper.SetDefault("POSTGRES_DB", "casera") viper.SetDefault("DB_SSLMODE", "disable") viper.SetDefault("DB_MAX_OPEN_CONNS", 25) viper.SetDefault("DB_MAX_IDLE_CONNS", 10) viper.SetDefault("DB_MAX_LIFETIME", 600*time.Second) // Redis defaults viper.SetDefault("REDIS_URL", "redis://localhost:6379/0") viper.SetDefault("REDIS_DB", 0) // Email defaults viper.SetDefault("EMAIL_HOST", "smtp.gmail.com") viper.SetDefault("EMAIL_PORT", 587) viper.SetDefault("EMAIL_USE_TLS", true) viper.SetDefault("EMAIL_HOST_USER", "") viper.SetDefault("EMAIL_HOST_PASSWORD", "") viper.SetDefault("DEFAULT_FROM_EMAIL", "Casera ") // Push notification defaults viper.SetDefault("APNS_TOPIC", "com.example.casera") viper.SetDefault("APNS_USE_SANDBOX", true) viper.SetDefault("APNS_PRODUCTION", false) // Worker defaults (all times in UTC, jobs run at minute 0) viper.SetDefault("TASK_REMINDER_HOUR", 14) // 8:00 PM UTC viper.SetDefault("OVERDUE_REMINDER_HOUR", 15) // 9:00 AM UTC viper.SetDefault("DAILY_DIGEST_HOUR", 3) // 3:00 AM UTC // Storage defaults viper.SetDefault("STORAGE_UPLOAD_DIR", "./uploads") viper.SetDefault("STORAGE_BASE_URL", "/uploads") viper.SetDefault("STORAGE_MAX_FILE_SIZE", 10*1024*1024) // 10MB viper.SetDefault("STORAGE_ALLOWED_TYPES", "image/jpeg,image/png,image/gif,image/webp,application/pdf") // Apple IAP defaults viper.SetDefault("APPLE_IAP_SANDBOX", true) // Default to sandbox for safety // Google IAP defaults - no defaults needed, will fail gracefully if not configured } func validate(cfg *Config) error { if cfg.Security.SecretKey == "" { if cfg.Server.Debug { // In debug mode, use a default key with a warning for local development cfg.Security.SecretKey = "change-me-in-production-secret-key-12345" fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)") } else { // In production, refuse to start without a proper secret key return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)") } } // Database password might come from DATABASE_URL, don't require it separately // The actual connection will fail if credentials are wrong return nil } // DSN returns the database connection string func (d *DatabaseConfig) DSN() string { return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", d.Host, d.Port, d.User, d.Password, d.Database, d.SSLMode, ) } // ReadAPNSKey reads the APNs key from file if path is provided func (p *PushConfig) ReadAPNSKey() (string, error) { if p.APNSKeyPath == "" { return "", nil } content, err := os.ReadFile(p.APNSKeyPath) if err != nil { return "", fmt.Errorf("failed to read APNs key: %w", err) } return string(content), nil } // parseCorsOrigins splits a comma-separated CORS_ALLOWED_ORIGINS string // into a slice, trimming whitespace. Returns nil if the input is empty. func parseCorsOrigins(raw string) []string { if raw == "" { return nil } parts := strings.Split(raw, ",") var origins []string for _, p := range parts { trimmed := strings.TrimSpace(p) if trimmed != "" { origins = append(origins, trimmed) } } return origins } // parseDatabaseURL parses a PostgreSQL URL into DatabaseConfig // Format: postgres://user:password@host:port/database?sslmode=disable func parseDatabaseURL(databaseURL string) (*DatabaseConfig, error) { u, err := url.Parse(databaseURL) if err != nil { return nil, fmt.Errorf("failed to parse DATABASE_URL: %w", err) } // Default port port := 5432 if u.Port() != "" { port, err = strconv.Atoi(u.Port()) if err != nil { return nil, fmt.Errorf("invalid port in DATABASE_URL: %w", err) } } // Get password password, _ := u.User.Password() // Get database name (remove leading slash) database := strings.TrimPrefix(u.Path, "/") // Get sslmode from query params sslMode := u.Query().Get("sslmode") return &DatabaseConfig{ Host: u.Hostname(), Port: port, User: u.User.Username(), Password: password, Database: database, SSLMode: sslMode, }, nil }