package config import ( "errors" "os" "strconv" "strings" "time" "github.com/ultisuite/ulti-backend/internal/secrets" ) type Config struct { Port int Domain string AppEnv string // Browser clients (web app on another origin than the API gateway). CORSAllowedOrigins []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 // Authentik API (suite app auto-provisioning) AuthentikAPIURL string AuthentikAPIToken string AuthentikPublicHTTPS bool // Suite OIDC clients (Authentik applications) NCOIDCClientID string NCOIDCClientSecret string OnlyOfficeOIDCClientID string OnlyOfficeOIDCClientSecret string ImmichOIDCClientID string ImmichOIDCClientSecret string DriveOIDCClientID string DriveOIDCClientSecret string // Nextcloud NextcloudEnabled bool NextcloudURL string NextcloudPublicURL string NCAdminUser string NCAdminPass string // OnlyOffice OnlyOfficeEnabled bool OnlyOfficeURL string OnlyOfficePublicURL string OnlyOfficeAPIInternalURL string OnlyOfficeJWTSecret string UltidPublicURL string DrivePublicURL 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 MailOutboxMaxRetries int MailSendRatePerMinute int MailSendBurst int MailSMTPCircuitFailures int MailSMTPCircuitCooldown time.Duration MailCredentialKeys string MailActiveCredentialKeyID string MailWebhookSharedSecret string MailGoogleOAuthClientID string MailGoogleOAuthClientSecret string MailMicrosoftOAuthClientID string MailMicrosoftOAuthSecret string MailMicrosoftOAuthTenant string MailOAuthRedirectURL string MailAppURL string // Secret rotation policy SecretRotationMaxAge time.Duration OIDCSecretRotatedAt time.Time SMTPCredentialKeyRotatedAt time.Time WebhookSharedSecretRotatedAt time.Time // Search SearchEngine string MeilisearchURL string MeilisearchKey string MeilisearchIndex string TypesenseURL string TypesenseKey string TypesenseCollection string // VirusTotal (optional env fallback for org file_policies.virustotal_api_key) VirusTotalAPIKey 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"))), CORSAllowedOrigins: parseCSVEnv("ULTID_CORS_ALLOWED_ORIGINS"), 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"), AuthentikAPIURL: envOrDefault("AUTHENTIK_API_URL", "http://authentik-server:9000"), AuthentikAPIToken: secrets.Env("AUTHENTIK_API_TOKEN"), AuthentikPublicHTTPS: authentikPublicHTTPS(), NCOIDCClientID: envOrDefault("NC_OIDC_CLIENT_ID", "ulti-nextcloud"), NCOIDCClientSecret: secrets.Env("NC_OIDC_CLIENT_SECRET"), OnlyOfficeOIDCClientID: envOrDefault("ONLYOFFICE_OIDC_CLIENT_ID", "ulti-onlyoffice"), OnlyOfficeOIDCClientSecret: secrets.Env("ONLYOFFICE_OIDC_CLIENT_SECRET"), ImmichOIDCClientID: envOrDefault("IMMICH_OIDC_CLIENT_ID", "ulti-immich"), ImmichOIDCClientSecret: secrets.Env("IMMICH_OIDC_CLIENT_SECRET"), DriveOIDCClientID: os.Getenv("DRIVE_OIDC_CLIENT_ID"), DriveOIDCClientSecret: secrets.Env("DRIVE_OIDC_CLIENT_SECRET"), NextcloudEnabled: envBool("NEXTCLOUD_ENABLED", true), NextcloudURL: envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), NextcloudPublicURL: nextcloudPublicURL(), NCAdminUser: envOrDefault("NC_ADMIN_USER", "admin"), NCAdminPass: envOrDefaultSecret("NC_ADMIN_PASSWORD", "changeme"), OnlyOfficeEnabled: envBool("ONLYOFFICE_ENABLED", false), OnlyOfficeURL: envOrDefault("ONLYOFFICE_URL", "http://onlyoffice"), OnlyOfficePublicURL: envOrDefault("ONLYOFFICE_PUBLIC_URL", "http://localhost/office"), OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"), OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"), UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), DrivePublicURL: drivePublicURL(), 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), MailOutboxMaxRetries: envInt("MAIL_OUTBOX_MAX_RETRIES", 8), MailSendRatePerMinute: envInt("MAIL_SEND_RATE_PER_MINUTE", 30), MailSendBurst: envInt("MAIL_SEND_BURST", 10), MailSMTPCircuitFailures: envInt("MAIL_SMTP_CIRCUIT_FAILURES", 5), MailSMTPCircuitCooldown: envDuration("MAIL_SMTP_CIRCUIT_COOLDOWN", 5*time.Minute), MailCredentialKeys: secrets.Env("MAIL_CREDENTIAL_KEYS"), MailActiveCredentialKeyID: envOrDefault("MAIL_ACTIVE_CREDENTIAL_KEY_ID", ""), MailWebhookSharedSecret: secrets.Env("MAIL_WEBHOOK_SHARED_SECRET"), MailGoogleOAuthClientID: os.Getenv("MAIL_GOOGLE_OAUTH_CLIENT_ID"), MailGoogleOAuthClientSecret: secrets.Env("MAIL_GOOGLE_OAUTH_CLIENT_SECRET"), MailMicrosoftOAuthClientID: os.Getenv("MAIL_MICROSOFT_OAUTH_CLIENT_ID"), MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"), MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"), MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"), MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000")), 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"), MeilisearchIndex: envOrDefault("MEILISEARCH_INDEX", "ulti"), TypesenseURL: os.Getenv("TYPESENSE_URL"), TypesenseKey: secrets.Env("TYPESENSE_API_KEY"), TypesenseCollection: envOrDefault("TYPESENSE_COLLECTION", "ulti"), VirusTotalAPIKey: secrets.Env("VIRUSTOTAL_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 parseCSVEnv(key string) []string { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out } 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 } // nextcloudPublicURL is the external WebDAV base (OVERWRITECLIURL), used for MOVE/COPY Destination headers. func nextcloudPublicURL() string { if v := strings.TrimSpace(os.Getenv("NC_PUBLIC_URL")); v != "" { return strings.TrimRight(v, "/") } domain := envOrDefault("DOMAIN", "localhost") proto := envOrDefault("NC_OVERWRITE_PROTOCOL", "http") return proto + "://" + domain + "/cloud" } // drivePublicURL is the browser-facing UltiDrive base used in public share links. func drivePublicURL() string { if v := strings.TrimSpace(os.Getenv("DRIVE_PUBLIC_URL")); v != "" { return strings.TrimRight(v, "/") } return strings.TrimRight(envOrDefault("ULTID_PUBLIC_URL", "http://localhost"), "/") + "/drive" } func defaultHealthJitsiURL(publicURL string) string { trimmed := strings.TrimRight(publicURL, "/") trimmed = strings.TrimSuffix(trimmed, "/meet") return trimmed + "/about/health" } func authentikPublicHTTPS() bool { if envBool("AUTHENTIK_PUBLIC_HTTPS", false) { return true } host := strings.TrimSpace(os.Getenv("AUTHENTIK_HOST")) return strings.HasPrefix(strings.ToLower(host), "https://") }