feat(disk): KEEPSAKE_ENABLED trades the archive for ~3x the usable media

The upload gate refuses any photo that would leave too little room to build the
keepsake, and the archive needs both halves at once — so it demands
`2.2 x media + 10 GB reserve + 1.5 GB headroom`. Every gigabyte of photos
therefore costs about 3.2 GB of disk budget, and usable media works out at
`(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB of photos.

Measured, not theorised: a 945-photo wedding (8.74 GB of real files) stopped
after ~780 photos with 24.98 GB free — the gate wanted 25.91 GB on a disk that
was 79% empty. Uploads ended for want of an archive nobody had asked for yet.

`KEEPSAKE_ENABLED=false` drops the archive term, leaving only `DISK_RESERVE_BYTES`
— the one question still live without an archive, which is whether Postgres can
still write. That turns the same 30 GB volume into ~20 GB of usable media.
Verified against the exact disk state that ended the run: the photo that returned
413 with the keepsake armed is accepted with it off.

The headroom term goes with it, deliberately. It exists only to keep this gate
strictly ahead of the EXPORT PREFLIGHT, and with no keepsake there is no
preflight to stay ahead of.

Boot-time immutable, like COMMENTS_ENABLED: flipping it mid-event would move the
gate under uploads already accepted against the old one. Releasing the gallery
and minting download tickets are refused while it is off, and the host dashboard
hides the release control rather than offering a button that answers 403 — a new
`keepsake_enabled` field on `GET /host/event` carries that.

The gate and the host's low-disk banner now share `upload_gate_required_free`.
They were already two expressions of one threshold, and the banner exists to fire
BEFORE the gate closes; applying the switch to only one of them would warn the
host about a limit that can no longer fire, or stay silent past one that can.
Tests pin the lead in both modes.

The cost is real and worth stating plainly: there is no downloadable gallery at
the end, so guests keep only what they save from the feed. Prefer a bigger disk
where you can — ~45 GB holds this library with the keepsake intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-20 22:57:25 +02:00
parent cacf616c2d
commit 464b270716
6 changed files with 238 additions and 35 deletions

View File

@@ -207,6 +207,30 @@ COMPRESSION_WORKER_CONCURRENCY=2
# production. Remove that line from the compose file first if you want comments back. # production. Remove that line from the compose file first if you want comments back.
COMMENTS_ENABLED=true 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 ─────────────────────────────────────────────────────────────────── # ── Logging ───────────────────────────────────────────────────────────────────
# SET THIS IN PRODUCTION. Without it the app falls back to # 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 # `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a

View File

@@ -136,6 +136,23 @@ pub struct AppConfig {
/// comment UI. Existing comments stay in the DB (hidden), so flipping it back /// comment UI. Existing comments stay in the DB (hidden), so flipping it back
/// restores them. Boot-time immutable, like `compression_concurrency`. /// restores them. Boot-time immutable, like `compression_concurrency`.
pub comments_enabled: bool, 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. /// 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 /// 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. /// seed the initial default. `preset` is an id the frontend knows (e.g.
@@ -236,6 +253,14 @@ impl AppConfig {
) )
}) })
.unwrap_or(true), .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") default_theme_preset: std::env::var("THEME_PRESET")
.unwrap_or_else(|_| "champagne-gold".to_string()), .unwrap_or_else(|_| "champagne-gold".to_string()),
default_theme_primary: std::env::var("THEME_PRIMARY") default_theme_primary: std::env::var("THEME_PRIMARY")

View File

@@ -391,6 +391,13 @@ pub async fn export_ticket(
axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>, axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>,
auth: crate::auth::middleware::AuthUser, auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, 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 // 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 // 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 // released — Spec design choice"). The export is read-only, so it stays available

View File

@@ -38,8 +38,15 @@ pub struct EventStatus {
/// Free space on the volume the keepsake is written to. `None` when the mount can't be /// 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. /// resolved — the UI hides the widget rather than rendering a confident zero.
pub disk_free_bytes: Option<u64>, pub disk_free_bytes: Option<u64>,
/// 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, 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`]. /// Whether the host should be warned. See [`disk_is_low`].
pub disk_low: bool, 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 /// 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). /// 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<u64>) -> bool {
// Mirrors the gate EXACTLY, headroom included. The gate now demands // Mirrors the gate EXACTLY, headroom included. The gate now demands
// `UPLOAD_GATE_HEADROOM_BYTES` more than the export preflight does, so that ordinary // `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 // 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 // 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. // 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) // Both extra terms fall away together when the keepsake is off, because the gate drops them
.saturating_add(crate::handlers::upload::UPLOAD_GATE_HEADROOM_BYTES as u64); // 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); 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, // 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 // 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 .disk_cache
.snapshot(&state.config.export_path) .snapshot(&state.config.export_path)
.map(|d| d.free); .map(|d| d.free);
let keepsake_required_bytes = // `None` when the keepsake is off — there is no archive to reserve room for, so the
crate::services::export::keepsake_space_required(&state.pool, event.id) // dashboard reports 0 required and the banner uses the same reduced threshold the gate does.
.await let keepsake_required = if state.config.keepsake_enabled {
.unwrap_or(0); Some(
crate::services::export::keepsake_space_required(&state.pool, event.id)
.await
.unwrap_or(0),
)
} else {
None
};
Ok(Json(EventStatus { Ok(Json(EventStatus {
name: event.name, name: event.name,
@@ -170,10 +191,11 @@ pub async fn get_event_status(
uploads_locked: event.uploads_locked_at.is_some(), uploads_locked: event.uploads_locked_at.is_some(),
export_released: event.export_released_at.is_some(), export_released: event.export_released_at.is_some(),
disk_free_bytes: free, 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 // 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. // 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<AppState>, State(state): State<AppState>,
RequireHost(auth): RequireHost, RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> { ) -> Result<StatusCode, AppError> {
// 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 // 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: // transaction. Two reasons, both of which were live bugs:
// //
@@ -1041,7 +1071,9 @@ pub async fn release_gallery(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::disk_is_low; 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; use crate::services::export::required_free_bytes;
const GB: u64 = 1_000_000_000; const GB: u64 = 1_000_000_000;
@@ -1049,7 +1081,7 @@ mod tests {
#[test] #[test]
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() { 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. // 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 /// 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 — // 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 // 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. // the keepsake test comfortably; the floor is what catches this.
assert!(disk_is_low(5 * GB, GB)); assert!(disk_is_low(5 * GB, Some(GB)));
assert!(disk_is_low(9 * GB, 0)); assert!(disk_is_low(9 * GB, Some(0)));
assert!(!disk_is_low(20 * GB, 0), "a roomy empty disk is not low"); 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. /// 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. // Just above the gate: guests can still upload, and the host must already be warned.
assert!( 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" "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. // The warning must lead by a margin the host can act inside, not by one byte.
let mut warn_starts_at = gate_closes_at; 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; warn_starts_at += GB / 10;
} }
assert!( assert!(
@@ -1148,7 +1180,7 @@ mod tests {
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near // 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 // any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this
// before releasing; after releasing, they cannot. // before releasing; after releasing, they cannot.
assert!(disk_is_low(30 * GB, 66 * GB)); assert!(disk_is_low(30 * GB, Some(66 * GB)));
} }
#[test] #[test]
@@ -1159,8 +1191,8 @@ mod tests {
let required = 20 * GB; let required = 20 * GB;
let gate = required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64; let gate = required + DISK_RESERVE_BYTES as u64 + UPLOAD_GATE_HEADROOM_BYTES as u64;
let warn_at = gate + gate / 4; let warn_at = gate + gate / 4;
assert!(!disk_is_low(warn_at, required), "exactly enough is enough"); assert!(!disk_is_low(warn_at, Some(required)), "exactly enough is enough");
assert!(disk_is_low(warn_at - 1, required)); assert!(disk_is_low(warn_at - 1, Some(required)));
} }
#[test] #[test]
@@ -1168,7 +1200,50 @@ mod tests {
// With no gallery the keepsake term is 0, so the warn threshold collapses to // 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, // 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. // which dominates the 10 GB absolute floor.
assert!(!disk_is_low(15 * GB, 0)); assert!(!disk_is_low(15 * GB, Some(0)));
assert!(disk_is_low(9 * GB, 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
);
} }
} }

View File

@@ -690,29 +690,35 @@ pub async fn upload(
.get(&state.pool, &state.config.event_slug) .get(&state.pool, &state.config.event_slug)
.await .await
.saturating_add(size); .saturating_add(size);
let keepsake_needs = // `None` when the keepsake is switched off: there is no archive to keep buildable, so
crate::services::export::required_free_bytes(media_after.max(0) as u64, 2) as i64; // 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`. // 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 // 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. // stopped, so the night's remaining writes decided whether the keepsake could be built.
let required = keepsake_needs let required = upload_gate_required_free(keepsake_needs);
.saturating_add(DISK_RESERVE_BYTES)
.saturating_add(UPLOAD_GATE_HEADROOM_BYTES);
if free < required { if free < required {
tracing::error!( tracing::error!(
free_bytes = free, free_bytes = free,
upload_size = size, upload_size = size,
media_after, media_after,
keepsake_needs, keepsake_needs = keepsake_needs.unwrap_or(0),
keepsake_enabled = state.config.keepsake_enabled,
reserve = DISK_RESERVE_BYTES, reserve = DISK_RESERVE_BYTES,
headroom = UPLOAD_GATE_HEADROOM_BYTES, "refusing upload: not enough free disk"
"refusing upload: it would leave too little room to build the keepsake"
); );
return Err(AppError::QuotaExceeded( return Err(AppError::QuotaExceeded(
"Der Speicher des Events ist fast voll — damit die Galerie am Ende noch als \ if state.config.keepsake_enabled {
Download erstellt werden kann, sind neue Uploads jetzt gesperrt. Bitte sag \ "Der Speicher des Events ist fast voll — damit die Galerie am Ende noch als \
einem Host Bescheid." Download erstellt werden kann, sind neue Uploads jetzt gesperrt. Bitte sag \
.into(), einem Host Bescheid."
} else {
"Der Speicher des Events ist voll — neue Uploads sind gesperrt. Bitte sag \
einem Host Bescheid."
}
.into(),
)); ));
} }
} else { } else {
@@ -1426,6 +1432,25 @@ pub const DISK_RESERVE_BYTES: i64 = 10_000_000_000;
/// that the archive can never be built. /// that the archive can never be built.
pub const UPLOAD_GATE_HEADROOM_BYTES: i64 = 1_500_000_000; 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>) -> 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)`. /// 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 /// `divisor` is the LARGER of the observed uploader count and the operator's
@@ -1839,8 +1864,42 @@ pub async fn get_thumbnail(
mod tests { mod tests {
use super::{ use super::{
DISK_RESERVE_BYTES, MIN_QUOTA_LIMIT_BYTES, RangeSpec, parse_range, quota_limit_bytes, 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 `<video>` with // `Range` handling exists because iOS Safari probes every `<video>` with
// `Range: bytes=0-1` and abandons the load without a 206. These pin the forms a // `Range: bytes=0-1` and abandons the load without a 206. These pin the forms a
// media element actually sends, plus the edges that decide 206 vs 200 vs 416. // media element actually sends, plus the edges that decide 206 vs 200 vs 416.

View File

@@ -30,6 +30,8 @@
export_released: boolean; export_released: boolean;
disk_free_bytes: number | null; disk_free_bytes: number | null;
keepsake_required_bytes: number; keepsake_required_bytes: number;
/** False when KEEPSAKE_ENABLED=false — no downloadable gallery for this event. */
keepsake_enabled: boolean;
disk_low: boolean; disk_low: boolean;
} }
@@ -736,6 +738,11 @@
> >
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'} {event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
</button> </button>
<!-- Hidden entirely when the keepsake is switched off (KEEPSAKE_ENABLED=false):
there is no archive to build, the backend refuses the call with 403, and a
button that always errors reads as a broken app rather than a disabled
feature. The note below says so in words instead. -->
{#if event.keepsake_enabled}
<button <button
onclick={() => onclick={() =>
(confirmAction = { (confirmAction = {
@@ -756,6 +763,12 @@
> >
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'} {event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button> </button>
{:else}
<p class="self-center text-sm text-gray-500 dark:text-gray-400">
Der Galerie-Download ist für dieses Event deaktiviert. Gäste speichern Fotos
direkt aus der Galerie.
</p>
{/if}
</div> </div>
<!-- Live keepsake status: after release the ZIP/HTML still take time to build, and <!-- Live keepsake status: after release the ZIP/HTML still take time to build, and