package router import ( "bytes" "context" "log" "net/http" "strconv" "time" "bookhoard/internal/config" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" "bookhoard/internal/utils" "bookhoard/templates" "github.com/golang-jwt/jwt/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" "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" deleted := c.QueryParam("deleted") == "true" err := templates.Login(sessionExpired, deleted).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 "" } frontendProtected.GET("/bookshelf", 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 // Get library_id from query param or user's first library 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() } else { errorMsg = "No libraries available" } } // Get libraries for dropdown 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{} 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, } } // Fetch saved filters for SSR (using existing query) var savedFilters []database.SavedFilters if libraryID != "" && errorMsg == "" { savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, ResourceType: "media-items", }) if err != nil { log.Printf("GetSavedFilters failed: %v", err) savedFilters = []database.SavedFilters{} } } // Fetch first page of books for SSR var bookInfoList []handlers.BookInfo totalCount := 0 limit := 50 offset := 0 if libraryID != "" && errorMsg == "" { libUUID, err := uuid.Parse(libraryID) if err == nil { // Check URL params for pagination if limitStr := c.QueryParam("limit"); limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } if offsetStr := c.QueryParam("offset"); offsetStr != "" { if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 { offset = o } } // Convert user.ID (string) to pgtype.UUID for service layer userUUID, err := uuid.Parse(user.ID) if err != nil { log.Printf("Failed to parse user ID: %v", err) return renderErrorPage(c, "Error loading user", "user_id_error") } // Build search params (same as search.go:76-91) params := services.SearchParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, SearchQuery: "", // Empty for initial SSR load AuthorFilter: "", SeriesFilter: "", GenreFilter: "", TagsFilter: "", LanguageFilter: "", YearMin: 0, YearMax: 0, HasCover: false, Sort: "created_at DESC", Limit: limit, Offset: offset, } // Execute search using the same handler as API (search.go:93) var results []database.SearchMediaItemsUnifiedRow results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params) if err != nil { log.Printf("ExecuteSearch failed: %v", err) // Continue without books - will show empty state } else { // Convert to BookInfo (same as search.go:99-109) bookInfoList = make([]handlers.BookInfo, len(results)) for i, book := range results { bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16]) bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16]) bookInfoList[i] = handlers.BookInfo{ MediaItemID: bookUUID.String(), Title: book.Title, Author: textToString(book.Author), CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath), } } log.Printf("SSR: fetched %d books for library %s", len(bookInfoList), libraryID) } } } var buf bytes.Buffer err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // 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, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) if err != nil { log.Printf("GetDashboardPreferences failed: %v", err) prefs = database.UserDashboardPreferences{ HiddenCollections: []string{}, CollectionOrder: []string{}, ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true}, } } limit := 20 if prefs.ItemsPerSection.Int32 > 0 { limit = int(prefs.ItemsPerSection.Int32) } // Get ALL sections (unfiltered) for the modal allSections, err := cfg.DashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, limit, prefs.CollectionOrder, []string{}, // No filtering - get all sections ) if err != nil { log.Printf("Dashboard sections query failed: %v", err) allSections = []services.DashboardSection{} errorMsg = "Error loading dashboard" } // Get only visible sections for the dashboard display visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections) 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(visibleSections, libraryID) allSectionsData := handlers.BuildSections(allSections, libraryID) var buf bytes.Buffer err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, 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()) }) // Collection create modal frontendProtected.GET("/collections/create-modal", func(c *echo.Context) error { var buf bytes.Buffer err := templates.CollectionModal(templates.CollectionData{}).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // Collection edit modal frontendProtected.GET("/collections/:id/edit-modal", func(c *echo.Context) error { // Parse collection ID collectionID := c.Param("id") collUUID, err := uuid.Parse(collectionID) if err != nil { return c.HTML(http.StatusBadRequest, "
Invalid collection ID
") } // Fetch collection data collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) if err != nil { return c.HTML(http.StatusNotFound, "
Collection not found
") } // Convert to template type colData := templates.CollectionData{ ID: uuid.UUID(collection.ID.Bytes).String(), Name: collection.Name, Description: getText(collection.Description), Color: getText(collection.Color), Icon: getText(collection.Icon), } // Ensure default color if empty if colData.Color == "" { colData.Color = "blue" } var buf bytes.Buffer err = templates.CollectionModal(colData).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // Restore system collection modal frontendProtected.GET("/collections/restore-modal", func(c *echo.Context) error { var buf bytes.Buffer err := templates.RestoreSystemCollectionModal().Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // Collection detail page (works for both system and user collections) frontendProtected.GET("/collections/:id", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } // Parse collection ID from URL collectionID := c.Param("id") collUUID, err := uuid.Parse(collectionID) if err != nil { return renderErrorPage(c, "Invalid collection ID", "invalid_id") } // Fetch collection details collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) if err != nil { if err.Error() == "no rows in result set" { return renderErrorPage(c, "Collection not found", "not_found") } return renderErrorPage(c, "Error loading collection", "collection_load_error") } // Fetch books in collection userUUID, _ := uuid.Parse(user.ID) var books []handlers.BookInfo if collection.QueryType.Valid && collection.QueryType.String != "" { // System collection - use query type // System collection - need library_id for system collections // Get library_id from query param or default to user's first library libraryID := c.QueryParam("library_id") if libraryID == "" { 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) dashboardSvc := services.NewDashboardService(cfg.Queries) sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) if err != nil { return renderErrorPage(c, "Error loading books", "books_load_error") } // Find the matching section and convert items for _, section := range sections { if section.CollectionID.String() == collectionID { // Convert []database.MediaItems to []handlers.BookInfo bookCards := make([]handlers.BookInfo, len(section.Items)) for i, item := range section.Items { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = handlers.BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: getText(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), } } books = bookCards break } } } else { // User collection - check if library_id filter is present libraryID := c.QueryParam("library_id") if libraryID != "" { // Filter by library - reuse dashboard query libUUID, err := uuid.Parse(libraryID) if err != nil { return renderErrorPage(c, "Invalid library ID", "invalid_library_id") } // Use GetCollectionItemsForDashboard for library-filtered results collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{ CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, Limit: 1000, }) if err != nil { books = []handlers.BookInfo{} } else { // Convert to BookInfo format (non-excluded only) var validItems []database.GetCollectionItemsForDashboardRow for _, item := range collItems { if !item.Excluded.Valid || !item.Excluded.Bool { validItems = append(validItems, item) } } bookCards := make([]handlers.BookInfo, len(validItems)) for i, item := range validItems { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = handlers.BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: getText(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), } } books = bookCards } } else { // No library filter - show all books in collection collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) if err != nil { books = []handlers.BookInfo{} } // Convert to BookInfo format bookCards := make([]handlers.BookInfo, len(collItems)) for i, item := range collItems { itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16]) bookCards[i] = handlers.BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: getText(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), } } books = bookCards } } // Build collection data colData := templates.CollectionData{ ID: collectionID, Name: collection.Name, Description: collection.Description.String, Color: collection.Color.String, Icon: collection.Icon.String, } // Get library_id from query params for template libraryID := c.QueryParam("library_id") // Render the CollectionDetail template var buf bytes.Buffer err = templates.CollectionDetail(user, colData, books, libraryID).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) } // Get base URL from database config with fallback to config/env var baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) if baseURL == "" { baseURL = cfg.Cfg.BaseURL } var buf bytes.Buffer err = templates.Devices(user, devices, pendingList, errorMsg, 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()) }) // Profile page (all users) frontendProtected.GET("/profile", 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.Profile(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/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") } // Fetch all libraries (admin-only) libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context()) if err != nil { log.Printf("ListLibrariesData failed: %v", err) libraries = []database.ListLibrariesRow{} } // Convert to template LibraryData 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, } } // Fetch all users for visibility management users, err := cfg.Queries.ListUsers(c.Request().Context()) if err != nil { log.Printf("ListUsers failed: %v", err) users = []database.ListUsersRow{} } // Convert to template User types userData := make([]templates.User, len(users)) for i, u := range users { userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16]) userData[i] = templates.User{ ID: userUUID.String(), Username: u.Username, Email: u.Email, Role: u.Role, } } var buf bytes.Buffer err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) })) // Admin users page frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } // Fetch all users users, err := cfg.Queries.ListUsers(c.Request().Context()) if err != nil { return renderErrorPage(c, "Error loading users", "users_load_error") } // Count admins for UI protection adminCount := 0 for _, u := range users { if u.Role == "admin" { adminCount++ } } // Convert to template users templateUsers := make([]templates.User, len(users)) for i, u := range users { templateUsers[i] = templates.User{ ID: uuid.UUID(u.ID.Bytes).String(), Username: u.Username, Email: u.Email, Role: u.Role, Theme: getText(u.Theme), FirstName: getText(u.FirstName), LastName: getText(u.LastName), CreatedAt: u.CreatedAt.Time, } } var buf bytes.Buffer err = templates.AdminUsers(user, templateUsers, adminCount).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) })) // Admin: Get profile modal for editing user frontendProtected.GET("/admin/settings", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } // Fetch current system configuration - just base_url baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) if baseURL == "" { baseURL = cfg.Cfg.BaseURL } systemConfig := map[string]string{ "base_url": baseURL, } var buf bytes.Buffer err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) })) frontendProtected.GET("/admin/users/:id/profile-modal", handlers.AdminMiddleware(func(c *echo.Context) error { // Get target user ID from URL targetUserID := c.Param("id") parsedUUID, err := uuid.Parse(targetUserID) if err != nil { return c.HTML(http.StatusBadRequest, "
Invalid user ID
") } // Fetch target user targetUser, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(parsedUUID)) if err != nil { return c.HTML(http.StatusNotFound, "
User not found
") } // Convert to template user templateUser := templates.User{ ID: uuid.UUID(targetUser.ID.Bytes).String(), Username: targetUser.Username, Email: targetUser.Email, Role: targetUser.Role, Theme: getText(targetUser.Theme), FirstName: getText(targetUser.FirstName), LastName: getText(targetUser.LastName), CreatedAt: targetUser.CreatedAt.Time, } // Render modal var buf bytes.Buffer err = templates.ProfileModal(templateUser).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) } // Get base URL from database config with fallback to config/env var baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) if baseURL == "" { baseURL = cfg.Cfg.BaseURL } var buf bytes.Buffer err = templates.Devices(user, devices, pendingList, errorMsg, 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()) }) e.GET("/health", cfg.GetHealth) } // ============================================================================ // HEALTH CHECK // ============================================================================ // Health check func (cfg *Config) GetHealth(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": err.Error(), }) } return c.JSON(http.StatusOK, map[string]interface{}{ "status": "healthy", "database": "connected", "scan": map[string]interface{}{ "scan_in_progress": cfg.Worker.HasActiveScans(), "active_jobs": cfg.Worker.GetActiveJobCount(), }, }) }