Delete Account (Plan #2): - DELETE /api/auth/account/ with password or "DELETE" confirmation - Cascade delete across 15+ tables in correct FK order - Auth provider detection (email/apple/google) for /auth/me/ - File cleanup after account deletion - Handler + repository tests (12 tests) Encryption at Rest (Plan #3): - AES-256-GCM envelope encryption (per-file DEK wrapped by KEK) - Encrypt on upload, auto-decrypt on serve via StorageService.ReadFile() - MediaHandler serves decrypted files via c.Blob() - TaskService email image loading uses ReadFile() - cmd/migrate-encrypt CLI tool with --dry-run for existing files - Encryption service + storage service tests (18 tests)
522 lines
18 KiB
Go
522 lines
18 KiB
Go
package config
|
|
|
|
import (
|
|
"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
|
|
}
|
|
|
|
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 (e.g., com.tt.honeyDue.honeyDueDev)
|
|
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.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
|
|
}
|
|
|
|
// 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
|
|
EncryptionKey string // 64-char hex key for file encryption at rest (optional)
|
|
}
|
|
|
|
// 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
|
|
cfgOnce sync.Once
|
|
)
|
|
|
|
// 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
|
|
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"),
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
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"),
|
|
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
|
|
}
|
|
|
|
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 <noreply@myhoneydue.com>")
|
|
|
|
// 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
|
|
|
|
// 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))]
|
|
}
|
|
|
|
func validate(cfg *Config) error {
|
|
// S-08: Validate SECRET_KEY against known weak defaults
|
|
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)")
|
|
fmt.Println("WARNING: *** DO NOT USE THIS DEFAULT KEY IN PRODUCTION ***")
|
|
} 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)
|
|
}
|
|
}
|
|
|
|
// 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 "<unparseable-url>" to avoid leaking credentials.
|
|
func MaskURLCredentials(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "<unparseable-url>"
|
|
}
|
|
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
|
|
}
|