package main import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/cors" "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/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/calendar" "github.com/ultisuite/ulti-backend/internal/api/contacts" "github.com/ultisuite/ulti-backend/internal/api/drive" mailapi "github.com/ultisuite/ulti-backend/internal/api/mail" 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/auth" "github.com/ultisuite/ulti-backend/internal/config" "github.com/ultisuite/ulti-backend/internal/envexpand" mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap" "github.com/ultisuite/ulti-backend/internal/mail/smtp" "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) } 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() rdb := redis.NewClient(&redis.Options{Addr: cfg.KeyDBAddr}) defer rdb.Close() _, 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) } verifier, err := auth.NewVerifier(ctx, cfg.OIDCIssuer, cfg.OIDCClientID) if err != nil { slog.Warn("OIDC verifier not available (Authentik may not be running)", "error", err) } 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 verifier == nil { 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) slog.Info("nextcloud enabled", "url", cfg.NextcloudURL) } // 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() healthChecker := observability.NewHealthChecker(cfg, pool, rdb) // Start background workers go imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager).Start(ctx) sender := smtp.NewSender(pool, credentialManager) go smtp.NewOutboxProcessor(pool, sender, cfg.MailOutboxInterval).Start(ctx) // Router r := chi.NewRouter() r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Authorization", "Content-Type", apiresponse.TraceIDHeader}, ExposedHeaders: []string{apiresponse.TraceIDHeader}, AllowCredentials: false, MaxAge: 300, })) 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.Group(func(r chi.Router) { r.Use(middleware.Auth(verifier, auditLogger)) r.Mount("/api/v1/mail", mailapi.NewHandler(pool, auditLogger, credentialManager).Routes()) r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes()) r.Get("/api/v1/search", search.NewHandler(pool).Search) if ncClient != nil { r.Mount("/api/v1/drive", drive.NewHandler(ncClient).Routes()) r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient).Routes()) r.Mount("/api/v1/contacts", contacts.NewHandler(ncClient).Routes()) } if meetCfg != nil { r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) } if photosClient != nil { r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient).Routes()) } }) _ = rdb 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 } } }