- F1: backend/Dockerfile now copies Cargo.lock alongside Cargo.toml
and builds with --locked, so the production image runs against the
exact crate versions CI tested. Without this, cargo silently
resolved fresh on each image build and "we tested it" stopped being
true for the binary you ship.
- F2: POST /api/v1/mangas/{id}/chapters rejects chapter `number < 1`
with 422 validation_failed. Mirrors the bookmark page>=1 rule from
0.9.4 — chapter numbers are 1-indexed everywhere (URLs, upload
form, reader) and 0/negative numbers had no legitimate use. Three
cases (0, -1, -100) in api_uploads.rs.
- F3: bookmarks/+page.ts no longer re-throws non-401 ApiErrors as
SvelteKit's generic 500 page. Surfaces the error message inline via
a new `data.error` field; the page renders an alert when present.
Same UX shape as the home page's existing error handling.
- F4: dropped Space from the reader keyboard binding. On portrait
phones and narrow desktop windows the page image overflows the
viewport and the user expects Space to scroll — preventDefaulting
it skipped past unread content. ArrowRight + j remain.
- New backend/.dockerignore and frontend/.dockerignore so the local
target/ and node_modules/ don't get shipped into the build context
on every `docker compose build`.
Lockstep version bump to 0.10.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
5.8 KiB
Rust
175 lines
5.8 KiB
Rust
//! Chapter list + get + multipart upload.
|
|
//!
|
|
//! Reads are public. Uploads (POST) require auth and use the same
|
|
//! multipart conventions as `POST /api/v1/mangas`:
|
|
//! - `metadata` part (JSON) with `{ number, title? }`.
|
|
//! - One or more `page` parts (images, ordered by arrival).
|
|
|
|
use axum::extract::{Multipart, Path, Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use crate::api::mangas::{next_field, read_field_bytes};
|
|
use crate::api::pagination::PagedResponse;
|
|
use crate::app::AppState;
|
|
use crate::auth::extractor::CurrentUser;
|
|
use crate::domain::chapter::NewChapter;
|
|
use crate::domain::{Chapter, Page};
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::repo;
|
|
use crate::upload::{parse_image, UploadedImage};
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/mangas/:manga_id/chapters", get(list).post(create))
|
|
.route("/mangas/:manga_id/chapters/:number", get(get_one))
|
|
.route(
|
|
"/mangas/:manga_id/chapters/:number/pages",
|
|
get(list_pages),
|
|
)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListParams {
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i64,
|
|
#[serde(default)]
|
|
pub offset: i64,
|
|
}
|
|
|
|
fn default_limit() -> i64 {
|
|
50
|
|
}
|
|
|
|
async fn list(
|
|
State(state): State<AppState>,
|
|
Path(manga_id): Path<Uuid>,
|
|
Query(params): Query<ListParams>,
|
|
) -> AppResult<Json<PagedResponse<Chapter>>> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
|
|
let limit = params.limit.clamp(1, 200);
|
|
let offset = params.offset.max(0);
|
|
let items = repo::chapter::list_for_manga(&state.db, manga_id, limit, offset).await?;
|
|
Ok(Json(PagedResponse::new(items, limit, offset)))
|
|
}
|
|
|
|
async fn get_one(
|
|
State(state): State<AppState>,
|
|
Path((manga_id, number)): Path<(Uuid, i32)>,
|
|
) -> AppResult<Json<Chapter>> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number)
|
|
.await?
|
|
.ok_or(AppError::NotFound)?;
|
|
Ok(Json(chapter))
|
|
}
|
|
|
|
async fn create(
|
|
State(state): State<AppState>,
|
|
CurrentUser(_user): CurrentUser,
|
|
Path(manga_id): Path<Uuid>,
|
|
mut multipart: Multipart,
|
|
) -> AppResult<(StatusCode, Json<Chapter>)> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
|
|
let mut metadata: Option<NewChapter> = None;
|
|
let mut pages: Vec<UploadedImage> = Vec::new();
|
|
|
|
while let Some(field) = next_field(&mut multipart).await? {
|
|
match field.name() {
|
|
Some("metadata") => {
|
|
let bytes = read_field_bytes(field).await?;
|
|
metadata =
|
|
Some(serde_json::from_slice(&bytes).map_err(|e| {
|
|
AppError::ValidationFailed {
|
|
message: "metadata is not valid JSON".into(),
|
|
details: json!({ "metadata": e.to_string() }),
|
|
}
|
|
})?);
|
|
}
|
|
Some("page") => {
|
|
let bytes = read_field_bytes(field).await?.to_vec();
|
|
let field_name = format!("page[{}]", pages.len());
|
|
pages.push(parse_image(bytes, state.upload.max_file_bytes, &field_name)?);
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
|
|
let metadata = metadata.ok_or_else(|| AppError::ValidationFailed {
|
|
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(),
|
|
details: json!({ "page": "at least one required" }),
|
|
});
|
|
}
|
|
|
|
// Transactional create. If any storage put or page-row insert
|
|
// fails mid-loop, the chapter row + any earlier page rows are
|
|
// rolled back so we don't leave a chapter with stale page_count=0
|
|
// and orphaned page rows. Bytes already written to storage on a
|
|
// rolled-back transaction become orphans on disk; a future reaper
|
|
// can sweep them. DB consistency wins over storage tidiness here.
|
|
let mut tx = state.db.begin().await?;
|
|
let mut chapter = repo::chapter::create(
|
|
&mut *tx,
|
|
manga_id,
|
|
metadata.number,
|
|
metadata.title.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
for (idx, page) in pages.iter().enumerate() {
|
|
let page_number = (idx + 1) as i32;
|
|
let nnnn = format!("{:04}", page_number);
|
|
let key = format!(
|
|
"mangas/{}/chapters/{}/pages/{}.{}",
|
|
manga_id, chapter.id, nnnn, page.ext
|
|
);
|
|
state.storage.put(&key, &page.bytes).await?;
|
|
repo::page::create(&mut *tx, chapter.id, page_number, &key, page.mime).await?;
|
|
}
|
|
|
|
let page_count = pages.len() as i32;
|
|
repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?;
|
|
chapter.page_count = page_count;
|
|
|
|
tx.commit().await?;
|
|
|
|
Ok((StatusCode::CREATED, Json(chapter)))
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct PagesResponse {
|
|
pages: Vec<Page>,
|
|
}
|
|
|
|
async fn list_pages(
|
|
State(state): State<AppState>,
|
|
Path((manga_id, number)): Path<(Uuid, i32)>,
|
|
) -> AppResult<Json<PagesResponse>> {
|
|
repo::manga::get(&state.db, manga_id).await?;
|
|
let chapter = repo::chapter::find_by_manga_and_number(&state.db, manga_id, number)
|
|
.await?
|
|
.ok_or(AppError::NotFound)?;
|
|
let pages = repo::page::list_for_chapter(&state.db, chapter.id).await?;
|
|
Ok(Json(PagesResponse { pages }))
|
|
}
|