feat: multipart manga + chapter uploads with magic-byte MIME sniff
POST /api/v1/mangas and POST /api/v1/mangas/{id}/chapters now accept
multipart/form-data, gated by CurrentUser:
- /mangas: required `metadata` part (NewManga JSON) + optional `cover`
image part.
- /mangas/{id}/chapters: required `metadata` (NewChapter JSON) + one or
more `page` parts ordered by arrival. Returns 404 if the parent manga
doesn't exist, 409 on duplicate (manga_id, number).
MIME is sniffed via the `infer` crate (magic bytes), not the
client-supplied filename or Content-Type. Whitelist:
jpeg / png / webp / gif / avif. Anything else → 415
unsupported_media_type. The stored key's extension is derived from the
sniffed type so a "page1.png" that's actually a JPEG lands as `.jpg`.
Size cap is two-layer:
- Request body cap (config.max_request_bytes, default 200 MiB) enforced
by axum's DefaultBodyLimit before the handler sees the request.
- Per-image-part cap (config.max_file_bytes, default 20 MiB) enforced
after reading the part, so a single oversized image can't pass even
if the total request fits.
Storage keys follow the layout documented in CLAUDE.md:
- mangas/{manga_id}/cover.{ext}
- mangas/{manga_id}/chapters/{chapter_id}/pages/{nnnn}.{ext} (1-indexed).
AppError grows PayloadTooLarge/UnsupportedMediaType/ValidationFailed
(413 / 415 / 422). ValidationFailed carries a `details` JSON object the
client can use to highlight bad fields (e.g. {"title":"required"}).
Top-level matching in code() stays exhaustive.
Backend coverage in tests/api_uploads.rs (10 cases):
- create_manga_with_cover_stores_image — file is reachable via
/api/v1/files/{key} with the right Content-Type.
- create_manga_without_cover_leaves_path_null.
- create_manga_rejects_non_image_cover_with_415 — PDF claimed as png.
- create_manga_rejects_oversized_cover_with_413.
- create_chapter_with_pages_stores_each — extension derived from
sniffed MIME, files reachable in arrival order.
- create_chapter_rejects_when_no_pages_with_422 — details.page set.
- create_chapter_rejects_renamed_non_image_page → 415.
- create_chapter_returns_409_on_duplicate_number.
- create_chapter_requires_authentication → 401.
- create_chapter_under_unknown_manga_is_404.
Existing tests/api_mangas.rs is migrated to multipart; the create
response is now 201 Created. tests/common::MultipartBuilder builds the
body by hand so the test crate stays free of HTTP-client deps.
Frontend lib/api/mangas.ts: createManga now sends FormData (metadata +
optional cover Blob). Browser fills in the boundary header automatically.
Vitest asserts the FormData structure via FileReader (jsdom doesn't
implement Blob.text()).
E2E tests wait for the post-hydration nav-login link before
interacting with the login form, fixing a flake where pre-hydration
clicks would submit via the browser default and bypass our handler.
Lockstep version bump to 0.5.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,12 @@ use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use common::MultipartBuilder;
|
||||
|
||||
fn metadata(title: &str) -> serde_json::Value {
|
||||
json!({ "title": title })
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn list_is_empty_initially(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
@@ -25,14 +31,17 @@ async fn create_then_list_roundtrip(pool: PgPool) {
|
||||
let created = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
"/api/v1/mangas",
|
||||
json!({ "title": "Berserk", "author": "Kentaro Miura", "description": null }),
|
||||
MultipartBuilder::new().add_json(
|
||||
"metadata",
|
||||
json!({ "title": "Berserk", "author": "Kentaro Miura", "description": null }),
|
||||
),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::OK);
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let body = common::body_json(created).await;
|
||||
assert_eq!(body["title"], "Berserk");
|
||||
assert_eq!(body["author"], "Kentaro Miura");
|
||||
@@ -58,9 +67,10 @@ async fn search_filters_by_title_and_author(pool: PgPool) {
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
"/api/v1/mangas",
|
||||
json!({ "title": title, "author": author }),
|
||||
MultipartBuilder::new()
|
||||
.add_json("metadata", json!({ "title": title, "author": author })),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
@@ -98,23 +108,41 @@ async fn search_filters_by_title_and_author(pool: PgPool) {
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_rejects_empty_title_with_envelope(pool: PgPool) {
|
||||
async fn create_rejects_empty_title_with_validation_failed(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
"/api/v1/mangas",
|
||||
json!({ "title": " ", "author": null }),
|
||||
MultipartBuilder::new().add_json("metadata", metadata(" ")),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "invalid_input");
|
||||
let msg = body["error"]["message"].as_str().expect("message is string");
|
||||
assert!(!msg.is_empty(), "message should be non-empty");
|
||||
assert_eq!(body["error"]["code"], "validation_failed");
|
||||
assert!(body["error"]["details"]["title"].is_string());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_rejects_missing_metadata_part(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_multipart_with_cookie(
|
||||
"/api/v1/mangas",
|
||||
MultipartBuilder::new(), // no metadata part
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "validation_failed");
|
||||
assert_eq!(body["error"]["details"]["metadata"], "required");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
@@ -122,9 +150,9 @@ async fn create_requires_authentication(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json(
|
||||
.oneshot(common::post_multipart(
|
||||
"/api/v1/mangas",
|
||||
json!({ "title": "Berserk" }),
|
||||
MultipartBuilder::new().add_json("metadata", metadata("Berserk")),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user