diff --git a/internal/config/config.go b/internal/config/config.go index 0c792dd..6376c20 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,8 +89,12 @@ type PushConfig struct { } type AppleAuthConfig struct { - ClientID string // Bundle ID (e.g., com.tt.honeyDue.honeyDueDev) - TeamID string // Apple Developer Team ID + 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 { @@ -178,8 +182,8 @@ type FeatureFlags struct { } var ( - cfg *Config - cfgOnce sync.Once + cfg *Config + cfgMu sync.Mutex ) // knownWeakSecretKeys contains well-known default or placeholder secret keys @@ -192,163 +196,163 @@ var knownWeakSecretKeys = map[string]bool{ "change-me-in-production-secret-key-12345": true, } -// Load reads configuration from environment variables +// 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) { - var loadErr error - - cfgOnce.Do(func() { - 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"), - 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 - } - } - } - - cfg = &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"), - }, - 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"), - }, - } - - // Validate required fields - if err := validate(cfg); err != nil { - loadErr = err - // Reset so a subsequent call can retry after env is fixed - cfg = nil - cfgOnce = sync.Once{} - } - }) - - if loadErr != nil { - return nil, loadErr + cfgMu.Lock() + defer cfgMu.Unlock() + if cfg != nil { + return cfg, nil } + 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"), + 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"), + }, + 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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 97a4d10..494f36b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,7 +1,6 @@ package config import ( - "sync" "testing" "github.com/spf13/viper" @@ -11,8 +10,9 @@ import ( // resetConfigState resets the package-level singleton so each test starts fresh. func resetConfigState() { + cfgMu.Lock() cfg = nil - cfgOnce = sync.Once{} + cfgMu.Unlock() viper.Reset() }