package config import ( "fmt" "os" "strconv" "strings" "time" "github.com/joho/godotenv" ) // Config holds all configuration for the application. type Config struct { // Server Env string // "development" | "production" Port int // MongoDB (content database) MongoURI string MongoDB string // PostgreSQL (users, relations, URL shortener) PostgresURI string // Typesense TypesenseURL string TypesenseAPIKey string // DragonflyDB / Redis RedisURI string // Cloudflare R2 (S3-compatible) — for avatar uploads via presigned URLs. R2AccountID string R2AccessKeyID string R2SecretAccessKey string R2Bucket string R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL // SMTP — used for password-reset, email-verification, and // /feedback admin-notification mails. SMTPHost string SMTPPort int SMTPUsername string SMTPPassword string SMTPFrom string // "Prabhupāda Says " // Comma-separated FEEDBACK_ADMIN_EMAILS — recipients for the // /feedback notification fan-out. Empty disables the email send // (the DB row is still written and visible via /stats). FeedbackAdminEmails []string // Push Notifications (existing — unchanged). FCMCredentialsFile string APNsKeyFile string APNsKeyID string APNsTeamID string APNsBundleID string // JWT Auth JWTSecret string JWTExpiration time.Duration // parsed at load time CookieDomain string // empty in dev; ".prabhupadasays.org" in prod CookieSecure bool // true in production // Frontend FrontendURL string // for CORS + reset/verify links in emails // RevalidateSecret authenticates BFF→frontend on-demand cache // revalidation pings (POST /api/revalidate). Empty disables the pings. RevalidateSecret string // BFF public URL — the externally-reachable origin of this server. Used // to build the OAuth redirect_uri the provider sees (must match the // allow-list configured in the provider's console). In dev this is // usually http://localhost:8080. BFFPublicURL string // OAuth providers — empty creds disable the corresponding provider. GoogleOAuthClientID string GoogleOAuthClientSecret string FacebookOAuthClientID string FacebookOAuthClientSecret string // Soketi (Pusher-compatible WebSocket server) — used to push realtime // notifications. Empty App credentials disable realtime delivery (the // DB row + email still happen). SoketiHost is the HTTP-API origin the // BFF triggers events against (e.g. "ws.prabhupadasays.org"); the // frontend connects to the same host over WSS. SoketiAppID string SoketiAppKey string SoketiAppSecret string SoketiHost string SoketiPort int SoketiUseTLS bool // Web Push (VAPID). Empty keys disable push delivery (other channels // still run). VAPIDSubject is a mailto: or https: contact URL required // by the spec. The public key is also served to the browser to // subscribe. VAPIDPublicKey string VAPIDPrivateKey string VAPIDSubject string } // Load reads configuration from environment variables (with .env fallback). func Load() (*Config, error) { _ = godotenv.Load() jwtExp, err := time.ParseDuration(getEnv("JWT_EXPIRATION", "168h")) if err != nil { return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err) } cfg := &Config{ Env: getEnv("APP_ENV", "development"), Port: getEnvInt("PORT", 8080), MongoURI: getEnv("MONGO_URI", ""), MongoDB: getEnv("MONGO_DB", "spsays"), PostgresURI: getEnv("POSTGRES_URI", ""), TypesenseURL: getEnv("TYPESENSE_URL", "https://search.prabhupadasays.org"), TypesenseAPIKey: getEnv("TYPESENSE_API_KEY", ""), RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"), R2AccountID: getEnv("R2_ACCOUNT_ID", ""), R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""), R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""), R2Bucket: getEnv("R2_BUCKET", ""), R2PublicURL: getEnv("R2_PUBLIC_URL", ""), SMTPHost: getEnv("SMTP_HOST", ""), SMTPPort: getEnvInt("SMTP_PORT", 587), SMTPUsername: getEnv("SMTP_USERNAME", ""), SMTPPassword: getEnv("SMTP_PASSWORD", ""), SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"), FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")), FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""), APNsKeyFile: getEnv("APNS_KEY_FILE", ""), APNsKeyID: getEnv("APNS_KEY_ID", ""), APNsTeamID: getEnv("APNS_TEAM_ID", ""), APNsBundleID: getEnv("APNS_BUNDLE_ID", ""), JWTSecret: getEnv("JWT_SECRET", ""), JWTExpiration: jwtExp, CookieDomain: getEnv("COOKIE_DOMAIN", ""), CookieSecure: getEnvBool("COOKIE_SECURE", false), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), RevalidateSecret: getEnv("REVALIDATE_SECRET", ""), BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"), GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""), GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""), FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""), FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""), SoketiAppID: getEnv("SOKETI_APP_ID", ""), SoketiAppKey: getEnv("SOKETI_APP_KEY", ""), SoketiAppSecret: getEnv("SOKETI_APP_SECRET", ""), SoketiHost: getEnv("SOKETI_HOST", "ws.prabhupadasays.org"), SoketiPort: getEnvInt("SOKETI_PORT", 443), SoketiUseTLS: getEnvBool("SOKETI_USE_TLS", true), VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""), VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""), VAPIDSubject: getEnv("VAPID_SUBJECT", "mailto:admin@prabhupadasays.org"), } if cfg.MongoURI == "" { return nil, fmt.Errorf("MONGO_URI is required") } if cfg.Env == "production" && cfg.JWTSecret == "" { return nil, fmt.Errorf("JWT_SECRET is required in production") } if cfg.JWTSecret == "" { // Dev fallback so the server can boot without secrets configured. cfg.JWTSecret = "dev-only-jwt-secret-do-not-use-in-production" } return cfg, nil } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func getEnvInt(key string, fallback int) int { if v := os.Getenv(key); v != "" { if i, err := strconv.Atoi(v); err == nil { return i } } return fallback } func getEnvBool(key string, fallback bool) bool { if v := os.Getenv(key); v != "" { if b, err := strconv.ParseBool(v); err == nil { return b } } return fallback } // parseList splits a comma-separated env value, trimming whitespace // and discarding empty entries. Returns nil for empty input so // callers can range over it safely without a length check. func parseList(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }