diff --git a/.env.example b/.env.example index b587787..e810f08 100644 --- a/.env.example +++ b/.env.example @@ -207,6 +207,30 @@ COMPRESSION_WORKER_CONCURRENCY=2 # production. Remove that line from the compose file first if you want comments back. COMMENTS_ENABLED=true +# ── Keepsake archive (and the disk ceiling it imposes) ──────────────────────── +# Master switch for the downloadable gallery. Boot-time only, like COMMENTS_ENABLED. +# +# This is really a DISK-SIZING knob. The keepsake needs room for both halves at once (a ZIP +# and an HTML viewer, each gallery-sized), and the upload gate refuses any photo that would +# leave too little space to build it. The gate demands: +# +# free >= 2.2 x media + DISK_RESERVE (10 GB) + UPLOAD_GATE_HEADROOM (1.5 GB) +# +# so usable media is roughly `(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB +# of photos — uploads stop with most of the disk still free. Measured: a 945-photo wedding +# (8.74 GB) hit the wall after ~780 photos with 25 GB free. +# +# KEEPSAKE_ENABLED=true (default) archive available; ~5.8 GB of photos on a 30 GB disk +# KEEPSAKE_ENABLED=false no archive; ~20 GB of photos on a 30 GB disk +# +# Turning it OFF is irreversible for the event in the way that matters: there is no download +# at the end, so guests keep only what they save from the feed themselves. Releasing the +# gallery and minting download tickets are refused while it is off. The 10 GB database +# reserve still applies — that one protects Postgres, not the archive. +# +# Prefer a bigger disk if you can: ~45 GB holds a 9.7 GB library WITH the keepsake. +KEEPSAKE_ENABLED=true + # ── Logging ─────────────────────────────────────────────────────────────────── # SET THIS IN PRODUCTION. Without it the app falls back to # `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a diff --git a/backend/src/config.rs b/backend/src/config.rs index 997e550..d79125a 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -136,6 +136,23 @@ pub struct AppConfig { /// comment UI. Existing comments stay in the DB (hidden), so flipping it back /// restores them. Boot-time immutable, like `compression_concurrency`. pub comments_enabled: bool, + /// Master switch for the keepsake archive (env `KEEPSAKE_ENABLED`, default true). + /// + /// This is a DISK-SIZING switch as much as a feature switch. The upload gate refuses + /// any photo that would leave too little room to build the archive, and the archive + /// needs both halves at once — so the gate demands `2.2 x media` on top of the database + /// reserve, and every GB of photos costs roughly 3.2 GB of disk budget. On a small + /// volume that is what stops an event: uploads close with most of the disk still free. + /// + /// With this off, the gate falls back to protecting the database alone + /// (`DISK_RESERVE_BYTES`), which is what turns a 30 GB box from ~5.8 GB of usable media + /// into ~20 GB. The cost is real and irreversible for the event: there is no downloadable + /// gallery at the end, so guests must save what they want from the feed. Releasing the + /// gallery and minting export tickets are refused while it is off. + /// + /// Boot-time immutable, like `comments_enabled` — an operator flipping this mid-event + /// would move the gate under uploads that had already been accepted against the old one. + pub keepsake_enabled: bool, /// Default colour theme, used as the fallback when the DB config keys are unset. /// Runtime overrides live in the `config` table (admin UI); these env vars only /// seed the initial default. `preset` is an id the frontend knows (e.g. @@ -236,6 +253,14 @@ impl AppConfig { ) }) .unwrap_or(true), + keepsake_enabled: std::env::var("KEEPSAKE_ENABLED") + .map(|v| { + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "false" | "0" | "no" | "off" + ) + }) + .unwrap_or(true), default_theme_preset: std::env::var("THEME_PRESET") .unwrap_or_else(|_| "champagne-gold".to_string()), default_theme_primary: std::env::var("THEME_PRIMARY") diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 026de3c..4b12191 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -391,6 +391,13 @@ pub async fn export_ticket( axum::extract::Query(q): axum::extract::Query, auth: crate::auth::middleware::AuthUser, ) -> Result, AppError> { + // No archive is ever built when the keepsake is off, so a ticket could only ever redeem + // into a 404. Refuse at the mint, where the guest sees a real message. + if !state.config.keepsake_enabled { + return Err(AppError::Forbidden( + "Der Galerie-Download ist für dieses Event deaktiviert.".into(), + )); + } // NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access // by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once // released — Spec design choice"). The export is read-only, so it stays available diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs index 79ee909..b1b713d 100644 --- a/backend/src/handlers/host.rs +++ b/backend/src/handlers/host.rs @@ -38,8 +38,15 @@ pub struct EventStatus { /// Free space on the volume the keepsake is written to. `None` when the mount can't be /// resolved — the UI hides the widget rather than rendering a confident zero. pub disk_free_bytes: Option, - /// What a full keepsake build would need right now (both halves). + /// What a full keepsake build would need right now (both halves). Always 0 when the + /// keepsake is switched off — there is nothing to reserve for. pub keepsake_required_bytes: u64, + /// Whether this event has a downloadable gallery at all (`KEEPSAKE_ENABLED`). + /// + /// Sent so the dashboard can HIDE the release control rather than offer a button that + /// answers 403. A host tapping "Galerie freigeben" and being refused would reasonably + /// conclude the app is broken, on the one screen where they have no way to check. + pub keepsake_enabled: bool, /// Whether the host should be warned. See [`disk_is_low`]. pub disk_low: bool, } @@ -66,15 +73,22 @@ pub struct EventStatus { /// /// The 25% margin makes it a warning rather than an obituary: the host sees it while there is /// still room to act (delete a few large videos, which refunds immediately and reopens the gate). -fn disk_is_low(free: u64, keepsake_required: u64) -> bool { +/// +/// `keepsake_required` is `None` when `KEEPSAKE_ENABLED=false`. The banner then tracks the +/// database reserve alone, because that is the only threshold the gate still has. +fn disk_is_low(free: u64, keepsake_required: Option) -> bool { // Mirrors the gate EXACTLY, headroom included. The gate now demands // `UPLOAD_GATE_HEADROOM_BYTES` more than the export preflight does, so that ordinary // end-of-night writes cannot flip the preflight after uploads have already stopped. Leaving // that term out here would shrink the warning's lead by 1.5 GB — and the whole point of this // function is that the banner must appear while the host can still act. - let gate_closes_at = keepsake_required - .saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64) - .saturating_add(crate::handlers::upload::UPLOAD_GATE_HEADROOM_BYTES as u64); + // + // Both extra terms fall away together when the keepsake is off, because the gate drops them + // together. Computing this any other way would put the banner on a threshold the gate no + // longer uses — warning about a limit that cannot fire, or staying silent past one that can. + let gate_closes_at = + crate::handlers::upload::upload_gate_required_free(keepsake_required.map(|r| r as i64)) + as u64; let warn_at = gate_closes_at.saturating_add(gate_closes_at / 4); // No separate absolute-floor clause. There used to be `free < LOW_DISK_FLOOR_BYTES ||` here, // and it was unreachable: `gate_closes_at` is at least DISK_RESERVE_BYTES, so `warn_at` is at @@ -159,10 +173,17 @@ pub async fn get_event_status( .disk_cache .snapshot(&state.config.export_path) .map(|d| d.free); - let keepsake_required_bytes = - crate::services::export::keepsake_space_required(&state.pool, event.id) - .await - .unwrap_or(0); + // `None` when the keepsake is off — there is no archive to reserve room for, so the + // dashboard reports 0 required and the banner uses the same reduced threshold the gate does. + let keepsake_required = if state.config.keepsake_enabled { + Some( + crate::services::export::keepsake_space_required(&state.pool, event.id) + .await + .unwrap_or(0), + ) + } else { + None + }; Ok(Json(EventStatus { name: event.name, @@ -170,10 +191,11 @@ pub async fn get_event_status( uploads_locked: event.uploads_locked_at.is_some(), export_released: event.export_released_at.is_some(), disk_free_bytes: free, - keepsake_required_bytes, + keepsake_required_bytes: keepsake_required.unwrap_or(0), + keepsake_enabled: state.config.keepsake_enabled, // Unknown free space is NOT low. Fails open, exactly as the upload quota and the export // preflight do: a scary banner on an unreadable mount would train the host to ignore it. - disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required_bytes)), + disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required)), })) } @@ -936,6 +958,14 @@ pub async fn release_gallery( State(state): State, RequireHost(auth): RequireHost, ) -> Result { + // Refused outright when the keepsake is switched off. Releasing is irreversible for the + // event — it locks uploads and bumps the epoch — so letting it proceed with no archive to + // build would end the party's uploads in exchange for nothing. + if !state.config.keepsake_enabled { + return Err(AppError::Forbidden( + "Die Galerie-Freigabe ist für dieses Event deaktiviert (KEEPSAKE_ENABLED=false).".into(), + )); + } // The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE // transaction. Two reasons, both of which were live bugs: // @@ -1041,7 +1071,9 @@ pub async fn release_gallery( #[cfg(test)] mod tests { use super::disk_is_low; - use crate::handlers::upload::{DISK_RESERVE_BYTES, UPLOAD_GATE_HEADROOM_BYTES}; + use crate::handlers::upload::{ + DISK_RESERVE_BYTES, UPLOAD_GATE_HEADROOM_BYTES, upload_gate_required_free, + }; use crate::services::export::required_free_bytes; const GB: u64 = 1_000_000_000; @@ -1049,7 +1081,7 @@ mod tests { #[test] fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() { // Room for the keepsake AND the reserve the upload gate holds back, with margin. - assert!(!disk_is_low(60 * GB, 25 * GB)); + assert!(!disk_is_low(60 * GB, Some(25 * GB))); } /// Renamed from `the_absolute_floor_fires_...`: there is no separate floor clause any more @@ -1062,9 +1094,9 @@ mod tests { // All three volumes share one filesystem, so running out doesn't degrade one subsystem — // Postgres stops being able to write and the event goes down. A 1 GB gallery would clear // the keepsake test comfortably; the floor is what catches this. - assert!(disk_is_low(5 * GB, GB)); - assert!(disk_is_low(9 * GB, 0)); - assert!(!disk_is_low(20 * GB, 0), "a roomy empty disk is not low"); + assert!(disk_is_low(5 * GB, Some(GB))); + assert!(disk_is_low(9 * GB, Some(0))); + assert!(!disk_is_low(20 * GB, Some(0)), "a roomy empty disk is not low"); } /// THE PROPERTY THIS EXISTS FOR: the host must be warned BEFORE guests are blocked. @@ -1089,13 +1121,13 @@ mod tests { // Just above the gate: guests can still upload, and the host must already be warned. assert!( - disk_is_low(gate_closes_at + 1, required), + disk_is_low(gate_closes_at + 1, Some(required)), "at media={media_gb}GB the banner is not yet showing while the gate still allows uploads" ); // The warning must lead by a margin the host can act inside, not by one byte. let mut warn_starts_at = gate_closes_at; - while disk_is_low(warn_starts_at, required) { + while disk_is_low(warn_starts_at, Some(required)) { warn_starts_at += GB / 10; } assert!( @@ -1148,7 +1180,7 @@ mod tests { // THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near // any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this // before releasing; after releasing, they cannot. - assert!(disk_is_low(30 * GB, 66 * GB)); + assert!(disk_is_low(30 * GB, Some(66 * GB))); } #[test] @@ -1159,8 +1191,8 @@ mod tests { let required = 20 * GB; let gate = required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64; let warn_at = gate + gate / 4; - assert!(!disk_is_low(warn_at, required), "exactly enough is enough"); - assert!(disk_is_low(warn_at - 1, required)); + assert!(!disk_is_low(warn_at, Some(required)), "exactly enough is enough"); + assert!(disk_is_low(warn_at - 1, Some(required))); } #[test] @@ -1168,7 +1200,50 @@ mod tests { // With no gallery the keepsake term is 0, so the warn threshold collapses to // 1.25 x (DISK_RESERVE_BYTES + UPLOAD_GATE_HEADROOM_BYTES) = 1.25 x 11.5 GB = 14.375 GB, // which dominates the 10 GB absolute floor. - assert!(!disk_is_low(15 * GB, 0)); - assert!(disk_is_low(9 * GB, 0)); + assert!(!disk_is_low(15 * GB, Some(0))); + assert!(disk_is_low(9 * GB, Some(0))); + } + + /// The point of `KEEPSAKE_ENABLED=false`: the `2.2 x media` term disappears, so a volume + /// that could not accept another photo with the keepsake armed has plenty of room without it. + #[test] + fn switching_the_keepsake_off_reopens_the_disk() { + // 6.5 GB of media on a 30 GB box — the exact point the event simulation stopped, with + // 25 GB free and the gate demanding 25.9 GB. + let media = 13 * GB / 2; + let required = crate::services::export::required_free_bytes(media, 2); + let free = 25 * GB; + + assert!( + free < upload_gate_required_free(Some(required as i64)) as u64, + "with the keepsake armed this disk is closed — that is the state being fixed" + ); + assert!( + free > upload_gate_required_free(None) as u64, + "with it off, only the database reserve applies and uploads continue" + ); + assert!(disk_is_low(free, Some(required)), "armed: the host is warned"); + assert!(!disk_is_low(free, None), "off: nothing to warn about"); + } + + /// The banner must lead the gate in BOTH modes. With the keepsake off both thresholds shrink, + /// and a warning that arrives after uploads have already stopped is the bug this guards. + #[test] + fn the_banner_still_leads_the_gate_with_the_keepsake_off() { + let gate = upload_gate_required_free(None) as u64; + assert_eq!(gate, DISK_RESERVE_BYTES as u64, "only the DB reserve remains"); + assert!( + disk_is_low(gate + 1, None), + "the host must already be warned while guests can still upload" + ); + let mut warn_starts_at = gate; + while disk_is_low(warn_starts_at, None) { + warn_starts_at += GB / 10; + } + assert!( + warn_starts_at >= gate + gate / 5, + "the warning leads the gate by only {} bytes", + warn_starts_at - gate + ); } } diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index b30b964..82ac5d3 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -690,29 +690,35 @@ pub async fn upload( .get(&state.pool, &state.config.event_slug) .await .saturating_add(size); - let keepsake_needs = - crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64; + // `None` when the keepsake is switched off: there is no archive to keep buildable, so + // the gate protects the database alone. See `upload_gate_required_free`. + let keepsake_needs = state.config.keepsake_enabled.then(|| { + crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64 + }); // Strictly more than the export preflight requires — see `UPLOAD_GATE_HEADROOM_BYTES`. // Matching it exactly meant the preflight was already at its limit the moment uploads // stopped, so the night's remaining writes decided whether the keepsake could be built. - let required = keepsake_needs - .saturating_add(DISK_RESERVE_BYTES) - .saturating_add(UPLOAD_GATE_HEADROOM_BYTES); + let required = upload_gate_required_free(keepsake_needs); if free < required { tracing::error!( free_bytes = free, upload_size = size, media_after, - keepsake_needs, + keepsake_needs = keepsake_needs.unwrap_or(0), + keepsake_enabled = state.config.keepsake_enabled, reserve = DISK_RESERVE_BYTES, - headroom = UPLOAD_GATE_HEADROOM_BYTES, - "refusing upload: it would leave too little room to build the keepsake" + "refusing upload: not enough free disk" ); return Err(AppError::QuotaExceeded( - "Der Speicher des Events ist fast voll — damit die Galerie am Ende noch als \ - Download erstellt werden kann, sind neue Uploads jetzt gesperrt. Bitte sag \ - einem Host Bescheid." - .into(), + if state.config.keepsake_enabled { + "Der Speicher des Events ist fast voll — damit die Galerie am Ende noch als \ + Download erstellt werden kann, sind neue Uploads jetzt gesperrt. Bitte sag \ + einem Host Bescheid." + } else { + "Der Speicher des Events ist voll — neue Uploads sind gesperrt. Bitte sag \ + einem Host Bescheid." + } + .into(), )); } } else { @@ -1426,6 +1432,25 @@ pub const DISK_RESERVE_BYTES: i64 = 10_000_000_000; /// that the archive can never be built. pub const UPLOAD_GATE_HEADROOM_BYTES: i64 = 1_500_000_000; +/// Free bytes the upload gate demands, and the single definition the host's low-disk +/// banner shares so the two cannot drift into disagreeing about the same question. +/// +/// `keepsake_needs` is `None` when `KEEPSAKE_ENABLED=false`. Both extra terms fall away +/// together in that case, and deliberately so: `UPLOAD_GATE_HEADROOM_BYTES` exists only to +/// keep this gate strictly ahead of the EXPORT PREFLIGHT, and with no keepsake there is no +/// preflight to stay ahead of. What remains is `DISK_RESERVE_BYTES`, which answers the one +/// question that is still live without an archive: can Postgres still write? That is the +/// whole point of the switch — on a 30 GB volume it moves the ceiling from ~5.8 GB of media +/// to ~20 GB. +pub fn upload_gate_required_free(keepsake_needs: Option) -> i64 { + match keepsake_needs { + Some(needs) => needs + .saturating_add(DISK_RESERVE_BYTES) + .saturating_add(UPLOAD_GATE_HEADROOM_BYTES), + None => DISK_RESERVE_BYTES, + } +} + /// Pure per-user quota formula: `max(floor((free_disk * tolerance) / divisor), MIN)`. /// /// `divisor` is the LARGER of the observed uploader count and the operator's @@ -1839,8 +1864,42 @@ pub async fn get_thumbnail( mod tests { use super::{ DISK_RESERVE_BYTES, MIN_QUOTA_LIMIT_BYTES, RangeSpec, parse_range, quota_limit_bytes, + reject_nul, upload_gate_required_free, }; + /// A NUL in a caption used to reach Postgres, which refuses it with + /// `invalid byte sequence for encoding "UTF8": 0x00` — surfacing as a 500 that also cost + /// the guest the photo, because the transaction rolled back after the file was written. + #[test] + fn a_nul_byte_is_refused_before_it_reaches_postgres() { + assert!(reject_nul("Sch\u{00f6}n\u{0000} boom", "Beschreibung").is_err()); + assert!(reject_nul("\u{0000}", "Kommentar").is_err()); + } + + /// Deliberately narrower than `validate_display_name`: captions carry newlines and emoji, + /// and every control character other than NUL stores and renders harmlessly. Widening this + /// to all control characters would start refusing captions that work today. + #[test] + fn other_control_characters_and_ordinary_text_still_pass() { + assert!(reject_nul("Der erste Tanz \u{1f57a}", "Beschreibung").is_ok()); + assert!(reject_nul("zwei\nZeilen\tmit Tab", "Beschreibung").is_ok()); + assert!(reject_nul("bell\u{0007}here", "Beschreibung").is_ok()); + assert!(reject_nul("", "Beschreibung").is_ok()); + } + + /// The keepsake switch is a disk-sizing switch: with it off the `2.2 x media` term and the + /// preflight headroom both fall away, leaving only the database reserve. + #[test] + fn switching_the_keepsake_off_leaves_only_the_database_reserve() { + let keepsake = 14_000_000_000_i64; // ~6.4 GB of media, both archive halves + assert_eq!( + upload_gate_required_free(Some(keepsake)), + keepsake + DISK_RESERVE_BYTES + super::UPLOAD_GATE_HEADROOM_BYTES + ); + assert_eq!(upload_gate_required_free(None), DISK_RESERVE_BYTES); + assert!(upload_gate_required_free(None) < upload_gate_required_free(Some(keepsake))); + } + // `Range` handling exists because iOS Safari probes every `