feat(backend): add server-side directory browsing API

- Add BrowseDirectories() to library service with path traversal protection
- Add BrowseDirectories handler with proper error handling
- Register GET /api/libraries/browse endpoint (admin-only)
- Returns current path, parent path, and list of subdirectories
- Security: blocks "..", validates path exists, checks is directory

Fixes: Issue 2 (backend)
This commit is contained in:
2026-02-23 17:02:52 -05:00
parent c333c82c6b
commit 2af035d87f
3 changed files with 67 additions and 0 deletions
+19
View File
@@ -250,6 +250,25 @@ func (h *LibraryHandler) DeleteLibraryFolder(c echo.Context) error {
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
} }
// BrowseDirectories returns directory listings for folder browser UI
func (h *LibraryHandler) BrowseDirectories(c echo.Context) error {
path := c.QueryParam("path")
if path == "" {
path = "/" // Start from root
}
dirs, currentPath, parentPath, err := h.libraryService.BrowseDirectories(c.Request().Context(), path)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"current_path": currentPath,
"parent_path": parentPath,
"directories": dirs,
})
}
// SetLibraryVisibility sets library visibility for a user // SetLibraryVisibility sets library visibility for a user
func (h *LibraryHandler) SetLibraryVisibility(c echo.Context) error { func (h *LibraryHandler) SetLibraryVisibility(c echo.Context) error {
user := c.Get("user").(database.Users) user := c.Get("user").(database.Users)
+1
View File
@@ -28,6 +28,7 @@ func registerLibraryRoutes(cfg *Config) {
adminLibrary := library.Group("", handlers.AdminMiddleware) adminLibrary := library.Group("", handlers.AdminMiddleware)
adminLibrary.POST("", cfg.LibraryHandler.CreateLibrary) adminLibrary.POST("", cfg.LibraryHandler.CreateLibrary)
adminLibrary.GET("", cfg.LibraryHandler.ListLibraries) adminLibrary.GET("", cfg.LibraryHandler.ListLibraries)
adminLibrary.GET("/browse", cfg.LibraryHandler.BrowseDirectories)
adminLibrary.GET("/:id", cfg.LibraryHandler.GetLibrary) adminLibrary.GET("/:id", cfg.LibraryHandler.GetLibrary)
adminLibrary.PUT("/:id", cfg.LibraryHandler.UpdateLibrary) adminLibrary.PUT("/:id", cfg.LibraryHandler.UpdateLibrary)
adminLibrary.DELETE("/:id", cfg.LibraryHandler.DeleteLibrary) adminLibrary.DELETE("/:id", cfg.LibraryHandler.DeleteLibrary)
+47
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"context" "context"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -207,3 +208,49 @@ func (s *LibraryService) HasFolders(ctx context.Context, libraryID pgtype.UUID)
} }
return len(folders) > 0, nil return len(folders) > 0, nil
} }
// BrowseDirectories lists directories at a given path for folder browser UI
// Returns: (directories, currentPath, parentPath, error)
func (s *LibraryService) BrowseDirectories(ctx context.Context, path string) ([]string, string, string, error) {
// Security: path traversal protection
if strings.Contains(path, "..") {
return nil, "", "", fmt.Errorf("path traversal not allowed")
}
cleanPath := filepath.Clean(path)
// Check if path exists and is accessible
fileInfo, err := os.Stat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return nil, "", "", fmt.Errorf("path does not exist")
}
return nil, "", "", fmt.Errorf("path not accessible: %w", err)
}
if !fileInfo.IsDir() {
return nil, "", "", fmt.Errorf("not a directory")
}
// Read directory contents
entries, err := os.ReadDir(cleanPath)
if err != nil {
return nil, "", "", fmt.Errorf("failed to read directory: %w", err)
}
// Filter only directories
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, entry.Name())
}
}
// Get parent path for navigation
parentPath := filepath.Dir(cleanPath)
if parentPath == cleanPath {
parentPath = "" // At root
}
return dirs, cleanPath, parentPath, nil
}