From 755417730fda8061546699bc858d0417fda7df81 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 14:41:19 +0200 Subject: [PATCH] feat: serve width-bounded thumbnail variants for image grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /files/{key}?w=: the endpoint decodes JPEG/PNG sources, downscales to a width snapped to a small allow-list (aspect-preserving, never upscaling), caches the result under a thumbs/ prefix, and serves it — so cover grids download ~KB instead of the 1–5 MB original. Cover handlers purge cached variants when a cover (whose key is reused) is replaced or deleted. Frontend grids use new thumbUrl/thumbSrcset helpers (MangaCard with responsive srcset). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 3 +- backend/src/api/files.rs | 284 ++++++++++++++++-- backend/src/api/mangas.rs | 7 + backend/tests/api_mangas_cover.rs | 60 ++++ frontend/package.json | 2 +- frontend/src/lib/api/client.test.ts | 17 +- frontend/src/lib/api/client.ts | 19 ++ .../src/lib/components/BookmarkList.svelte | 4 +- .../src/lib/components/CollectionsGrid.svelte | 4 +- .../components/ContinueReadingShelf.svelte | 4 +- frontend/src/lib/components/MangaCard.svelte | 6 +- .../src/lib/components/TaggedMangaRow.svelte | 4 +- 13 files changed, 380 insertions(+), 36 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 73602e1..fe500a9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.24" +version = "0.125.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5c4162d..8827010 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.24" +version = "0.125.0" edition = "2021" default-run = "mangalord" @@ -62,6 +62,7 @@ http-body-util = "0.1" mime = "0.3" futures-util = "0.3" tokio = { version = "1", features = ["test-util"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } # Trim debug builds: keep line numbers in panics / backtraces but drop the # full DWARF info (variable-level inspection in gdb/lldb). With a sqlx + diff --git a/backend/src/api/files.rs b/backend/src/api/files.rs index 51ca9d4..667536c 100644 --- a/backend/src/api/files.rs +++ b/backend/src/api/files.rs @@ -5,6 +5,13 @@ //! The handler uses `Storage::get_stream` so a multi-MB page is piped to //! the client a chunk at a time instead of buffered server-side. //! +//! **Thumbnails.** `?w=` serves a width-bounded variant (grids ask for a +//! small width so they download ~KB instead of the 1–5 MB original). The width +//! snaps to a small allow-list so the number of cached derivatives stays +//! bounded; the resized image is cached in storage under a `thumbs/w{W}/` prefix +//! and regenerated on demand. Only JPEG/PNG sources are thumbnailed (encoders we +//! ship); other formats fall back to the original. +//! //! **Auth model — capability URLs by design.** This endpoint is //! deliberately unauthenticated: reads stay public per the project //! brief, and per-page authorisation would require either a per-request @@ -16,35 +23,130 @@ //! would gate this endpoint behind a `Storage::owner_of(key)` check; //! the seam is intentional. +use std::io::Cursor; + use axum::body::Body; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderName}; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; +use image::imageops::FilterType; +use image::{ImageFormat, ImageReader}; +use serde::Deserialize; use crate::app::AppState; -use crate::error::AppResult; -use crate::storage::StorageError; +use crate::error::{AppError, AppResult}; +use crate::storage::{Storage, StorageError}; + +/// Widths a thumbnail may be rendered at. A requested width snaps up to the +/// smallest of these so the set of cached derivatives stays tiny. +const ALLOWED_THUMB_WIDTHS: &[u32] = &[160, 320, 480, 640, 960]; + +/// Storage key prefix for cached thumbnails. +const THUMB_PREFIX: &str = "thumbs"; + +/// Decode allocation cap (mirrors `analysis::ocr`): a tiny file declaring huge +/// dimensions is rejected before the decoder allocates, not after (OOM guard). +const MAX_THUMB_DECODE_PIXELS: u64 = 40_000_000; pub fn routes() -> Router { Router::new().route("/files/*key", get(serve)) } -async fn serve(State(state): State, Path(key): Path) -> AppResult { - let file = match state.storage.get_stream(&key).await { +#[derive(Debug, Deserialize)] +struct ServeQuery { + /// Requested thumbnail width in pixels; absent = serve the original. + w: Option, +} + +async fn serve( + State(state): State, + Path(key): Path, + Query(q): Query, +) -> AppResult { + // Thumbnail request: only for source formats we can re-encode; anything + // else falls through to serving the original. + if let Some(width) = resolve_thumb_width(q.w.as_deref()) { + if let Some(fmt) = thumb_format_for(&key) { + return serve_thumbnail(&state, &key, width, fmt).await; + } + } + serve_original(&state, &key).await +} + +async fn serve_original(state: &AppState, key: &str) -> AppResult { + let file = match state.storage.get_stream(key).await { Ok(f) => f, - Err(StorageError::NotFound) => return Err(crate::error::AppError::NotFound), + Err(StorageError::NotFound) => return Err(AppError::NotFound), Err(e) => return Err(e.into()), }; - let ct = content_type_for(&key); - // `nosniff` makes the contract explicit: the browser must trust the - // Content-Type we declared (and that the magic-byte sniff at upload - // time produced) instead of trying to detect HTML/JS in the body. - // Belt-and-braces vs. polyglot files that survive the upload sniff. + Ok(image_response( + state, + content_type_for(key), + file.size_bytes.to_string(), + Body::from_stream(file.stream), + )) +} + +async fn serve_thumbnail( + state: &AppState, + key: &str, + width: u32, + fmt: ImageFormat, +) -> AppResult { + let derived = thumb_key(key, width); + + // Serve the cached variant if it exists. + match state.storage.get_stream(&derived).await { + Ok(f) => { + return Ok(image_response( + state, + content_type_for(key), + f.size_bytes.to_string(), + Body::from_stream(f.stream), + )); + } + Err(StorageError::NotFound) => {} + Err(e) => return Err(e.into()), + } + + // Generate from the original. + let original = match state.storage.get(key).await { + Ok(b) => b, + Err(StorageError::NotFound) => return Err(AppError::NotFound), + Err(e) => return Err(e.into()), + }; + // Resizing is CPU-bound; keep it off the async worker threads. + let thumb = tokio::task::spawn_blocking(move || make_thumbnail(&original, width, fmt)) + .await + .map_err(|e| AppError::Other(anyhow::anyhow!("thumbnail task join: {e}")))??; + + // Best-effort cache; a write failure just means we regenerate next time. + let _ = state.storage.put(&derived, &thumb).await; + + let len = thumb.len().to_string(); + Ok(image_response( + state, + content_type_for(key), + len, + Body::from(thumb), + )) +} + +/// Shared response builder for both the original and thumbnail paths. +fn image_response( + state: &AppState, + content_type: &str, + content_length: String, + body: Body, +) -> Response { let headers = [ - (header::CONTENT_TYPE, ct.to_string()), - (header::CONTENT_LENGTH, file.size_bytes.to_string()), + (header::CONTENT_TYPE, content_type.to_string()), + (header::CONTENT_LENGTH, content_length), + // `nosniff` makes the contract explicit: the browser must trust the + // Content-Type we declared (and that the magic-byte sniff at upload + // time produced) instead of trying to detect HTML/JS in the body. ( HeaderName::from_static("x-content-type-options"), "nosniff".to_string(), @@ -52,15 +154,12 @@ async fn serve(State(state): State, Path(key): Path) -> AppRes // Blobs are content-addressed by unguessable, immutable keys (a // re-upload mints new UUIDs), so a fetched page/cover never changes. // Cache it for a year and mark it `immutable` so browsers skip - // revalidation entirely — this is what lets the reader's page and - // next-chapter preloading hit cache instead of re-downloading (and - // re-proxying every byte through the SvelteKit node server in prod). + // revalidation entirely. // - // BUT under PRIVATE_MODE these blobs are auth-gated (see - // `private_mode_guard`), so they must NOT be marked `public`: a shared - // cache / CDN in front of the app would store the object and then serve - // it to unauthenticated clients, defeating the gate. Use `private` so - // only the requesting user's browser caches it. + // BUT under PRIVATE_MODE these blobs are auth-gated, so they must NOT be + // marked `public`: a shared cache / CDN would store the object and serve + // it to unauthenticated clients. Use `private` so only the requesting + // user's browser caches it. ( header::CACHE_CONTROL, if state.auth.private_mode { @@ -70,7 +169,83 @@ async fn serve(State(state): State, Path(key): Path) -> AppRes }, ), ]; - Ok((headers, Body::from_stream(file.stream)).into_response()) + (headers, body).into_response() +} + +/// Parse and clamp a requested thumbnail width. Returns `None` for absent / +/// unparseable / zero widths (serve the original); otherwise snaps the request +/// up to the smallest allowed width (capped at the largest) so cached variants +/// stay bounded. +fn resolve_thumb_width(raw: Option<&str>) -> Option { + let requested: u32 = raw?.trim().parse().ok()?; + if requested == 0 { + return None; + } + Some( + ALLOWED_THUMB_WIDTHS + .iter() + .copied() + .find(|&w| w >= requested) + .unwrap_or_else(|| *ALLOWED_THUMB_WIDTHS.last().expect("non-empty")), + ) +} + +/// The re-encode format for a source key, or `None` when it isn't one we ship an +/// encoder for (gif/avif → serve the original instead of a broken thumbnail). +fn thumb_format_for(key: &str) -> Option { + match content_type_for(key) { + "image/jpeg" => Some(ImageFormat::Jpeg), + "image/png" => Some(ImageFormat::Png), + _ => None, + } +} + +/// The storage key a width-`w` thumbnail of `key` is cached under. +fn thumb_key(key: &str, width: u32) -> String { + format!("{THUMB_PREFIX}/w{width}/{key}") +} + +/// Every cached thumbnail key for an original `key`, across all allowed widths. +/// Used by the cover handlers to purge stale variants when a cover (whose key is +/// reused, unlike content-addressed pages) is replaced or deleted. +pub(crate) fn thumbnail_keys(key: &str) -> Vec { + ALLOWED_THUMB_WIDTHS + .iter() + .map(|&w| thumb_key(key, w)) + .collect() +} + +/// Best-effort deletion of every cached thumbnail for `key`. Call after the +/// underlying blob at `key` changes or is removed. +pub(crate) async fn purge_thumbnails(storage: &dyn Storage, key: &str) { + for derived in thumbnail_keys(key) { + let _ = storage.delete(&derived).await; + } +} + +/// Decode, downscale to `width` (aspect-preserving, never upscaling), and +/// re-encode in `fmt`. Pure + synchronous so it runs under `spawn_blocking` and +/// is unit-testable without a server. +fn make_thumbnail(bytes: &[u8], width: u32, fmt: ImageFormat) -> anyhow::Result> { + use anyhow::Context; + let mut reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .context("guess image format for thumbnail")?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_THUMB_DECODE_PIXELS.saturating_mul(4)); + reader.limits(limits); + let img = reader.decode().context("decode image for thumbnail")?; + + // Only downscale; a source narrower than the target is served as-is. + let out = if img.width() > width { + img.resize(width, u32::MAX, FilterType::Lanczos3) + } else { + img + }; + + let mut buf = Cursor::new(Vec::new()); + out.write_to(&mut buf, fmt).context("encode thumbnail")?; + Ok(buf.into_inner()) } fn content_type_for(key: &str) -> &'static str { @@ -84,3 +259,68 @@ fn content_type_for(key: &str) -> &'static str { _ => "application/octet-stream", } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_thumb_width_snaps_and_validates() { + // Absent / unparseable / zero → serve the original. + assert_eq!(resolve_thumb_width(None), None); + assert_eq!(resolve_thumb_width(Some("")), None); + assert_eq!(resolve_thumb_width(Some("abc")), None); + assert_eq!(resolve_thumb_width(Some("0")), None); + // Snap up to the smallest allowed width. + assert_eq!(resolve_thumb_width(Some("1")), Some(160)); + assert_eq!(resolve_thumb_width(Some("160")), Some(160)); + assert_eq!(resolve_thumb_width(Some("161")), Some(320)); + assert_eq!(resolve_thumb_width(Some("640")), Some(640)); + // Above the max → capped at the largest allowed width. + assert_eq!(resolve_thumb_width(Some("5000")), Some(960)); + } + + #[test] + fn thumb_format_only_for_encodable_sources() { + assert_eq!(thumb_format_for("a/b/cover.jpg"), Some(ImageFormat::Jpeg)); + assert_eq!(thumb_format_for("a/b/cover.png"), Some(ImageFormat::Png)); + // Formats we can't re-encode fall back to the original. + assert_eq!(thumb_format_for("a/b/cover.webp"), None); + assert_eq!(thumb_format_for("a/b/cover.gif"), None); + assert_eq!(thumb_format_for("a/b/cover.avif"), None); + } + + #[test] + fn thumb_key_is_prefixed_by_width() { + assert_eq!( + thumb_key("mangas/x/cover.png", 320), + "thumbs/w320/mangas/x/cover.png" + ); + assert_eq!(thumbnail_keys("k.png").len(), ALLOWED_THUMB_WIDTHS.len()); + } + + #[test] + fn make_thumbnail_downscales_and_preserves_aspect() { + // 100x50 red PNG → thumbnail width 40 → 40x20, still decodable PNG. + let mut src = Cursor::new(Vec::new()); + image::RgbImage::from_pixel(100, 50, image::Rgb([255, 0, 0])) + .write_to(&mut src, ImageFormat::Png) + .unwrap(); + let out = make_thumbnail(src.get_ref(), 40, ImageFormat::Png).unwrap(); + let decoded = image::load_from_memory(&out).unwrap(); + assert_eq!(decoded.width(), 40); + assert_eq!(decoded.height(), 20, "aspect ratio preserved"); + } + + #[test] + fn make_thumbnail_does_not_upscale() { + // A 30px-wide source requested at 40 stays 30 wide (no upscaling). + let mut src = Cursor::new(Vec::new()); + image::RgbImage::from_pixel(30, 30, image::Rgb([0, 255, 0])) + .write_to(&mut src, ImageFormat::Png) + .unwrap(); + let out = make_thumbnail(src.get_ref(), 40, ImageFormat::Png).unwrap(); + let decoded = image::load_from_memory(&out).unwrap(); + assert_eq!(decoded.width(), 30); + } +} diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index 08af8bc..db0ef9c 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -414,8 +414,14 @@ async fn put_cover( Ok(()) | Err(StorageError::NotFound) => {} Err(e) => return Err(e.into()), } + // Old key's cached thumbnails are now orphaned. + crate::api::files::purge_thumbnails(state.storage.as_ref(), prev).await; } } + // Cover keys are reused (mangas/{id}/cover.{ext}), so a same-extension + // replacement overwrites the blob at an existing key — drop any thumbnails + // cached for it so we don't serve a stale variant of the old cover. + crate::api::files::purge_thumbnails(state.storage.as_ref(), &new_key).await; repo::manga::set_cover_image_path(&state.db, id, &new_key, img.bytes.len() as i64).await?; Ok(Json(repo::manga::get_detail(&state.db, id).await?)) @@ -439,6 +445,7 @@ async fn delete_cover( Ok(()) | Err(StorageError::NotFound) => {} Err(e) => return Err(e.into()), } + crate::api::files::purge_thumbnails(state.storage.as_ref(), &key).await; repo::manga::clear_cover_image_path(&state.db, id).await?; } Ok(Json(repo::manga::get_detail(&state.db, id).await?)) diff --git a/backend/tests/api_mangas_cover.rs b/backend/tests/api_mangas_cover.rs index 398a3f3..e8242e7 100644 --- a/backend/tests/api_mangas_cover.rs +++ b/backend/tests/api_mangas_cover.rs @@ -1,6 +1,7 @@ mod common; use axum::http::StatusCode; +use http_body_util::BodyExt; use serde_json::{json, Value}; use sqlx::PgPool; use tower::ServiceExt; @@ -45,6 +46,65 @@ fn cover_form(bytes: &[u8]) -> MultipartBuilder { MultipartBuilder::new().add_file("cover", "cover.bin", "application/octet-stream", bytes) } +/// A real, decodable PNG (unlike `fake_png_bytes`, which is only magic bytes) +/// so the thumbnail endpoint has something to resize. +fn real_png(width: u32, height: u32) -> Vec { + let mut buf = std::io::Cursor::new(Vec::new()); + image::RgbImage::from_pixel(width, height, image::Rgb([10, 120, 200])) + .write_to(&mut buf, image::ImageFormat::Png) + .unwrap(); + buf.into_inner() +} + +#[sqlx::test(migrations = "./migrations")] +async fn files_serves_downscaled_thumbnail_variant(pool: PgPool) { + let h = harness(pool); + let (_, cookie) = register_user(&h.app).await; + let manga = create_manga_with_cover(&h.app, &cookie, "Thumb", None).await; + let id = id_of(&manga); + + // Upload a real 800x400 PNG cover. + let resp = h + .app + .clone() + .oneshot(put_multipart_with_cookie( + &format!("/api/v1/mangas/{id}/cover"), + cover_form(&real_png(800, 400)), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let key = format!("mangas/{id}/cover.png"); + + // ?w=320 serves a 320px-wide variant with the source content-type. + let resp = h + .app + .clone() + .oneshot(get(&format!("/api/v1/files/{key}?w=320"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap().to_str().unwrap(), + "image/png" + ); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let thumb = image::load_from_memory(&body).unwrap(); + assert_eq!(thumb.width(), 320, "served a 320px-wide variant"); + assert_eq!(thumb.height(), 160, "aspect ratio preserved"); + + // The un-parametrised request still serves the full-resolution original. + let resp = h + .app + .clone() + .oneshot(get(&format!("/api/v1/files/{key}"))) + .await + .unwrap(); + let full = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(image::load_from_memory(&full).unwrap().width(), 800); +} + #[sqlx::test(migrations = "./migrations")] async fn put_cover_sets_path_when_none_existed(pool: PgPool) { let h = harness(pool); diff --git a/frontend/package.json b/frontend/package.json index 3e661d6..d6441aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.24", + "version": "0.125.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index 2bd7c1b..ba00014 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; -import { ApiError, request, setOn401Hook, fileUrl } from './client'; +import { ApiError, request, setOn401Hook, fileUrl, thumbUrl, thumbSrcset } from './client'; import { getManga } from './mangas'; describe('fileUrl', () => { @@ -19,6 +19,21 @@ describe('fileUrl', () => { }); }); +describe('thumbUrl / thumbSrcset', () => { + it('appends the width query to the file URL', () => { + expect(thumbUrl('mangas/abc/cover.png', 320)).toBe( + '/api/v1/files/mangas/abc/cover.png?w=320' + ); + }); + + it('builds a srcset over the given candidate widths', () => { + expect(thumbSrcset('mangas/abc/cover.png', [160, 320])).toBe( + '/api/v1/files/mangas/abc/cover.png?w=160 160w, ' + + '/api/v1/files/mangas/abc/cover.png?w=320 320w' + ); + }); +}); + describe('request error envelope parsing', () => { let fetchSpy: MockInstance; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index da01af5..f82075b 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -19,6 +19,25 @@ export function fileUrl(key: string): string { return `${BASE}/v1/files/${encoded}`; } +/** + * URL to a width-bounded thumbnail variant of a stored image (the backend + * `/files/{key}?w=` endpoint). Grids use this so a cover downloads ~KB instead + * of the 1–5 MB original; the backend snaps `width` to a small allow-list and + * caches the result. + */ +export function thumbUrl(key: string, width: number): string { + return `${fileUrl(key)}?w=${width}`; +} + +/** + * A `srcset` string over the candidate `widths` for `key`, e.g. + * `.../files/k?w=320 320w, .../files/k?w=480 480w`. Pair with a `sizes` + * attribute so the browser picks the right variant for the rendered size. + */ +export function thumbSrcset(key: string, widths: number[]): string { + return widths.map((w) => `${thumbUrl(key, w)} ${w}w`).join(', '); +} + /** * Builds an API URL for non-`fetch` consumers (e.g. `EventSource` for SSE), * applying the same `VITE_API_BASE` prefix as `request()`. `path` is the diff --git a/frontend/src/lib/components/BookmarkList.svelte b/frontend/src/lib/components/BookmarkList.svelte index 02f4cf5..ff9807b 100644 --- a/frontend/src/lib/components/BookmarkList.svelte +++ b/frontend/src/lib/components/BookmarkList.svelte @@ -1,5 +1,5 @@