fix: commit cover DB pointer before mutating the old blob
put_cover/delete_cover mutated storage before the DB pointer, so a transient DB failure could leave the row pointing at a deleted blob. Reorder to put->set-DB->delete-old and clear-DB->delete-blob, so a failure only orphans a blob, never dangles the pointer. Happy path covered by the 16 existing cover tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.15"
|
version = "0.128.16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.15"
|
version = "0.128.16"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -409,6 +409,17 @@ async fn put_cover(
|
|||||||
let old_key = repo::manga::get(&state.db, id).await?.cover_image_path;
|
let old_key = repo::manga::get(&state.db, id).await?.cover_image_path;
|
||||||
let new_key = format!("mangas/{}/cover.{}", id, img.ext);
|
let new_key = format!("mangas/{}/cover.{}", id, img.ext);
|
||||||
state.storage.put(&new_key, &img.bytes).await?;
|
state.storage.put(&new_key, &img.bytes).await?;
|
||||||
|
// Cover keys are reused (mangas/{id}/cover.{ext}), so a same-extension
|
||||||
|
// replacement overwrites the blob at an existing key — drop any thumbnails
|
||||||
|
// cached for it so we don't serve a stale variant of the old cover.
|
||||||
|
crate::api::files::purge_thumbnails(state.storage.as_ref(), &new_key).await;
|
||||||
|
|
||||||
|
// Commit the DB pointer to the new blob BEFORE removing the old one. If we
|
||||||
|
// deleted the old blob first and this write then failed, the row would point
|
||||||
|
// at a deleted blob (a cover that 404s) and the new blob would be orphaned.
|
||||||
|
// Ordering it first means a failure here leaves the row pointing at the
|
||||||
|
// still-present old blob; only a harmless orphan (new blob) can result.
|
||||||
|
repo::manga::set_cover_image_path(&state.db, id, &new_key, img.bytes.len() as i64).await?;
|
||||||
|
|
||||||
if let Some(prev) = old_key.as_deref() {
|
if let Some(prev) = old_key.as_deref() {
|
||||||
if prev != new_key {
|
if prev != new_key {
|
||||||
@@ -423,12 +434,6 @@ async fn put_cover(
|
|||||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), prev).await;
|
crate::api::files::purge_thumbnails(state.storage.as_ref(), prev).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cover keys are reused (mangas/{id}/cover.{ext}), so a same-extension
|
|
||||||
// replacement overwrites the blob at an existing key — drop any thumbnails
|
|
||||||
// cached for it so we don't serve a stale variant of the old cover.
|
|
||||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), &new_key).await;
|
|
||||||
|
|
||||||
repo::manga::set_cover_image_path(&state.db, id, &new_key, img.bytes.len() as i64).await?;
|
|
||||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,12 +451,15 @@ async fn delete_cover(
|
|||||||
}
|
}
|
||||||
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
|
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
|
||||||
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
|
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
|
||||||
|
// Clear the DB pointer BEFORE deleting the blob, so a storage-delete
|
||||||
|
// failure can't leave the row pointing at a removed blob. A failure
|
||||||
|
// after the clear only orphans the blob (harmless).
|
||||||
|
repo::manga::clear_cover_image_path(&state.db, id).await?;
|
||||||
match state.storage.delete(&key).await {
|
match state.storage.delete(&key).await {
|
||||||
Ok(()) | Err(StorageError::NotFound) => {}
|
Ok(()) | Err(StorageError::NotFound) => {}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
crate::api::files::purge_thumbnails(state.storage.as_ref(), &key).await;
|
crate::api::files::purge_thumbnails(state.storage.as_ref(), &key).await;
|
||||||
repo::manga::clear_cover_image_path(&state.db, id).await?;
|
|
||||||
}
|
}
|
||||||
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
Ok(Json(repo::manga::get_detail(&state.db, id).await?))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.15",
|
"version": "0.128.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user