7 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
john-okeefe 311049379d docs(android): add QR pairing sign-in to roadmap, update auth design
Document the authentication decision reached for the Android client:
username/password login is primary (the app needs the user-JWT API
surface that device tokens cannot reach), with the app self-approving
its own device registration post-login so it still shows up on the
Devices page with sync attribution.

Add the Netflix-style QR pairing flow to the post-v1 roadmap with its
constraints: the QR grants a full login with zero typing; a typed-code
fallback covers phones with broken cameras; KOReader keeps its existing
flow (no typed codes there); and pairing must encode the configured
BASE_URL rather than a detected LAN IP so remote instances
(https://public.domain) work identically.
2026-08-28 22:45:29 -04:00
john-okeefe f65db5ab4f docs(api): align auth/devices/libraries/media-items docs with handlers
Verified against the Echo routes and handler structs, fixing drift that
would break API clients:

- login: response field is access_token, not token (AuthResponse struct)
- register status: status is only pending|approved; expiry is HTTP 410
  (not a status value), approved responses are single-use, and pending
  registrations do not survive server restarts
- visible libraries: endpoint is GET /api/libraries/visibility and
  returns a top-level array of full library rows, not a wrapped object
- media items list: response is {"data": [...]}, library_id is optional,
  limit defaults to 50 (max 1000), no total field; document the sort
  parameter, the two response shapes, and raw-vs-resolved file paths

refresh and device-registration docs verified accurate; no changes.
2026-08-28 22:15:42 -04:00
10 changed files with 273 additions and 143 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);
+28 -3
View File
@@ -68,10 +68,11 @@ Keeping `:core:domain` free of Android dependencies preserves optionality: a fut
4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader) 4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader)
5. Books are downloaded to app storage for fully offline reading, with storage management UI 5. Books are downloaded to app storage for fully offline reading, with storage management UI
### Device registration & auth ### Authentication & device identity
- The app registers as a Bookhoard **device** using the existing QR-approval flow (`POST /api/devices/register` + web-based approval) — no passwords stored on the device - **Primary auth: username/password login** via the existing endpoints (`POST /api/auth/login` + refresh). The app is a full user client — browse, collections, ratings, and annotation management all live behind the user JWT, which device tokens cannot reach
- JWT access/refresh handled by the existing auth endpoints - After login, the app registers itself as a **device** (`device_type: mobile`) and **self-approves** its registration using its own JWT — approval only requires a logged-in user. The phone then appears on the Devices page with sync attribution, per-device settings, and individually revocable access, with no QR ceremony
- Netflix-style QR pairing as a zero-typing sign-in option: post-v1 (see below)
--- ---
@@ -95,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
@@ -125,6 +138,18 @@ iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay
## 🔭 Post-v1 Ideas ## 🔭 Post-v1 Ideas
### QR pairing sign-in (Netflix-style)
"Add device" on the web (while logged in) displays a QR code; a fresh app install scans it and is **fully signed in** — no server URL, no password, nothing typed on the phone.
- **QR is a full login**: the claim endpoint returns JWT + refresh token (plus the device token for sync identity)
- **Typed-code fallback** (GitHub/Netflix device-flow style: app displays a short code, user enters it on the web) for phones with broken cameras or no camera
- **KOReader keeps its existing flow unchanged** — no typed-code pairing there; it is already as convenient as it can be
- **Use the configured `BASE_URL`, never a detected LAN IP** — if the server is published at `https://public.domain`, pairing must work identically from outside the LAN
- Requires small server additions: `pair`/`claim` endpoints backed by single-use pairing sessions with a short TTL (in-memory like `pendingRegistrations`)
### Other ideas
- Webtoon / continuous vertical reading mode - Webtoon / continuous vertical reading mode
- Home-screen widgets and app shortcuts ("continue reading") - Home-screen widgets and app shortcuts ("continue reading")
- Text-to-speech - Text-to-speech
+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
+3 -1
View File
@@ -26,7 +26,7 @@ Authenticate with email and password.
```json ```json
{ {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...", "refresh_token": "d4f5g6h7...",
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 604800, "expires_in": 604800,
@@ -41,6 +41,8 @@ Authenticate with email and password.
} }
``` ```
Note: the access token field is `access_token` (not `token`). Nullable profile fields (`first_name`, `last_name`) may be empty strings.
**Set-Cookie Header**: **Set-Cookie Header**:
``` ```
+12 -18
View File
@@ -24,7 +24,9 @@ Check device registration status or get device details.
```json ```json
{ {
"status": "pending|approved|expired", "status": "pending|approved",
"message": "awaiting user approval",
"expires_in": 123,
"auth_token": "device-bearer-token...", "auth_token": "device-bearer-token...",
"device_id": "uuid", "device_id": "uuid",
"sync_endpoints": { "sync_endpoints": {
@@ -35,24 +37,16 @@ Check device registration status or get device details.
} }
``` ```
## Response (200 OK) - Device Details `status` is `pending` or `approved`. While pending, the response includes `message` and `expires_in` (seconds remaining). Once approved, the response includes `auth_token`, `device_id`, and `sync_endpoints`; `auth_token` fields are empty when pending.
```json **The approved response is single-use**: the registration is deleted from the pending map once returned, so store the `auth_token` immediately. A repeat status check for the same `registration_id` returns 404.
{
"id": "uuid",
"device_name": "My Kobo Clara",
"device_type": "kobo",
"last_sync": "2026-01-31T10:00:00Z",
"last_seen": "2026-01-31T10:05:00Z",
"sync_enabled": true,
"auto_sync": true,
"sync_frequency_minutes": 5
}
```
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
| ---- | --------------------------------------------- | | ---- | -------------------------------------------------- |
| 401 | Invalid or expired token (for device details) | | 400 | Invalid or missing `registration_id` |
| 404 | Device or registration not found | | 404 | Registration not found (unknown or already issued) |
| 410 | Registration expired (`{"error": "registration expired"}`) |
Note: expiration is signaled by HTTP 410 Gone, not a `"status": "expired"` value. Pending registrations are held in server memory, so a server restart also invalidates them (subsequent checks return 404).
@@ -2,7 +2,7 @@
Retrieve all libraries visible to the current user. Retrieve all libraries visible to the current user.
**Endpoint**: `GET /api/libraries/visible` **Endpoint**: `GET /api/libraries/visibility`
**Auth**: Required **Auth**: Required
## Request Headers ## Request Headers
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
### Example Request ### Example Request
```http ```http
GET /api/libraries/visible GET /api/libraries/visibility
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
``` ```
## Response (200 OK) ## Response (200 OK)
A top-level JSON **array** of library rows:
```json ```json
{ [
"libraries": [ {
{ "id": "uuid",
"id": "uuid", "name": "My Ebooks",
"name": "My Ebooks", "description": "Ebook collection",
"description": "Ebook collection", "library_type_id": "uuid",
"type_name": "ebooks", "created_by_admin_id": "uuid",
"is_visible": true "created_at": "2026-01-31T10:00:00Z",
} "updated_at": "2026-01-31T10:00:00Z",
] "type_name": "ebooks",
} "type_description": "Ebook libraries",
"is_visible": true
}
]
``` ```
Nullable columns (`description`, `type_description`) serialize as `null` when unset. Timestamps are RFC 3339.
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
@@ -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
@@ -1,17 +1,22 @@
# List Media Items # List Media Items
Retrieve a paginated list of media items from a library. Retrieve a paginated list of media items, scoped to a library or across all libraries.
**Endpoint**: `GET /api/media-items` **Endpoint**: `GET /api/media-items`
**Auth**: Required **Auth**: Required
## Query Parameters ## Query Parameters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| ---------- | ------- | -------- | ----------------------------------------------- | | ---------- | ------ | -------- | ------------------------------------------------------ |
| library_id | string | Yes | Library UUID | | library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
| limit | integer | No | Number of items to return (max 100, default 20) | | limit | int | No | Items to return (default 50, max 1000) |
| offset | integer | No | Number of items to skip | | offset | int | No | Items to skip (must be >= 0) |
| sort | string | No | Sort expression, default `created_at DESC` |
### Allowed sort expressions
`created_at`, `title`, `author`, `series`, `date_published`, `copyright_year`, `page_count`, `genre` — each with ` ASC` or ` DESC` (e.g. `title ASC`). Any other value silently falls back to `created_at DESC`.
## Request Headers ## Request Headers
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
### Example Request ### Example Request
```http ```http
GET /api/media-items?library_id=uuid&limit=20&offset=0 GET /api/media-items?library_id=uuid&limit=20&offset=0&sort=title%20ASC
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
``` ```
## Response (200 OK) ## Response (200 OK)
The response body is `{"data": [...]}` in both modes. The item shape differs by mode.
**No total is returned** — page until fewer items than `limit` come back.
### With `library_id` — full database rows
Nullable columns serialize as `null`.
```json ```json
{ {
"media_items": [ "data": [
{ {
"id": "uuid", "id": "uuid",
"library_id": "uuid", "library_id": "uuid",
"title": "Book Title", "title": "Book Title",
"author": "Author Name", "author": "Author Name",
"isbn": "978-...",
"description": "Book description", "description": "Book description",
"file_path": "/path/to/book.epub", "file_path": "relative/path/book.epub",
"file_size": 1024000, "file_size": 1024000,
"mime_type": "application/epub+zip", "mime_type": "application/epub+zip",
"cover_image_path": "/path/to/cover.jpg", "cover_image_path": "relative/path/cover.jpg",
"series": "Series Name", "series": "Series Name",
"series_number": 1, "series_number": 1,
"tags": ["sci-fi", "space opera"], "tags": ["sci-fi"],
"tags_search": ["sci fi", "space opera"], "asin": null,
"contributors": ["Author Name", "ACME CORP."], "date_published": "2023-06-01",
"contributors_search": ["author name", "acme corp"], "publisher": null,
"contributors": ["Author Name"],
"language": "en", "language": "en",
"edition": null,
"page_count": 350, "page_count": 350,
"genre": "Science Fiction", "genre": "Science Fiction",
"copyright_year": 2023, "copyright_year": 2023,
"created_at": "2026-01-31T10:00:00Z" "goodreads_id": null,
"openlibrary_id": null,
"google_books_id": null,
"added_by_admin_id": "uuid",
"created_at": "2026-01-31T10:00:00Z",
"imported_at": "2026-01-31T10:00:00Z",
"updated_at": "2026-01-31T10:00:00Z",
"format_group": "epub",
"format_mimetype": "application/epub+zip",
"is_reflowable": true,
"has_fixed_layout": false,
"total_characters": 480000,
"chapter_count": 24
} }
], ]
"total": 100 }
```
Note: in this mode `file_path` and `cover_image_path` are the raw relative storage paths, not URLs.
### Without `library_id` — curated items with resolved URLs
Across all libraries; file and cover paths are resolved to fetchable URL paths (`/uploads/...` or library-scoped paths):
```json
{
"data": [
{
"id": "uuid",
"library_id": "uuid",
"title": "Book Title",
"author": "Author Name",
"isbn": "978-...",
"description": "Book description",
"file_path": "/api/libraries/<uuid>/files/...",
"file_size": 1024000,
"mime_type": "application/epub+zip",
"cover_image_path": "/api/libraries/<uuid>/files/.../cover.jpg",
"series": "Series Name",
"series_number": 1,
"tags": ["sci-fi"],
"asin": null,
"date_published": "2023-06-01",
"publisher": null,
"contributors": ["Author Name"],
"language": "en",
"edition": null,
"page_count": 350,
"genre": "Science Fiction",
"created_at": "2026-01-31T10:00:00Z",
"updated_at": "2026-01-31T10:00:00Z",
"format_group": "epub",
"manga_type": null,
"reading_direction": null,
"series_count": null,
"volume": null,
"imprint": null,
"age_rating": null,
"web_url": null,
"metadata_notes": null,
"community_rating": null,
"story_arc": null,
"is_black_and_white": false,
"alternate_info": null,
"scan_information": null,
"summary": null
}
]
} }
``` ```
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
| ---- | ----------------------------------------- | | ---- | ------------------------------------------ |
| 400 | Invalid query parameters | | 400 | Invalid `library_id`, `offset` < 0 |
| 401 | Invalid or expired token | | 401 | Invalid or expired token |
| 403 | User does not have access to this library | | 500 | Query failure (returned as `{"error": …}`) |
+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)