Files
honeyDueAPI/internal/config/config.go
T
Trey t 3d3ba84df0
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
fix(auth): delete the Kratos identity on account deletion
Account deletion removed all local data but left the Ory Kratos
identity intact — an orphaned identity that can still authenticate.
Close the gap:

- kratos.Client gains the admin API: NewClient(publicURL, adminURL)
  and DeleteIdentity (DELETE /admin/identities/{id}; a 404 is treated
  as success so a retry after a partial failure is idempotent).
- AuthService.DeleteAccount deletes the Kratos identity FIRST; if that
  call fails it aborts before touching local data, so the operation is
  retryable rather than partially applied.
- KRATOS_ADMIN_URL config (default http://kratos:4434) + router wiring.
- kratos NetworkPolicy split: the api pods may now reach the admin API
  :4434 (Traefik still reaches only the public API :4433).
- kratos CORS: allow_credentials + OPTIONS so the web browser flows
  (ory_kratos_session cookie) work; origins stay an explicit allowlist.
- Regression tests: identity teardown happens, and a Kratos failure
  aborts the deletion instead of orphaning local data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:33 -05:00

636 lines
23 KiB
Go

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 <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
// 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/<pid>/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 "<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
}