diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..308fe72 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +# Shrink the build context the daemon ships to Docker — without this, +# the multi-GB `target/` from a local `cargo build` would be uploaded +# on every `docker compose build`. + +target/ +.sqlx/ +tests/ +*.md +.env +.env.* diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 322bc77..fc32685 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.10.1" +version = "0.10.2" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2da469c..91ad53b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.10.1" +version = "0.10.2" edition = "2021" [lib] diff --git a/backend/Dockerfile b/backend/Dockerfile index ebf2c21..68b7892 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,15 +5,18 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ && rm -rf /var/lib/apt/lists/* -# Cache deps separately from sources. -COPY Cargo.toml ./ +# Cache deps separately from sources. `--locked` makes cargo refuse to +# update the lockfile, so the production image is built against the +# exact crate versions CI tested. Without Cargo.lock + the flag, cargo +# would silently resolve fresh on every image build. +COPY Cargo.toml Cargo.lock ./ RUN mkdir src && echo "fn main() {}" > src/main.rs && echo "" > src/lib.rs \ - && cargo build --release \ + && cargo build --locked --release \ && rm -rf src COPY src ./src COPY migrations ./migrations -RUN touch src/main.rs src/lib.rs && cargo build --release +RUN touch src/main.rs src/lib.rs && cargo build --locked --release FROM debian:bookworm-slim RUN apt-get update \ diff --git a/backend/src/api/chapters.rs b/backend/src/api/chapters.rs index 4778c58..2026363 100644 --- a/backend/src/api/chapters.rs +++ b/backend/src/api/chapters.rs @@ -105,6 +105,15 @@ async fn create( message: "metadata part is required".into(), details: json!({ "metadata": "required" }), })?; + // Chapter number is 1-indexed everywhere (URLs, upload form, + // reader). Reject 0 / negative numbers up front so the row never + // makes it into the DB. Mirrors the page>=1 rule on bookmarks. + if metadata.number < 1 { + return Err(AppError::ValidationFailed { + message: "chapter number must be 1 or greater".into(), + details: json!({ "number": "must be >= 1" }), + }); + } if pages.is_empty() { return Err(AppError::ValidationFailed { message: "at least one page is required".into(), diff --git a/backend/tests/api_uploads.rs b/backend/tests/api_uploads.rs index 385bc62..aabfddc 100644 --- a/backend/tests/api_uploads.rs +++ b/backend/tests/api_uploads.rs @@ -242,6 +242,36 @@ async fn create_chapter_with_pages_stores_each(pool: PgPool) { } } +#[sqlx::test(migrations = "./migrations")] +async fn create_chapter_rejects_non_positive_number_with_422(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await; + + for bad_number in [0i32, -1, -100] { + let resp = h + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters"), + MultipartBuilder::new() + .add_json("metadata", json!({ "number": bad_number })) + .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), + &cookie, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "number={bad_number} should be rejected" + ); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "validation_failed"); + assert!(body["error"]["details"]["number"].is_string()); + } +} + #[sqlx::test(migrations = "./migrations")] async fn create_chapter_rejects_when_no_pages_with_422(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1b0358e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,13 @@ +# Shrink the build context the daemon ships to Docker. node_modules in +# particular would otherwise be re-uploaded on every build despite the +# Dockerfile re-installing it from scratch inside the builder. + +node_modules/ +build/ +.svelte-kit/ +e2e/ +test-results/ +playwright-report/ +*.md +.env +.env.* diff --git a/frontend/package.json b/frontend/package.json index e8c2653..806924c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.10.1", + "version": "0.10.2", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/routes/bookmarks/+page.svelte b/frontend/src/routes/bookmarks/+page.svelte index 150638f..c0b2612 100644 --- a/frontend/src/routes/bookmarks/+page.svelte +++ b/frontend/src/routes/bookmarks/+page.svelte @@ -2,6 +2,7 @@ let { data } = $props(); const authenticated = $derived(data.authenticated); const bookmarks = $derived(data.bookmarks); + const error = $derived(data.error); @@ -10,7 +11,11 @@

Bookmarks

-{#if !authenticated} +{#if error} +

+ Couldn't load bookmarks: {error} +

+{:else if !authenticated}

Sign in to see your bookmarks.

diff --git a/frontend/src/routes/bookmarks/+page.ts b/frontend/src/routes/bookmarks/+page.ts index dc7a694..bd431ed 100644 --- a/frontend/src/routes/bookmarks/+page.ts +++ b/frontend/src/routes/bookmarks/+page.ts @@ -7,10 +7,18 @@ export const ssr = false; export const load: PageLoad = async () => { try { const page = await listMyBookmarks(); - return { bookmarks: page.items, authenticated: true }; + return { bookmarks: page.items, authenticated: true, error: null }; } catch (e) { if (e instanceof ApiError && e.status === 401) { - return { bookmarks: [], authenticated: false }; + return { bookmarks: [], authenticated: false, error: null }; + } + // Anything else (502 upstream_unavailable from a backend + // restart, 500 internal_error) is rendered inline rather than + // re-thrown — SvelteKit's generic error.html is not the right + // UX for a transient API blip and the user is already + // authenticated as far as we know. + if (e instanceof ApiError) { + return { bookmarks: [], authenticated: true, error: e.message }; } throw e; } diff --git a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte index 33d643e..8a7de67 100644 --- a/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[n]/+page.svelte @@ -32,10 +32,14 @@ // Don't hijack keys while the user is typing in an input. const target = e.target as HTMLElement | null; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return; + // Space is deliberately *not* bound — on viewports where a page + // image overflows (portrait phones, narrow desktop windows), + // users expect Space to scroll the page, and stealing it for + // next-page would skip past unread content. ArrowRight + j + // are the discoverable bindings. switch (e.key) { case 'ArrowRight': case 'j': - case ' ': e.preventDefault(); next(); break;