ultisuite-backend/internal/config/config.go
R3D347HR4Y bda75aeb0d
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(config): enhance AI gateway and model management features
- Updated .env.example to include new configuration options for AI gateway and WebUI secret key.
- Modified Nginx configuration to support additional API routes for model management and migration.
- Implemented new API endpoints for discovering organization-level LLM models and managing hosted mail services.
- Enhanced AI gateway logic to support organization-specific model access and permissions.
- Improved error handling and response structures in the AI and mail APIs.
- Added integration tests for new features and updated existing tests for model access control.
2026-06-13 20:38:26 +02:00

475 lines
16 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
// 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"),
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://")
}