package server import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" aiapi "github.com/ultisuite/ulti-backend/internal/api/ai" "github.com/ultisuite/ulti-backend/internal/api/admin" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" "github.com/ultisuite/ulti-backend/internal/api/docs" "github.com/ultisuite/ulti-backend/internal/api/drive" mailapi "github.com/ultisuite/ulti-backend/internal/api/mail" "github.com/ultisuite/ulti-backend/internal/api/mail/drivebridge" "github.com/ultisuite/ulti-backend/internal/api/mail/sendguard" meetapi "github.com/ultisuite/ulti-backend/internal/api/meet" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/office" "github.com/ultisuite/ulti-backend/internal/api/richtext" "github.com/ultisuite/ulti-backend/internal/api/ultidraw" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" usersapi "github.com/ultisuite/ulti-backend/internal/api/users" "github.com/ultisuite/ulti-backend/internal/automation" "github.com/ultisuite/ulti-backend/internal/authentik" "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/config" "github.com/ultisuite/ulti-backend/internal/filescan" "github.com/ultisuite/ulti-backend/internal/httpcors" mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap" mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/smtp" mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage" "github.com/ultisuite/ulti-backend/internal/mail/webhooks" "github.com/ultisuite/ulti-backend/internal/meet" "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/observability" "github.com/ultisuite/ulti-backend/internal/orgpolicy" "github.com/ultisuite/ulti-backend/internal/photos" "github.com/ultisuite/ulti-backend/internal/realtime" "github.com/ultisuite/ulti-backend/internal/search" "github.com/ultisuite/ulti-backend/internal/securityaudit" ) // App holds wired dependencies and the HTTP router. type App struct { Router chi.Router Pool *pgxpool.Pool Redis *redis.Client VerifierHolder *auth.Holder MailOAuth *mailoauth.Service ownsPool bool ownsRedis bool cancel context.CancelFunc } // New builds the application router and optional background workers. func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { if cfg == nil { return nil, fmt.Errorf("config is nil") } workerCtx, cancel := context.WithCancel(ctx) pool := opts.Pool ownsPool := false if pool == nil { var err error pool, err = pgxpool.New(workerCtx, cfg.DatabaseURL) if err != nil { cancel() return nil, fmt.Errorf("connect database: %w", err) } ownsPool = true } if !opts.SkipAuthentikProvisioner { authentik.StartProvisioner(workerCtx, pool, cfg) } rdb := opts.Redis ownsRedis := false if rdb == nil { rdb = redis.NewClient(&redis.Options{ Addr: cfg.KeyDBAddr, Password: cfg.KeyDBPassword, DB: cfg.KeyDBDB, }) ownsRedis = true } minioClient, err := minio.New(cfg.RustFSEndpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""), Secure: cfg.RustFSUseSSL, }) if err != nil { cancel() if ownsPool { pool.Close() } if ownsRedis { _ = rdb.Close() } return nil, fmt.Errorf("rustfs client: %w", err) } attachmentStorage := mailstorage.NewClient(minioClient, cfg.MailAttachmentsBucket) if err := attachmentStorage.EnsureBucket(workerCtx); err != nil { slog.Warn("mail attachments bucket check failed", "error", err) } verifierHolder := opts.VerifierHolder if verifierHolder == nil { verifier, verr := auth.NewVerifierWithRetry(workerCtx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second) if verr != nil { slog.Warn("OIDC verifier not available at startup (Authentik may still be starting)", "error", verr) } verifierHolder = auth.NewHolder(verifier) if !verifierHolder.Ready() && cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" { pending := auth.NewHolderPending(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain) verifierHolder = pending verifierHolder.StartBackgroundRetry(workerCtx, 5*time.Second) } } if cfg.IsProduction() { if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("missing required OIDC configuration in production") } if !verifierHolder.Ready() { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("OIDC verifier initialization failed in production") } if err := cfg.ValidateSecretRotation(); err != nil { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("secret rotation policy: %w", err) } } else if err := cfg.ValidateSecretRotation(); err != nil { slog.Warn("secret rotation policy warning", "error", err) } credentialManager, err := mailcredentials.NewManager(cfg.MailCredentialKeys, cfg.MailActiveCredentialKeyID) if err != nil { cancel() closeOwned(ownsPool, pool, ownsRedis, rdb) return nil, fmt.Errorf("mail credential encryption: %w", err) } auditLogger := securityaudit.NewLogger(pool) var ncClient *nextcloud.Client if cfg.NextcloudEnabled { ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass). WithPublicURL(cfg.NextcloudPublicURL). WithDrivePublicURL(cfg.DrivePublicURL). WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager)) slog.Info("nextcloud enabled", "url", cfg.NextcloudURL, "public_url", cfg.NextcloudPublicURL, "drive_public_url", cfg.DrivePublicURL) } var meetCfg *meet.Config if cfg.JitsiEnabled { meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain) slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain) } var photosClient *photos.Client if cfg.ImmichEnabled { photosClient = photos.NewClient(cfg.ImmichAPIURL) slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL) } hub := realtime.NewHub(verifierHolder, pool) healthChecker := observability.NewHealthChecker(cfg, pool, rdb) hookExec := webhooks.NewExecutor(pool) rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec) autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec) oauthRedirect := cfg.MailOAuthRedirectURL if oauthRedirect == "" { oauthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/mail/accounts/oauth/callback", cfg.Port) if cfg.Domain != "" && cfg.Domain != "localhost" { oauthRedirect = fmt.Sprintf("https://%s/api/v1/mail/accounts/oauth/callback", cfg.Domain) } } mailOAuthSvc := mailoauth.NewService(mailoauth.Config{ GoogleClientID: cfg.MailGoogleOAuthClientID, GoogleClientSecret: cfg.MailGoogleOAuthClientSecret, MicrosoftClientID: cfg.MailMicrosoftOAuthClientID, MicrosoftSecret: cfg.MailMicrosoftOAuthSecret, MicrosoftTenant: cfg.MailMicrosoftOAuthTenant, RedirectURL: oauthRedirect, }, rdb) orgPolicyLoader := orgpolicy.NewLoader(pool, cfg) fileScanner := filescan.NewScanner(orgPolicyLoader, slog.Default()) var syncWorker *imapsync.SyncWorker if !opts.WithoutWorkers { syncWorker = imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{ Storage: attachmentStorage, AttachBucket: cfg.MailAttachmentsBucket, Automation: autoDispatcher, Hub: hub, FileScanner: fileScanner, }) go syncWorker.Start(workerCtx) } sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc) smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) guardedSender := smtp.NewGuardedSender(sender, smtpCircuit) if !opts.WithoutWorkers { go smtp.NewOutboxProcessor( pool, guardedSender, cfg.MailOutboxInterval, cfg.MailOutboxMaxRetries, smtp.WithAttachmentLoader(&smtp.StorageAttachmentLoader{Client: attachmentStorage}), ).Start(workerCtx) } sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender) mailHandler.SetFileScanner(fileScanner) if syncWorker != nil { mailHandler.SetAccountSync(syncWorker) } r := chi.NewRouter() r.Use(httpcors.Middleware(cfg)) r.Use(middleware.TraceID) r.Use(observability.HTTPMetrics) r.Use(middleware.Logging) r.Mount("/api/docs", docs.NewHandler().Routes()) r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { report := healthChecker.Check(req.Context()) statusCode := http.StatusOK if report.Status != "ok" { statusCode = http.StatusServiceUnavailable } w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) _ = json.NewEncoder(w).Encode(report) }) r.Handle("/metrics", promhttp.Handler()) r.Get("/ws", hub.HandleWS) r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback) var driveHandler *drive.Handler var driveSvc *drive.Service var contactsHandler *contacts.Handler if ncClient != nil { driveSvc = drive.NewService(ncClient, hub, pool) driveSvc.SetAutomation(autoDispatcher) driveSvc.SetFileScanner(fileScanner) driveHandler = drive.NewHandlerWithService(driveSvc) mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc}) contactsHandler = contacts.NewHandler(ncClient, pool) contactsHandler.SetAutomation(autoDispatcher) } if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil { officeSvc := office.NewService(ncClient, office.Config{ Enabled: true, DocumentURL: cfg.OnlyOfficeURL, PublicURL: cfg.OnlyOfficePublicURL, APIInternalURL: cfg.OnlyOfficeAPIInternalURL, JWTSecret: cfg.OnlyOfficeJWTSecret, }) officeHandler := office.NewHandler(officeSvc, driveSvc) r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicOffice(officeHandler) } if ncClient != nil && cfg.RichTextEnabled && driveSvc != nil { rtSvc := richtext.NewService(ncClient, richtext.Config{ Enabled: true, HocuspocusPublicURL: cfg.HocuspocusPublicURL, HocuspocusSecret: cfg.HocuspocusSecret, APIInternalURL: cfg.OnlyOfficeAPIInternalURL, StorageMode: cfg.RichTextStorageMode, ExportMirrorFormat: cfg.RichTextExportMirror, }, driveSvc) rtHandler := richtext.NewHandler(rtSvc, driveSvc) r.Mount("/api/v1/richtext", rtHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicRichText(rtHandler) udSvc := ultidraw.NewService(ncClient, ultidraw.Config{ Enabled: true, HocuspocusPublicURL: cfg.HocuspocusPublicURL, HocuspocusSecret: cfg.HocuspocusSecret, APIInternalURL: cfg.OnlyOfficeAPIInternalURL, }, driveSvc) udHandler := ultidraw.NewHandler(udSvc) r.Mount("/api/v1/ultidraw", udHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) driveHandler.SetPublicUltidraw(udHandler) } if driveHandler != nil { r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes()) } aiHandler := aiapi.NewHandler(pool, cfg, ncClient, verifierHolder) r.Mount("/api/v1/ai", aiHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))) r.Group(func(r chi.Router) { r.Use(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)) r.Use(middleware.EnforceApiTokenPolicy()) r.Mount("/api/v1/users", usersapi.NewHandler(pool).Routes()) r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger, cfg, ncClient).Routes()) if driveHandler != nil { r.Mount("/api/v1/drive", driveHandler.Routes()) } r.Group(func(r chi.Router) { r.Use(middleware.RequireFullAccount) r.Mount("/api/v1/mail", mailHandler.Routes()) r.Get("/api/v1/search", search.NewHandler(pool, search.Options{ Nextcloud: ncClient, Engine: cfg.SearchEngine, MeilisearchURL: cfg.MeilisearchURL, MeilisearchKey: cfg.MeilisearchKey, MeilisearchIndex: cfg.MeilisearchIndex, TypesenseURL: cfg.TypesenseURL, TypesenseKey: cfg.TypesenseKey, TypesenseCollection: cfg.TypesenseCollection, }).Search) if driveHandler != nil { r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg, orgPolicyLoader).Routes()) r.Mount("/api/v1/contacts", contactsHandler.Routes()) } r.Mount("/api/v1/meet", meetapi.NewHandler( meetCfg, cfg.JitsiEnabled, cfg.JitsiPublicURL, orgPolicyLoader, pool, ncClient, cfg.MeetTranscriptWebhookSecret, ).Routes()) if photosClient != nil { r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) } }) }) slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect) return &App{ Router: r, Pool: pool, Redis: rdb, VerifierHolder: verifierHolder, MailOAuth: mailOAuthSvc, ownsPool: ownsPool, ownsRedis: ownsRedis, cancel: cancel, }, nil } // Close stops workers and releases owned connections. func (a *App) Close() { if a == nil { return } if a.cancel != nil { a.cancel() } if a.ownsPool && a.Pool != nil { a.Pool.Close() } if a.ownsRedis && a.Redis != nil { _ = a.Redis.Close() } } func closeOwned(ownsPool bool, pool *pgxpool.Pool, ownsRedis bool, rdb *redis.Client) { if ownsPool && pool != nil { pool.Close() } if ownsRedis && rdb != nil { _ = rdb.Close() } }