diff --git a/src/db/app_db.rs b/src/db/app_db.rs index 7e3a4ea..9441463 100644 --- a/src/db/app_db.rs +++ b/src/db/app_db.rs @@ -355,6 +355,15 @@ impl AppDb { enabled INTEGER NOT NULL, PRIMARY KEY (user_id, book_id) )"#, + // Override cover for a book, stored on disk as {cover_dir}/{book_id}.jpg. + // A present row means "this book has an override cover"; `version` is + // bumped on each replacement to bust the browser and device caches. + // Kept in its own table (not on the `books` row) so it survives the + // startup Calibre re-ingest, which deletes and reinserts calibre rows. + r#"CREATE TABLE IF NOT EXISTS book_covers ( + book_id INTEGER NOT NULL PRIMARY KEY, + version INTEGER NOT NULL + )"#, ]; for sql in &statements { @@ -1112,6 +1121,38 @@ impl AppDb { Ok(()) } + /// Record that a book's cover has been replaced by an override (stored on + /// disk at `{cover_dir}/{book_id}.jpg`). Bumps the per-book cover `version` + /// (busts the browser `?v=` and device `CoverImageId` caches) and touches + /// `books.last_modified` so the next device sync re-emits the entitlement. + /// Returns the new version. + pub async fn set_book_cover(&self, book_id: i64) -> Result { + let version: i64 = sqlx::query( + r#" + INSERT INTO book_covers (book_id, version) VALUES (?, 1) + ON CONFLICT(book_id) DO UPDATE SET version = version + 1 + RETURNING version + "#, + ) + .bind(book_id) + .fetch_one(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to record book cover")? + .get::("version"); + + sqlx::query( + "UPDATE books SET last_modified = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(book_id) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to bump book last_modified after cover change")?; + + Ok(version) + } + // ---- Shelf Operations ---- /// Get all shelves for a user. diff --git a/src/db/book_store.rs b/src/db/book_store.rs index 9afb34d..17df852 100644 --- a/src/db/book_store.rs +++ b/src/db/book_store.rs @@ -119,6 +119,9 @@ pub struct CatalogBook { pub sync_enabled: bool, /// When the book entered the catalog (upload time, or Calibre's add date). pub timestamp: DateTime, + /// Override-cover version (0 = none). Bumped each time the cover is replaced; + /// used as a `/covers/{id}?v=` cache-buster in the web UI. + pub cover_version: i64, } /// The full `books` row, as read back from the app DB. @@ -188,22 +191,33 @@ pub struct BookStore { pool: SqlitePool, calibre_library_path: Option, upload_dir: PathBuf, + /// Directory holding override covers (`{book_id}.jpg`), used to change a + /// book's cover without touching the read-only Calibre FS. + cover_override_dir: PathBuf, } impl BookStore { - /// Create a book store over the app DB pool. + /// Create a book store over the app DB pool. Override covers live under + /// `{upload_dir}/overrides` (uploads use uuid dirs, so no name collision). pub fn new( pool: SqlitePool, calibre_library_path: Option, upload_dir: PathBuf, ) -> Self { + let cover_override_dir = upload_dir.join("overrides"); Self { pool, calibre_library_path, upload_dir, + cover_override_dir, } } + /// On-disk path of a book's override cover (may not exist). + pub fn cover_override_path(&self, book_id: i64) -> PathBuf { + self.cover_override_dir.join(format!("{book_id}.jpg")) + } + // ---- Reads ---- /// List every book with the fields the web UI needs (including `source`, @@ -214,10 +228,13 @@ impl BookStore { r#" SELECT b.id, b.uuid, b.title, b.authors, b.publisher, b.language, b.description, b.series_name, b.series_index, b.file_format, - b.has_cover, b.source, b.timestamp, + b.source, b.timestamp, + (b.has_cover != 0 OR c.book_id IS NOT NULL) AS has_cover, + COALESCE(c.version, 0) AS cover_version, COALESCE(p.enabled, b.default_sync) AS sync_enabled FROM books b LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 + LEFT JOIN book_covers c ON c.book_id = b.id ORDER BY b.title COLLATE NOCASE ASC "#, ) @@ -257,6 +274,7 @@ impl BookStore { .as_deref() .and_then(parse_dt) .unwrap_or_else(Utc::now), + cover_version: r.get::("cover_version"), } }) .collect()) @@ -286,11 +304,18 @@ impl BookStore { &self, user_id: i64, ) -> Result> { - let rows = sqlx::query(sqlx::AssertSqlSafe(Self::select( - "LEFT JOIN book_sync_prefs p ON p.book_id = books.id AND p.user_id = ?1 \ - WHERE COALESCE(p.enabled, books.default_sync) = 1 \ - ORDER BY id ASC", - ))) + let rows = sqlx::query( + r#" + SELECT b.id, b.uuid, b.timestamp, b.last_modified, + b.file_format, b.file_size, + COALESCE(c.version, 0) AS cover_version + FROM books b + LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 + LEFT JOIN book_covers c ON c.book_id = b.id + WHERE COALESCE(p.enabled, b.default_sync) = 1 + ORDER BY b.id ASC + "#, + ) .bind(user_id) .fetch_all(&self.pool) .await @@ -299,20 +324,41 @@ impl BookStore { Ok(rows .iter() - .map(|r| { - let b = row_to_book(r); - CalibreBookSyncRow { - id: b.id, - uuid: b.uuid, - timestamp: b.timestamp, - last_modified: b.last_modified, - file_format: b.file_format, - file_size: b.file_size, - } + .map(|r| CalibreBookSyncRow { + id: r.get::("id"), + uuid: r.get::("uuid"), + timestamp: r + .get::, _>("timestamp") + .as_deref() + .and_then(parse_dt) + .unwrap_or_else(Utc::now), + last_modified: r + .get::, _>("last_modified") + .as_deref() + .and_then(parse_dt) + .unwrap_or_else(Utc::now), + file_format: r + .get::, _>("file_format") + .unwrap_or_default(), + file_size: r.get::, _>("file_size").unwrap_or(0), + cover_version: r.get::("cover_version"), }) .collect()) } + /// Current override-cover version for a book (0 if it has no override). + pub async fn cover_version(&self, book_id: i64) -> Result { + let version = sqlx::query("SELECT version FROM book_covers WHERE book_id = ?") + .bind(book_id) + .fetch_optional(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to read cover version")? + .map(|r| r.get::("version")) + .unwrap_or(0); + Ok(version) + } + /// Fetch a single book by id. pub async fn fetch_book_by_id(&self, id: i64) -> Result> { Ok(self @@ -364,8 +410,14 @@ impl BookStore { } /// Resolve the on-disk path of a book's cover image. `None` if the book is - /// unknown or its source is unresolvable. + /// unknown or its source is unresolvable. An override cover (set from the web + /// UI) wins over the source cover, so both the browser and the device serve + /// it — without ever writing into the read-only Calibre library. pub async fn cover_path(&self, book: &CalibreBook) -> Result> { + let override_path = self.cover_override_path(book.id); + if override_path.exists() { + return Ok(Some(override_path)); + } let Some(row) = self.fetch_row_by("id", &book.id.to_string()).await? else { return Ok(None); }; diff --git a/src/db/calibre_db.rs b/src/db/calibre_db.rs index f4194c6..bce41a0 100644 --- a/src/db/calibre_db.rs +++ b/src/db/calibre_db.rs @@ -37,6 +37,9 @@ pub struct CalibreBookSyncRow { pub last_modified: DateTime, pub file_format: String, // 'KEPUB' or 'EPUB' pub file_size: i64, + /// Override-cover version (0 = none). Suffixed onto the device `CoverImageId` + /// so the Kobo re-fetches the image after the cover changes. + pub cover_version: i64, } /// Represents series info from the Calibre database. @@ -153,6 +156,8 @@ impl CalibreDb { .get::, _>("file_format") .unwrap_or_default(), file_size: r.get::, _>("file_size").unwrap_or(0), + // The read-only Calibre reader has no override-cover state. + cover_version: 0, }) .collect()) } diff --git a/src/kobo/handlers.rs b/src/kobo/handlers.rs index 661c245..288a7db 100644 --- a/src/kobo/handlers.rs +++ b/src/kobo/handlers.rs @@ -149,6 +149,12 @@ impl KoboHandlers { .as_ref() .map(|s| Series::new(&s.name, s.index)); + let cover_version = state + .books + .cover_version(book_full.book.id) + .await + .map_err(internal_error)?; + let metadata = BookMetadata::new( &book_full.book.uuid, &book_full.book.title, @@ -159,6 +165,7 @@ impl KoboHandlers { book_full.book.pubdate, series, download_urls, + cover_version, ); Ok(Json(json!([metadata]))) @@ -689,7 +696,7 @@ impl KoboHandlers { let book = state .books - .fetch_book_by_uuid(¶ms.book_uuid) + .fetch_book_by_uuid(strip_cover_version(¶ms.book_uuid)) .await .map_err(internal_error)? .ok_or_else(|| (StatusCode::NOT_FOUND, "Book not found".to_string()))?; @@ -730,7 +737,7 @@ impl KoboHandlers { let book = state .books - .fetch_book_by_uuid(¶ms.book_uuid) + .fetch_book_by_uuid(strip_cover_version(¶ms.book_uuid)) .await .map_err(internal_error)? .ok_or_else(|| (StatusCode::NOT_FOUND, "Book not found".to_string()))?; diff --git a/src/kobo/models.rs b/src/kobo/models.rs index a645009..bcb8857 100644 --- a/src/kobo/models.rs +++ b/src/kobo/models.rs @@ -202,6 +202,29 @@ pub struct BookMetadata { /// Default category UUID used by Kobo. const DEFAULT_CATEGORY_ID: &str = "00000000-0000-0000-0000-000000000001"; +/// Build the `CoverImageId` the device echoes into its cover URL template. +/// Unversioned covers (`cover_version == 0`) use the bare book uuid, so existing +/// devices are unaffected; a replaced cover appends `_v{n}` so the device sees a +/// new id and re-fetches the image. [`strip_cover_version`] reverses this on the +/// device cover route. +pub fn cover_image_id(book_uuid: &str, cover_version: i64) -> String { + if cover_version > 0 { + format!("{book_uuid}_v{cover_version}") + } else { + book_uuid.to_string() + } +} + +/// Recover the bare book uuid from a device `ImageId` that may carry a `_v{n}` +/// cover-version suffix (see [`cover_image_id`]). Only a trailing `_v` followed +/// by digits is stripped, so real uuids (hex + hyphens) pass through untouched. +pub fn strip_cover_version(image_id: &str) -> &str { + match image_id.rsplit_once("_v") { + Some((uuid, ver)) if !ver.is_empty() && ver.bytes().all(|b| b.is_ascii_digit()) => uuid, + _ => image_id, + } +} + impl BookMetadata { #[allow(clippy::too_many_arguments)] pub fn new( @@ -214,10 +237,11 @@ impl BookMetadata { publication_date: Option>, series: Option, download_urls: Vec, + cover_version: i64, ) -> Self { Self { categories: vec![DEFAULT_CATEGORY_ID.to_string()], - cover_image_id: book_uuid.to_string(), + cover_image_id: cover_image_id(book_uuid, cover_version), cross_revision_id: book_uuid.to_string(), current_display_price: DisplayPrice::default(), current_love_display_price: LoveDisplayPrice::default(), @@ -579,3 +603,40 @@ pub struct TagCreateRequest { pub struct TagItemsRequest { pub items: Vec, } + +#[cfg(test)] +mod tests { + use super::{cover_image_id, strip_cover_version}; + + const UUID: &str = "a1b2c3d4-0000-4000-8000-000000000001"; + + #[test] + fn unversioned_cover_id_is_bare_uuid() { + assert_eq!(cover_image_id(UUID, 0), UUID); + } + + #[test] + fn versioned_cover_id_has_suffix() { + assert_eq!(cover_image_id(UUID, 3), format!("{UUID}_v3")); + } + + #[test] + fn strip_recovers_uuid_from_both_forms() { + assert_eq!(strip_cover_version(UUID), UUID); + assert_eq!(strip_cover_version(&format!("{UUID}_v3")), UUID); + assert_eq!(strip_cover_version(&format!("{UUID}_v42")), UUID); + } + + #[test] + fn strip_leaves_non_version_suffix_untouched() { + // A trailing "_v" without digits, or non-numeric, is not a version. + assert_eq!(strip_cover_version("book_vX"), "book_vX"); + assert_eq!(strip_cover_version("book_v"), "book_v"); + } + + #[test] + fn cover_id_round_trips_through_strip() { + assert_eq!(strip_cover_version(&cover_image_id(UUID, 7)), UUID); + assert_eq!(strip_cover_version(&cover_image_id(UUID, 0)), UUID); + } +} diff --git a/src/kobo/sync.rs b/src/kobo/sync.rs index c79cb21..7044f38 100644 --- a/src/kobo/sync.rs +++ b/src/kobo/sync.rs @@ -290,7 +290,8 @@ impl<'a> SyncEngine<'a> { EmitMode::NewEntitlement | EmitMode::ChangedEntitlement => { let book_full = self.fetch_book_full_for(row).await?; let entitlement = self.build_entitlement(row); - let metadata = self.build_metadata_from_full(&book_full); + let cover_version = self.books.cover_version(book_full.book.id).await?; + let metadata = self.build_metadata_from_full(&book_full, cover_version); let reading_state = self.build_reading_state_for_book(row).await?; let item = match mode { @@ -320,8 +321,10 @@ impl<'a> SyncEngine<'a> { // best-effort: if Calibre still has it, use the real metadata; // otherwise emit a stub metadata payload. let entitlement = build_removed_entitlement(row); + // The book is being removed, so the cover id is moot; 0 keeps the + // bare-uuid form. let metadata = match self.fetch_book_full_for(row).await { - Ok(full) => self.build_metadata_from_full(&full), + Ok(full) => self.build_metadata_from_full(&full, 0), Err(_) => stub_metadata(row), }; let reading_state = self.build_reading_state_for_book(row).await?; @@ -391,7 +394,7 @@ impl<'a> SyncEngine<'a> { BookEntitlement::new(&row.book_uuid, &created, &last_modified, row.is_archived) } - fn build_metadata_from_full(&self, book: &CalibreBookFull) -> BookMetadata { + fn build_metadata_from_full(&self, book: &CalibreBookFull, cover_version: i64) -> BookMetadata { let download_urls = self.build_download_urls(book); let series = book.series.as_ref().map(|s| Series::new(&s.name, s.index)); BookMetadata::new( @@ -404,6 +407,7 @@ impl<'a> SyncEngine<'a> { book.book.pubdate, series, download_urls, + cover_version, ) } @@ -559,6 +563,7 @@ fn stub_metadata(row: &SyncPointBookRow) -> BookMetadata { None, None, Vec::new(), + 0, ) } diff --git a/src/upload/cover_search.rs b/src/upload/cover_search.rs new file mode 100644 index 0000000..db03a7d --- /dev/null +++ b/src/upload/cover_search.rs @@ -0,0 +1,193 @@ +//! Online cover-image search for the "change cover" UI. Unlike [`crate::upload::enrich`] +//! — which auto-picks a single cover during upload and is gated on the configured +//! enrichment provider — this queries OpenLibrary and Google Books for **multiple** +//! candidate covers a user can pick from, and is always available (an explicit +//! user action, independent of the `enrichment` config). + +use std::time::Duration; + +use reqwest::Client; +use serde::Deserialize; + +/// A candidate cover image a user can select. `thumb_url` is a small preview for +/// the picker grid; `full_url` is the full-resolution image actually stored. +#[derive(Debug, Clone)] +pub struct CoverCandidate { + pub thumb_url: String, + pub full_url: String, + pub source: &'static str, + pub title: Option, +} + +/// Hosts a cover image may legitimately come from. The "set cover from URL" +/// endpoint fetches server-side, so restricting the host prevents the endpoint +/// from being used as an SSRF vector against arbitrary internal addresses. +const ALLOWED_COVER_HOSTS: &[&str] = &[ + "covers.openlibrary.org", + "books.google.com", + "books.googleapis.com", + "books.googleusercontent.com", +]; + +/// Whether `url` points at an allowlisted cover host (exact host, or a +/// `*.googleusercontent.com` subdomain used by Google Books thumbnails). +pub fn is_allowed_cover_url(url: &str) -> bool { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + if parsed.scheme() != "https" { + return false; + } + let Some(host) = parsed.host_str() else { + return false; + }; + ALLOWED_COVER_HOSTS.contains(&host) || host.ends_with(".googleusercontent.com") +} + +/// Build the shared HTTP client (same shape as `enrich.rs`). +fn client() -> reqwest::Result { + Client::builder() + .user_agent("kobors/0.1 (self-hosted Kobo sync)") + .timeout(Duration::from_secs(8)) + .build() +} + +/// Search OpenLibrary and Google Books for candidate covers matching `query` +/// (typically "title author"). Best-effort: provider failures yield no +/// candidates rather than an error. Returns OpenLibrary results first. +pub async fn search_covers(query: &str) -> Vec { + let query = query.trim(); + if query.is_empty() { + return Vec::new(); + } + let Ok(client) = client() else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + candidates.extend(open_library(&client, query).await.unwrap_or_default()); + candidates.extend(google_books(&client, query).await.unwrap_or_default()); + candidates +} + +// ---- OpenLibrary ---- + +#[derive(Deserialize)] +struct OlSearch { + #[serde(default)] + docs: Vec, +} + +#[derive(Deserialize)] +struct OlDoc { + title: Option, + cover_i: Option, +} + +async fn open_library(client: &Client, query: &str) -> reqwest::Result> { + let search: OlSearch = client + .get("https://openlibrary.org/search.json") + .query(&[("q", query), ("limit", "12"), ("fields", "title,cover_i")]) + .send() + .await? + .json() + .await?; + + Ok(search + .docs + .into_iter() + .filter_map(|doc| { + let id = doc.cover_i?; + Some(CoverCandidate { + thumb_url: format!("https://covers.openlibrary.org/b/id/{id}-M.jpg"), + full_url: format!("https://covers.openlibrary.org/b/id/{id}-L.jpg"), + source: "OpenLibrary", + title: doc.title, + }) + }) + .collect()) +} + +// ---- Google Books ---- + +#[derive(Deserialize)] +struct GbResponse { + #[serde(default)] + items: Vec, +} + +#[derive(Deserialize)] +struct GbItem { + #[serde(rename = "volumeInfo")] + volume_info: GbVolumeInfo, +} + +#[derive(Deserialize)] +struct GbVolumeInfo { + title: Option, + #[serde(rename = "imageLinks")] + image_links: Option, +} + +#[derive(Deserialize)] +struct GbImageLinks { + thumbnail: Option, + #[serde(rename = "smallThumbnail")] + small_thumbnail: Option, +} + +async fn google_books(client: &Client, query: &str) -> reqwest::Result> { + let resp: GbResponse = client + .get("https://www.googleapis.com/books/v1/volumes") + .query(&[("q", query), ("maxResults", "8")]) + .send() + .await? + .json() + .await?; + + Ok(resp + .items + .into_iter() + .filter_map(|item| { + let links = item.volume_info.image_links?; + let raw = links.thumbnail.or(links.small_thumbnail)?; + // Google returns http thumbnails; the allowlist requires https. + let url = raw.replacen("http://", "https://", 1); + Some(CoverCandidate { + thumb_url: url.clone(), + full_url: url, + source: "Google Books", + title: item.volume_info.title, + }) + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::is_allowed_cover_url; + + #[test] + fn allows_known_cover_hosts() { + assert!(is_allowed_cover_url( + "https://covers.openlibrary.org/b/id/123-L.jpg" + )); + assert!(is_allowed_cover_url( + "https://books.google.com/books/content?id=x" + )); + assert!(is_allowed_cover_url( + "https://lh3.googleusercontent.com/proxy/abc" + )); + } + + #[test] + fn rejects_other_hosts_and_schemes() { + assert!(!is_allowed_cover_url("https://evil.example.com/x.jpg")); + assert!(!is_allowed_cover_url("http://covers.openlibrary.org/x.jpg")); // not https + assert!(!is_allowed_cover_url( + "http://169.254.169.254/latest/meta-data" + )); + assert!(!is_allowed_cover_url("file:///etc/passwd")); + assert!(!is_allowed_cover_url("not a url")); + } +} diff --git a/src/upload/mod.rs b/src/upload/mod.rs index 9872ab0..87e89bc 100644 --- a/src/upload/mod.rs +++ b/src/upload/mod.rs @@ -1,5 +1,6 @@ pub mod convert; pub mod cover; +pub mod cover_search; pub mod enrich; pub mod epub; pub mod handler; diff --git a/src/web.rs b/src/web.rs index 33a026c..d3879ab 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{DefaultBodyLimit, Path, Query, State}, + extract::{DefaultBodyLimit, Multipart, Path, Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, response::{Html, IntoResponse, Redirect, Response}, routing::{delete, get, post, put}, @@ -52,6 +52,9 @@ struct BookJson { /// RFC 3339 catalog-entry time (upload time, or Calibre add date). Uniform /// format so the web UI can sort by recency with a plain string compare. timestamp: String, + /// Override-cover version (0 = none). The UI appends it as `/covers/{id}?v=` + /// to bust the browser cache when the cover changes. + cover_version: i64, } /// JSON representation of the current user. @@ -112,6 +115,15 @@ pub fn router(max_upload_size: usize) -> Router { get(api_get_settings).put(api_update_settings), ) .route("/api/books/{book_id}/sync", put(api_set_book_sync)) + .route("/api/books/{book_id}/cover/search", get(api_search_cover)) + // PUT sets the cover from a chosen search-result URL; POST uploads a + // custom image file (multipart, so it needs a body limit). + .route( + "/api/books/{book_id}/cover", + put(api_set_cover_url) + .post(api_upload_cover) + .layer(DefaultBodyLimit::max(20 * 1024 * 1024)), + ) .route("/covers/{book_id}", get(cover)) // Upload routes authenticate via the `ApiUser` extractor (browser // session cookie OR `Authorization: Bearer` token) so the Calibre @@ -209,6 +221,7 @@ async fn api_books( source: b.source.as_str().to_string(), sync_enabled: b.sync_enabled, timestamp: b.timestamp.to_rfc3339(), + cover_version: b.cover_version, }) .collect(); @@ -348,6 +361,191 @@ async fn cover( Ok((headers, content)) } +// ---- Cover editing ---- + +/// Query params for the cover-search endpoint. +#[derive(Deserialize)] +struct CoverSearchQuery { + #[serde(default)] + q: String, +} + +/// A candidate cover returned by the search endpoint. +#[derive(Serialize)] +struct CoverCandidateJson { + thumb_url: String, + full_url: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, +} + +/// Request body for setting a cover from a chosen search-result URL. +#[derive(Deserialize)] +struct CoverUrlReq { + url: String, +} + +/// GET /api/books/{book_id}/cover/search?q=… — Search online cover candidates. +/// `q` defaults to the book's title when omitted. +async fn api_search_cover( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, + Query(params): Query, +) -> Result>, (StatusCode, String)> { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let book = state + .books + .fetch_book_by_id(book_id) + .await + .map_err(|e| { + error!("Failed to fetch book: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })? + .ok_or((StatusCode::NOT_FOUND, "Book not found".to_string()))?; + + let query = if params.q.trim().is_empty() { + book.title + } else { + params.q + }; + + let candidates = crate::upload::cover_search::search_covers(&query) + .await + .into_iter() + .map(|c| CoverCandidateJson { + thumb_url: c.thumb_url, + full_url: c.full_url, + source: c.source.to_string(), + title: c.title, + }) + .collect(); + Ok(Json(candidates)) +} + +/// PUT /api/books/{book_id}/cover — Set the cover from an allowlisted image URL. +async fn api_set_cover_url( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, + Json(req): Json, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + if !crate::upload::cover_search::is_allowed_cover_url(&req.url) { + return Err(( + StatusCode::BAD_REQUEST, + "Cover URL host not allowed".to_string(), + )); + } + let raw = crate::upload::enrich::fetch_cover(&req.url).await.ok_or(( + StatusCode::BAD_GATEWAY, + "Failed to download cover".to_string(), + ))?; + store_override_cover(&state, book_id, raw).await +} + +/// POST /api/books/{book_id}/cover — Set the cover from an uploaded image file +/// (`multipart/form-data`, single `file` field). +async fn api_upload_cover( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, + mut multipart: Multipart, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + ensure_book_exists(&state, book_id).await?; + + let mut bytes: Option> = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Malformed upload: {e}")))? + { + let is_file = field.name() == Some("file") || field.file_name().is_some(); + if bytes.is_some() || !is_file { + continue; + } + let data = field.bytes().await.map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Failed to read image: {e}"), + ) + })?; + bytes = Some(data.to_vec()); + } + let raw = bytes.ok_or((StatusCode::BAD_REQUEST, "No image uploaded".to_string()))?; + store_override_cover(&state, book_id, raw).await +} + +/// 404 unless a book with `book_id` exists. +async fn ensure_book_exists(state: &AppState, book_id: i64) -> Result<(), (StatusCode, String)> { + let exists = state + .books + .fetch_book_by_id(book_id) + .await + .map_err(|e| { + error!("Failed to fetch book: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })? + .is_some(); + exists + .then_some(()) + .ok_or((StatusCode::NOT_FOUND, "Book not found".to_string())) +} + +/// Transcode `raw` to JPEG, write it as the book's override cover, and record the +/// version bump (busts browser + device caches). Returns `204` on success. +async fn store_override_cover( + state: &AppState, + book_id: i64, + raw: Vec, +) -> Result { + let jpeg = tokio::task::spawn_blocking(move || crate::upload::cover::to_jpeg(&raw)) + .await + .map_err(|e| { + error!("cover transcode task panicked: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to process image".to_string(), + ) + })? + .ok_or((StatusCode::BAD_REQUEST, "Not a valid image".to_string()))?; + + let path = state.books.cover_override_path(book_id); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + error!("Failed to create cover dir: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to store cover".to_string(), + ) + })?; + } + tokio::fs::write(&path, &jpeg).await.map_err(|e| { + error!("Failed to write cover: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to store cover".to_string(), + ) + })?; + + state.app_db.set_book_cover(book_id).await.map_err(|e| { + error!("Failed to record cover: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + Ok(StatusCode::NO_CONTENT) +} + // ---- Token Management Handlers ---- /// GET /api/tokens — List all Kobo auth tokens for the logged-in user. diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 0b25fdd..5c2d8eb 100644 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -23,6 +23,19 @@ pub struct Book { pub sync_enabled: bool, /// RFC 3339 catalog-entry time; uniform format, sortable as a string. pub timestamp: String, + /// Override-cover version (0 = none). Used as `/covers/{id}?v=` to bust the + /// browser cache after the cover changes. + pub cover_version: i64, +} + +/// A candidate cover from `GET /api/books/{id}/cover/search`. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct CoverCandidate { + pub thumb_url: String, + pub full_url: String, + pub source: String, + #[serde(default)] + pub title: Option, } /// Global server settings (`GET`/`PUT /api/settings`). @@ -166,6 +179,32 @@ pub async fn set_book_sync(id: i64, enabled: bool) -> ApiResult<()> { check(resp).await.map(|_| ()) } +/// `GET /api/books/{id}/cover/search?q=…` — candidate covers for a book. +pub async fn search_covers(id: i64, query: &str) -> ApiResult> { + let encoded = String::from(js_sys::encode_uri_component(query)); + let url = format!("/api/books/{id}/cover/search?q={encoded}"); + let resp = gloo_net::http::Request::get(&url).send().await?; + Ok(check(resp).await?.json().await?) +} + +/// `PUT /api/books/{id}/cover` — set the cover from a chosen candidate URL. +pub async fn set_cover_url(id: i64, url: &str) -> ApiResult<()> { + let resp = gloo_net::http::Request::put(&format!("/api/books/{id}/cover")) + .json(&serde_json::json!({ "url": url }))? + .send() + .await?; + check(resp).await.map(|_| ()) +} + +/// `POST /api/books/{id}/cover` — set the cover from an uploaded image file. +pub async fn upload_cover(id: i64, form: FormData) -> ApiResult<()> { + let resp = gloo_net::http::Request::post(&format!("/api/books/{id}/cover")) + .body(JsValue::from(form))? + .send() + .await?; + check(resp).await.map(|_| ()) +} + /// `GET /api/tokens` — the user's Kobo auth tokens. pub async fn tokens() -> ApiResult> { let resp = gloo_net::http::Request::get("/api/tokens").send().await?; diff --git a/web-ui/src/components/cover_picker.rs b/web-ui/src/components/cover_picker.rs new file mode 100644 index 0000000..cfd6305 --- /dev/null +++ b/web-ui/src/components/cover_picker.rs @@ -0,0 +1,191 @@ +//! Modal to change a book's cover: search OpenLibrary/Google for candidates and +//! pick one, or upload a custom image file. Driven by an `edit` signal (open +//! when `Some`), mirroring the confirm-modal overlay pattern in `library.rs`. +//! On a successful change it calls `reload` so the parent grid re-fetches (which +//! also bumps the `/covers/{id}?v=` cache-buster). + +use leptos::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; +use web_sys::{FormData, HtmlInputElement}; + +use crate::api::{self, ApiResult, CoverCandidate}; +use crate::style::{ALERT_ERROR, ALERT_INFO, BTN, BTN_SM, INPUT, SPINNER}; + +/// The book whose cover is being edited (open state for the picker). +#[derive(Clone)] +pub struct CoverEdit { + pub id: i64, + pub title: String, +} + +#[component] +pub fn CoverPicker(edit: RwSignal>, reload: Callback<()>) -> impl IntoView { + let query = RwSignal::new(String::new()); + let results = RwSignal::new(None::>>); + let status = RwSignal::new(None::<(String, &'static str)>); + let busy = RwSignal::new(false); + let file_input: NodeRef = NodeRef::new(); + + // Opening a book prefills the search box with its title and resets state. + Effect::new(move |_| { + if let Some(e) = edit.get() { + query.set(e.title.clone()); + results.set(None); + status.set(None); + busy.set(false); + } + }); + + let do_search = move || { + let Some(e) = edit.get_untracked() else { + return; + }; + let q = query.get_untracked(); + results.set(None); + busy.set(true); + spawn_local(async move { + let r = api::search_covers(e.id, &q).await; + results.set(Some(r)); + busy.set(false); + }); + }; + + // Save a chosen candidate URL as the new cover, then close + reload. + let choose = move |full_url: String| { + let Some(e) = edit.get_untracked() else { + return; + }; + busy.set(true); + status.set(Some(("Saving\u{2026}".to_string(), ALERT_INFO))); + spawn_local(async move { + match api::set_cover_url(e.id, &full_url).await { + Ok(()) => { + reload.run(()); + edit.set(None); + } + Err(err) => { + status.set(Some((format!("Failed to save cover: {err}"), ALERT_ERROR))); + busy.set(false); + } + } + }); + }; + + let on_file = move |ev: leptos::ev::Event| { + let Some(e) = edit.get_untracked() else { + return; + }; + let Some(target) = ev.target() else { return }; + let Ok(input) = target.dyn_into::() else { + return; + }; + let Some(file) = input.files().and_then(|f| f.get(0)) else { + return; + }; + let name = file.name(); + let Ok(form) = FormData::new() else { return }; + if form + .append_with_blob_and_filename("file", file.as_ref(), &name) + .is_err() + { + return; + } + busy.set(true); + status.set(Some(("Uploading\u{2026}".to_string(), ALERT_INFO))); + spawn_local(async move { + match api::upload_cover(e.id, form).await { + Ok(()) => { + reload.run(()); + edit.set(None); + } + Err(err) => { + status.set(Some((format!("Upload failed: {err}"), ALERT_ERROR))); + busy.set(false); + } + } + input.set_value(""); + }); + }; + + view! { + {move || edit.get().map(|e| view! { +
+
+
+

"Change cover"

+

{e.title.clone()}

+
+ +
+ + + + +
+ + {move || status.get().map(|(m, c)| view! {

{m}

})} + +
+ {move || match results.get() { + None => if busy.get() { + view! {
}.into_any() + } else { + view! {

+ "Search for a cover, or upload your own image." +

}.into_any() + }, + Some(Err(err)) => view! { +

{format!("Search failed: {err}")}

+ }.into_any(), + Some(Ok(list)) if list.is_empty() => view! { +

"No covers found."

+ }.into_any(), + Some(Ok(list)) => { + let tiles = list.into_iter().map(|c| { + let full = c.full_url.clone(); + view! { + + } + }).collect_view(); + view! { +
+ {tiles} +
+ }.into_any() + } + }} +
+ +
+ +
+
+
+ })} + } +} diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs index 7e74843..9f5512e 100644 --- a/web-ui/src/components/library.rs +++ b/web-ui/src/components/library.rs @@ -9,6 +9,7 @@ use wasm_bindgen_futures::spawn_local; use web_sys::{FormData, HtmlInputElement}; use crate::api::{self, ApiError, ApiResult, Book, Token, User}; +use crate::components::cover_picker::{CoverEdit, CoverPicker}; use crate::style::{ ALERT_ERROR, ALERT_INFO, ALERT_SUCCESS, BRAND, BTN, BTN_ACCENT, BTN_PRIMARY, BTN_SM, HEADER, LINK, SWITCH, SWITCH_INPUT, SWITCH_SLIDER, @@ -87,6 +88,7 @@ pub fn Library() -> impl IntoView { // ---- ui state ---- let sort = RwSignal::new(Sort::Title); let filter = RwSignal::new(Filter::All); + let cover_edit = RwSignal::new(None::); let generating = RwSignal::new(false); let upload_status = RwSignal::new(None::<(String, &'static str)>); let copied = RwSignal::new(None::); @@ -138,8 +140,10 @@ pub fn Library() -> impl IntoView { spawn_local(async move { match api::upload(form).await { Ok(book) => { - upload_status - .set(Some((format!("Added \u{201C}{}\u{201D}.", book.title), ALERT_SUCCESS))); + upload_status.set(Some(( + format!("Added \u{201C}{}\u{201D}.", book.title), + ALERT_SUCCESS, + ))); reload_books(); } Err(e) => upload_status.set(Some((format!("Upload failed: {e}"), ALERT_ERROR))), @@ -329,9 +333,11 @@ pub fn Library() -> impl IntoView { } let cards = list.into_iter().map(|b| { let id = b.id; + let cover_version = b.cover_version; let is_upload = b.source == "upload"; let sync_enabled = b.sync_enabled; let title = b.title.clone(); + let edit_title = title.clone(); let author = if b.authors.is_empty() { "Unknown".to_string() } else { @@ -352,15 +358,25 @@ pub fn Library() -> impl IntoView { "\u{00D7}" })} -
{if b.has_cover { - view! { }.into_any() + view! { }.into_any() } else { view! { {title.clone()} }.into_any() }} +

{title.clone()}

{author}

@@ -442,5 +458,8 @@ pub fn Library() -> impl IntoView { } })} + + // ---- Change-cover modal ---- + } } diff --git a/web-ui/src/components/mod.rs b/web-ui/src/components/mod.rs index a5fd767..7f8c410 100644 --- a/web-ui/src/components/mod.rs +++ b/web-ui/src/components/mod.rs @@ -1,6 +1,7 @@ //! UI components, one module per page plus shared pieces. pub mod app; +pub mod cover_picker; pub mod library; pub mod login; pub mod register;