package config import ( "fmt" "net/url" "os" "strings" "sync" ) // DBURL returns the database connection string, preferring DATABASE_URL and // falling back to DB_URL (used by the Makefile). func DBURL() string { if v := os.Getenv("DATABASE_URL"); v != "" { return v } return os.Getenv("DB_URL") } // Config holds the runtime configuration, loaded from environment variables. // See porting guide section 7. type Config struct { DatabaseURL string NextAuthSecret string // == AUTH_SECRET; signs sessions and derives IMAP key AuthSecret string NextAuthURL string OIDCIssuer string OIDCClientID string OIDCClientSecret string AdminEmails []string } // Load reads configuration from the environment. Placeholders keep OIDC off. func Load() *Config { return &Config{ DatabaseURL: DBURL(), NextAuthSecret: os.Getenv("NEXTAUTH_SECRET"), AuthSecret: os.Getenv("AUTH_SECRET"), NextAuthURL: os.Getenv("NEXTAUTH_URL"), OIDCIssuer: os.Getenv("OIDC_ISSUER"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), AdminEmails: AdminEmails(), } } // Secret returns the signing secret, preferring NEXTAUTH_SECRET then AUTH_SECRET. // The original code falls back to a hard-coded string; a port must fail instead. func (c *Config) Secret() string { if c.NextAuthSecret != "" { return c.NextAuthSecret } return c.AuthSecret } // EncryptionSecret returns the key material for at-rest encryption of // integration credentials (review OPS-01). A dedicated ENCRYPTION_KEY lets // operators rotate AUTH_SECRET (sessions) without invalidating stored IMAP // passwords. Without it the auth secret remains the key, matching the // original derivation, and stored rows keep decrypting via the legacy // fallback in signals.LoadMailbox. func (c *Config) EncryptionSecret() string { if v := os.Getenv("ENCRYPTION_KEY"); v != "" { return v } return c.Secret() } // CookieSecure reports whether session cookies must carry the Secure flag. // An explicit COOKIE_SECURE setting wins; otherwise the NEXTAUTH_URL scheme // decides, so TLS deployments are secure by default without knowing about // the extra variable. func (c *Config) CookieSecure() bool { if v := os.Getenv("COOKIE_SECURE"); v != "" { return v == "1" } u, err := url.Parse(strings.TrimSpace(c.NextAuthURL)) return err == nil && u.Scheme == "https" } // adminEmailsCache memoizes the parsed ADMIN_EMAILS value by its raw env // string (review M-12: IsAdminEmail runs on the auth hot path and re-split // and re-lowercased the list on every call). Keying on the raw value keeps // t.Setenv-based tests correct without a process-global startup snapshot. var adminEmailsCache struct { mu sync.Mutex raw string out []string } // AdminEmails splits env ADMIN_EMAILS on commas, trims, lowercases. func AdminEmails() []string { s := os.Getenv("ADMIN_EMAILS") adminEmailsCache.mu.Lock() defer adminEmailsCache.mu.Unlock() if adminEmailsCache.raw == s { return adminEmailsCache.out } var out []string for _, email := range strings.Split(s, ",") { if email = strings.ToLower(strings.TrimSpace(email)); email != "" { out = append(out, email) } } adminEmailsCache.raw, adminEmailsCache.out = s, out return out } // IsAdminEmail reports whether email is listed in ADMIN_EMAILS. func IsAdminEmail(email string) bool { e := strings.ToLower(strings.TrimSpace(email)) for _, a := range AdminEmails() { if strings.ToLower(a) == e { return true } } return false } // AllowBootstrapSignup reports whether the first self-service signup on a // fresh installation may claim administrator (review SEC-5). Default is on // so the documented "sign up, then sign in" onboarding keeps working; // exposed deployments set ALLOW_BOOTSTRAP_SIGNUP=0 and provision operators // via ADMIN_EMAILS/seed instead, so an attacker scanning for new // installations cannot claim the instance. func AllowBootstrapSignup() bool { return os.Getenv("ALLOW_BOOTSTRAP_SIGNUP") != "0" } // Validate fails fast on configuration the app cannot use, instead of // discovering it on the first request (review M-12). func (c *Config) Validate() error { if strings.TrimSpace(c.Secret()) == "" { return fmt.Errorf("AUTH_SECRET (or NEXTAUTH_SECRET) must be set") } if c.NextAuthURL != "" { u, err := url.Parse(c.NextAuthURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("NEXTAUTH_URL must be an absolute http(s) URL, got %q", c.NextAuthURL) } } return nil }