ultisuite-backend/internal/server/bootstrap.go
R3D347HR4Y b90edf317c
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled
feat(scan): add VirusTotal upload antivirus
Admin-stored API key with env fallback; scan drive/mail/IMAP uploads.
Fail-open if VT down, 422 on malware; migration for virus_scan_status.
2026-06-07 22:05:27 +02:00

368 lines
12 KiB
Go

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"
"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"
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)))
driveHandler.SetPublicOffice(officeHandler)
}
if driveHandler != nil {
r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes())
}
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(verifierHolder, pool, auditLogger))
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).Routes())
r.Mount("/api/v1/contacts", contactsHandler.Routes())
}
if meetCfg != nil {
r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).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()
}
}