package config import ( "errors" "os" "strconv" "strings" "time" "github.com/ultisuite/ulti-backend/internal/secrets" ) type Config struct { Port int Domain string AppEnv string // PostgreSQL DatabaseURL string // KeyDB KeyDBAddr string KeyDBPassword string KeyDBDB int // Object Storage (S3-compatible) RustFSEndpoint string RustFSAccessKey string RustFSSecretKey string RustFSUseSSL bool RustFSRegion string // OIDC OIDCIssuer string OIDCClientID string OIDCClientSecret string // Nextcloud NextcloudEnabled bool NextcloudURL string NCAdminUser string NCAdminPass string // Jitsi JitsiEnabled bool JitsiDomain string JitsiAppID string JitsiAppSecret string JitsiPublicURL string // Immich ImmichEnabled bool ImmichAPIURL string // Mail MailAttachmentsBucket string MailSyncInterval time.Duration MailOutboxInterval time.Duration MailCredentialKeys string MailActiveCredentialKeyID string MailWebhookSharedSecret string // Secret rotation policy SecretRotationMaxAge time.Duration OIDCSecretRotatedAt time.Time SMTPCredentialKeyRotatedAt time.Time WebhookSharedSecretRotatedAt time.Time // Search SearchEngine string MeilisearchURL string MeilisearchKey string // Observability HealthNextcloudURL string HealthImmichURL string HealthJitsiURL string HealthHTTPTimeout time.Duration } func Load() (*Config, error) { port := 8080 if v := os.Getenv("ULTID_PORT"); v != "" { if p, err := strconv.Atoi(v); err == nil { port = p } } return &Config{ Port: port, Domain: envOrDefault("DOMAIN", "localhost"), AppEnv: strings.ToLower(envOrDefault("ULTID_ENV", envOrDefault("APP_ENV", "development"))), DatabaseURL: envOrDefault("ULTID_DB_URL", "postgres://ulti:changeme@localhost:5432/ultidb?sslmode=disable"), KeyDBAddr: envOrDefault("ULTID_KEYDB_URL", "localhost:6379"), KeyDBPassword: secrets.Env("ULTID_KEYDB_PASSWORD"), KeyDBDB: envInt("ULTID_KEYDB_DB", 0), RustFSEndpoint: envOrDefault("ULTID_RUSTFS_ENDPOINT", "localhost:9000"), RustFSAccessKey: secrets.Env("ULTID_RUSTFS_ACCESS_KEY"), RustFSSecretKey: secrets.Env("ULTID_RUSTFS_SECRET_KEY"), RustFSUseSSL: envBool("ULTID_RUSTFS_USE_SSL", false), RustFSRegion: envOrDefault("ULTID_RUSTFS_REGION", "us-east-1"), OIDCIssuer: os.Getenv("ULTID_OIDC_ISSUER"), OIDCClientID: os.Getenv("ULTID_OIDC_CLIENT_ID"), OIDCClientSecret: secrets.Env("ULTID_OIDC_CLIENT_SECRET"), NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true), NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"), NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"), JitsiEnabled: envBool("JITSI_ENABLED", true), JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"), JitsiAppID: envOrDefault("JITSI_APP_ID", "ulti"), JitsiAppSecret: envOrDefaultSecret("JITSI_APP_SECRET", "changeme-jwt-secret"), JitsiPublicURL: envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"), ImmichEnabled: envBool("IMMICH_ENABLED", true), ImmichAPIURL: envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), MailAttachmentsBucket: envOrDefault("MAIL_ATTACHMENTS_BUCKET", "mail-attachments"), MailSyncInterval: envDuration("MAIL_SYNC_INTERVAL", 2*time.Minute), MailOutboxInterval: envDuration("MAIL_OUTBOX_INTERVAL", 10*time.Second), MailCredentialKeys: secrets.Env("MAIL_CREDENTIAL_KEYS"), MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""), MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"), SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour), OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"), SMTPCredentialKeyRotatedAt: envTime("MAIL_CREDENTIAL_KEY_ROTATED_AT"), WebhookSharedSecretRotatedAt: envTime("MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT"), SearchEngine: envOrDefault("SEARCH_ENGINE", "postgres"), MeilisearchURL: os.Getenv("MEILISEARCH_URL"), MeilisearchKey: secrets.Env("MEILISEARCH_API_KEY"), HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")), HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")), HealthJitsiURL: envOrDefault("HEALTH_JITSI_URL", defaultHealthJitsiURL(envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"))), HealthHTTPTimeout: envDuration("HEALTH_HTTP_TIMEOUT", 3*time.Second), }, nil } func (c *Config) IsProduction() bool { return c != nil && c.AppEnv == "production" } func (c *Config) ValidateSecretRotation() error { if c == nil { return nil } maxAge := c.SecretRotationMaxAge if maxAge <= 0 { maxAge = 90 * 24 * time.Hour } now := time.Now() checks := []struct { name string rotatedAt time.Time }{ {name: "ULTID_OIDC_CLIENT_SECRET", rotatedAt: c.OIDCSecretRotatedAt}, {name: "MAIL_CREDENTIAL_KEY", rotatedAt: c.SMTPCredentialKeyRotatedAt}, {name: "MAIL_WEBHOOK_SHARED_SECRET", rotatedAt: c.WebhookSharedSecretRotatedAt}, } var stale []string for _, check := range checks { if check.rotatedAt.IsZero() || now.Sub(check.rotatedAt) > maxAge { stale = append(stale, check.name) } } if len(stale) == 0 { return nil } return errors.New("stale or missing rotation metadata for: " + strings.Join(stale, ", ")) } func envOrDefault(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func envOrDefaultSecret(key, fallback string) string { if v := secrets.Env(key); v != "" { return v } return fallback } func envBool(key string, fallback bool) bool { v := os.Getenv(key) if v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { return fallback } return b } func envInt(key string, fallback int) int { v := os.Getenv(key) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } func envDuration(key string, fallback time.Duration) time.Duration { v := os.Getenv(key) if v == "" { return fallback } d, err := time.ParseDuration(v) if err != nil { return fallback } return d } func envTime(key string) time.Time { v := os.Getenv(key) if v == "" { return time.Time{} } ts, err := time.Parse(time.RFC3339, v) if err != nil { return time.Time{} } return ts } func joinURL(base, path string) string { return strings.TrimRight(base, "/") + path } func defaultHealthJitsiURL(publicURL string) string { trimmed := strings.TrimRight(publicURL, "/") trimmed = strings.TrimSuffix(trimmed, "/meet") return trimmed + "/about/health" }