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:
@@ -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,
|
||||
|
||||
@@ -262,6 +262,17 @@ pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_cover_image_path<'e, E: PgExecutor<'e>>(
|
||||
executor: E,
|
||||
id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query("UPDATE mangas SET cover_image_path = NULL, updated_at = now() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn exists(pool: &PgPool, id: Uuid) -> AppResult<bool> {
|
||||
let (exists,): (bool,) =
|
||||
sqlx::query_as("SELECT EXISTS(SELECT 1 FROM mangas WHERE id = $1)")
|
||||
|
||||
Reference in New Issue
Block a user