// SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "kode.naiv.no/olemd/favoritter/internal/config" "kode.naiv.no/olemd/favoritter/internal/database" "kode.naiv.no/olemd/favoritter/internal/handler" "kode.naiv.no/olemd/favoritter/internal/handler/api" "kode.naiv.no/olemd/favoritter/internal/middleware" "kode.naiv.no/olemd/favoritter/internal/render" "kode.naiv.no/olemd/favoritter/internal/store" ) var ( version = "dev" buildDate = "unknown" ) func main() { // Handle flags and subcommands before starting the server. if len(os.Args) > 1 { switch os.Args[1] { case "-healthcheck": runHealthCheck() return case "-version": fmt.Printf("favoritter %s (built %s)\n", version, buildDate) return case "user": runUserCommand(os.Args[2:]) return } } cfg := config.Load() // Set up structured logging. logLevel := slog.LevelInfo if cfg.DevMode { logLevel = slog.LevelDebug } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) slog.SetDefault(logger) slog.Info("starting favoritter", "version", version, "build_date", buildDate) // Open database and run migrations. db, err := database.Open(cfg.DBPath) if err != nil { slog.Error("failed to open database", "error", err) os.Exit(1) } defer db.Close() if err := database.Migrate(db); err != nil { slog.Error("failed to run migrations", "error", err) os.Exit(1) } // Initialize stores. users := store.NewUserStore(db) sessions := store.NewSessionStore(db) settings := store.NewSettingsStore(db) faves := store.NewFaveStore(db) tags := store.NewTagStore(db) signupRequests := store.NewSignupRequestStore(db) sessions.SetLifetime(cfg.SessionLifetime) // Ensure initial admin user exists. if err := users.EnsureAdmin(cfg.AdminUsername, cfg.AdminPassword); err != nil { slog.Error("failed to ensure admin user", "error", err) os.Exit(1) } // Initialize template renderer. renderer, err := render.New(cfg) if err != nil { slog.Error("failed to initialize templates", "error", err) os.Exit(1) } // Configure Argon2 parameters from config. store.Argon2Memory = cfg.Argon2Memory store.Argon2Time = cfg.Argon2Time store.Argon2Parallelism = cfg.Argon2Parallelism // Build the handler with all dependencies. h := handler.New(handler.Deps{ Config: cfg, Users: users, Sessions: sessions, Settings: settings, Faves: faves, Tags: tags, SignupRequests: signupRequests, Renderer: renderer, }) // Register JSON API routes on the same mux. apiHandler := api.New(api.Deps{ Config: cfg, Users: users, Sessions: sessions, Faves: faves, Tags: tags, }) // Build the middleware chain. mux := h.Routes() apiHandler.Routes(mux) chain := middleware.Chain( mux, middleware.Recovery, middleware.SecurityHeaders, middleware.BasePath(cfg.BasePath), middleware.RealIP(cfg.TrustedProxies), middleware.RequestLogger, middleware.SessionLoader(sessions, users), middleware.CSRFProtection(cfg), middleware.MustResetPasswordGuard(cfg.BasePath), ) srv := &http.Server{ Addr: cfg.Listen, Handler: chain, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } // Start background cleanup goroutines. ctx, cancel := context.WithCancel(context.Background()) defer cancel() go sessions.CleanupLoop(ctx, 1*time.Hour) go h.RateLimiterCleanupLoop(ctx, 5*time.Minute) // Start the server in a goroutine. errCh := make(chan error, 1) go func() { slog.Info("listening", "addr", cfg.Listen, "base_path", cfg.BasePath) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() // Wait for interrupt signal or server error. quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) select { case sig := <-quit: slog.Info("shutting down", "signal", sig) case err := <-errCh: slog.Error("server error", "error", err) } // Graceful shutdown with 10-second timeout. cancel() shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("shutdown error", "error", err) } slog.Info("stopped") } // runHealthCheck performs a health check by hitting the local health endpoint. // It reads the configured listen address to determine the correct port. func runHealthCheck() { cfg := config.Load() addr := cfg.Listen // If addr is just ":port", use localhost. if len(addr) > 0 && addr[0] == ':' { addr = "127.0.0.1" + addr } resp, err := http.Get("http://" + addr + cfg.BasePath + "/health") if err != nil { os.Exit(1) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { os.Exit(1) } }