diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 66277fa..46540b8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.8" +version = "0.124.9" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 760687c..c2c436c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.8" +version = "0.124.9" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/files.rs b/backend/src/api/files.rs index e874e9b..51ca9d4 100644 --- a/backend/src/api/files.rs +++ b/backend/src/api/files.rs @@ -55,9 +55,19 @@ async fn serve(State(state): State, Path(key): Path) -> AppRes // revalidation entirely — this is what lets the reader's page and // next-chapter preloading hit cache instead of re-downloading (and // re-proxying every byte through the SvelteKit node server in prod). + // + // BUT under PRIVATE_MODE these blobs are auth-gated (see + // `private_mode_guard`), so they must NOT be marked `public`: a shared + // cache / CDN in front of the app would store the object and then serve + // it to unauthenticated clients, defeating the gate. Use `private` so + // only the requesting user's browser caches it. ( header::CACHE_CONTROL, - "public, max-age=31536000, immutable".to_string(), + if state.auth.private_mode { + "private, max-age=31536000, immutable".to_string() + } else { + "public, max-age=31536000, immutable".to_string() + }, ), ]; Ok((headers, Body::from_stream(file.stream)).into_response()) diff --git a/backend/tests/api_private_mode.rs b/backend/tests/api_private_mode.rs index fe97715..dc33616 100644 --- a/backend/tests/api_private_mode.rs +++ b/backend/tests/api_private_mode.rs @@ -149,6 +149,56 @@ async fn private_mode_blocks_register_even_when_self_register_enabled(pool: PgPo assert_eq!(body["error"]["code"], "forbidden"); } +#[sqlx::test(migrations = "./migrations")] +async fn private_mode_serves_files_as_private_not_public_cacheable(pool: PgPool) { + // In private mode /files is auth-gated, so a served blob must NOT carry a + // `public` Cache-Control: a shared cache / CDN would otherwise store it and + // hand it to anonymous clients, defeating the gate. It should be `private`. + // + // Register via a public harness on the shared pool so the session exists, + // then upload + fetch a cover through the private harness (same storage). + let public = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&public.app).await; + + let private = common::harness_with_private_mode(pool); + let form = common::MultipartBuilder::new() + .add_json("metadata", json!({ "title": "Secret Library" })) + .add_file("cover", "cover.png", "image/png", &common::fake_png_bytes()); + let created = private + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + form, + &cookie, + )) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + let body = common::body_json(created).await; + let key = body["cover_image_path"].as_str().unwrap().to_string(); + + let resp = private + .app + .oneshot(common::get_with_cookie( + &format!("/api/v1/files/{key}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let cc = resp + .headers() + .get(axum::http::header::CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap(); + assert!( + cc.starts_with("private") && !cc.contains("public"), + "private-mode blobs must be `private`, not `public`; got: {cc}" + ); +} + #[sqlx::test(migrations = "./migrations")] async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) { let h = common::harness_with_private_mode(pool); diff --git a/frontend/package.json b/frontend/package.json index af347bd..1a5c882 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.8", + "version": "0.124.9", "private": true, "type": "module", "scripts": {