From 2a7338200cd5ffc8a6f1c70c081ecad1836ef746 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 6 Feb 2026 10:52:11 -0500 Subject: [PATCH] feat: add health check and restore frontend routes Health check endpoint: - Add /health endpoint that pings database with 2-second timeout - Returns 200 when DB connected, 503 when unavailable - Provides true end-to-end health verification Frontend routes restoration (routes removed in c5f327b): - Add public routes: /, /login, /register with smart auth detection - Add redirect routes: /bookshelf, /dashboard - Add admin routes: /admin, /admin/profile, /admin/library - Add SSR routes: /api/devices-page, /api/conflicts-page - Add 'FRONTEND ROUTES - DO NOT DELETE' comment block to prevent future removal Docker Compose healthcheck: - Update to use curl on /health endpoint (pg_isready not in Alpine) - Add 10s start_period for app initialization - Accurately reflects app + database health status All changes maintain backward compatibility and existing API behavior. --- cmd/server/main.go | 222 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 5 +- 2 files changed, 225 insertions(+), 2 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 11d8736..cc6acbe 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -623,6 +623,228 @@ func main() { return c.HTML(http.StatusOK, buf.String()) }) + // ============================================================================ + // FRONTEND ROUTES - DO NOT DELETE + // These routes serve Server-Side Rendered (SSR) HTML pages for the web UI. + // They are NOT API endpoints and should NOT be removed during refactors. + // All authenticated frontend routes use the jwtMiddleware to validate tokens. + // ============================================================================ + + // Public routes for login and registration pages (no auth required) + e.GET("/login", func(c echo.Context) error { + var buf bytes.Buffer + err := templates.Login().Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + e.GET("/register", func(c echo.Context) error { + var buf bytes.Buffer + err := templates.Register().Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Root route - landing page with smart login detection + e.GET("/", func(c echo.Context) error { + var buf bytes.Buffer + + tokenString := c.Request().Header.Get("Authorization") + if tokenString != "" && len(tokenString) > 7 && tokenString[:7] == "Bearer " { + tokenString = tokenString[7:] + } else { + cookie, err := c.Cookie("token") + if err == nil { + tokenString = cookie.Value + } + } + + loggedIn := false + if tokenString != "" { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(cfg.JWTSecret), nil + }) + loggedIn = err == nil && token.Valid + } + + err = templates.Index(loggedIn).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Public redirect routes - convenience shortcuts to authenticated routes + e.GET("/bookshelf", func(c echo.Context) error { + return c.Redirect(http.StatusTemporaryRedirect, "/api/bookshelf") + }) + + e.GET("/dashboard", func(c echo.Context) error { + return c.Redirect(http.StatusTemporaryRedirect, "/api/bookshelf") + }) + + // Admin area routes (authenticated, admin role required, SSR) + e.GET("/admin", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, queries) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + var buf bytes.Buffer + err = templates.Admin(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + e.GET("/admin/", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, queries) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + var buf bytes.Buffer + err = templates.Admin(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + e.GET("/admin/profile", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, queries) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + var buf bytes.Buffer + err = templates.AdminProfile(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + e.GET("/admin/library", handlers.AdminMiddleware(func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, queries) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + var buf bytes.Buffer + err = templates.AdminLibrary(user).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + })) + + // Devices management page (authenticated SSR route) + protected.GET("/devices-page", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, queries) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + deviceData, err := deviceHandler.GetDevicesData(c) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading devices") + } + + pendingData, err := deviceHandler.GetPendingRegistrationsData(c) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading pending registrations") + } + + devicesList := make([]templates.DeviceData, len(deviceData)) + for i, d := range deviceData { + lastSync := "" + if d.LastSync != nil { + lastSync = d.LastSync.Format("2006-01-02T15:04:05Z07:00") + } + lastSeen := "" + if d.LastSeen != nil { + lastSeen = d.LastSeen.Format("2006-01-02T15:04:05Z07:00") + } + + devicesList[i] = templates.DeviceData{ + ID: d.ID.String(), + DeviceName: d.DeviceName, + DeviceType: d.DeviceType, + SyncEnabled: d.SyncEnabled, + LastSync: lastSync, + LastSeen: lastSeen, + } + } + + pendingList := make([]templates.PendingRegistrationData, len(pendingData)) + for i, p := range pendingData { + pendingList[i] = templates.PendingRegistrationData{ + RegistrationID: p["registration_id"].(string), + DeviceName: p["device_name"].(string), + DeviceType: p["device_type"].(string), + ExpiresAt: p["expires_at"].(string), + } + } + + var buf bytes.Buffer + err = templates.Devices(user, devicesList, pendingList).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Conflicts management page (authenticated SSR route) + protected.GET("/conflicts-page", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, queries) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + conflictsData, total, unresolved, err := conflictHandler.GetConflictsData(c) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading conflicts") + } + + var buf bytes.Buffer + err = templates.Conflicts(user, conflictsData, total, unresolved).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // ============================================================================ + // HEALTH CHECK (public - no authentication required) + // ============================================================================ + + e.GET("/health", func(c echo.Context) error { + ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second) + defer cancel() + + if err := dbPool.Ping(ctx); err != nil { + return c.JSON(http.StatusServiceUnavailable, map[string]string{ + "status": "unhealthy", + "error": "database unavailable", + }) + } + + return c.JSON(http.StatusOK, map[string]string{ + "status": "healthy", + "database": "connected", + }) + }) + + // ============================================================================ + // DOCUMENTATION ROUTES (public - no authentication required) + // ============================================================================ + // Documentation routes (no authentication required) docsHandler := docs.NewHTTPHandler("docs") e.GET("/docs", docsHandler.DocsHome) diff --git a/docker-compose.yml b/docker-compose.yml index f232178..aa8d465 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,10 +53,11 @@ services: - ./uploads:/app/uploads - bookhoard_conversion_cache:/app/cache/kepub healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s + test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"] + interval: 10s timeout: 5s retries: 3 + start_period: 10s # Named Volumes volumes: