ultisuite-backend/cmd/ultid/main.go
R3D347HR4Y 556d5f416d Enhance API and configuration for contact discovery and public sharing
- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts.
- Implemented retry logic for handling missing DAV credentials during contact operations.
- Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations.
- Updated Nextcloud configuration to support public share links and improved error handling for public share permissions.
- Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback.
- Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
2026-06-06 20:27:02 +02:00

321 lines
11 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"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/drive"
"github.com/ultisuite/ulti-backend/internal/api/office"
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
"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"
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
"github.com/ultisuite/ulti-backend/internal/authentik"
"github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/dbmigrate"
"github.com/ultisuite/ulti-backend/internal/config"
"github.com/ultisuite/ulti-backend/internal/envexpand"
"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/photos"
"github.com/ultisuite/ulti-backend/internal/realtime"
"github.com/ultisuite/ulti-backend/internal/search"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
loadDotEnv()
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
if err := dbmigrate.Up(cfg.DatabaseURL); err != nil {
slog.Error("database migration failed", "error", err)
os.Exit(1)
}
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()
authentik.StartProvisioner(ctx, pool, cfg)
rdb := redis.NewClient(&redis.Options{Addr: cfg.KeyDBAddr})
defer rdb.Close()
minioClient, err := minio.New(cfg.RustFSEndpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""),
Secure: cfg.RustFSUseSSL,
})
if err != nil {
slog.Error("failed to create RustFS client", "error", err)
os.Exit(1)
}
attachmentStorage := mailstorage.NewClient(minioClient, cfg.MailAttachmentsBucket)
if err := attachmentStorage.EnsureBucket(ctx); err != nil {
slog.Warn("mail attachments bucket check failed", "error", err)
}
verifier, err := auth.NewVerifierWithRetry(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second)
if err != nil {
slog.Warn("OIDC verifier not available at startup (Authentik may still be starting)", "error", err)
}
verifierHolder := auth.NewHolder(verifier)
if !verifierHolder.Ready() && cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
pending := auth.NewHolderPending(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain)
verifierHolder = pending
verifierHolder.StartBackgroundRetry(ctx, 5*time.Second)
}
if cfg.IsProduction() {
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
slog.Error("missing required OIDC configuration in production",
"ULTID_OIDC_ISSUER_set", cfg.OIDCIssuer != "",
"ULTID_OIDC_CLIENT_ID_set", cfg.OIDCClientID != "")
os.Exit(1)
}
if !verifierHolder.Ready() {
slog.Error("OIDC verifier initialization failed in production")
os.Exit(1)
}
if err := cfg.ValidateSecretRotation(); err != nil {
slog.Error("secret rotation policy check failed", "error", err)
os.Exit(1)
}
} 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 {
slog.Error("mail credential encryption not configured", "error", err)
os.Exit(1)
}
auditLogger := securityaudit.NewLogger(pool)
// Nextcloud client (nil if disabled)
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)
}
// Meet config (nil if disabled)
var meetCfg *meet.Config
if cfg.JitsiEnabled {
meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain)
slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain)
}
// Photos client (nil if disabled)
var photosClient *photos.Client
if cfg.ImmichEnabled {
photosClient = photos.NewClient(cfg.ImmichAPIURL)
slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL)
}
// WebSocket hub
hub := realtime.NewHub(verifierHolder, pool)
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
rulesEngine := rules.NewEngineWithWebhooks(pool, webhooks.NewExecutor(pool))
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)
// Start background workers
syncWorker := imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{
Storage: attachmentStorage,
AttachBucket: cfg.MailAttachmentsBucket,
Rules: rulesEngine,
Hub: hub,
})
go syncWorker.Start(ctx)
sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc)
smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown)
guardedSender := smtp.NewGuardedSender(sender, smtpCircuit)
go smtp.NewOutboxProcessor(
pool,
guardedSender,
cfg.MailOutboxInterval,
cfg.MailOutboxMaxRetries,
smtp.WithAttachmentLoader(&smtp.StorageAttachmentLoader{Client: attachmentStorage}),
).Start(ctx)
sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst)
mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender)
mailHandler.SetAccountSync(syncWorker)
// Router
r := chi.NewRouter()
r.Use(httpcors.Middleware(cfg))
r.Use(middleware.TraceID)
r.Use(observability.HTTPMetrics)
r.Use(middleware.Logging)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
report := healthChecker.Check(r.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
if ncClient != nil {
driveSvc = drive.NewService(ncClient, hub)
driveHandler = drive.NewHandler(ncClient, hub)
}
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.Mount("/api/v1/mail", mailHandler.Routes())
r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).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/drive", driveHandler.Routes())
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient, pool).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)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: r,
}
errCh := make(chan error, 1)
go func() {
slog.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case <-quit:
case err := <-errCh:
slog.Error("server error", "error", err)
}
slog.Info("shutting down server")
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("server forced shutdown", "error", err)
os.Exit(1)
}
slog.Info("server stopped")
}
func loadDotEnv() {
for _, path := range []string{".env", "../.env"} {
if err := envexpand.ApplyFile(path); err == nil {
slog.Debug("loaded env file", "path", path)
return
}
}
}