package config import ( "crypto/rand" "encoding/hex" "fmt" "net/url" "os" "strconv" "strings" "sync" "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 Stripe StripeConfig Features FeatureFlags } type ServerConfig struct { Port int Debug bool DebugFixedCodes bool // Separate from Debug: enables fixed confirmation codes for local testing 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://honeyDue.treytartt.com) } type DatabaseConfig struct { Host string Port int User string Password string Database string SSLMode string MaxOpenConns int MaxIdleConns int MaxLifetime time.Duration MaxIdleTime 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 FCM HTTP v1 API with OAuth 2.0 FCMProjectID string // Firebase project ID (required for v1 API) FCMServiceAccountPath string // Path to Google service account JSON file FCMServiceAccountJSON string // Raw service account JSON (alternative to path, e.g. for env var injection) // Deprecated: FCMServerKey is for the legacy HTTP API. Use FCMProjectID + service account instead. FCMServerKey string } type AppleAuthConfig struct { ClientID string // Bundle ID, used as the `aud` claim in Sign in with Apple identity tokens // TeamID is currently unused — services/apple_auth.go validates identity tokens // against ClientID + Apple's JWKS only, with no server-to-server REST calls. // Wire this in if/when token revocation or refresh-token exchange is added, // since both require signing a client_secret JWT with team_id + key_id. TeamID string } 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.honeyDue) 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.honeyDue) } // StripeConfig holds Stripe payment configuration type StripeConfig struct { SecretKey string // Stripe secret API key WebhookSecret string // Stripe webhook endpoint signing secret PriceMonthly string // Stripe Price ID for monthly Pro subscription PriceYearly string // Stripe Price ID for yearly Pro subscription } 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 TokenExpiryDays int // Number of days before auth tokens expire (default 90) TokenRefreshDays int // Token must be at least this many days old before refresh (default 60) // KratosPublicURL is the Ory Kratos public API base URL. The auth // middleware validates sessions against {KratosPublicURL}/sessions/whoami. KratosPublicURL string // KratosAdminURL is the Ory Kratos admin API base URL. Account deletion // removes the user's Kratos identity via // {KratosAdminURL}/admin/identities/{id}. KratosAdminURL string } // StorageConfig holds file storage settings. // When S3Endpoint is set, files are stored in S3-compatible storage (B2, MinIO). // When S3Endpoint is empty, files are stored on the local filesystem using UploadDir. type StorageConfig struct { // Local filesystem settings UploadDir string // Directory to store uploaded files (local mode) BaseURL string // Public URL prefix for serving files (e.g., "/uploads") // S3-compatible storage settings (B2, MinIO) S3Endpoint string // S3 endpoint (e.g., "s3.us-west-004.backblazeb2.com" or "minio:9000") S3KeyID string // Access key ID S3AppKey string // Secret access key S3Bucket string // Bucket name S3UseSSL bool // Use HTTPS (true for B2, false for in-cluster MinIO) S3Region string // Region (optional, defaults to "us-east-1") // Shared settings MaxFileSize int64 // Max file size in bytes (default 10MB) AllowedTypes string // Comma-separated MIME types EncryptionKey string // 64-char hex key for file encryption at rest (optional) } // IsS3 returns true if S3-compatible storage is configured func (c *StorageConfig) IsS3() bool { return c.S3Endpoint != "" && c.S3KeyID != "" && c.S3AppKey != "" && c.S3Bucket != "" } // FeatureFlags holds kill switches for major subsystems. // All default to true (enabled). Set to false via env vars to disable. type FeatureFlags struct { PushEnabled bool // FEATURE_PUSH_ENABLED (default: true) EmailEnabled bool // FEATURE_EMAIL_ENABLED (default: true) WebhooksEnabled bool // FEATURE_WEBHOOKS_ENABLED (default: true) OnboardingEmailsEnabled bool // FEATURE_ONBOARDING_EMAILS_ENABLED (default: true) PDFReportsEnabled bool // FEATURE_PDF_REPORTS_ENABLED (default: true) WorkerEnabled bool // FEATURE_WORKER_ENABLED (default: true) } var ( cfg *Config cfgMu sync.Mutex ) // knownWeakSecretKeys contains well-known default or placeholder secret keys // that must not be used in production. var knownWeakSecretKeys = map[string]bool{ "secret": true, "changeme": true, "change-me": true, "password": true, "change-me-in-production-secret-key-12345": true, } // Load reads configuration from environment variables. // // Caches the result so repeated calls are cheap. On validation failure, the // cache stays nil so a subsequent call (after env is corrected) can retry. The // previous implementation used sync.Once with an in-Do reset of the Once // itself, which races and panics with "sync: unlock of unlocked mutex". func Load() (*Config, error) { cfgMu.Lock() defer cfgMu.Unlock() if cfg != nil { return cfg, nil } viper.SetEnvPrefix("") viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // Set defaults setDefaults() // Audit F8: overlay file-mounted secrets onto Viper. No-op when the // directory is absent (local/dev), so this is safe to ship before the // manifests mount honeydue-secrets as a volume. loadFileSecrets() // 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"), MaxIdleTime: viper.GetDuration("DB_MAX_IDLE_TIME"), } // Override with DATABASE_URL if present (F-16: log warning on parse failure) if databaseURL := viper.GetString("DATABASE_URL"); databaseURL != "" { parsed, err := parseDatabaseURL(databaseURL) if err != nil { maskedURL := MaskURLCredentials(databaseURL) fmt.Printf("WARNING: Failed to parse DATABASE_URL (%s): %v — falling back to individual DB_* env vars\n", maskedURL, err) } else { 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 } } } c := &Config{ Server: ServerConfig{ Port: viper.GetInt("PORT"), Debug: viper.GetBool("DEBUG"), DebugFixedCodes: viper.GetBool("DEBUG_FIXED_CODES"), 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"), FCMProjectID: viper.GetString("FCM_PROJECT_ID"), FCMServiceAccountPath: viper.GetString("FCM_SERVICE_ACCOUNT_PATH"), FCMServiceAccountJSON: viper.GetString("FCM_SERVICE_ACCOUNT_JSON"), 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, TokenExpiryDays: viper.GetInt("TOKEN_EXPIRY_DAYS"), TokenRefreshDays: viper.GetInt("TOKEN_REFRESH_DAYS"), KratosPublicURL: viper.GetString("KRATOS_PUBLIC_URL"), KratosAdminURL: viper.GetString("KRATOS_ADMIN_URL"), }, Storage: StorageConfig{ UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"), BaseURL: viper.GetString("STORAGE_BASE_URL"), S3Endpoint: viper.GetString("B2_ENDPOINT"), S3KeyID: viper.GetString("B2_KEY_ID"), S3AppKey: viper.GetString("B2_APP_KEY"), S3Bucket: viper.GetString("B2_BUCKET_NAME"), S3UseSSL: viper.GetString("STORAGE_USE_SSL") == "" || viper.GetBool("STORAGE_USE_SSL"), S3Region: viper.GetString("B2_REGION"), MaxFileSize: viper.GetInt64("STORAGE_MAX_FILE_SIZE"), AllowedTypes: viper.GetString("STORAGE_ALLOWED_TYPES"), EncryptionKey: viper.GetString("STORAGE_ENCRYPTION_KEY"), }, 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"), }, Stripe: StripeConfig{ SecretKey: viper.GetString("STRIPE_SECRET_KEY"), WebhookSecret: viper.GetString("STRIPE_WEBHOOK_SECRET"), PriceMonthly: viper.GetString("STRIPE_PRICE_MONTHLY"), PriceYearly: viper.GetString("STRIPE_PRICE_YEARLY"), }, Features: FeatureFlags{ PushEnabled: viper.GetBool("FEATURE_PUSH_ENABLED"), EmailEnabled: viper.GetBool("FEATURE_EMAIL_ENABLED"), WebhooksEnabled: viper.GetBool("FEATURE_WEBHOOKS_ENABLED"), OnboardingEmailsEnabled: viper.GetBool("FEATURE_ONBOARDING_EMAILS_ENABLED"), PDFReportsEnabled: viper.GetBool("FEATURE_PDF_REPORTS_ENABLED"), WorkerEnabled: viper.GetBool("FEATURE_WORKER_ENABLED"), }, } if err := validate(c); err != nil { // Leave cfg nil so the next Load() retries after env is corrected. return nil, err } cfg = c 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("DEBUG_FIXED_CODES", false) // Separate flag for fixed confirmation codes viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1") viper.SetDefault("TIMEZONE", "UTC") viper.SetDefault("STATIC_DIR", "/app/static") viper.SetDefault("BASE_URL", "https://api.myhoneydue.com") // Database defaults viper.SetDefault("DB_HOST", "localhost") viper.SetDefault("DB_PORT", 5432) viper.SetDefault("POSTGRES_USER", "postgres") viper.SetDefault("POSTGRES_DB", "honeydue") 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", "honeyDue ") // Push notification defaults viper.SetDefault("APNS_TOPIC", "com.tt.honeyDue") 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 // Token expiry defaults viper.SetDefault("TOKEN_EXPIRY_DAYS", 90) // Tokens expire after 90 days viper.SetDefault("KRATOS_PUBLIC_URL", "http://kratos:4433") // Ory Kratos public API viper.SetDefault("KRATOS_ADMIN_URL", "http://kratos:4434") // Ory Kratos admin API viper.SetDefault("TOKEN_REFRESH_DAYS", 60) // Tokens can be refreshed after 60 days // 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 // Feature flags (all enabled by default) viper.SetDefault("FEATURE_PUSH_ENABLED", true) viper.SetDefault("FEATURE_EMAIL_ENABLED", true) viper.SetDefault("FEATURE_WEBHOOKS_ENABLED", true) viper.SetDefault("FEATURE_ONBOARDING_EMAILS_ENABLED", true) viper.SetDefault("FEATURE_PDF_REPORTS_ENABLED", true) viper.SetDefault("FEATURE_WORKER_ENABLED", true) } // isWeakSecretKey checks if the provided key is a known weak/default value. func isWeakSecretKey(key string) bool { return knownWeakSecretKeys[strings.ToLower(strings.TrimSpace(key))] } // loadFileSecrets overlays file-mounted secrets onto Viper (audit F8). When // the honeydue-secrets Secret is mounted as a volume at /etc/honeydue/secrets // each key is a file; reading the value here and viper.Set-ing it (highest // Viper precedence) keeps the secret out of the process environment // (/proc//environ), which plain env-var injection cannot. When the // directory is absent it is a silent no-op and env vars are used as before. func loadFileSecrets() { dir := os.Getenv("HONEYDUE_SECRETS_DIR") if dir == "" { dir = "/etc/honeydue/secrets" } for _, k := range []string{ "POSTGRES_PASSWORD", "SECRET_KEY", "EMAIL_HOST_PASSWORD", "FCM_SERVER_KEY", "REDIS_PASSWORD", "B2_KEY_ID", "B2_APP_KEY", "OBS_INGEST_TOKEN", "OBS_TRACES_URL", } { b, err := os.ReadFile(dir + "/" + k) if err != nil { continue } if v := strings.TrimSpace(string(b)); v != "" { viper.Set(k, v) } } } // SecretValue resolves a configuration value that is not part of the typed // Config struct. It reads through Viper, so a value supplied via a file-mounted // secret (audit F8, loaded by loadFileSecrets) is found just like an env var. // // Must be called after Load(). Used by cmd/api and cmd/worker for the // observability endpoints, which are needed before the full Config is wired // and would otherwise be read with os.Getenv — which misses file-mounted // secrets entirely once F8 removes them from the process environment. func SecretValue(key string) string { return viper.GetString(key) } // randomHexKey returns a cryptographically secure random hex string // representing n random bytes (2n hex characters). func randomHexKey(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } func validate(cfg *Config) error { // M8: SECRET_KEY validation — no static fallback secret in the binary. if cfg.Security.SecretKey == "" { if cfg.Server.Debug { // Debug only: generate a random key per boot. Tokens signed with // it do not survive a restart, which is acceptable for local dev // and far safer than a well-known hardcoded fallback. randomKey, err := randomHexKey(32) if err != nil { return fmt.Errorf("failed to generate ephemeral debug SECRET_KEY: %w", err) } cfg.Security.SecretKey = randomKey fmt.Println("WARNING: SECRET_KEY not set, generated an ephemeral random key (debug mode only)") fmt.Println("WARNING: tokens will not survive a restart — set SECRET_KEY for stable local sessions") } 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)") } } else if isWeakSecretKey(cfg.Security.SecretKey) { if cfg.Server.Debug { fmt.Printf("WARNING: SECRET_KEY is set to a well-known weak value (%q). Change it for production use.\n", cfg.Security.SecretKey) } else { return fmt.Errorf("FATAL: SECRET_KEY is set to a well-known weak value (%q). Use a strong, unique secret in production", cfg.Security.SecretKey) } } // C4: fixed confirmation codes ("123456") must never be enabled outside // debug — with DEBUG=false they are a full authentication bypass. if cfg.Server.DebugFixedCodes && !cfg.Server.Debug { return fmt.Errorf("FATAL: DEBUG_FIXED_CODES is enabled with DEBUG=false — fixed confirmation codes must never run in production") } // Database password might come from DATABASE_URL, don't require it separately // The actual connection will fail if credentials are wrong // Validate STORAGE_ENCRYPTION_KEY if set: must be exactly 64 hex characters if cfg.Storage.EncryptionKey != "" { if len(cfg.Storage.EncryptionKey) != 64 { return fmt.Errorf("STORAGE_ENCRYPTION_KEY must be exactly 64 hex characters (got %d)", len(cfg.Storage.EncryptionKey)) } if _, err := hex.DecodeString(cfg.Storage.EncryptionKey); err != nil { return fmt.Errorf("STORAGE_ENCRYPTION_KEY contains invalid hex: %w", err) } } return nil } // MaskURLCredentials parses a URL and replaces any password with "***". // If parsing fails, it returns the string "" to avoid leaking credentials. func MaskURLCredentials(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return "" } if u.User != nil { if _, hasPassword := u.User.Password(); hasPassword { u.User = url.UserPassword(u.User.Username(), "***") } } return u.Redacted() } // 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 }