package router import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/templates" "bytes" "context" "fmt" "log" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v5" ) func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) { g := frontendProtected.Group("", handlers.AdminMiddleware) // HTMX: Create library g.POST("/admin/library/create", func(c *echo.Context) error { user := c.Get("user").(database.Users) name := c.FormValue("name") desc := c.FormValue("description") libType := c.FormValue("type") if name == "" || libType == "" { return c.HTML(http.StatusBadRequest, `
Name and type are required
`) } _, err := cfg.LibraryService.CreateLibrary( c.Request().Context(), name, desc, libType, user.ID, ) if err != nil { return c.HTML(http.StatusInternalServerError, `
Failed to create library
`) } return renderLibraryList(c, cfg) }) // HTMX: Update library g.PUT("/admin/library/:id", func(c *echo.Context) error { libraryID, err := parseAdminUUID(c.Param("id")) if err != nil { return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) } name := c.FormValue("name") desc := c.FormValue("description") if name == "" { return c.HTML(http.StatusBadRequest, `
Name is required
`) } _, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc) if err != nil { return c.HTML(http.StatusInternalServerError, `
Failed to update library
`) } return renderLibraryList(c, cfg) }) // HTMX: Delete library g.DELETE("/admin/library/:id", func(c *echo.Context) error { libraryID, err := parseAdminUUID(c.Param("id")) if err != nil { return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) } err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID) if err != nil { return c.HTML(http.StatusInternalServerError, `
Failed to delete library
`) } return renderLibraryList(c, cfg) }) // HTMX: Library expanded panel g.GET("/admin/library/:id/panel", func(c *echo.Context) error { libraryID, err := parseAdminUUID(c.Param("id")) if err != nil { return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) } return renderLibraryPanel(c, cfg, libraryID) }) // HTMX: Add folder g.POST("/admin/library/:id/folders", func(c *echo.Context) error { libraryID, err := parseAdminUUID(c.Param("id")) if err != nil { return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) } folderPath := c.FormValue("folder_path") if folderPath == "" { return renderLibraryPanel(c, cfg, libraryID) } if strings.Contains(folderPath, "..") { return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed") } cleanPath := filepath.Clean(folderPath) fileInfo, err := os.Stat(cleanPath) if err != nil { return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist") } if !fileInfo.IsDir() { return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory") } _, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath) if err != nil { return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error()) } return renderLibraryPanel(c, cfg, libraryID) }) // HTMX: Remove folder g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error { libraryID, err := parseAdminUUID(c.Param("id")) if err != nil { return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) } folderPath := c.FormValue("folder_path") if folderPath == "" { return renderLibraryPanel(c, cfg, libraryID) } err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath) if err != nil { return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder") } return renderLibraryPanel(c, cfg, libraryID) }) // HTMX: Folder browser g.GET("/admin/library/browse", func(c *echo.Context) error { path := c.QueryParam("path") if path == "" { path = "/" } targetInput := c.QueryParam("target_input") libraryID := c.QueryParam("library_id") dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path) if err != nil { return c.HTML(http.StatusBadRequest, `
Cannot browse: `+err.Error()+`
`) } entries := make([]templates.DirEntry, len(dirs)) for i, d := range dirs { fullPath := filepath.Join(currentPath, d) entries[i] = templates.DirEntry{Name: d, Path: fullPath} } var buf bytes.Buffer err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // HTMX: Set user visibility for library g.POST("/admin/library/:id/visibility", func(c *echo.Context) error { libraryID, err := parseAdminUUID(c.Param("id")) if err != nil { return c.HTML(http.StatusBadRequest, `
Invalid library ID
`) } userIDStr := c.FormValue("user_id") isVisible := c.FormValue("is_visible") == "true" userID, err := parseAdminUUID(userIDStr) if err != nil { return c.HTML(http.StatusBadRequest, `
Invalid user ID
`) } _, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible) if err != nil { return c.HTML(http.StatusInternalServerError, `
Failed to update visibility
`) } return renderLibraryPanel(c, cfg, libraryID) }) } // renderLibraryList fetches all libraries + users and renders the LibraryList partial. func renderLibraryList(c *echo.Context, cfg *Config) error { libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context()) if err != nil { return c.HTML(http.StatusInternalServerError, `
Failed to load libraries
`) } libData := make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID) libData[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: getText(lib.Description), TypeName: lib.TypeName, TypeValue: lib.TypeName, FolderCount: folderCount, } } users, err := cfg.Queries.ListUsers(c.Request().Context()) if err != nil { log.Printf("ListUsers failed: %v", err) users = []database.ListUsersRow{} } 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.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) } // renderLibraryPanel fetches library details and renders the LibraryPanel partial. func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error { return renderLibraryPanelWithError(c, cfg, libraryID, "") } func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error { ctx := c.Request().Context() libraryIDStr := uuid.UUID(libraryID.Bytes).String() // Get library details lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID) if err != nil { return c.HTML(http.StatusNotFound, `
Library not found
`) } libData := templates.LibraryData{ ID: libraryIDStr, Name: lib.Name, Description: getText(lib.Description), TypeName: lib.TypeName, TypeValue: lib.TypeName, } // Get folders dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID) if err != nil { log.Printf("GetLibraryFolders failed: %v", err) } folders := make([]templates.FolderData, len(dbFolders)) for i, f := range dbFolders { folders[i] = templates.FolderData{FolderPath: f.FolderPath} } libData.FolderCount = len(folders) // Get users dbUsers, err := cfg.Queries.ListUsers(ctx) if err != nil { log.Printf("ListUsers failed: %v", err) dbUsers = []database.ListUsersRow{} } userData := make([]templates.User, len(dbUsers)) for i, u := range dbUsers { userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16]) userData[i] = templates.User{ ID: userUUID.String(), Username: u.Username, Email: u.Email, } } // Get visibility for all users visibility := make([]templates.UserVisibilityData, len(userData)) for i, u := range userData { userUUID, _ := parseAdminUUID(u.ID) visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID) if err != nil { log.Printf("GetUserVisibleLibraries failed: %v", err) } isVisible := false for _, vl := range visibleLibs { if vl.ID.Bytes == libraryID.Bytes { isVisible = true break } } visibility[i] = templates.UserVisibilityData{ UserID: u.ID, Username: u.Username, Email: u.Email, IsVisible: isVisible, } } // Get issue count issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID) if err != nil { log.Printf("GetProcessingIssueStats failed: %v", err) } issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount // Get current user for template tmplUser := templates.User{} if u, ok := c.Get("user").(database.Users); ok { userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16]) tmplUser = templates.User{ ID: userUUID.String(), Username: u.Username, Role: u.Role, } } var buf bytes.Buffer err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf) if err != nil { return err } html := buf.String() if errMsg != "" { html = `
` + errMsg + `
` + html } return c.HTML(http.StatusOK, html) } func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int { folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID) if err != nil { return 0 } return len(folders) } func parseAdminUUID(s string) (pgtype.UUID, error) { parsed, err := uuid.Parse(s) if err != nil { return pgtype.UUID{}, err } return pgtype.UUID{Bytes: parsed, Valid: true}, nil } func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats { stats := templates.AdminStats{} libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx) stats.LibraryCount = len(libs) users, _ := cfg.Queries.ListUsers(ctx) stats.UserCount = len(users) if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok { _ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount) _ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount) } return stats } func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) { g := frontendProtected.Group("", handlers.AdminMiddleware) g.PUT("/admin/settings/scan", func(c *echo.Context) error { ctx := c.Request().Context() autoScan := c.FormValue("auto_scan_enabled") == "true" intervalStr := c.FormValue("scan_poll_interval_seconds") interval, err := strconv.Atoi(intervalStr) if err != nil || interval < 1 || interval > 3600 { return c.HTML(http.StatusBadRequest, `
Interval must be between 1 and 3600 seconds
`) } autoScanStr := "false" if autoScan { autoScanStr = "true" } _ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{ SettingKey: "auto_scan_enabled", SettingValue: autoScanStr, }) _ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{ SettingKey: "scan_poll_interval_seconds", SettingValue: strconv.Itoa(interval), }) // Refresh the registry cache so the change is visible immediately. if cfg.Settings != nil { cfg.Settings.Reload(ctx) } scanSettings := templates.ScanSettingsData{ AutoScanEnabled: autoScan, ScanPollIntervalSeconds: interval, } var buf bytes.Buffer _ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf) return c.HTML(http.StatusOK, buf.String()) }) // HTMX endpoint for saving a single tunable setting. Returns a small HTML // status snippet rendered into the row's status span. g.PUT("/admin/settings/tunable", func(c *echo.Context) error { ctx := c.Request().Context() key := c.FormValue("key") value := c.FormValue("value") if cfg.SystemSettingsHandler == nil { return c.HTML(http.StatusServiceUnavailable, `settings unavailable`) } resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value) if err != nil { return c.HTML(http.StatusBadRequest, fmt.Sprintf(`%s`, err.Error())) } color := "var(--status-success)" msg := "Saved" if resp.ReloadRequired { color = "var(--status-warning)" msg = "Saved — restart required" } return c.HTML(http.StatusOK, fmt.Sprintf(`%s`, color, msg)) }) }