From f37de6c07cc153449e92b56004d39ca670d77470 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 6 Jun 2026 00:04:00 -0400 Subject: [PATCH] feat(router): add setup redirect middleware and setup routes Add setupRedirectMiddleware that checks the setup_complete system setting on every request. If setup is incomplete, all non-setup requests are redirected to /setup so the wizard is the first thing new users see. The check uses an in-memory cache (10s TTL) to avoid hitting the database on every request, with cache invalidation on setup completion. The middleware skips /setup, /api/*, /static/*, /health, and /favicon.ico so the wizard page, API calls, and static assets load normally during setup. Register two new routes: - GET /setup: renders the setup wizard SSR template - PUT /api/setup/complete: marks setup as complete (JWT-protected, requires an authenticated admin user created in step 1) --- internal/router/router.go | 4 ++ internal/router/setup.go | 118 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 internal/router/setup.go diff --git a/internal/router/router.go b/internal/router/router.go index 19e21e8..44a7176 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -188,6 +188,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { } e.Validator = &CustomValidator{validator: v} + // Setup redirect middleware - must run before all routes + e.Pre(setupRedirectMiddleware(cfg)) + // Rate limiter rateLimiterConfig := ratelimit.RateLimiterConfig{ Enabled: cfg.Cfg.RateLimitEnabled, @@ -206,6 +209,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler { cfg.ScannerHandler = scannerHandler // Register route groups + registerSetupRoutes(cfg) registerAuthRoutes(cfg, rateLimitMiddleware) registerLibraryRoutes(cfg) registerDeviceRoutes(cfg) diff --git a/internal/router/setup.go b/internal/router/setup.go new file mode 100644 index 0000000..d8e69a2 --- /dev/null +++ b/internal/router/setup.go @@ -0,0 +1,118 @@ +package router + +import ( + "bytes" + "context" + "errors" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "bookhoard/templates" + + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v5" +) + +var ( + setupCacheMu sync.RWMutex + setupCacheComplete bool = true + setupCacheExpiry time.Time + setupCacheTTL = 10 * time.Second +) + +func isSetupComplete(cfg *Config) bool { + setupCacheMu.RLock() + if time.Now().Before(setupCacheExpiry) { + complete := setupCacheComplete + setupCacheMu.RUnlock() + return complete + } + setupCacheMu.RUnlock() + + val, err := cfg.Queries.GetSystemSetting(context.Background(), "setup_complete") + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + setupCacheMu.Lock() + setupCacheComplete = false + setupCacheExpiry = time.Now().Add(setupCacheTTL) + setupCacheMu.Unlock() + return false + } + return true + } + + complete, err := strconv.ParseBool(val) + if err != nil { + complete = false + } + + setupCacheMu.Lock() + setupCacheComplete = complete + setupCacheExpiry = time.Now().Add(setupCacheTTL) + setupCacheMu.Unlock() + return complete +} + +func invalidateSetupCache() { + setupCacheMu.Lock() + setupCacheComplete = true + setupCacheExpiry = time.Time{} + setupCacheMu.Unlock() +} + +func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + path := c.Request().URL.Path + + if path == "/setup" || path == "/setup/" { + return next(c) + } + + if strings.HasPrefix(path, "/api/") { + return next(c) + } + + if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" { + return next(c) + } + + if !isSetupComplete(cfg) { + return c.Redirect(http.StatusFound, "/setup") + } + + return next(c) + } + } +} + +func registerSetupRoutes(cfg *Config) { + e := cfg.Echo + + e.GET("/setup", func(c *echo.Context) error { + if isSetupComplete(cfg) { + return c.Redirect(http.StatusFound, "/") + } + var buf bytes.Buffer + if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil { + log.Printf("Failed to render setup template: %v", err) + return c.HTML(http.StatusInternalServerError, "Failed to render setup page") + } + return c.HTML(http.StatusOK, buf.String()) + }) + + jwtMiddleware := createJWTMiddleware(cfg) + protected := e.Group("/api/setup", jwtMiddleware) + protected.PUT("/complete", func(c *echo.Context) error { + err := cfg.SystemSettingsHandler.SetSetupComplete(c) + if err != nil { + return err + } + invalidateSetupCache() + return nil + }) +}