feat: edit existing manga metadata (0.31.0)

Adds PUT /mangas/:id/cover (multipart) and DELETE /mangas/:id/cover so
covers can be replaced or cleared after creation, and wires a dedicated
/manga/[id]/edit SvelteKit route that combines the existing PATCH with
the new cover endpoints. Cover PUT cleans up the old blob when the
extension changes, swallowing StorageError::NotFound so a manually-gone
file doesn't surface as a 404 to the client. Edit link on the manga
detail page is gated on session.user, matching the auth posture of the
underlying handlers.

Also pins the local-dev port story via loadEnv() in vite.config.ts so
VITE_PORT / BACKEND_URL from a (gitignored) .env keep the dev URL
stable across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 20:26:23 +02:00
parent 9ff49166a5
commit fa0a7da311
14 changed files with 1277 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
use axum::extract::{Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
@@ -14,12 +14,14 @@ use crate::domain::patch::Patch;
use crate::domain::tag::TagRef;
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::storage::StorageError;
use crate::upload::{parse_image, UploadedImage};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/mangas", get(list).post(create))
.route("/mangas/:id", get(get_one).patch(update))
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
.route("/mangas/:id/tags", post(attach_tag))
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
}
@@ -259,6 +261,82 @@ async fn update(
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
/// `PUT /api/v1/mangas/:id/cover` is multipart/form-data with a single
/// required `cover` part containing image bytes. MIME is sniffed by
/// magic bytes (jpeg/png/webp/gif/avif); filename and Content-Type from
/// the client are ignored. Replaces any existing cover, deleting the
/// previous blob if its extension differs. Returns the refreshed
/// `MangaDetail`.
async fn put_cover(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<Uuid>,
mut multipart: Multipart,
) -> AppResult<Json<MangaDetail>> {
// TODO(auth): until uploaders are tracked (Phase 5), any signed-in
// user can edit any manga's cover. Restrict to uploader + admin
// once that column lands.
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
let mut cover: Option<UploadedImage> = None;
while let Some(field) = next_field(&mut multipart).await? {
if field.name() == Some("cover") {
let bytes = read_field_bytes(field).await?.to_vec();
cover = Some(parse_image(bytes, state.upload.max_file_bytes, "cover")?);
}
}
let img = cover.ok_or_else(|| AppError::ValidationFailed {
message: "cover part is required".into(),
details: json!({ "cover": "required" }),
})?;
// Read the old key BEFORE writing so we can clean up an orphan if
// the extension changed (e.g., .png → .jpg). Same-extension is a
// `put` overwrite — no delete needed.
let old_key = repo::manga::get(&state.db, id).await?.cover_image_path;
let new_key = format!("mangas/{}/cover.{}", id, img.ext);
state.storage.put(&new_key, &img.bytes).await?;
if let Some(prev) = old_key.as_deref() {
if prev != new_key {
// Swallow NotFound — AppError maps it to a client 404,
// which would be wrong here. The DB row can outlive a
// manually-deleted blob.
match state.storage.delete(prev).await {
Ok(()) | Err(StorageError::NotFound) => {}
Err(e) => return Err(e.into()),
}
}
}
repo::manga::set_cover_image_path(&state.db, id, &new_key).await?;
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
/// `DELETE /api/v1/mangas/:id/cover` clears `cover_image_path` and
/// removes the blob. Idempotent: removing a non-existent cover succeeds
/// with the unchanged detail.
async fn delete_cover(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<MangaDetail>> {
// TODO(auth): same caveat as put_cover.
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
match state.storage.delete(&key).await {
Ok(()) | Err(StorageError::NotFound) => {}
Err(e) => return Err(e.into()),
}
repo::manga::clear_cover_image_path(&state.db, id).await?;
}
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
}
#[derive(Debug, Deserialize)]
pub struct AttachTagBody {
pub name: String,