Compare commits
7
Commits
7ddcdd0756
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1eda696f1 | ||
|
|
27b3dcb69f | ||
|
|
5c5593644d | ||
|
|
9e516b96cc | ||
|
|
44735554f7 | ||
|
|
311049379d | ||
|
|
f65db5ab4f |
@@ -335,8 +335,8 @@ CREATE TABLE IF NOT EXISTS media_highlights (
|
||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
selection_text TEXT NOT NULL,
|
||||
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
|
||||
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends
|
||||
start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts
|
||||
end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends
|
||||
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
||||
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
||||
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_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 last_modified_at TIMESTAMPTZ;
|
||||
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
- JWT access/refresh handled by the existing auth endpoints
|
||||
- **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
|
||||
- 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
|
||||
- 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
|
||||
@@ -125,6 +138,18 @@ iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay
|
||||
|
||||
## 🔭 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
|
||||
- Home-screen widgets and app shortcuts ("continue reading")
|
||||
- Text-to-speech
|
||||
|
||||
@@ -89,7 +89,7 @@ See [Media Item Operations](media-items/)
|
||||
- GET /api/media-items/:id - Get media item details
|
||||
- POST /api/media-items/bulk-delete - Bulk delete media items
|
||||
- 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
|
||||
- GET /api/media-items/:id/rating - Get rating
|
||||
- PUT /api/media-items/:id/rating - Update rating
|
||||
|
||||
@@ -26,7 +26,7 @@ Authenticate with email and password.
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "d4f5g6h7...",
|
||||
"token_type": "Bearer",
|
||||
"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**:
|
||||
|
||||
```
|
||||
|
||||
@@ -24,7 +24,9 @@ Check device registration status or get device details.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending|approved|expired",
|
||||
"status": "pending|approved",
|
||||
"message": "awaiting user approval",
|
||||
"expires_in": 123,
|
||||
"auth_token": "device-bearer-token...",
|
||||
"device_id": "uuid",
|
||||
"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
|
||||
{
|
||||
"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
|
||||
}
|
||||
```
|
||||
**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.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | --------------------------------------------- |
|
||||
| 401 | Invalid or expired token (for device details) |
|
||||
| 404 | Device or registration not found |
|
||||
| Code | Description |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 400 | Invalid or missing `registration_id` |
|
||||
| 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.
|
||||
|
||||
**Endpoint**: `GET /api/libraries/visible`
|
||||
**Endpoint**: `GET /api/libraries/visibility`
|
||||
**Auth**: Required
|
||||
|
||||
## Request Headers
|
||||
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/libraries/visible
|
||||
GET /api/libraries/visibility
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
A top-level JSON **array** of library rows:
|
||||
|
||||
```json
|
||||
{
|
||||
"libraries": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My Ebooks",
|
||||
"description": "Ebook collection",
|
||||
"type_name": "ebooks",
|
||||
"is_visible": true
|
||||
}
|
||||
]
|
||||
}
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "My Ebooks",
|
||||
"description": "Ebook collection",
|
||||
"library_type_id": "uuid",
|
||||
"created_by_admin_id": "uuid",
|
||||
"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
|
||||
|
||||
| Code | Description |
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/:uuid/download`
|
||||
**Auth**: None (public endpoint for Kobo devices)
|
||||
**Content-Type**: Binary file 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):
|
||||
|
||||
**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
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| uuid | string | Yes | Media item UUID |
|
||||
| Parameter | Type | Required | Description |
|
||||
| ----------- | ------ | -------- | ------------------------------------ |
|
||||
| library_id | string | Yes | Library UUID (the item's library) |
|
||||
| file_path | string | Yes | The item's relative `file_path` |
|
||||
|
||||
## Response
|
||||
|
||||
@@ -18,25 +22,27 @@ Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||
|
||||
**Response Headers**:
|
||||
|
||||
- `Content-Type`: `application/epub+zip`, `application/pdf`, or appropriate MIME type
|
||||
- `Content-Disposition`: `attachment; filename="filename.epub"`
|
||||
- `Content-Type`: MIME type by file extension (`application/epub+zip`, `application/pdf`, …; `application/octet-stream` fallback)
|
||||
- `Cache-Control`: `public, max-age=86400`
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | --------------------------------- |
|
||||
| 404 | Media item not found |
|
||||
| 500 | Server error during file download |
|
||||
| Code | Description |
|
||||
| ---- | --------------------------- |
|
||||
| 400 | Invalid library ID or path |
|
||||
| 401 | Missing/invalid token |
|
||||
| 404 | File not found on disk |
|
||||
|
||||
## Example
|
||||
|
||||
```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
|
||||
|
||||
- **Public endpoint**: No authentication required for Kobo device downloads
|
||||
- **File format**: Returns the original file format (EPUB, PDF, etc.)
|
||||
- **Kobo integration**: Designed for direct downloads from Kobo e-readers
|
||||
- **Cover images**: Use `/api/media-items/:uuid/cover` for cover images
|
||||
- **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.
|
||||
- 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).
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
# 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`
|
||||
**Auth**: Required
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ---------- | ------- | -------- | ----------------------------------------------- |
|
||||
| library_id | string | Yes | Library UUID |
|
||||
| limit | integer | No | Number of items to return (max 100, default 20) |
|
||||
| offset | integer | No | Number of items to skip |
|
||||
| Parameter | Type | Required | Description |
|
||||
| ---------- | ------ | -------- | ------------------------------------------------------ |
|
||||
| library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
|
||||
| limit | int | No | Items to return (default 50, max 1000) |
|
||||
| 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
|
||||
|
||||
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
|
||||
### Example Request
|
||||
|
||||
```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...
|
||||
```
|
||||
|
||||
## 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
|
||||
{
|
||||
"media_items": [
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"library_id": "uuid",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"isbn": "978-...",
|
||||
"description": "Book description",
|
||||
"file_path": "/path/to/book.epub",
|
||||
"file_path": "relative/path/book.epub",
|
||||
"file_size": 1024000,
|
||||
"mime_type": "application/epub+zip",
|
||||
"cover_image_path": "/path/to/cover.jpg",
|
||||
"cover_image_path": "relative/path/cover.jpg",
|
||||
"series": "Series Name",
|
||||
"series_number": 1,
|
||||
"tags": ["sci-fi", "space opera"],
|
||||
"tags_search": ["sci fi", "space opera"],
|
||||
"contributors": ["Author Name", "ACME CORP."],
|
||||
"contributors_search": ["author name", "acme corp"],
|
||||
"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",
|
||||
"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
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------------------------------------- |
|
||||
| 400 | Invalid query parameters |
|
||||
| 401 | Invalid or expired token |
|
||||
| 403 | User does not have access to this library |
|
||||
| Code | Description |
|
||||
| ---- | ------------------------------------------ |
|
||||
| 400 | Invalid `library_id`, `offset` < 0 |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Query failure (returned as `{"error": …}`) |
|
||||
|
||||
+72
-66
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -191,55 +190,6 @@ func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
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
|
||||
// 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) {
|
||||
@@ -2073,30 +2023,86 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
||||
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
||||
}
|
||||
|
||||
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
|
||||
// Requires JWT authentication
|
||||
// ServeFile serves stored library files (covers and books).
|
||||
//
|
||||
// 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 {
|
||||
// URL format: /uploads/library-{libraryID}/{relativePath}
|
||||
// Get library ID directly from route parameter
|
||||
libraryIDStr := c.Param("id")
|
||||
libraryUUID, err := uuid.Parse(libraryIDStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
||||
}
|
||||
var libraryUUID pgtype.UUID
|
||||
var relativePath string
|
||||
|
||||
// Get remaining path from URL
|
||||
rawPath := c.Param("*")
|
||||
relativePath, err := url.QueryUnescape(rawPath)
|
||||
if err != nil {
|
||||
relativePath = rawPath
|
||||
if rawPath != "" {
|
||||
// Path form: /uploads/library-{libraryID}/{relativePath}
|
||||
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 == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
||||
// Library visibility gate: the library is what grants permission to
|
||||
// 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
|
||||
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 {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// Media item routes (all authenticated users)
|
||||
protected.GET("/media-items", cfg.MediaHandler.ListMediaItems)
|
||||
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)
|
||||
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
||||
|
||||
Reference in New Issue
Block a user