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)
This commit is contained in:
2026-06-06 00:04:00 -04:00
parent cea8e64da2
commit f37de6c07c
2 changed files with 122 additions and 0 deletions
+118
View File
@@ -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
})
}