fix: close the nine ways an unattended event loses photos or dies
Every one of these was found in the pre-event audit, verified against source, and
survives to production on the current main. Grouped by what actually goes wrong.
PHOTOS DISAPPEAR
* compression.rs no longer soft-deletes on a failed derivative. The guest got a
201, watched the card appear, then watched it vanish — the row left v_feed,
find_visible_media and BOTH keepsakes, while its bytes sat on disk for 14 days
waiting for a cleanup nothing announced. No screen anywhere lists compression
failures, so recovery meant hand-written SQL that also had to re-add the
refunded quota. Now it does exactly what the ENOSPC arm beside it already did
and documented as correct: keep the row, serve the original, retry on the next
boot (bounded by derivative_attempts). `upload-deleted` is no longer emitted;
`upload-processed` is, so the card re-renders instead of sitting on a
placeholder.
* A 413 is now a reversible lock, so the blob survives. The quota moves — free
disk falls, uploader count rises — so a guest goes over it having done nothing,
and treating that as permanent meant a 400 MB video was pushed across cellular
in full and THEN deleted from IndexedDB. Gone on both sides, and unrecoverable
for an in-app camera capture that exists nowhere else.
* quota_limit_bytes gained a floor and a stable divisor. The ceiling used to
decrease monotonically all evening; it now settles at max(uploaders,
estimated_guest_count) — a config key that was seeded, validated in the admin
whitelist, and read by no code at all. The floor is clamped to what the disk
can actually back, so a full volume still yields zero rather than handing out
an allowance it cannot honour.
* Because that floor gives up the aggregate guarantee the formula used to imply,
uploads now check a hard 10 GB reserve first, independent of every quota
toggle. postgres_data, media_data and exports_data share one filesystem: the
end state was not a degraded feature, it was Postgres unable to write WAL.
THE ARCHIVE DISAPPEARS
* prune_superseded_archives runs only after the new generation lands. It ran
before the preflight, reasoning the old archive was already unreachable — true
of reachability, false of recoverability. An epoch is a value that can be
rolled back; deleted bytes cannot. Any failed rebuild left the event with NO
keepsake at all.
* The export preflight reserves the same 10 GB. `free < needed` authorised an
export sized at exactly free, which ran for half an hour and landed the box at
zero with the keepsake still unfinished.
THE APP DIES
* The feed reconcile re-reads the id set after its awaits instead of reusing one
captured up to three round-trips earlier. The new-upload SSE handler prepends
during exactly that window, so the row was both already present and absent from
the stale set — prepended twice, and a duplicate key in a keyed {#each} throws
in production, not just dev. The SSE handler and loadMore now dedupe too.
* Added routes/+error.svelte. Without it any uncaught error fell through to
SvelteKit's unstyled English 500 with no reload control — inside a chromeless
standalone PWA with no URL bar, for the rest of the evening.
THE OPERATOR IS LOCKED OUT
* admin_login verifies the password BEFORE charging the rate bucket, and a
correct password is never throttled. The old order made this a denial of
service against its own operator: every guest shares one NAT IP, the check ran
first, so five requests a minute from any phone in the room kept the bucket
full — and the escape hatch needed the admin session being blocked. A generous
separate ceiling still bounds bcrypt CPU.
THE PROJECTOR DIES
* The preload budget is now strictly inside the dwell. At the 3s option the 4s
budget could never land a commit on a slow uplink, so the wall froze on one
photo while the queue drained silently behind it.
* The wake lock retries every 30s while visible, and the page says so on screen
when the browser has no wake lock API. visibilitychange was the only retry
trigger and a kiosk never changes visibility, so one refusal — iOS in Low Power
Mode, say — was permanent.
* Caddy: /api/v1/upload/*/display joins the cacheable carve-out. The backend set
max-age=300 on it and the blanket no-store silently replaced it, so a projector
re-fetched a full-size JPEG per slide, ~2-4 GB over an evening on the uplink
the guests are uploading over.
Also removes Upload::soft_delete, now unreferenced and an unscoped footgun next
to soft_delete_in_event.
Verified: 146/146 backend tests against a live Postgres, clippy clean, 51/51
vitest, svelte-check 0 errors, eslint clean, vite build, caddy validate, compose
YAML parse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
Caddyfile
23
Caddyfile
@@ -31,20 +31,27 @@
|
|||||||
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
|
||||||
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
# Preview/thumbnail images. These are served by the app through a visibility-checked
|
# Preview/thumbnail/display images. These are served by the app through a
|
||||||
# alias (/api/v1/upload/{id}/{preview,thumbnail}) so moderation can revoke access;
|
# visibility-checked alias (/api/v1/upload/{id}/{preview,thumbnail,display}) so
|
||||||
# the app serves no /media route at all, so there is no direct path to the bytes.
|
# moderation can revoke access; the app serves no /media route at all, so there is no
|
||||||
# Privately cacheable for a short window (the app sets the same header; this is the
|
# direct path to the bytes. Privately cacheable for a short window (the app sets the
|
||||||
# edge carve-out from the blanket no-store below). Kept short so a moderated image
|
# same header; this is the edge carve-out from the blanket no-store below). Kept short
|
||||||
# stops being served to a direct-URL holder promptly.
|
# so a moderated image stops being served to a direct-URL holder promptly.
|
||||||
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
#
|
||||||
|
# `display` was missing here while the backend set `private, max-age=300` on it, and
|
||||||
|
# because `header` REPLACES, the blanket no-store below silently won. That route is the
|
||||||
|
# ~2048px derivative the diashow uses exclusively, so a projector left running all
|
||||||
|
# evening re-fetched a full-size JPEG for every slide — roughly 2-4 GB pulled through
|
||||||
|
# the app over 8 hours, on the same venue uplink 100 guests are uploading over, and a
|
||||||
|
# blank frame on every network hiccup.
|
||||||
|
@media_api path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail /api/v1/upload/*/display
|
||||||
header @media_api Cache-Control "private, max-age=300"
|
header @media_api Cache-Control "private, max-age=300"
|
||||||
|
|
||||||
# API and health — never cache, EXCEPT the gated image routes above. A cached health
|
# API and health — never cache, EXCEPT the gated image routes above. A cached health
|
||||||
# response would report the last known state rather than the current one.
|
# response would report the last known state rather than the current one.
|
||||||
@api {
|
@api {
|
||||||
path /api/* /health
|
path /api/* /health
|
||||||
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail
|
not path /api/v1/upload/*/preview /api/v1/upload/*/thumbnail /api/v1/upload/*/display
|
||||||
}
|
}
|
||||||
header @api Cache-Control "no-store"
|
header @api Cache-Control "no-store"
|
||||||
|
|
||||||
|
|||||||
@@ -409,6 +409,14 @@ pub struct AdminLoginResponse {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requests per minute per IP that may reach `verify_password` at all.
|
||||||
|
///
|
||||||
|
/// Not a security control — the failure bucket below is. This exists solely so an unauthenticated
|
||||||
|
/// endpoint cannot burn the box's CPU on cost-12 bcrypt (~250 ms each) at line rate. Set far above
|
||||||
|
/// anything a person typing a password can produce, because on venue NAT every guest shares the
|
||||||
|
/// operator's IP and this ceiling, unlike the failure bucket, can still refuse a correct password.
|
||||||
|
const ADMIN_LOGIN_CPU_CEILING: usize = 120;
|
||||||
|
|
||||||
pub async fn admin_login(
|
pub async fn admin_login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
@@ -421,21 +429,29 @@ pub async fn admin_login(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttle password attempts. The admin password is bcrypt-hashed (slow to
|
// Throttling here is in two parts, and the ORDER is the whole point.
|
||||||
// verify) but with no IP-level limit a determined attacker can still mount
|
//
|
||||||
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
// A single tight IP-keyed bucket checked before the password was verified made this
|
||||||
// honest typos.
|
// endpoint a denial-of-service against its own operator. Every guest at the venue shares
|
||||||
|
// one public IP behind NAT, `/admin/login` is a public linkable page, and the check ran
|
||||||
|
// BEFORE `verify_password` — so five requests a minute from any phone in the room kept the
|
||||||
|
// bucket permanently full and the admin, on that same IP, could never spend a slot.
|
||||||
|
// Successful logins consumed budget too, so a typo plus a retry on two devices did it by
|
||||||
|
// accident. And the escape hatch was circular: `admin_login_rate_enabled` can only be
|
||||||
|
// flipped through `PATCH /admin/config`, which needs the session being blocked.
|
||||||
let ip = client_ip(&headers, &peer.ip().to_string());
|
let ip = client_ip(&headers, &peer.ip().to_string());
|
||||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||||
let admin_rate_on =
|
let admin_rate_on =
|
||||||
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
||||||
// Stays keyed by IP on purpose: this guards a single shared credential, so a per-user
|
|
||||||
// or per-name key would just hand an attacker a fresh bucket per guess.
|
// Part 1: a deliberately GENEROUS ceiling whose only job is to bound bcrypt CPU. Cost-12
|
||||||
|
// verification is ~250 ms of a core, so an unbounded endpoint is a CPU exhaustion vector
|
||||||
|
// regardless of whether anyone guesses right. No human typing a password reaches this.
|
||||||
if rate_limits_on
|
if rate_limits_on
|
||||||
&& admin_rate_on
|
&& admin_rate_on
|
||||||
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
format!("admin_login:{ip}"),
|
format!("admin_login_cpu:{ip}"),
|
||||||
5,
|
ADMIN_LOGIN_CPU_CEILING,
|
||||||
Duration::from_secs(60),
|
Duration::from_secs(60),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -452,6 +468,24 @@ pub async fn admin_login(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
|
// Part 2: the tight bucket, charged ONLY on a wrong password. A correct password is
|
||||||
|
// never rate-limited, so no amount of guessing by anyone else can lock the operator
|
||||||
|
// out — which also dissolves the circular escape hatch above. Brute force is still
|
||||||
|
// bounded: every wrong guess costs a slot, and slots are per-IP.
|
||||||
|
if rate_limits_on
|
||||||
|
&& admin_rate_on
|
||||||
|
&& let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
|
format!("admin_login_fail:{ip}"),
|
||||||
|
5,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
tracing::warn!(ip = %ip, "admin_login: wrong password, failure bucket exhausted");
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
||||||
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,6 +443,38 @@ pub async fn upload(
|
|||||||
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
|
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
|
||||||
// pre-check and both increment, blowing past the quota. The pre-check stays as a
|
// pre-check and both increment, blowing past the quota. The pre-check stays as a
|
||||||
// fast path that avoids the disk write when the user is already clearly over.
|
// fast path that avoids the disk write when the user is already clearly over.
|
||||||
|
// GLOBAL RESERVE, checked before the per-user ceiling and independent of every quota
|
||||||
|
// toggle. The per-user quota is a fairness mechanism, not a disk guarantee — and since it
|
||||||
|
// now carries a floor (MIN_QUOTA_LIMIT_BYTES) so a guest's allowance stops shrinking as
|
||||||
|
// the party fills up, the aggregate ceiling it used to imply is gone entirely. Something
|
||||||
|
// has to own "do not fill the volume", because `postgres_data`, `media_data` and
|
||||||
|
// `exports_data` share one filesystem: the end state is not a degraded feature, it is
|
||||||
|
// Postgres unable to write WAL and the whole event down with nobody watching.
|
||||||
|
//
|
||||||
|
// Deliberately NOT gated behind `quota_enabled`. That switch exists so an operator can
|
||||||
|
// stop rationing space between guests; it was never meant to authorise running the disk
|
||||||
|
// to zero, and an operator flipping it at 23:00 to unblock a guest should not silently
|
||||||
|
// disarm the last thing standing between the party and a dead database.
|
||||||
|
if let Some(free) = crate::services::disk::free_bytes(&state.config.media_path) {
|
||||||
|
let remaining = (free as i64).saturating_sub(size);
|
||||||
|
if remaining < DISK_RESERVE_BYTES {
|
||||||
|
tracing::error!(
|
||||||
|
free_bytes = free,
|
||||||
|
upload_size = size,
|
||||||
|
reserve = DISK_RESERVE_BYTES,
|
||||||
|
"refusing upload: it would take the media volume below the reserve"
|
||||||
|
);
|
||||||
|
return Err(AppError::QuotaExceeded(
|
||||||
|
"Der Speicher des Events ist voll. Bitte sag einem Host Bescheid — neue \
|
||||||
|
Uploads sind vorübergehend nicht möglich."
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Failing OPEN when the disk can't be read is deliberate and matches the per-user quota
|
||||||
|
// above: refusing every upload because a `statfs` failed would be a worse outage than the
|
||||||
|
// one being guarded against.
|
||||||
|
|
||||||
let mut quota_limit: Option<i64> = None;
|
let mut quota_limit: Option<i64> = None;
|
||||||
if quota_on && storage_quota_on {
|
if quota_on && storage_quota_on {
|
||||||
let estimate = compute_storage_quota(&state).await;
|
let estimate = compute_storage_quota(&state).await;
|
||||||
@@ -956,11 +988,52 @@ pub struct QuotaEstimate {
|
|||||||
pub tolerance: f64,
|
pub tolerance: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure per-user quota formula: `floor((free_disk * tolerance) / max(active, 1))`.
|
/// The smallest per-user ceiling this formula is ever allowed to produce.
|
||||||
|
///
|
||||||
|
/// Without a floor the quota is not a limit, it is a moving target: the numerator (free disk)
|
||||||
|
/// only falls and the denominator (uploaders who have posted) only rises, so the ceiling
|
||||||
|
/// decreases monotonically across the event. A guest comfortably under it at 20:00 is over it
|
||||||
|
/// at 22:00 having done nothing, and because a delete refunds the quota but does not free the
|
||||||
|
/// bytes for 24h, the remedy the error message names ("delete older posts") cannot move it
|
||||||
|
/// back either.
|
||||||
|
///
|
||||||
|
/// 500 MB is chosen to clear `max_video_size_mb` (500, seeded in 005) — below that the ceiling
|
||||||
|
/// could refuse a single legal video outright, which is the worst version of this: the guest
|
||||||
|
/// pushes 500 MB across cellular and is rejected on arrival, every time, with no way to comply.
|
||||||
|
///
|
||||||
|
/// This deliberately trades the quota's disk guarantee for a usability floor. The disk is now
|
||||||
|
/// bounded by the low-disk warning and the export preflight rather than by this formula alone —
|
||||||
|
/// see the reserve check in `ensure_export_space`.
|
||||||
|
const MIN_QUOTA_LIMIT_BYTES: i64 = 500 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Free space on the media volume that uploads may never consume, whatever any quota says.
|
||||||
|
///
|
||||||
|
/// Matches the host dashboard's low-disk threshold (`handlers::host`), so the banner the host
|
||||||
|
/// sees and the wall guests hit are the same number rather than two unrelated opinions. 10 GB
|
||||||
|
/// is chosen to leave Postgres, its WAL and a rotation of container logs comfortable room on
|
||||||
|
/// the shared filesystem long after new uploads have been refused.
|
||||||
|
pub const DISK_RESERVE_BYTES: i64 = 10_000_000_000;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// `estimated_guest_count`, so the ceiling settles at its final value early instead of sliding
|
||||||
|
/// down all evening as guests arrive. (Before this, `estimated_guest_count` was seeded and
|
||||||
|
/// validated in the admin whitelist but read by no code at all — an operator who set it
|
||||||
|
/// expecting a stable divisor changed nothing.) It also blunts the abuse case, where the
|
||||||
|
/// divisor was attacker-controlled: ~1000 throwaway accounts drove every real guest's ceiling
|
||||||
|
/// to ~52 MB.
|
||||||
|
///
|
||||||
/// Extracted from `compute_storage_quota` so it's unit-testable without a DB or disk.
|
/// Extracted from `compute_storage_quota` so it's unit-testable without a DB or disk.
|
||||||
fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64) -> i64 {
|
fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64, expected: i64) -> i64 {
|
||||||
let active = active_uploaders.max(1);
|
let divisor = active_uploaders.max(expected).max(1);
|
||||||
((free_disk as f64 * tolerance) / active as f64).floor() as i64
|
let budget = (free_disk as f64 * tolerance).max(0.0);
|
||||||
|
let computed = (budget / divisor as f64).floor() as i64;
|
||||||
|
// The floor may never exceed what the disk can actually back. Raising a ceiling the volume
|
||||||
|
// cannot honour would hand out an allowance on a full disk — turning the quota from a
|
||||||
|
// usability floor into a way to finish filling the filesystem that Postgres writes WAL to.
|
||||||
|
let backed_floor = MIN_QUOTA_LIMIT_BYTES.min(budget as i64);
|
||||||
|
computed.max(backed_floor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the per-user storage quota using
|
/// Computes the per-user storage quota using
|
||||||
@@ -979,6 +1052,9 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or((0,));
|
.unwrap_or((0,));
|
||||||
let active = active_count.max(1);
|
let active = active_count.max(1);
|
||||||
|
// The operator's expected headcount, used as a FLOOR on the divisor so the ceiling doesn't
|
||||||
|
// slide down as guests arrive — see `quota_limit_bytes`. Admin-editable at runtime.
|
||||||
|
let expected_guests = config::get_i64(&state.config_cache, "estimated_guest_count", 100).await;
|
||||||
|
|
||||||
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
|
// Cached disk reading. `None` means we couldn't resolve the media filesystem.
|
||||||
let disk = state.disk_cache.snapshot(&state.config.media_path);
|
let disk = state.disk_cache.snapshot(&state.config.media_path);
|
||||||
@@ -986,7 +1062,12 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
|||||||
|
|
||||||
let limit_bytes = if quota_on && storage_quota_on {
|
let limit_bytes = if quota_on && storage_quota_on {
|
||||||
match disk {
|
match disk {
|
||||||
Some(d) => Some(quota_limit_bytes(d.free as i64, tolerance, active)),
|
Some(d) => Some(quota_limit_bytes(
|
||||||
|
d.free as i64,
|
||||||
|
tolerance,
|
||||||
|
active,
|
||||||
|
expected_guests,
|
||||||
|
)),
|
||||||
// Fail OPEN, not closed: if the disk can't be read we don't know the real
|
// Fail OPEN, not closed: if the disk can't be read we don't know the real
|
||||||
// free space, and enforcing a 0-byte limit would reject every upload with a
|
// free space, and enforcing a 0-byte limit would reject every upload with a
|
||||||
// spurious "quota reached". Skip enforcement this round and warn instead.
|
// spurious "quota reached". Skip enforcement this round and warn instead.
|
||||||
@@ -1289,7 +1370,7 @@ pub async fn get_thumbnail(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{RangeSpec, parse_range, quota_limit_bytes};
|
use super::{MIN_QUOTA_LIMIT_BYTES, RangeSpec, parse_range, quota_limit_bytes};
|
||||||
|
|
||||||
// `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
|
||||||
@@ -1392,33 +1473,70 @@ mod tests {
|
|||||||
assert_eq!(parse_range(None, 0), RangeSpec::Full);
|
assert_eq!(parse_range(None, 0), RangeSpec::Full);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GB: i64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn divides_free_space_by_uploaders_with_tolerance() {
|
fn divides_free_space_by_uploaders_with_tolerance() {
|
||||||
// 1000 * 0.75 / 3 = 250
|
// 100 GB * 0.75 / 3 uploaders, well above the floor so the formula shows through.
|
||||||
assert_eq!(quota_limit_bytes(1000, 0.75, 3), 250);
|
assert_eq!(quota_limit_bytes(100 * GB, 0.75, 3, 1), 26_843_545_600);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn floors_fractional_results() {
|
fn floors_fractional_results() {
|
||||||
// 1000 * 0.75 / 7 = 107.14… → 107
|
// 100 GB * 0.75 / 7 = 11_504_376_685.71… → truncated, not rounded.
|
||||||
assert_eq!(quota_limit_bytes(1000, 0.75, 7), 107);
|
assert_eq!(quota_limit_bytes(100 * GB, 0.75, 7, 1), 11_504_376_685);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn active_uploaders_below_one_is_clamped_to_one() {
|
fn divisor_below_one_is_clamped_to_one() {
|
||||||
// Guards against divide-by-zero when no one has uploaded yet.
|
// Guards against divide-by-zero when no one has uploaded yet.
|
||||||
assert_eq!(quota_limit_bytes(1000, 1.0, 0), 1000);
|
assert_eq!(quota_limit_bytes(10 * GB, 1.0, 0, 0), 10 * GB);
|
||||||
assert_eq!(quota_limit_bytes(1000, 1.0, -5), 1000);
|
assert_eq!(quota_limit_bytes(10 * GB, 1.0, -5, 0), 10 * GB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The property the floor exists for: a guest's ceiling must not keep shrinking as more
|
||||||
|
/// guests arrive. Same disk, 10 uploaders vs 1000 — the second must not be starved.
|
||||||
#[test]
|
#[test]
|
||||||
fn zero_free_disk_yields_zero() {
|
fn the_ceiling_stops_falling_once_it_reaches_the_floor() {
|
||||||
assert_eq!(quota_limit_bytes(0, 0.75, 3), 0);
|
let ten = quota_limit_bytes(70 * GB, 0.75, 10, 1);
|
||||||
|
let thousand = quota_limit_bytes(70 * GB, 0.75, 1000, 1);
|
||||||
|
assert!(ten > MIN_QUOTA_LIMIT_BYTES, "10 uploaders should be roomy");
|
||||||
|
assert_eq!(
|
||||||
|
thousand, MIN_QUOTA_LIMIT_BYTES,
|
||||||
|
"1000 uploaders (or 1000 fake accounts) must not drive the ceiling below the floor"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
thousand >= 500 * 1024 * 1024,
|
||||||
|
"the floor must still clear a single max-size video"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `estimated_guest_count` is a FLOOR on the divisor, so the ceiling settles early instead
|
||||||
|
/// of sliding down all evening as guests arrive.
|
||||||
|
#[test]
|
||||||
|
fn expected_headcount_holds_the_divisor_steady_while_guests_arrive() {
|
||||||
|
let early = quota_limit_bytes(70 * GB, 0.75, 5, 100);
|
||||||
|
let late = quota_limit_bytes(70 * GB, 0.75, 100, 100);
|
||||||
|
assert_eq!(
|
||||||
|
early, late,
|
||||||
|
"the 5th guest and the 100th must see the same ceiling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The floor must never write a cheque the volume cannot cash — otherwise a full disk
|
||||||
|
/// still hands out a 500 MB allowance and the filesystem Postgres needs fills up.
|
||||||
|
#[test]
|
||||||
|
fn the_floor_never_exceeds_what_the_disk_can_back() {
|
||||||
|
assert_eq!(quota_limit_bytes(0, 0.75, 3, 1), 0, "no disk, no allowance");
|
||||||
|
// 400 MB free * 0.75 = 300 MB — below the floor, so the disk wins.
|
||||||
|
let tight = quota_limit_bytes(400 * 1024 * 1024, 0.75, 3, 1);
|
||||||
|
assert_eq!(tight, 300 * 1024 * 1024);
|
||||||
|
assert!(tight < MIN_QUOTA_LIMIT_BYTES);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_tolerance_is_identity_for_a_single_uploader() {
|
fn full_tolerance_is_identity_for_a_single_uploader() {
|
||||||
assert_eq!(quota_limit_bytes(500, 1.0, 1), 500);
|
assert_eq!(quota_limit_bytes(50 * GB, 1.0, 1, 1), 50 * GB);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The guard is the only reclaim mechanism that survives a client disconnect, so its three
|
/// The guard is the only reclaim mechanism that survives a client disconnect, so its three
|
||||||
|
|||||||
@@ -259,40 +259,8 @@ impl Upload {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`.
|
/// Soft-deletes an upload within its event and refunds the uploader's
|
||||||
/// Done in a single transaction so a crash between the two writes can't leave
|
/// `total_upload_bytes`, in one transaction. Returns `false` if no row
|
||||||
/// the quota counter pointing at bytes the user has already deleted (which would
|
|
||||||
/// silently lock them out of future uploads).
|
|
||||||
///
|
|
||||||
/// No-op if the row is already deleted — protects against a double-tap on the
|
|
||||||
/// delete action double-decrementing the counter.
|
|
||||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
|
||||||
"UPDATE upload
|
|
||||||
SET deleted_at = NOW()
|
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
|
||||||
RETURNING user_id, original_size_bytes",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
if let Some((user_id, bytes)) = row {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE \"user\"
|
|
||||||
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(bytes)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
|
|
||||||
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
/// matched (already deleted, wrong event, or unknown id) so host handlers
|
||||||
/// can return a clean 404 instead of silently no-op'ing.
|
/// can return a clean 404 instead of silently no-op'ing.
|
||||||
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
|
/// Executor-generic so a caller can run the delete and the keepsake regeneration in ONE
|
||||||
|
|||||||
@@ -165,35 +165,43 @@ impl CompressionWorker {
|
|||||||
tracing::error!(
|
tracing::error!(
|
||||||
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
||||||
);
|
);
|
||||||
// Refund + soft-delete (one tx, so v_feed excludes it) so a failed
|
// KEEP THE ROW. This used to soft-delete, which made a derivative failure
|
||||||
// transcode doesn't leave a permanently broken feed card or silently
|
// indistinguishable — to the guest — from their photo being deleted: they
|
||||||
// charge the uploader's quota. Then tell the uploader (upload-error
|
// got a `201 Created`, watched the card appear, and then watched it vanish.
|
||||||
// toast) and evict the card everywhere (upload-deleted).
|
// The row left `v_feed`, `find_visible_media` and BOTH keepsake archives,
|
||||||
|
// so the photo was gone from the product's core promise while its bytes sat
|
||||||
|
// on disk for 14 days waiting for a `cleanup_deleted_media` that nothing
|
||||||
|
// told anyone about. There is no host or admin screen listing compression
|
||||||
|
// failures, so recovery meant hand-written SQL that also had to re-add the
|
||||||
|
// refunded quota bytes. Against "0 lost uploads", that was silent per-photo
|
||||||
|
// loss on any error the ENOSPC arm above doesn't catch — a HEIC that slipped
|
||||||
|
// the allowlist, a truncated frame, an ffmpeg hiccup, a pool blip.
|
||||||
//
|
//
|
||||||
// The ORIGINAL IS DELIBERATELY KEPT. This path used to `remove_file` it
|
// This is exactly what the ENOSPC arm already does and documents as correct:
|
||||||
// unconditionally, which meant any transient error — a disk-full blip
|
// every client falls back to the original when `preview_url` and
|
||||||
// while saving a derivative, a pool hiccup, a panic in the image codec —
|
// `thumbnail_url` are NULL, so the photo stays visible and downloadable —
|
||||||
// irreversibly destroyed the guest's only copy of a photo they can never
|
// just uncompressed — and `backfill_stale_derivatives` retries it on the
|
||||||
// retake. The row is only soft-deleted, so keeping the bytes makes the
|
// next boot, now bounded by `derivative_attempts` so a poisoned row cannot
|
||||||
// upload fully recoverable; the file is orphaned rather than lost, and
|
// loop. The quota stays charged, which is correct: the bytes are still on
|
||||||
// the path is logged so it can be found. `backfill_stale_derivatives`
|
// disk and still the guest's.
|
||||||
// already refuses to destroy data on error for exactly this reason.
|
|
||||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||||
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
|
|
||||||
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
|
|
||||||
}
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%upload_id,
|
%upload_id,
|
||||||
path = %worker.media_path.join(&original_path).display(),
|
path = %worker.media_path.join(&original_path).display(),
|
||||||
"original retained for recovery after compression failure"
|
"derivatives failed; the upload is kept and served from its original"
|
||||||
);
|
);
|
||||||
|
// `upload-error` still fires so the uploader learns the photo will look
|
||||||
|
// uncompressed. `upload-deleted` deliberately does NOT — nothing was
|
||||||
|
// deleted, and evicting the card was the visible half of the data loss.
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-error".to_string(),
|
event_type: "upload-error".to_string(),
|
||||||
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
|
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
|
||||||
.to_string(),
|
.to_string(),
|
||||||
});
|
});
|
||||||
|
// Tell every client to refetch, so the card re-renders from the original
|
||||||
|
// instead of sitting on a stale "processing" placeholder forever.
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-deleted".to_string(),
|
event_type: "upload-processed".to_string(),
|
||||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -499,9 +499,6 @@ async fn run_zip_export(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reclaim BEFORE measuring: the superseded archive is already unreachable, and the space it
|
|
||||||
// holds is very often exactly the space this rebuild needs.
|
|
||||||
prune_superseded_archives(pool, export_path, "Gallery", event_id, epoch).await;
|
|
||||||
// AFTER the claim, not before. A preflight that bailed before claiming would leave the row
|
// AFTER the claim, not before. A preflight that bailed before claiming would leave the row
|
||||||
// `pending` with no worker and no error — the spinner-forever state `mark_failed`'s status
|
// `pending` with no worker and no error — the spinner-forever state `mark_failed`'s status
|
||||||
// guard was widened to prevent. Failing here goes through the caller's `mark_failed`, so the
|
// guard was widened to prevent. Failing here goes through the caller's `mark_failed`, so the
|
||||||
@@ -517,6 +514,25 @@ async fn run_zip_export(
|
|||||||
tokio::fs::remove_file(export_path.join(gen_name(event_id, "Gallery", epoch, ".tmp")))
|
tokio::fs::remove_file(export_path.join(gen_name(event_id, "Gallery", epoch, ".tmp")))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reclaim the PREVIOUS generation only once this one has actually landed.
|
||||||
|
//
|
||||||
|
// This used to run before the preflight, reasoning that the superseded archive is already
|
||||||
|
// unreachable and its space is usually exactly what the rebuild needs. That is true about
|
||||||
|
// REACHABILITY and false about RECOVERABILITY: an epoch is a database value that can be
|
||||||
|
// rolled back, deleted bytes cannot. Any rebuild that then failed — ENOSPC mid-write, an
|
||||||
|
// OOM, a hung ffmpeg, a host tapping "Neu erzeugen" on a bad day — left the event with NO
|
||||||
|
// archive at all, which is the one outcome the whole product exists to prevent, at the one
|
||||||
|
// moment nobody is watching.
|
||||||
|
//
|
||||||
|
// The cost of deferring is that a rebuild now needs room for both generations at once, and
|
||||||
|
// `ensure_export_space` above no longer gets to count the old archive's bytes as available.
|
||||||
|
// That is the correct trade: it converts "silently destroyed the only copy" into "refused
|
||||||
|
// to start, and said why".
|
||||||
|
if res.is_ok() {
|
||||||
|
prune_superseded_archives(pool, export_path, "Gallery", event_id, epoch).await;
|
||||||
|
}
|
||||||
|
|
||||||
abandon_if_superseded("ZIP", event_id, epoch, res)
|
abandon_if_superseded("ZIP", event_id, epoch, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,9 +706,8 @@ async fn run_html_export(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// See run_zip_export: reclaim the superseded generation first, then refuse at the door rather
|
// See run_zip_export: refuse at the door rather than ENOSPC mid-write, and reclaim the
|
||||||
// than ENOSPC mid-write.
|
// superseded generation only AFTER this one lands.
|
||||||
prune_superseded_archives(pool, export_path, "Memories", event_id, epoch).await;
|
|
||||||
ensure_export_space(pool, event_id, export_path).await?;
|
ensure_export_space(pool, event_id, export_path).await?;
|
||||||
|
|
||||||
let res = run_html_export_inner(
|
let res = run_html_export_inner(
|
||||||
@@ -716,6 +731,13 @@ async fn run_html_export(
|
|||||||
tokio::fs::remove_dir_all(export_path.join(format!("viewer_tmp_{event_id}_{epoch}")))
|
tokio::fs::remove_dir_all(export_path.join(format!("viewer_tmp_{event_id}_{epoch}")))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only once the new keepsake exists — see the reasoning in run_zip_export. A failed rebuild
|
||||||
|
// must never be the reason the previous one is gone.
|
||||||
|
if res.is_ok() {
|
||||||
|
prune_superseded_archives(pool, export_path, "Memories", event_id, epoch).await;
|
||||||
|
}
|
||||||
|
|
||||||
abandon_if_superseded("HTML", event_id, epoch, res)
|
abandon_if_superseded("HTML", event_id, epoch, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1383,18 +1405,32 @@ async fn ensure_export_space(pool: &PgPool, event_id: Uuid, export_path: &Path)
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
if free < needed {
|
// The archive may not consume the last byte of the volume. `needed` alone authorised an
|
||||||
|
// export sized at exactly `free`: it would pass the check, run for half an hour, and land
|
||||||
|
// the box at zero — at which point Postgres cannot write WAL and the event is over, with
|
||||||
|
// the keepsake still unfinished. `postgres_data`, `media_data` and `exports_data` share one
|
||||||
|
// filesystem, so "enough room for the archive" was never the same question as "enough room
|
||||||
|
// for the archive AND a working database".
|
||||||
|
//
|
||||||
|
// Same reserve the upload path and the host dashboard's low-disk banner use, so all three
|
||||||
|
// agree on what "full" means.
|
||||||
|
let required = needed.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
|
||||||
|
|
||||||
|
if free < required {
|
||||||
let gb = |b: u64| b as f64 / 1_000_000_000.0;
|
let gb = |b: u64| b as f64 / 1_000_000_000.0;
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
needed,
|
needed,
|
||||||
|
required,
|
||||||
free,
|
free,
|
||||||
armed,
|
armed,
|
||||||
"export preflight: not enough free space to build the keepsake for event {event_id}"
|
"export preflight: not enough free space to build the keepsake for event {event_id}"
|
||||||
);
|
);
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Nicht genug Speicherplatz für das Keepsake: benötigt ca. {:.1} GB, frei sind {:.1} GB. \
|
"Nicht genug Speicherplatz für das Keepsake: benötigt ca. {:.1} GB (plus {:.0} GB \
|
||||||
Bitte Speicher freigeben und das Keepsake anschließend neu erstellen.",
|
Reserve), frei sind {:.1} GB. Bitte Speicher freigeben und das Keepsake \
|
||||||
|
anschließend neu erstellen.",
|
||||||
gb(needed),
|
gb(needed),
|
||||||
|
gb(crate::handlers::upload::DISK_RESERVE_BYTES as u64),
|
||||||
gb(free)
|
gb(free)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ type WakeLock = { request: (t: string) => Promise<SentinelLike> };
|
|||||||
|
|
||||||
let sentinel: SentinelLike | null = null;
|
let sentinel: SentinelLike | null = null;
|
||||||
let visibilityHandler: (() => void) | null = null;
|
let visibilityHandler: (() => void) | null = null;
|
||||||
|
let retryTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often to retry while visible and lock-less.
|
||||||
|
*
|
||||||
|
* `visibilitychange` was the ONLY retry trigger, and a kiosk never changes visibility: the
|
||||||
|
* projector tab is opened once and left alone for eight hours. So a single refusal at startup
|
||||||
|
* was permanent. Refusal is not exotic either — iOS declines Screen Wake Lock outright in Low
|
||||||
|
* Power Mode, which is exactly the state a tablet that has been sitting on a table all
|
||||||
|
* afternoon is in. The symptom is the screen sleeping mid-party and someone having to walk
|
||||||
|
* over and tap it, repeatedly.
|
||||||
|
*/
|
||||||
|
const RETRY_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
async function request(wakeLock: WakeLock): Promise<void> {
|
async function request(wakeLock: WakeLock): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -33,9 +46,17 @@ async function request(wakeLock: WakeLock): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acquireWakeLock(): Promise<void> {
|
/**
|
||||||
|
* Acquire the screen wake lock, and keep trying.
|
||||||
|
*
|
||||||
|
* Returns whether the API exists at all, so the caller can tell "the browser cannot do this,
|
||||||
|
* warn the operator" (Firefox, Safari < 16.4, most TV browsers) apart from "asked for, may
|
||||||
|
* still arrive". It deliberately does NOT report whether the first request succeeded — that
|
||||||
|
* answer goes stale immediately, and the retry loop below is what actually matters.
|
||||||
|
*/
|
||||||
|
export async function acquireWakeLock(): Promise<boolean> {
|
||||||
const wakeLock = (navigator as Navigator & { wakeLock?: WakeLock }).wakeLock;
|
const wakeLock = (navigator as Navigator & { wakeLock?: WakeLock }).wakeLock;
|
||||||
if (!wakeLock) return;
|
if (!wakeLock) return false;
|
||||||
await request(wakeLock);
|
await request(wakeLock);
|
||||||
|
|
||||||
// Re-acquire when the page becomes visible again (the OS releases the lock
|
// Re-acquire when the page becomes visible again (the OS releases the lock
|
||||||
@@ -48,6 +69,17 @@ export async function acquireWakeLock(): Promise<void> {
|
|||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', visibilityHandler);
|
document.addEventListener('visibilitychange', visibilityHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The kiosk case: visible, no lock, and no visibility change ever coming. Cheap enough to
|
||||||
|
// run all evening — it does nothing at all once a lock is held.
|
||||||
|
if (!retryTimer) {
|
||||||
|
retryTimer = setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible' && sentinel === null) {
|
||||||
|
void request(wakeLock);
|
||||||
|
}
|
||||||
|
}, RETRY_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseWakeLock(): Promise<void> {
|
export async function releaseWakeLock(): Promise<void> {
|
||||||
@@ -63,4 +95,8 @@ export async function releaseWakeLock(): Promise<void> {
|
|||||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||||
visibilityHandler = null;
|
visibilityHandler = null;
|
||||||
}
|
}
|
||||||
|
if (retryTimer) {
|
||||||
|
clearInterval(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,22 @@ export type EventConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const eventConfig = writable<EventConfig | null>(null);
|
export const eventConfig = writable<EventConfig | null>(null);
|
||||||
/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */
|
/**
|
||||||
export const commentsEnabled = writable<boolean>(true);
|
* Convenience flag for the comment UI. `null` until `/event` answers — deliberately NOT an
|
||||||
|
* optimistic `true`.
|
||||||
|
*
|
||||||
|
* Optimistic-open was wrong in the direction that costs a guest their words. Production pins
|
||||||
|
* `COMMENTS_ENABLED=false`, so on every cold open the comment button, the grid tile button and
|
||||||
|
* the whole lightbox composer rendered for as long as `/event` took — up to the 20s api timeout
|
||||||
|
* on congested venue wifi, and PERMANENTLY if that request failed, because the catch below
|
||||||
|
* leaves the last value in place. A guest would type a comment, tap Senden, get a red error,
|
||||||
|
* and watch the text be discarded.
|
||||||
|
*
|
||||||
|
* `null` is falsy, so every `{#if $commentsEnabled}` hides and every `$commentsEnabled ? a : b`
|
||||||
|
* picks the comments-off copy until the server has actually said otherwise. The failure mode
|
||||||
|
* flips from "offered something that doesn't work" to "revealed a moment late".
|
||||||
|
*/
|
||||||
|
export const commentsEnabled = writable<boolean | null>(null);
|
||||||
|
|
||||||
type PublicEventDto = {
|
type PublicEventDto = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -535,13 +535,24 @@ export function classifyUploadStatus(status: number): UploadOutcome {
|
|||||||
*
|
*
|
||||||
* Reversible when:
|
* Reversible when:
|
||||||
* - the backend tagged it `uploads_locked` (event closed / gallery released — a host can reopen), OR
|
* - the backend tagged it `uploads_locked` (event closed / gallery released — a host can reopen), OR
|
||||||
|
* - it's `quota_exceeded` (413). The per-user ceiling is `free_disk * tolerance / uploaders`,
|
||||||
|
* which MOVES: the numerator falls and the denominator rises all evening, so a guest who was
|
||||||
|
* comfortably under it at 20:00 is over it at 22:00 through nobody's action, and a host
|
||||||
|
* deleting content or the hourly reclaim can put them back under it just as passively. It is
|
||||||
|
* the textbook reversible lock, and treating it as permanent meant a 400 MB video was pushed
|
||||||
|
* across cellular in full and THEN deleted from IndexedDB — gone on both sides, unrecoverable
|
||||||
|
* without re-picking from the camera roll (impossible for an in-app camera capture). OR
|
||||||
* - it's ANY 403 we can't positively identify as a permanent ban (`forbidden`). An unparseable
|
* - it's ANY 403 we can't positively identify as a permanent ban (`forbidden`). An unparseable
|
||||||
* 403 body (proxy/WAF/captive portal) must NOT purge the blob — losing a photo is the worst
|
* 403 body (proxy/WAF/captive portal) must NOT purge the blob — losing a photo is the worst
|
||||||
* outcome, and 403 is the reversible-lock status here.
|
* outcome, and 403 is the reversible-lock status here.
|
||||||
* A `forbidden` 403 (banned) and every non-403 4xx (e.g. 413 quota) are permanent → purge.
|
* A `forbidden` 403 (banned) and every other 4xx (too large, wrong type) are permanent → purge.
|
||||||
*/
|
*/
|
||||||
export function isReversibleLock(status: number, errorCode: unknown): boolean {
|
export function isReversibleLock(status: number, errorCode: unknown): boolean {
|
||||||
return errorCode === 'uploads_locked' || (status === 403 && errorCode !== 'forbidden');
|
return (
|
||||||
|
errorCode === 'uploads_locked' ||
|
||||||
|
errorCode === 'quota_exceeded' ||
|
||||||
|
(status === 403 && errorCode !== 'forbidden')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -982,7 +993,9 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
settle(() => reject(new LockedError(body?.message || 'Event ist geschlossen.')));
|
settle(() => reject(new LockedError(body?.message || 'Event ist geschlossen.')));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Any other 4xx the server will keep rejecting (banned / quota).
|
// Any other 4xx the server will keep rejecting (banned, too large, wrong
|
||||||
|
// type). Quota is NOT here any more — it moves with free disk and the
|
||||||
|
// uploader count, so it is a reversible lock handled above.
|
||||||
let msg = body?.message || 'Upload nicht möglich.';
|
let msg = body?.message || 'Upload nicht möglich.';
|
||||||
if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||||
settle(() => reject(new TerminalError(msg)));
|
settle(() => reject(new TerminalError(msg)));
|
||||||
@@ -1028,9 +1041,12 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
throw e; // Propagate to processQueue for scheduling
|
throw e; // Propagate to processQueue for scheduling
|
||||||
}
|
}
|
||||||
if (e instanceof LockedError) {
|
if (e instanceof LockedError) {
|
||||||
// Event closed / gallery released, but a host can reopen — KEEP the blob and park
|
// A condition that can lift without the guest doing anything: the event is closed or
|
||||||
// the item as retryable so it survives until reopen. The `event-opened` SSE
|
// the gallery released (a host can reopen), or the storage quota is currently
|
||||||
// (bindSse) auto-resumes it; a manual "Erneut" also works. Never purge here.
|
// exceeded (it moves with free disk and the uploader count, and a host takedown or
|
||||||
|
// the hourly media reclaim puts them back under it). KEEP the blob and park the item
|
||||||
|
// as retryable so it survives until then. `event-opened` and the `feed-delta`
|
||||||
|
// reconnect both auto-resume it; a manual "Erneut" also works. Never purge here.
|
||||||
const exhausted = chargeAttempt(entry);
|
const exhausted = chargeAttempt(entry);
|
||||||
entry.status = 'error';
|
entry.status = 'error';
|
||||||
entry.error = withRetryHint(e.message, exhausted);
|
entry.error = withRetryHint(e.message, exhausted);
|
||||||
|
|||||||
40
frontend/src/routes/+error.svelte
Normal file
40
frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Without this file SvelteKit renders its built-in fallback: an unstyled English
|
||||||
|
// "500 / Internal Error" with no reload control. That is reachable from any uncaught
|
||||||
|
// render or load error, and `ssr = false` means there is nothing else on the page to
|
||||||
|
// fall back to. In a `display: standalone` PWA there is also no URL bar, so a guest who
|
||||||
|
// hit it had no way back to the app at all for the rest of the evening.
|
||||||
|
//
|
||||||
|
// Deliberately dependency-free: no stores, no api client, no fetch. Whatever broke may
|
||||||
|
// be one of those, and an error page that can itself throw is worse than none.
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-dvh flex-col items-center justify-center gap-6 px-6 text-center">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="font-mono text-xs tracking-widest text-gray-500 uppercase">
|
||||||
|
Fehler {page.status}
|
||||||
|
</p>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
{page.status === 404 ? 'Diese Seite gibt es nicht' : 'Da ist etwas schiefgelaufen'}
|
||||||
|
</h1>
|
||||||
|
<p class="mx-auto max-w-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{page.status === 404
|
||||||
|
? 'Der Link stimmt nicht ganz. Geh zurück zur Galerie, dort sind alle Fotos.'
|
||||||
|
: 'Die Seite konnte nicht geladen werden. Meistens hilft es, sie neu zu laden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
|
{#if page.status !== 404}
|
||||||
|
<button type="button" class="btn btn-primary" onclick={reload}> Neu laden </button>
|
||||||
|
{/if}
|
||||||
|
<!-- A full document load, not `goto`: if the client-side router is what broke, a
|
||||||
|
client-side navigation would fail exactly the same way. -->
|
||||||
|
<a href="/feed" data-sveltekit-reload class="btn btn-secondary"> Zur Galerie </a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -13,6 +13,20 @@
|
|||||||
const DWELL_OPTIONS = [3000, 6000, 10000];
|
const DWELL_OPTIONS = [3000, 6000, 10000];
|
||||||
// Cap on how long we wait for the next image to decode before showing it anyway.
|
// Cap on how long we wait for the next image to decode before showing it anyway.
|
||||||
const PRELOAD_TIMEOUT_MS = 4000;
|
const PRELOAD_TIMEOUT_MS = 4000;
|
||||||
|
// How far inside the dwell the preload budget must finish.
|
||||||
|
//
|
||||||
|
// `advance()` starts the preload and `scheduleNext()` starts the dwell timer together, and
|
||||||
|
// `commit()` is discarded if the slide token has moved on. So whenever the preload budget
|
||||||
|
// is >= the dwell, a screen whose images consistently decode slowly (cold cache on a
|
||||||
|
// congested venue uplink — the normal state for a projector on party wifi) can never land
|
||||||
|
// a commit: at 3s dwell the 4s preload is discarded, the next one gets 4s and is discarded
|
||||||
|
// at 6s, forever. The wall sticks on one photo for the rest of the night while the queue
|
||||||
|
// drains silently behind it, and nothing on screen says anything is wrong.
|
||||||
|
//
|
||||||
|
// Bounding the budget strictly below the dwell means the timeout always fires first and
|
||||||
|
// commits whatever it has — a possibly-undecoded image is a far better outcome than a
|
||||||
|
// frozen wall that needs a human.
|
||||||
|
const PRELOAD_HEADROOM_MS = 500;
|
||||||
// If every source for a slide is unreadable we skip it — but bail out of skipping after
|
// If every source for a slide is unreadable we skip it — but bail out of skipping after
|
||||||
// this many in a row so a total media outage can't hot-loop the show.
|
// this many in a row so a total media outage can't hot-loop the show.
|
||||||
const MAX_CONSECUTIVE_SKIPS = 5;
|
const MAX_CONSECUTIVE_SKIPS = 5;
|
||||||
@@ -41,6 +55,10 @@
|
|||||||
// Consecutive slides skipped because no source decoded — reset the moment one shows.
|
// Consecutive slides skipped because no source decoded — reset the moment one shows.
|
||||||
let consecutiveSkips = 0;
|
let consecutiveSkips = 0;
|
||||||
let dwellMs = $state(6000);
|
let dwellMs = $state(6000);
|
||||||
|
// Set when the browser has no Screen Wake Lock API at all (Firefox, Safari < 16.4, most TV
|
||||||
|
// browsers). The operator sets this screen up once and walks away, so a silent no-op meant
|
||||||
|
// discovering the OS had dimmed the projector only by looking at it.
|
||||||
|
let wakeLockUnsupported = $state(false);
|
||||||
let transitionId = $state('crossfade');
|
let transitionId = $state('crossfade');
|
||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
let showOverlay = $state(false);
|
let showOverlay = $state(false);
|
||||||
@@ -177,7 +195,12 @@
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
action();
|
action();
|
||||||
};
|
};
|
||||||
timer = setTimeout(() => done(() => commit(candidates[i])), PRELOAD_TIMEOUT_MS);
|
// Always strictly less than the dwell — see PRELOAD_HEADROOM_MS.
|
||||||
|
const preloadBudget = Math.max(
|
||||||
|
250,
|
||||||
|
Math.min(PRELOAD_TIMEOUT_MS, dwellMs - PRELOAD_HEADROOM_MS)
|
||||||
|
);
|
||||||
|
timer = setTimeout(() => done(() => commit(candidates[i])), preloadBudget);
|
||||||
const pre = new Image();
|
const pre = new Image();
|
||||||
pre.src = candidates[i];
|
pre.src = candidates[i];
|
||||||
pre.decode().then(
|
pre.decode().then(
|
||||||
@@ -414,7 +437,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showBottomNav.set(false);
|
showBottomNav.set(false);
|
||||||
void acquireWakeLock();
|
void acquireWakeLock().then((supported) => {
|
||||||
|
wakeLockUnsupported = !supported;
|
||||||
|
});
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
showControls(); // show briefly on entry, then fade after the idle timeout
|
showControls(); // show briefly on entry, then fade after the idle timeout
|
||||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||||
@@ -481,6 +506,20 @@
|
|||||||
<div class="text-white/60">Lade…</div>
|
<div class="text-white/60">Lade…</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- The one thing the operator must know before walking away. This browser cannot hold a
|
||||||
|
screen wake lock at all, so the OS will dim or lock the display on its own schedule and
|
||||||
|
somebody will have to walk over and tap it. Placed bottom-left, out of the controls'
|
||||||
|
corner, and deliberately not auto-hidden with them — it is setup information, and it is
|
||||||
|
only ever shown when it is actionable. -->
|
||||||
|
{#if wakeLockUnsupported}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute bottom-4 left-4 max-w-xs rounded-md bg-black/60 px-3 py-2 text-left text-xs text-white/70 backdrop-blur"
|
||||||
|
>
|
||||||
|
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische
|
||||||
|
Bildschirmsperre am Gerät deaktivieren.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Controls appear on pointer/keyboard activity and fade when idle. Still fully
|
<!-- Controls appear on pointer/keyboard activity and fade when idle. Still fully
|
||||||
keyboard-reachable (any key reveals them), so the show is never a trap. Notch-safe. -->
|
keyboard-reachable (any key reveals them), so the show is never a trap. Notch-safe. -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -319,6 +319,11 @@
|
|||||||
feedStale = true;
|
feedStale = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Never prepend an id we already hold. The server broadcasts once, but a
|
||||||
|
// reconcile that resolves just after this handler runs re-delivers the same
|
||||||
|
// row, and a duplicate id in a keyed `{#each}` is a thrown error, not a
|
||||||
|
// cosmetic glitch.
|
||||||
|
if (uploads.some((u) => u.id === upload.id)) return;
|
||||||
uploads = [upload, ...uploads];
|
uploads = [upload, ...uploads];
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -540,9 +545,24 @@
|
|||||||
const bridged = known.size === 0 || fetched.some((u) => known.has(u.id));
|
const bridged = known.size === 0 || fetched.some((u) => known.has(u.id));
|
||||||
if (page + 1 >= windowPages && bridged) break;
|
if (page + 1 >= windowPages && bridged) break;
|
||||||
}
|
}
|
||||||
|
// RE-READ the ids here rather than reusing `known` from before the awaits.
|
||||||
|
//
|
||||||
|
// `known` was captured up to three round-trips ago — 1-6s on venue wifi. The
|
||||||
|
// `new-upload` SSE handler prepends to `uploads` unconditionally during exactly that
|
||||||
|
// window (list view is designed to stay live), so an upload that arrived mid-fetch is
|
||||||
|
// BOTH already in `uploads` and absent from the stale `known`. It then passed the
|
||||||
|
// filter below and was prepended a second time, putting the same id at two positions
|
||||||
|
// in an array consumed by keyed `{#each}` blocks — and Svelte 5 throws
|
||||||
|
// `each_key_duplicate` in production builds, not just dev. With no `+error.svelte` the
|
||||||
|
// guest got the unstyled fallback page, inside a chromeless PWA, for the rest of the
|
||||||
|
// evening.
|
||||||
|
//
|
||||||
|
// `known` above is still the right value for the bridging check: that one genuinely
|
||||||
|
// asks "did the head we started from overlap what the server returned".
|
||||||
|
const present = new Set(uploads.map((u) => u.id));
|
||||||
const byId = new Map(fetched.map((u) => [u.id, u]));
|
const byId = new Map(fetched.map((u) => [u.id, u]));
|
||||||
uploads = uploads.map((u) => byId.get(u.id) ?? u);
|
uploads = uploads.map((u) => byId.get(u.id) ?? u);
|
||||||
const fresh = fetched.filter((u) => !known.has(u.id));
|
const fresh = fetched.filter((u) => !present.has(u.id));
|
||||||
if (fresh.length) uploads = [...fresh, ...uploads];
|
if (fresh.length) uploads = [...fresh, ...uploads];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +631,13 @@
|
|||||||
params.set('cursor', nextCursor);
|
params.set('cursor', nextCursor);
|
||||||
params.set('limit', '20');
|
params.set('limit', '20');
|
||||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||||
uploads = [...uploads, ...res.uploads];
|
// Same hazard as the reconcile: this page was requested against the list as it
|
||||||
|
// stood before the await, and a `new-upload` prepend or a filter change since
|
||||||
|
// then can put a row we are about to append already in the array. Keyed `{#each}`
|
||||||
|
// treats that as fatal, so filter against the CURRENT ids, not the ones we started
|
||||||
|
// with.
|
||||||
|
const present = new Set(uploads.map((u) => u.id));
|
||||||
|
uploads = [...uploads, ...res.uploads.filter((u) => !present.has(u.id))];
|
||||||
nextCursor = res.next_cursor;
|
nextCursor = res.next_cursor;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
|
|||||||
Reference in New Issue
Block a user