fix(media): gate file serving by library visibility; proper download URL

ServeFile previously authenticated only ("any logged-in user") and never
checked that the user can actually see the library owning the file, so
knowing a library UUID + path was enough to fetch content from hidden
libraries. Library visibility is the permission model - the library is
what grants access to its media.

- ServeFile now resolves two URL forms through one flow:
  /uploads/library-{id}/{path}   (covers, reader files)
  /api/media-items/{id}/download (explicit book download, new)
  The item form looks up the media item, derives its library and file
  path, and adds a Content-Disposition attachment header.
- Both forms enforce GetUserVisibleLibraries for the authenticated
  user, mirroring the OPDS download handler (403 when not visible).
- Deleted the dead MediaHandler.DownloadBook handler (never routed).

Also widen media_highlights.start_position/end_position from
VARCHAR(100) to TEXT: the API handlers validate up to 1000 characters
(full Readium locators, KOReader CRE xpointers) but the column rejected
anything longer at the database layer. Metadata-only change applied
idempotently at startup; existing rows are untouched.

Verified against the running server: download 200 + attachment headers
+ epub bytes, unauthenticated 401, user hidden from the library 403 on
both URL forms, visible user 200, covers unchanged, and a 334-char
locator JSON now round-trips through the highlights API.
This commit is contained in:
2026-08-30 11:44:36 -04:00
parent 9e516b96cc
commit 5c5593644d
3 changed files with 84 additions and 68 deletions
+9 -2
View File
@@ -335,8 +335,8 @@ CREATE TABLE IF NOT EXISTS media_highlights (
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
selection_text TEXT NOT NULL, selection_text TEXT NOT NULL,
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -1364,6 +1364,13 @@ ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Widen position columns for existing databases: the API handlers
-- validate up to 1000 characters (full Readium locators, KOReader CRE
-- xpointers) but VARCHAR(100) rejected anything longer at the database
-- layer. VARCHAR -> TEXT is a metadata-only change, safe to re-run.
ALTER TABLE media_highlights ALTER COLUMN start_position TYPE TEXT;
ALTER TABLE media_highlights ALTER COLUMN end_position TYPE TEXT;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40); ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ; ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30); ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
+72 -66
View File
@@ -11,7 +11,6 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"mime"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
@@ -191,55 +190,6 @@ func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
mh.annotationSvc = svc mh.annotationSvc = svc
} }
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
bookUUID, err := uuid.Parse(c.Param("uuid"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book UUID"})
}
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
// Resolve relative path to absolute filesystem path
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
}
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
}
file, err := os.Open(fullPath)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"})
}
defer file.Close()
mimeType := mediaItem.MimeType.String
if !mediaItem.MimeType.Valid || mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath))
}
c.Response().Header().Set("Content-Type", mimeType)
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"")
if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 {
c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10))
}
_, err = io.Copy(c.Response(), file)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"})
}
return nil
}
// ExecuteSearch performs search and returns results with count // ExecuteSearch performs search and returns results with count
// Public wrapper for shared search logic used by both JSON and HTML endpoints // Public wrapper for shared search logic used by both JSON and HTML endpoints
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) { func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
@@ -2073,30 +2023,86 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath) return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
} }
// ServeFile serves files (covers or books) via /uploads/library-{id}/path // ServeFile serves stored library files (covers and books).
// Requires JWT authentication //
// Two URL forms funnel into this handler:
//
// /uploads/library-{libraryID}/{relativePath} (covers, reader files)
// /api/media-items/{mediaItemID}/download (explicit book download)
//
// Both require JWT authentication and that the authenticated user can see
// the library owning the file - library visibility is the permission gate.
func (mh *MediaHandler) ServeFile(c *echo.Context) error { func (mh *MediaHandler) ServeFile(c *echo.Context) error {
// URL format: /uploads/library-{libraryID}/{relativePath} var libraryUUID pgtype.UUID
// Get library ID directly from route parameter var relativePath string
libraryIDStr := c.Param("id")
libraryUUID, err := uuid.Parse(libraryIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
}
// Get remaining path from URL
rawPath := c.Param("*") rawPath := c.Param("*")
relativePath, err := url.QueryUnescape(rawPath) if rawPath != "" {
if err != nil { // Path form: /uploads/library-{libraryID}/{relativePath}
relativePath = rawPath libraryIDStr := c.Param("id")
parsed, err := uuid.Parse(libraryIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
}
libraryUUID = pgtype.UUID{Bytes: parsed, Valid: true}
relativePath, err = url.QueryUnescape(rawPath)
if err != nil {
relativePath = rawPath
}
if relativePath == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
}
} else {
// Item form: /api/media-items/{mediaItemID}/download
itemUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
}
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: itemUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if !mediaItem.LibraryID.Valid || mediaItem.FilePath == "" {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found"})
}
libraryUUID = mediaItem.LibraryID
relativePath = mediaItem.FilePath
// Explicit download endpoint: suggest saving instead of inline display.
filename := strings.Map(func(r rune) rune {
if r == '"' || r == '\\' || r == '/' {
return -1
}
return r
}, filepath.Base(relativePath))
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
} }
if relativePath == "" { // Library visibility gate: the library is what grants permission to
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"}) // see and download media.
user := c.Get("user").(database.Users)
visibleLibraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
libraryVisible := false
for _, lib := range visibleLibraries {
if lib.ID.Valid && lib.ID.Bytes == libraryUUID.Bytes {
libraryVisible = true
break
}
}
if !libraryVisible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
} }
// Resolve using service // Resolve using service
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath) fullPath, err := mh.getFullFilePath(c.Request().Context(), libraryUUID, relativePath)
if err != nil { if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"}) return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
} }
+3
View File
@@ -15,6 +15,9 @@ func registerMediaRoutes(cfg *Config) {
// Media item routes (all authenticated users) // Media item routes (all authenticated users)
protected.GET("/media-items", cfg.MediaHandler.ListMediaItems) protected.GET("/media-items", cfg.MediaHandler.ListMediaItems)
protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem) protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem)
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
// (JWT + library-visibility gated), addressed by media item ID.
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
// Media rating routes (all authenticated users) // Media rating routes (all authenticated users)
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating) protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)