ultisuite-backend/internal/config/config.go
R3D347HR4Y 125169edee
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(deploy): update .env.example and Authentik blueprints for improved configuration
- Enhanced .env.example with new variables for PUBLIC_HOST, SECURE, and SUITE_ORIGIN to streamline environment setup.
- Updated Authentik blueprints to utilize the new configuration variables for redirect URIs and launch URLs.
- Introduced a new script to render Authentik blueprint templates dynamically based on environment variables.
- Modified docker-compose files to reference the updated environment variables for better maintainability.
- Improved expose.sh script to derive public URLs from the new configuration, ensuring consistency across deployments.
2026-06-18 08:14:02 +02:00

496 lines
17 KiB
Go

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
// Rich text editor (TipTap + Hocuspocus)
RichTextEnabled bool
HocuspocusPublicURL string
HocuspocusSecret string
RichTextStorageMode string
RichTextExportMirror string
// AI assistant (OpenWebUI + Ulti gateway)
AIAssistantEnabled bool
OpenWebUIInternalURL string
AIAssistantPublicPath string
AIGatewayAPIKey string
UltimailMCPURL string
// Jitsi
JitsiEnabled bool
JitsiDomain string
JitsiAppID string
JitsiAppSecret string
JitsiPublicURL string
MeetTranscriptWebhookSecret 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
// Stalwart hosted mail
StalwartEnabled bool
StalwartAPIURL string
StalwartAPIKey string
StalwartIMAPHost string
StalwartIMAPPort int
StalwartIMAPTLS bool
StalwartSMTPHost string
StalwartSMTPPort int
StalwartSMTPTLS bool
PlatformMailDomain string
ProvisionWebhookSecret string
// Migration OAuth (Google/Microsoft bulk import)
MigrationGoogleOAuthClientID string
MigrationGoogleOAuthClientSecret string
MigrationMicrosoftOAuthClientID string
MigrationMicrosoftOAuthSecret string
MigrationMicrosoftOAuthTenant string
MigrationOAuthRedirectURL string
MigrationWorkerInterval time.Duration
MigrationGoogleServiceAccountJSON string
MigrationRateLimitMaxRetries int
MigrationRateLimitBaseDelay time.Duration
MigrationRateLimitMaxDelay time.Duration
MigrationWorkerConcurrency int
MigrationWorkerJobLimit int
MigrationImportBatchSize int
MigrationDriveBatchSize int
MigrationCutoverMXHosts string
MigrationCutoverRequireMX bool
// 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
// Mobile push notifications (FCM Android + APNS iOS).
// All optional; when unset the dispatcher no-ops so local dev works.
FCMServiceAccountJSON string
FCMProjectID string
APNSPrivateKey string
APNSKeyID string
APNSTeamID string
APNSBundleID string
APNSProduction bool
// 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(),
RichTextEnabled: envBool("RICHTEXT_ENABLED", true),
HocuspocusPublicURL: envOrDefault("HOCUSPOCUS_PUBLIC_URL", "ws://localhost:1234"),
HocuspocusSecret: secrets.Env("HOCUSPOCUS_SECRET"),
RichTextStorageMode: envOrDefault("RICHTEXT_STORAGE_MODE", "sidecar"),
RichTextExportMirror: envOrDefault("RICHTEXT_EXPORT_MIRROR", ""),
AIAssistantEnabled: envBool("AI_ASSISTANT_ENABLED", false),
OpenWebUIInternalURL: envOrDefault("OPENWEBUI_URL", "http://openwebui:8080"),
AIAssistantPublicPath: envOrDefault("AI_ASSISTANT_PUBLIC_PATH", "/ai"),
AIGatewayAPIKey: envOrDefault("AI_GATEWAY_API_KEY", "ulti-gateway"),
UltimailMCPURL: envOrDefault("ULTIMAIL_MCP_URL", "http://ultimail-mcp:3100"),
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"),
MeetTranscriptWebhookSecret: envOrDefaultSecret("MEET_TRANSCRIPT_WEBHOOK_SECRET", ""),
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:3004")),
StalwartEnabled: envBool("STALWART_ENABLED", false),
StalwartAPIURL: envOrDefault("STALWART_API_URL", "http://stalwart:8080"),
StalwartAPIKey: secrets.Env("STALWART_API_KEY"),
StalwartIMAPHost: envOrDefault("STALWART_IMAP_HOST", "stalwart"),
StalwartIMAPPort: envInt("STALWART_IMAP_PORT", 993),
StalwartIMAPTLS: envBool("STALWART_IMAP_TLS", true),
StalwartSMTPHost: envOrDefault("STALWART_SMTP_HOST", "stalwart"),
StalwartSMTPPort: envInt("STALWART_SMTP_PORT", 587),
StalwartSMTPTLS: envBool("STALWART_SMTP_TLS", true),
PlatformMailDomain: envOrDefault("PLATFORM_MAIL_DOMAIN", "ultisuite.fr"),
ProvisionWebhookSecret: secrets.Env("PROVISION_WEBHOOK_SECRET"),
MigrationGoogleOAuthClientID: os.Getenv("MIGRATION_GOOGLE_OAUTH_CLIENT_ID"),
MigrationGoogleOAuthClientSecret: secrets.Env("MIGRATION_GOOGLE_OAUTH_CLIENT_SECRET"),
MigrationMicrosoftOAuthClientID: os.Getenv("MIGRATION_MICROSOFT_OAUTH_CLIENT_ID"),
MigrationMicrosoftOAuthSecret: secrets.Env("MIGRATION_MICROSOFT_OAUTH_CLIENT_SECRET"),
MigrationMicrosoftOAuthTenant: envOrDefault("MIGRATION_MICROSOFT_OAUTH_TENANT", "common"),
MigrationOAuthRedirectURL: os.Getenv("MIGRATION_OAUTH_REDIRECT_URL"),
MigrationWorkerInterval: envDuration("MIGRATION_WORKER_INTERVAL", 30*time.Second),
MigrationGoogleServiceAccountJSON: secrets.Env("MIGRATION_GOOGLE_SERVICE_ACCOUNT_JSON"),
MigrationRateLimitMaxRetries: envInt("MIGRATION_RATE_LIMIT_MAX_RETRIES", 6),
MigrationRateLimitBaseDelay: envDuration("MIGRATION_RATE_LIMIT_BASE_DELAY", 2*time.Second),
MigrationRateLimitMaxDelay: envDuration("MIGRATION_RATE_LIMIT_MAX_DELAY", 2*time.Minute),
MigrationWorkerConcurrency: envInt("MIGRATION_WORKER_CONCURRENCY", 2),
MigrationWorkerJobLimit: envInt("MIGRATION_WORKER_JOB_LIMIT", 0),
MigrationImportBatchSize: envInt("MIGRATION_IMPORT_BATCH_SIZE", 25),
MigrationDriveBatchSize: envInt("MIGRATION_DRIVE_BATCH_SIZE", 10),
MigrationCutoverMXHosts: os.Getenv("MIGRATION_CUTOVER_MX_HOSTS"),
MigrationCutoverRequireMX: envBool("MIGRATION_CUTOVER_REQUIRE_MX", false),
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"),
FCMServiceAccountJSON: secrets.Env("FCM_SERVICE_ACCOUNT_JSON"),
FCMProjectID: os.Getenv("FCM_PROJECT_ID"),
APNSPrivateKey: secrets.Env("APNS_PRIVATE_KEY"),
APNSKeyID: os.Getenv("APNS_KEY_ID"),
APNSTeamID: os.Getenv("APNS_TEAM_ID"),
APNSBundleID: os.Getenv("APNS_BUNDLE_ID"),
APNSProduction: envBool("APNS_PRODUCTION", false),
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
}
if strings.TrimSpace(os.Getenv("SECURE")) == "s" {
return true
}
host := strings.TrimSpace(os.Getenv("AUTHENTIK_HOST"))
return strings.HasPrefix(strings.ToLower(host), "https://")
}