5 Commits
Author SHA1 Message Date
john-okeefe b1eda696f1 Revert "fix(bookmarks): upsert on title conflict so position upgrades don't 500"
This reverts commit 27b3dcb69f.
2026-08-30 21:01:59 -04:00
john-okeefe 27b3dcb69f fix(bookmarks): upsert on title conflict so position upgrades don't 500
Bookmark dedup is keyed on hash(title + position bucket), but the table
also enforces UNIQUE(media_item_id, user_id, title). When a client re-
saves the same bookmark title with a changed position form - e.g. the
Android app upgrading a percentage-only row to an EPUB CFI, or a web and
app bookmark landing on the same 'Bookmark at 44%' title - the dedup-key
lookup misses and the INSERT violates the title constraint, returning
HTTP 500 and failing the sync.

A title collision on the same (user, item) is by definition the same
bookmark slot, so take the LWW semantics all the way: ON CONFLICT DO
UPDATE replaces position/cfi_position/page/chapter/percentage, refreshes
dedup_key and timestamps, merges device_sync_data, and - matching
UpdateMediaBookmarkForSync - clears deleted/deleted_at so a re-create
resurrects a tombstoned title slot instead of leaving an invisible row
holding it.

Device sync flows are unaffected: KOReader/Kobo pushes that carry their
own dedup-key echoes never reach the INSERT, and same-key saves still go
through applyBookmarkLWW with its tombstone freshness checks.
2026-08-30 20:57:15 -04:00
john-okeefe 5c5593644d 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.
2026-08-30 11:44:36 -04:00
john-okeefe 9e516b96cc docs(android): require reader settings parity with the web reader
The web reader's font roster (Literata default, plus seven self-hosted
variable fonts), typography controls, chrome/reading theme split,
fx brightness/contrast/invert stack, tap zones, and highlight palette
are the reference design for the Android reader - only the mobile
presentation differs. Document the mapping to the synced reader_settings
model so the app reuses it instead of inventing a parallel one.
2026-08-29 15:25:52 -04:00
john-okeefe 44735554f7 docs(api): document the real book download route
The documented GET /api/media-items/:uuid/download is not registered
anywhere - MediaHandler.DownloadBook exists but no route mounts it.
Book files (and covers) are actually served by the JWT-authenticated
GET /uploads/library-{id}/{path} route that the web reader uses.

Rewrite the download doc around the real file route (URL construction
from the item's library_id and relative file_path, MIME/Cache headers,
error codes), note the dead handler so nobody relies on the phantom
endpoint, and correct the API reference index. Mention the OPDS device
route as the conversion-capable alternative.
2026-08-29 10:25:59 -04:00
6 changed files with 120 additions and 86 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);
+12
View File
@@ -96,6 +96,18 @@ Planned reading features:
- Zoom and pan; aggressive preloading of adjacent pages - Zoom and pan; aggressive preloading of adjacent pages
- Webtoon / continuous vertical mode: post-v1 - Webtoon / continuous vertical mode: post-v1
### Reader settings parity with the web reader
The web reader (`web/src/reader/`) is the reference implementation for reading ergonomics — its font selection, reading themes, and highlight system are considered well-designed; only its desktop-oriented presentation is being replaced on mobile. The Android reader should reuse the same settings model (stored in the `reader_settings` table and synced via the settings endpoint) rather than inventing a parallel one:
- **Fonts**: the same roster of variable fonts, self-hosted under `/static/fonts/` — Literata (default), Crimson Pro, Source Serif 4, EB Garamond, Libertinus Serif, Noto Serif, Charis SIL, IBM Plex Serif (`FONT_MAP` in `web/src/reader/reader.ts`)
- **Typography**: `font_size` (default 18), `line_height` (1.6), `margin_width`, `double_page_spread`
- **Themes**: `chrome_theme` (default `tokyo-night`) for app chrome vs `reading_theme`/`reading_mode` for the page surface, plus the fx stack (`fx_brightness`, `fx_contrast`, `fx_invert`)
- **Navigation**: `tap_zones_enabled` + `tap_zone_size`, `reading_direction`, `progress_mode`
- **Highlights**: per-annotation color (default `#ffd54f`), matching the web palette
Settings chosen on one device should follow the user everywhere — mobile changes write back through the same sync.
--- ---
## 🍎 iOS Posture ## 🍎 iOS Posture
+1 -1
View File
@@ -89,7 +89,7 @@ See [Media Item Operations](media-items/)
- GET /api/media-items/:id - Get media item details - GET /api/media-items/:id - Get media item details
- POST /api/media-items/bulk-delete - Bulk delete media items - POST /api/media-items/bulk-delete - Bulk delete media items
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization) - POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
- GET /api/media-items/:uuid/download - Download media item file - GET /uploads/library-{library_id}/{file_path} - Download book file / cover (JWT; see [Download Media Item](media-items/download_media_item.md))
- POST /api/media-items/:id/rating - Create rating - POST /api/media-items/:id/rating - Create rating
- GET /api/media-items/:id/rating - Get rating - GET /api/media-items/:id/rating - Get rating
- PUT /api/media-items/:id/rating - Update rating - PUT /api/media-items/:id/rating - Update rating
@@ -2,15 +2,19 @@
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server. Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
**Endpoint**: `GET /api/media-items/:uuid/download` Book files are served by the authenticated file route, the same one the web reader uses. Build the URL from the media item's `library_id` and relative `file_path` (both returned by the media item list/get endpoints):
**Auth**: None (public endpoint for Kobo devices)
**Content-Type**: Binary file download **Endpoint**: `GET /uploads/library-{library_id}/{file_path}`
**Auth**: Required (JWT - Bearer header or session cookie)
The `file_path` segments are URL-escaped individually; slashes are preserved. `cover_image_path` uses the same route.
## Path Parameters ## Path Parameters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ------ | -------- | --------------- | | ----------- | ------ | -------- | ------------------------------------ |
| uuid | string | Yes | Media item UUID | | library_id | string | Yes | Library UUID (the item's library) |
| file_path | string | Yes | The item's relative `file_path` |
## Response ## Response
@@ -18,25 +22,27 @@ Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
**Response Headers**: **Response Headers**:
- `Content-Type`: `application/epub+zip`, `application/pdf`, or appropriate MIME type - `Content-Type`: MIME type by file extension (`application/epub+zip`, `application/pdf`, …; `application/octet-stream` fallback)
- `Content-Disposition`: `attachment; filename="filename.epub"` - `Cache-Control`: `public, max-age=86400`
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
| ---- | --------------------------------- | | ---- | --------------------------- |
| 404 | Media item not found | | 400 | Invalid library ID or path |
| 500 | Server error during file download | | 401 | Missing/invalid token |
| 404 | File not found on disk |
## Example ## Example
```bash ```bash
curl -O http://localhost:8765/api/media-items/550e8400-e29b-41d4-a716-446655440000/download curl -O -H "Authorization: Bearer $TOKEN" \
"http://localhost:8765/uploads/library/550e8400-.../books/1984.epub"
``` ```
(URL shape: `/uploads/library-{uuid}/{escaped-relative-path}`.)
## Notes ## Notes
- **Public endpoint**: No authentication required for Kobo device downloads - **Do not rely on `GET /api/media-items/:id/download`** — it appears in older docs but is **not registered**; `MediaHandler.DownloadBook` exists as dead code. Use the file route above.
- **File format**: Returns the original file format (EPUB, PDF, etc.) - OPDS-capable devices may alternatively use the device-authenticated `GET /opds/devices/{deviceId}/download/{bookId}`, which supports on-the-fly format conversion (epub, kepub, pdf, cbz).
- **Kobo integration**: Designed for direct downloads from Kobo e-readers
- **Cover images**: Use `/api/media-items/:uuid/cover` for cover images
+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)