- Added a new handler for completing authentication flows, including session validation and cookie management. - Implemented flow rate limiting to restrict the number of flow start requests per client IP. - Enhanced flow session management with Redis support for persistent session storage. - Updated existing handlers to integrate the new flow completion logic and error handling for various session states. - Introduced unit tests for the new flow completion and rate limiting functionalities to ensure reliability.
527 lines
20 KiB
Go
527 lines
20 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"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"
|
|
authapi "github.com/ultisuite/ulti-backend/internal/api/auth"
|
|
"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/devices"
|
|
"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"
|
|
migrationapi "github.com/ultisuite/ulti-backend/internal/api/migration"
|
|
"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/contacts/discovery"
|
|
"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/hosted"
|
|
"github.com/ultisuite/ulti-backend/internal/mail/stalwart"
|
|
"github.com/ultisuite/ulti-backend/internal/migration"
|
|
"github.com/ultisuite/ulti-backend/internal/provision"
|
|
"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/push"
|
|
"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)
|
|
|
|
pushDispatcher := push.NewDispatcher(pool, push.Config{
|
|
FCMServiceAccountJSON: cfg.FCMServiceAccountJSON,
|
|
FCMProjectID: cfg.FCMProjectID,
|
|
APNSPrivateKey: cfg.APNSPrivateKey,
|
|
APNSKeyID: cfg.APNSKeyID,
|
|
APNSTeamID: cfg.APNSTeamID,
|
|
APNSBundleID: cfg.APNSBundleID,
|
|
APNSProduction: cfg.APNSProduction,
|
|
})
|
|
|
|
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)
|
|
|
|
stlwClient := stalwart.NewClient(stalwart.Config{
|
|
Enabled: cfg.StalwartEnabled,
|
|
BaseURL: cfg.StalwartAPIURL,
|
|
APIKey: cfg.StalwartAPIKey,
|
|
IMAPHost: cfg.StalwartIMAPHost,
|
|
IMAPPort: cfg.StalwartIMAPPort,
|
|
IMAPTLS: cfg.StalwartIMAPTLS,
|
|
SMTPHost: cfg.StalwartSMTPHost,
|
|
SMTPPort: cfg.StalwartSMTPPort,
|
|
SMTPTLS: cfg.StalwartSMTPTLS,
|
|
})
|
|
hostedSvc := hosted.NewService(pool, stlwClient, credentialManager)
|
|
if cfg.PlatformMailDomain != "" {
|
|
if _, err := hostedSvc.EnsurePlatformDomain(workerCtx, cfg.PlatformMailDomain); err != nil {
|
|
slog.Warn("platform mail domain bootstrap failed", "domain", cfg.PlatformMailDomain, "error", err)
|
|
}
|
|
}
|
|
|
|
migrationOAuthRedirect := cfg.MigrationOAuthRedirectURL
|
|
if migrationOAuthRedirect == "" {
|
|
migrationOAuthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/migration/oauth/callback", cfg.Port)
|
|
if cfg.Domain != "" && cfg.Domain != "localhost" {
|
|
migrationOAuthRedirect = fmt.Sprintf("https://%s/api/v1/migration/oauth/callback", cfg.Domain)
|
|
}
|
|
}
|
|
migrationOAuthSvc := migration.NewOAuthService(migration.OAuthConfig{
|
|
GoogleClientID: firstNonEmpty(cfg.MigrationGoogleOAuthClientID, cfg.MailGoogleOAuthClientID),
|
|
GoogleClientSecret: firstNonEmpty(cfg.MigrationGoogleOAuthClientSecret, cfg.MailGoogleOAuthClientSecret),
|
|
MicrosoftClientID: firstNonEmpty(cfg.MigrationMicrosoftOAuthClientID, cfg.MailMicrosoftOAuthClientID),
|
|
MicrosoftSecret: firstNonEmpty(cfg.MigrationMicrosoftOAuthSecret, cfg.MailMicrosoftOAuthSecret),
|
|
MicrosoftTenant: firstNonEmpty(cfg.MigrationMicrosoftOAuthTenant, cfg.MailMicrosoftOAuthTenant),
|
|
RedirectURL: migrationOAuthRedirect,
|
|
}, rdb)
|
|
migrationSvc := migration.NewService(pool, rdb, credentialManager, hostedSvc, migrationOAuthSvc)
|
|
migrationSvc.SetCutoverConfig(migration.CutoverConfig{
|
|
ExpectedMXHosts: migration.ParseCutoverMXHosts(
|
|
cfg.MigrationCutoverMXHosts,
|
|
cfg.PlatformMailDomain,
|
|
cfg.StalwartIMAPHost,
|
|
),
|
|
RequireMX: cfg.MigrationCutoverRequireMX,
|
|
})
|
|
googleDWD, err := migration.NewGoogleDWD(cfg.MigrationGoogleServiceAccountJSON)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("google dwd: %w", err)
|
|
}
|
|
microsoftApp, err := migration.NewMicrosoftApp(migration.MicrosoftAppConfig{
|
|
ClientID: firstNonEmpty(cfg.MigrationMicrosoftOAuthClientID, cfg.MailMicrosoftOAuthClientID),
|
|
ClientSecret: firstNonEmpty(cfg.MigrationMicrosoftOAuthSecret, cfg.MailMicrosoftOAuthSecret),
|
|
DefaultTenant: firstNonEmpty(cfg.MigrationMicrosoftOAuthTenant, cfg.MailMicrosoftOAuthTenant),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("microsoft app: %w", err)
|
|
}
|
|
migration.ConfigureRateLimit(migration.RateLimitConfig{
|
|
MaxRetries: cfg.MigrationRateLimitMaxRetries,
|
|
BaseDelay: cfg.MigrationRateLimitBaseDelay,
|
|
MaxDelay: cfg.MigrationRateLimitMaxDelay,
|
|
})
|
|
migration.ConfigureImportBatch(migration.ImportBatchConfig{
|
|
Mail: cfg.MigrationImportBatchSize,
|
|
Drive: cfg.MigrationDriveBatchSize,
|
|
})
|
|
if !opts.WithoutWorkers {
|
|
go migration.NewWorker(
|
|
pool, migrationSvc, migrationOAuthSvc, credentialManager, googleDWD, microsoftApp, ncClient,
|
|
attachmentStorage, cfg.MailAttachmentsBucket,
|
|
migration.WorkerConfig{
|
|
Concurrency: cfg.MigrationWorkerConcurrency,
|
|
JobLimit: cfg.MigrationWorkerJobLimit,
|
|
},
|
|
).Start(workerCtx, cfg.MigrationWorkerInterval)
|
|
}
|
|
|
|
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,
|
|
Push: pushDispatcher,
|
|
})
|
|
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.SetHostedService(hostedSvc)
|
|
mailHandler.SetHostedPlatformDomain(cfg.PlatformMailDomain)
|
|
migrationHandler := migrationapi.NewHandler(migrationSvc, migrationOAuthSvc, cfg.MailAppURL)
|
|
provisionHandler := provision.NewHandler(cfg.ProvisionWebhookSecret, cfg.PlatformMailDomain, hostedSvc, ncClient, pool)
|
|
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)
|
|
r.Get("/api/v1/migration/oauth/callback", migrationHandler.OAuthCallback)
|
|
r.Get("/api/v1/mail/addresses/check", mailHandler.CheckAddressAvailability)
|
|
r.Get("/api/v1/migration/invite", migrationHandler.GetInvite)
|
|
r.Post("/internal/provision/user", provisionHandler.ProvisionUser)
|
|
r.Mount("/api/v1/auth", authapi.NewHandler(cfg.AuthentikAPIURL, cfg.MailAppURL, rdb).Routes())
|
|
|
|
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, cfg).Routes())
|
|
r.Mount("/api/v1/devices", devices.NewHandler(pool).Routes())
|
|
adminHandler := admin.NewHandler(pool, auditLogger, cfg, ncClient)
|
|
adminHandler.SetHostedService(hostedSvc)
|
|
adminHandler.SetMigrationService(migrationSvc)
|
|
r.Mount("/api/v1/admin", adminHandler.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.Mount("/api/v1/migration", migrationHandler.Routes())
|
|
searchHandler := 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,
|
|
})
|
|
r.Get("/api/v1/search", searchHandler.Search)
|
|
webDiscovery := discovery.NewService(pool)
|
|
if contactsHandler != nil && contactsHandler.Discovery() != nil {
|
|
webDiscovery = contactsHandler.Discovery()
|
|
}
|
|
r.Get("/api/v1/search/web", search.NewWebHandler(webDiscovery).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()
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|