- Add renderErrorPage helper for consistent error rendering - Add ensureUserExistsMiddleware to detect deleted users and redirect to login - Add catch-all 404 handler for unknown routes - Gracefully handle data loading failures with error messages instead of crashing - Log errors for debugging while still rendering pages
509 lines
15 KiB
Go
509 lines
15 KiB
Go
package router
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/templates"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func renderErrorPage(c echo.Context, message string, errorType string) error {
|
|
var buf bytes.Buffer
|
|
err := templates.ErrorPage(message, errorType).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
log.Printf("renderErrorPage failed to render template: %v", err)
|
|
return c.HTML(http.StatusInternalServerError, "Internal server error")
|
|
}
|
|
return c.HTML(http.StatusInternalServerError, buf.String())
|
|
}
|
|
|
|
func registerFrontendRoutes(cfg *Config) {
|
|
e := cfg.Echo
|
|
|
|
// ============================================================================
|
|
// 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.
|
|
// ============================================================================
|
|
|
|
// Use existing JWT middleware (sets database user object in context)
|
|
jwtMiddleware := createJWTMiddleware(cfg)
|
|
|
|
// Protected route group for API routes
|
|
protected := e.Group("/api", jwtMiddleware)
|
|
|
|
// ============================================================================
|
|
// PUBLIC FRONTEND ROUTES (No authentication required)
|
|
// ============================================================================
|
|
|
|
// Public routes for login and registration pages
|
|
e.GET("/login", func(c echo.Context) error {
|
|
var buf bytes.Buffer
|
|
sessionExpired := c.QueryParam("session") == "expired"
|
|
err := templates.Login(sessionExpired).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
|
|
var err error
|
|
|
|
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.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())
|
|
})
|
|
|
|
// ============================================================================
|
|
// PROTECTED FRONTEND ROUTES (Authentication required)
|
|
// ============================================================================
|
|
|
|
// Protected frontend routes (no /api prefix)
|
|
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
|
|
|
|
// Helper to extract text from pgtype.Text
|
|
getText := func(t pgtype.Text) string {
|
|
if t.Valid {
|
|
return t.String
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Dashboard page
|
|
frontendProtected.GET("/dashboard", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
|
|
libraryID := c.QueryParam("library_id")
|
|
if libraryID == "" {
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err == nil && len(libraries) > 0 {
|
|
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
libraryID = libUUID.String()
|
|
}
|
|
}
|
|
|
|
libUUID, _ := uuid.Parse(libraryID)
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
|
|
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
|
|
|
var sections []services.DashboardSection
|
|
sections, err = cfg.DashboardService.GetDashboardSections(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
libUUID,
|
|
int(prefs.ItemsPerSection.Int32),
|
|
prefs.CollectionOrder,
|
|
prefs.HiddenCollections,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Dashboard sections query failed: %v", err)
|
|
sections = []services.DashboardSection{}
|
|
errorMsg = "Error loading dashboard"
|
|
}
|
|
|
|
userUUID2, _ := uuid.Parse(user.ID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
if errorMsg == "" {
|
|
errorMsg = "Error loading libraries"
|
|
}
|
|
}
|
|
|
|
libData := make([]templates.LibraryData, len(libraries))
|
|
for i, lib := range libraries {
|
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
libData[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: getText(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
}
|
|
}
|
|
|
|
sectionData := handlers.BuildSections(sections)
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Dashboard(user, sectionData, libData, libraryID, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Collections page
|
|
frontendProtected.GET("/collections", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var collections []database.Collections
|
|
|
|
collections, err = cfg.CollectionHandler.GetCollectionsData(c)
|
|
if err != nil {
|
|
log.Printf("GetCollectionsData failed: %v", err)
|
|
collections = []database.Collections{}
|
|
errorMsg = "Error loading collections"
|
|
}
|
|
|
|
colData := make([]templates.CollectionData, len(collections))
|
|
for i, col := range collections {
|
|
colData[i] = templates.CollectionData{
|
|
ID: uuid.UUID(col.ID.Bytes).String(),
|
|
Name: col.Name,
|
|
Description: getText(col.Description),
|
|
Color: getText(col.Color),
|
|
Icon: getText(col.Icon),
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Collection(user, colData, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Custom Section Builder page
|
|
frontendProtected.GET("/custom-section", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var libraries []database.GetUserVisibleLibrariesRow
|
|
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
libraries, err = cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
errorMsg = "Error loading libraries"
|
|
}
|
|
|
|
libData := make([]templates.LibraryData, len(libraries))
|
|
for i, lib := range libraries {
|
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
libData[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: getText(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.CustomSectionBuilder(user, libData, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Progress page
|
|
frontendProtected.GET("/progress", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var progressData []handlers.ProgressWithMedia
|
|
|
|
progressData, err = cfg.ScannerHandler.GetAllProgressData(c)
|
|
if err != nil {
|
|
log.Printf("GetAllProgressData failed: %v", err)
|
|
progressData = []handlers.ProgressWithMedia{}
|
|
errorMsg = "Error loading progress"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Progress(user, progressData, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Devices page
|
|
frontendProtected.GET("/devices", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var devices []handlers.DeviceInfo
|
|
var pendingList []templates.PendingRegistrationData
|
|
|
|
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
|
if err != nil {
|
|
log.Printf("GetDevicesData failed: %v", err)
|
|
devices = []handlers.DeviceInfo{}
|
|
errorMsg = "Error loading devices"
|
|
}
|
|
|
|
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
|
if err != nil {
|
|
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
|
pendingList = []templates.PendingRegistrationData{}
|
|
if errorMsg == "" {
|
|
errorMsg = "Error loading pending registrations"
|
|
}
|
|
} else {
|
|
pendingList = convertPending(pendingMaps)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Devices(user, devices, pendingList, errorMsg, cfg.Cfg.BaseURL).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Conflicts page
|
|
frontendProtected.GET("/conflicts", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var conflictsData []handlers.ConflictDetailResponse
|
|
var total, unresolved int
|
|
|
|
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
|
|
if err != nil {
|
|
log.Printf("GetConflictsData failed: %v", err)
|
|
conflictsData = []handlers.ConflictDetailResponse{}
|
|
total = 0
|
|
unresolved = 0
|
|
errorMsg = "Error loading conflicts"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// Analytics page
|
|
frontendProtected.GET("/analytics", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
var buf bytes.Buffer
|
|
err = templates.Analytics(user).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// ============================================================================
|
|
// ADMIN FRONTEND ROUTES
|
|
// ============================================================================
|
|
|
|
// Admin routes
|
|
frontendProtected.GET("/admin", handlers.AdminMiddleware(func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
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())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/", handlers.AdminMiddleware(func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
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())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/profile", handlers.AdminMiddleware(func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
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())
|
|
}))
|
|
|
|
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
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())
|
|
}))
|
|
|
|
// ============================================================================
|
|
// LEGACY API ROUTES (for backward compatibility)
|
|
// ============================================================================
|
|
|
|
// Keep legacy routes under /api for existing API consumers
|
|
protected.GET("/devices-page", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
var errorMsg string
|
|
var devices []handlers.DeviceInfo
|
|
var pendingList []templates.PendingRegistrationData
|
|
|
|
devices, err = cfg.DeviceHandler.GetDevicesData(c)
|
|
if err != nil {
|
|
log.Printf("GetDevicesData failed: %v", err)
|
|
devices = []handlers.DeviceInfo{}
|
|
errorMsg = "Error loading devices"
|
|
}
|
|
|
|
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
|
|
if err != nil {
|
|
log.Printf("GetPendingRegistrationsData failed: %v", err)
|
|
pendingList = []templates.PendingRegistrationData{}
|
|
if errorMsg == "" {
|
|
errorMsg = "Error loading pending registrations"
|
|
}
|
|
} else {
|
|
pendingList = convertPending(pendingMaps)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Devices(user, devices, pendingList, errorMsg, cfg.Cfg.BaseURL).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
protected.GET("/conflicts-page", func(c echo.Context) error {
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
var errorMsg string
|
|
var conflictsData []handlers.ConflictDetailResponse
|
|
var total, unresolved int
|
|
|
|
conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c)
|
|
if err != nil {
|
|
log.Printf("GetConflictsData failed: %v", err)
|
|
conflictsData = []handlers.ConflictDetailResponse{}
|
|
total = 0
|
|
unresolved = 0
|
|
errorMsg = "Error loading conflicts"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = templates.Conflicts(user, conflictsData, total, unresolved, errorMsg).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
|
|
// ============================================================================
|
|
// HEALTH CHECK
|
|
// ============================================================================
|
|
|
|
// Health check
|
|
e.GET("/health", func(c echo.Context) error {
|
|
ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
if err := pingDB(cfg, 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",
|
|
})
|
|
})
|
|
}
|