fix(upload): stream chapter pages to storage instead of buffering the whole chapter

The chapter upload handler read every `page` part fully into a Vec before
writing any, so peak memory was the whole chapter (bounded only by the
200 MiB body limit and amplified by concurrent uploads). It also accepted
an unbounded number of pages.

Stream each page part to a `staging/{upload_id}/…` key as it arrives — at
most one page's bytes are held at a time — then, once the chapter row (and
its id) exists, promote each staged blob to its final key via a new
`Storage::rename` (LocalStorage: fs rename; default impl: stream+delete for
future backends). Finalization is all-or-nothing: on any failure the DB
rolls back and both staged and already-finalized blobs are cleaned up.

Add MAX_PAGES_PER_CHAPTER (UploadConfig, default 2000, 0 = disabled),
rejecting an over-cap upload with 413 before any DB write. Also document
the crawler-side CRAWLER_MAX_IMAGES_PER_CHAPTER (added earlier) in
.env.example + docker-compose so the env-coverage test passes.

Tests: LocalStorage rename unit tests; a 413 over-cap upload test; existing
rollback + happy-path upload tests still green (the fault-injecting storage
counts put/put_stream, so mid-upload failure still rolls back).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-03 21:33:47 +02:00
parent 141cd52f7e
commit 4e154434a1
13 changed files with 382 additions and 69 deletions

View File

@@ -103,6 +103,11 @@ MAX_REQUEST_BYTES=209715200
# oversized image is rejected even when the total request fits.
# Default 20 MiB.
MAX_FILE_BYTES=20971520
# Max page images accepted in one chapter upload. Bounds how many parts
# the handler will stage before rejecting the request with 413, so a
# client can't pin a worker with an unbounded page count. 0 disables the
# cap. Default 2000.
MAX_PAGES_PER_CHAPTER=2000
# ----- Crawler download safety -----
# Hosts the crawler is allowed to fetch images/covers from, in addition
@@ -117,6 +122,11 @@ CRAWLER_DOWNLOAD_ALLOWLIST=
CRAWLER_ALLOW_ANY_HOST=false
# Hard cap on a single image body. Default 32 MiB.
CRAWLER_MAX_IMAGE_BYTES=33554432
# Hard cap on the number of page images in one crawled chapter. The
# per-image byte cap doesn't stop a hostile reader page listing thousands
# of <img> tags; an over-cap chapter is acked failed instead of downloaded.
# 0 disables the cap. Default 2000.
CRAWLER_MAX_IMAGES_PER_CHAPTER=2000
# Max manga detail fetches per metadata pass (both the in-process daemon
# and the `bin/crawler` CLI). 0 means no cap — let the source walker run
# to completion. Useful for capped test runs against a new source.

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.93.9"
version = "0.93.10"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.93.9"
version = "0.93.10"
edition = "2021"
default-run = "mangalord"

View File

@@ -21,7 +21,8 @@ use crate::domain::chapter::NewChapter;
use crate::domain::{Chapter, Page};
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::upload::{parse_image, UploadedImage};
use crate::storage::Storage;
use crate::upload::{stage_image_part, StagedImage};
pub fn routes() -> Router<AppState> {
Router::new()
@@ -92,15 +93,22 @@ async fn create(
) -> AppResult<(StatusCode, Json<Chapter>)> {
repo::manga::get(&state.db, manga_id).await?;
// Each `page` part is streamed straight to a staging key as it arrives,
// so at most one page's bytes sit in memory — the whole chapter is never
// buffered (previously every page was held at once, bounded only by the
// 200 MiB body limit and amplified by concurrency). The staged blobs are
// promoted to their final chapter-scoped keys in `finalize_chapter` once
// the chapter id exists; any early exit cleans them up.
let upload_id = Uuid::new_v4();
let mut metadata: Option<NewChapter> = None;
let mut pages: Vec<UploadedImage> = Vec::new();
let mut staged: Vec<StagedImage> = Vec::new();
let stage_result: AppResult<NewChapter> = async {
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| {
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() }),
@@ -108,15 +116,31 @@ async fn create(
})?);
}
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)?);
if state.upload.max_pages_per_chapter != 0
&& staged.len() >= state.upload.max_pages_per_chapter
{
return Err(AppError::PayloadTooLarge(format!(
"chapter exceeds the {}-page limit",
state.upload.max_pages_per_chapter
)));
}
let field_name = format!("page[{}]", staged.len());
let img = stage_image_part(
state.storage.as_ref(),
field,
upload_id,
staged.len(),
state.upload.max_file_bytes,
&field_name,
)
.await?;
staged.push(img);
}
_ => continue,
}
}
let metadata = metadata.ok_or_else(|| AppError::ValidationFailed {
let metadata = metadata.take().ok_or_else(|| AppError::ValidationFailed {
message: "metadata part is required".into(),
details: json!({ "metadata": "required" }),
})?;
@@ -129,60 +153,113 @@ async fn create(
details: json!({ "number": "must be >= 1" }),
});
}
if pages.is_empty() {
if staged.is_empty() {
return Err(AppError::ValidationFailed {
message: "at least one page is required".into(),
details: json!({ "page": "at least one required" }),
});
}
Ok(metadata)
}
.await;
// 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(
let metadata = match stage_result {
Ok(m) => m,
Err(e) => {
// Reject before any DB write — remove every page we staged.
cleanup_staging(state.storage.as_ref(), &staged).await;
return Err(e);
}
};
finalize_chapter(&state, manga_id, user.id, &metadata, &staged).await
}
/// Promote the staged pages into a real chapter, all-or-nothing. Creates the
/// chapter row, renames each staged blob to its final chapter-scoped key,
/// and inserts the page rows in one transaction. On any failure the DB rolls
/// back and every blob (already-finalized and still-staged) is removed, so a
/// rejected upload leaves neither partial rows nor orphaned files.
async fn finalize_chapter(
state: &AppState,
manga_id: Uuid,
user_id: Uuid,
metadata: &NewChapter,
staged: &[StagedImage],
) -> AppResult<(StatusCode, Json<Chapter>)> {
let storage = state.storage.as_ref();
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
cleanup_staging(storage, staged).await;
return Err(e.into());
}
};
let mut chapter = match repo::chapter::create(
&mut *tx,
manga_id,
metadata.number,
metadata.title.as_deref(),
Some(user.id),
Some(user_id),
)
.await?;
.await
{
Ok(c) => c,
Err(e) => {
cleanup_staging(storage, staged).await;
return Err(e);
}
};
let mut page_ids: Vec<Uuid> = Vec::with_capacity(pages.len());
for (idx, page) in pages.iter().enumerate() {
let mut page_ids: Vec<Uuid> = Vec::with_capacity(staged.len());
let mut finalized: Vec<String> = Vec::with_capacity(staged.len());
for (idx, page) in staged.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
let final_key = format!(
"mangas/{}/chapters/{}/pages/{:04}.{}",
manga_id, chapter.id, page_number, page.ext
);
state.storage.put(&key, &page.bytes).await?;
let created = repo::page::create(
if let Err(e) = storage.rename(&page.staging_key, &final_key).await {
cleanup_keys(storage, &finalized).await;
cleanup_staging(storage, &staged[idx..]).await;
return Err(e.into());
}
finalized.push(final_key.clone());
match repo::page::create(
&mut *tx,
chapter.id,
page_number,
&key,
&final_key,
page.mime,
page.bytes.len() as i64,
page.size_bytes,
)
.await?;
page_ids.push(created.id);
.await
{
Ok(created) => page_ids.push(created.id),
Err(e) => {
cleanup_keys(storage, &finalized).await;
cleanup_staging(storage, &staged[idx + 1..]).await;
return Err(e);
}
}
}
let page_count = pages.len() as i32;
repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await?;
let page_count = staged.len() as i32;
if let Err(e) = repo::chapter::set_page_count(&mut *tx, chapter.id, page_count).await {
cleanup_keys(storage, &finalized).await;
return Err(e);
}
chapter.page_count = page_count;
// `repo::chapter::create` returned the row before any pages existed, so
// its `size_bytes` is a stale 0. Every uploaded page's size was just
// captured, so the true total is the sum of their byte lengths — set it
// on the response so the 201 body matches the persisted state.
chapter.size_bytes = Some(pages.iter().map(|p| p.bytes.len() as i64).sum());
// its `size_bytes` is a stale 0. Each staged page carried its byte
// length, so their sum is the chapter's true storage — set it on the
// response so the 201 body matches the persisted state.
chapter.size_bytes = Some(staged.iter().map(|p| p.size_bytes).sum());
tx.commit().await?;
if let Err(e) = tx.commit().await {
cleanup_keys(storage, &finalized).await;
return Err(e.into());
}
// Enqueue AI content-analysis for each new page. Done after commit so a
// rolled-back upload never leaves jobs pointing at nonexistent pages; a
@@ -190,9 +267,7 @@ async fn create(
// re-enqueue endpoint can backfill).
if state.analysis_enabled() {
for page_id in page_ids {
if let Err(e) =
repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await
{
if let Err(e) = repo::page_analysis::enqueue_for_page(&state.db, page_id, false).await {
tracing::warn!(%page_id, error = %e, "failed to enqueue page analysis");
}
}
@@ -201,6 +276,21 @@ async fn create(
Ok((StatusCode::CREATED, Json(chapter)))
}
/// Best-effort removal of staged page blobs after a rejected upload.
async fn cleanup_staging(storage: &dyn Storage, staged: &[StagedImage]) {
for page in staged {
let _ = storage.delete(&page.staging_key).await;
}
}
/// Best-effort removal of already-finalized page blobs when the upload
/// fails after some renames have landed.
async fn cleanup_keys(storage: &dyn Storage, keys: &[String]) {
for key in keys {
let _ = storage.delete(key).await;
}
}
#[derive(Debug, serde::Serialize)]
struct PagesResponse {
pages: Vec<Page>,

View File

@@ -649,7 +649,7 @@ pub(crate) async fn read_field_bytes(
field.bytes().await.map_err(map_multipart_error)
}
fn map_multipart_error(e: axum::extract::multipart::MultipartError) -> AppError {
pub(crate) fn map_multipart_error(e: axum::extract::multipart::MultipartError) -> AppError {
let status = e.status();
if status == StatusCode::PAYLOAD_TOO_LARGE {
AppError::PayloadTooLarge("upload exceeds the request size limit".into())

View File

@@ -55,6 +55,11 @@ pub struct UploadConfig {
/// reject a single oversized cover/page without failing the whole
/// request just because the total happens to fit.
pub max_file_bytes: usize,
/// Max page images accepted in one chapter upload. Bounds how many
/// parts the handler will stage before giving up, so a client can't
/// pin a worker streaming an unbounded page count. `0` disables the
/// cap. Defaults to 2000. `MAX_PAGES_PER_CHAPTER`.
pub max_pages_per_chapter: usize,
}
impl Default for UploadConfig {
@@ -62,6 +67,7 @@ impl Default for UploadConfig {
Self {
max_request_bytes: 200 * 1024 * 1024, // 200 MiB
max_file_bytes: 20 * 1024 * 1024, // 20 MiB
max_pages_per_chapter: 2000,
}
}
}
@@ -533,6 +539,7 @@ impl Config {
upload: UploadConfig {
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
max_file_bytes: env_usize("MAX_FILE_BYTES", 20 * 1024 * 1024),
max_pages_per_chapter: env_usize("MAX_PAGES_PER_CHAPTER", 2000),
},
cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS")
.ok()

View File

@@ -127,6 +127,23 @@ impl Storage for LocalStorage {
}
}
async fn rename(&self, from: &str, to: &str) -> Result<(), StorageError> {
let from_path = self.resolve(from)?;
let to_path = self.resolve(to)?;
// Create the destination parent so a rename into a not-yet-existing
// chapter directory succeeds. `from` and `to` share the storage
// root (same filesystem), so this is a cheap atomic metadata move,
// not a copy.
if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent).await?;
}
match fs::rename(&from_path, &to_path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(StorageError::NotFound),
Err(e) => Err(e.into()),
}
}
async fn exists(&self, key: &str) -> Result<bool, StorageError> {
let path: &Path = &self.resolve(key)?;
Ok(fs::try_exists(path).await?)
@@ -273,6 +290,34 @@ mod tests {
assert_eq!(entries, vec!["ok.bin"]);
}
#[tokio::test]
async fn rename_moves_blob_and_creates_destination_dirs() {
let dir = tempdir().unwrap();
let s = LocalStorage::new(dir.path());
s.put("staging/up/0000.png", b"page-bytes").await.unwrap();
// Destination dir doesn't exist yet — rename must create it.
s.rename("staging/up/0000.png", "mangas/m/chapters/c/pages/0001.png")
.await
.unwrap();
assert!(!s.exists("staging/up/0000.png").await.unwrap(), "source gone");
assert_eq!(
s.get("mangas/m/chapters/c/pages/0001.png").await.unwrap(),
b"page-bytes"
);
}
#[tokio::test]
async fn rename_missing_source_is_not_found() {
let dir = tempdir().unwrap();
let s = LocalStorage::new(dir.path());
assert!(matches!(
s.rename("staging/nope.png", "dest/x.png").await,
Err(StorageError::NotFound)
));
}
#[tokio::test]
async fn get_stream_emits_multiple_chunks_for_large_files() {
use futures_util::StreamExt as _;

View File

@@ -82,6 +82,27 @@ pub trait Storage: Send + Sync {
async fn delete(&self, key: &str) -> Result<(), StorageError>;
async fn exists(&self, key: &str) -> Result<bool, StorageError>;
/// Move a blob from `from` to `to`, overwriting any existing blob at
/// `to`. Returns `NotFound` if `from` doesn't exist. The chapter
/// upload path uses this to promote a staged page to its final,
/// chapter-scoped key once the chapter row (and thus its id) exists —
/// so pages can be streamed to storage as their multipart parts
/// arrive, without buffering the whole chapter in memory.
///
/// The default implementation streams `from` to `to` and deletes the
/// source, so backends without a native move still satisfy the
/// contract. LocalStorage overrides it with a filesystem rename (an
/// atomic metadata op within a mount); a future S3Storage would
/// override with a server-side copy + delete.
async fn rename(&self, from: &str, to: &str) -> Result<(), StorageError> {
use futures_util::StreamExt as _;
let StreamingFile { stream, .. } = self.get_stream(from).await?;
let mapped: PutByteStream<'_> = Box::pin(stream.map(|r| r.map_err(StorageError::Io)));
self.put_stream(to, mapped).await?;
self.delete(from).await?;
Ok(())
}
/// Size in bytes of the blob at `key`, `NotFound` when it doesn't
/// exist. Cheap metadata lookup (local: `fs::metadata`; a future
/// `S3Storage`: HEAD object). Used by the cover-capture path and the

View File

@@ -6,7 +6,12 @@
//! whitelist with 415. Filename and extension never reach the storage
//! key — we derive both from the sniffed type.
use axum::extract::multipart::Field;
use uuid::Uuid;
use crate::api::mangas::map_multipart_error;
use crate::error::{AppError, AppResult};
use crate::storage::Storage;
#[derive(Debug, Clone)]
pub struct UploadedImage {
@@ -15,6 +20,59 @@ pub struct UploadedImage {
pub ext: &'static str,
}
/// A page image written to a temporary staging key during a chapter
/// upload, awaiting promotion to its final chapter-scoped key once the
/// chapter row (and thus its id) exists. Carries only the small metadata
/// the caller needs — never the image bytes.
#[derive(Debug, Clone)]
pub struct StagedImage {
pub staging_key: String,
pub mime: &'static str,
pub ext: &'static str,
pub size_bytes: i64,
}
/// The staging-key prefix. Blobs left here by a failed or abandoned upload
/// are orphans a future reaper can sweep; a successful upload renames every
/// staged page out of this prefix.
pub const STAGING_PREFIX: &str = "staging";
/// Read one multipart image part and write it straight to a staging key,
/// returning only its metadata. The per-file byte cap is enforced as bytes
/// arrive, so an oversized part is rejected (413) without being fully
/// buffered, and at most one page's bytes are held in memory at a time —
/// the whole chapter is never buffered, unlike the previous
/// read-all-then-persist path.
pub async fn stage_image_part(
storage: &dyn Storage,
mut field: Field<'_>,
upload_id: Uuid,
seq: usize,
max_size: usize,
field_name: &str,
) -> AppResult<StagedImage> {
let mut bytes: Vec<u8> = Vec::new();
while let Some(chunk) = field.chunk().await.map_err(map_multipart_error)? {
if bytes.len().saturating_add(chunk.len()) > max_size {
return Err(AppError::PayloadTooLarge(format!(
"{field_name} exceeds {max_size}-byte cap"
)));
}
bytes.extend_from_slice(&chunk);
}
// Reuse the shared sniff + whitelist check; it re-verifies the size cap
// (already enforced above) and derives mime/ext from magic bytes.
let img = parse_image(bytes, max_size, field_name)?;
let staging_key = format!("{STAGING_PREFIX}/{}/{:04}.{}", upload_id.simple(), seq, img.ext);
storage.put(&staging_key, &img.bytes).await?;
Ok(StagedImage {
staging_key,
mime: img.mime,
ext: img.ext,
size_bytes: img.bytes.len() as i64,
})
}
pub fn parse_image(bytes: Vec<u8>, max_size: usize, field_name: &str) -> AppResult<UploadedImage> {
if bytes.len() > max_size {
return Err(AppError::PayloadTooLarge(format!(

View File

@@ -328,6 +328,42 @@ async fn create_chapter_rejects_when_no_pages_with_422(pool: PgPool) {
assert!(body["error"]["details"]["page"].is_string());
}
#[sqlx::test(migrations = "./migrations")]
async fn create_chapter_rejects_over_page_cap_with_413(pool: PgPool) {
// Cap of 2 pages: a 3-page upload is refused once the third `page` part
// arrives, before any chapter row is written.
let h = common::harness_with_page_cap(pool.clone(), 2);
let (_, cookie) = common::register_user(&h.app).await;
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
let resp = h
.app
.clone()
.oneshot(common::post_multipart_with_cookie(
&format!("/api/v1/mangas/{manga_id}/chapters"),
MultipartBuilder::new()
.add_json("metadata", json!({ "number": 1 }))
.add_file("page", "1.png", "image/png", &common::fake_png_bytes())
.add_file("page", "2.png", "image/png", &common::fake_png_bytes())
.add_file("page", "3.png", "image/png", &common::fake_png_bytes()),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let body = common::body_json(resp).await;
assert_eq!(body["error"]["code"], "payload_too_large");
// Nothing persisted — the cap trips before the chapter transaction.
let (chapter_count,): (i64,) =
sqlx::query_as("SELECT count(*) FROM chapters WHERE manga_id = $1")
.bind(manga_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(chapter_count, 0, "over-cap upload must not create a chapter");
}
#[sqlx::test(migrations = "./migrations")]
async fn create_chapter_rejects_renamed_non_image_page(pool: PgPool) {
let h = common::harness(pool);

View File

@@ -79,6 +79,7 @@ fn harness_with_auth_config(
// exercise without producing tens of MBs of bytes.
max_request_bytes: 4 * 1024 * 1024,
max_file_bytes: 256 * 1024,
max_pages_per_chapter: 2000,
},
auth_limiter,
// Default harness has no crawler daemon wired up; admin resync
@@ -170,6 +171,7 @@ pub fn harness_with_resync(
upload: UploadConfig {
max_request_bytes: 4 * 1024 * 1024,
max_file_bytes: 256 * 1024,
max_pages_per_chapter: 2000,
},
auth_limiter,
runtime,
@@ -203,6 +205,7 @@ pub fn harness_with_analysis(pool: PgPool) -> Harness {
upload: UploadConfig {
max_request_bytes: 4 * 1024 * 1024,
max_file_bytes: 256 * 1024,
max_pages_per_chapter: 2000,
},
auth_limiter,
runtime: Arc::new(RuntimeControls::new(true)),
@@ -218,6 +221,39 @@ pub fn harness_with_analysis(pool: PgPool) -> Harness {
}
}
/// Like [`harness`] but with a low `max_pages_per_chapter` so the chapter
/// upload page-count cap is cheap to exercise.
pub fn harness_with_page_cap(pool: PgPool, max_pages_per_chapter: usize) -> Harness {
let storage_dir = tempfile::tempdir().expect("tempdir");
let storage = Arc::new(LocalStorage::new(storage_dir.path()));
let auth = AuthConfig {
cookie_secure: false,
..AuthConfig::default()
};
let auth_limiter = Arc::new(AuthRateLimiter::new(auth.rate_limit));
let state = AppState {
db: pool,
storage,
auth,
upload: UploadConfig {
max_request_bytes: 4 * 1024 * 1024,
max_file_bytes: 256 * 1024,
max_pages_per_chapter,
},
auth_limiter,
runtime: Arc::new(RuntimeControls::new(false)),
reloader: None,
crawler_base: CrawlerConfig::default(),
analysis_base: AnalysisConfig::default(),
admin_allowed_origins: Arc::new(vec![TEST_ORIGIN.to_string()]),
analysis_events: Arc::new(mangalord::analysis::events::AnalysisEvents::new()),
};
Harness {
app: router(state),
_storage_dir: storage_dir,
}
}
/// A [`DaemonReloader`] stub that records the configs it was asked to apply
/// and flips the shared analysis gate, without spawning any real daemon. Lets
/// settings tests assert that a `PUT` triggers a reload with the converted
@@ -265,6 +301,7 @@ pub fn harness_with_settings_reloader(pool: PgPool) -> (Harness, Arc<StubReloade
upload: UploadConfig {
max_request_bytes: 4 * 1024 * 1024,
max_file_bytes: 256 * 1024,
max_pages_per_chapter: 2000,
},
auth_limiter,
runtime,
@@ -300,6 +337,7 @@ pub fn harness_with_admin_origins(pool: PgPool, origins: Vec<String>) -> Harness
upload: UploadConfig {
max_request_bytes: 4 * 1024 * 1024,
max_file_bytes: 256 * 1024,
max_pages_per_chapter: 2000,
},
auth_limiter,
runtime: Arc::new(RuntimeControls::new(false)),
@@ -376,6 +414,12 @@ impl Storage for FailingStorage {
async fn size(&self, key: &str) -> Result<u64, StorageError> {
self.inner.size(key).await
}
// Delegate straight to the inner filesystem rename — the fault
// injection counts `put`/`put_stream` only, so promoting a staged page
// to its final key never spuriously trips the injected failure.
async fn rename(&self, from: &str, to: &str) -> Result<(), StorageError> {
self.inner.rename(from, to).await
}
}
pub async fn body_json(response: axum::response::Response) -> serde_json::Value {

View File

@@ -96,6 +96,7 @@ services:
# Upload limits.
MAX_REQUEST_BYTES: ${MAX_REQUEST_BYTES:-209715200}
MAX_FILE_BYTES: ${MAX_FILE_BYTES:-20971520}
MAX_PAGES_PER_CHAPTER: ${MAX_PAGES_PER_CHAPTER:-2000}
# Crawler boot seeds (first-boot only; switch to dashboard-editable
# once the app_settings row exists).
CRAWLER_START_URL: ${CRAWLER_START_URL:-}
@@ -124,6 +125,7 @@ services:
CRAWLER_DOWNLOAD_ALLOWLIST: ${CRAWLER_DOWNLOAD_ALLOWLIST:-}
CRAWLER_ALLOW_ANY_HOST: ${CRAWLER_ALLOW_ANY_HOST:-false}
CRAWLER_MAX_IMAGE_BYTES: ${CRAWLER_MAX_IMAGE_BYTES:-33554432}
CRAWLER_MAX_IMAGES_PER_CHAPTER: ${CRAWLER_MAX_IMAGES_PER_CHAPTER:-2000}
# System-chromium override for the crawler. Leave blank to use the
# bundled fetcher; set to e.g. /usr/bin/chromium-headless-shell on
# arm64 deployments. Pair with `--build-arg INSTALL_CHROMIUM=true`

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.93.9",
"version": "0.93.10",
"private": true,
"type": "module",
"scripts": {