Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
164c7d2aa3 | ||
| c8795ddfac | |||
| 0fba8defc2 | |||
| 2b57f1728e | |||
|
|
2dd563b3ee | ||
|
|
0aaaa75128 | ||
|
|
0a33189e7e | ||
|
|
5a33ab460f | ||
|
|
e3159299c0 | ||
|
|
464b270716 | ||
|
|
cacf616c2d | ||
|
|
94d279fa69 | ||
|
|
a7d2df6e9e | ||
| da2d4f67e7 |
39
.env.example
39
.env.example
@@ -207,6 +207,45 @@ 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
|
||||||
|
|
||||||
|
# ── In-app camera ─────────────────────────────────────────────────────────────
|
||||||
|
# Whether the upload sheet offers "Kamera — Jetzt aufnehmen" alongside "Galerie".
|
||||||
|
# Read at RUNTIME by the frontend container, so changing it is this line plus
|
||||||
|
# `docker compose up -d frontend` — no rebuild.
|
||||||
|
#
|
||||||
|
# Set to false when the in-app camera misbehaves on the guests' actual phones: switching
|
||||||
|
# between front and back throwing "Kamera konnte nicht gestartet werden", or video capture
|
||||||
|
# failing its permission prompt. Those failures are per-device and cannot be diagnosed
|
||||||
|
# mid-event, so this removes the broken path instead of letting guests find it.
|
||||||
|
#
|
||||||
|
# Nothing is lost by turning it off. The gallery picker opens the phone's own file chooser,
|
||||||
|
# which reaches the camera app on both iOS and Android and handles video — it is the path
|
||||||
|
# most guests use anyway. The onboarding text and the upload sheet adjust themselves.
|
||||||
|
PUBLIC_CAMERA_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
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -34,6 +34,12 @@ e2e/.env.test
|
|||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
|
|
||||||
|
# Load-test sample media. The wedding sample set is ~8.7 GB of real photos and
|
||||||
|
# videos; it is input to e2e/loadtest/event-sim.mjs, not source. Ignored by name
|
||||||
|
# AND by extension so a stray archive can never be committed by accident.
|
||||||
|
/wedding_sample_images.zip
|
||||||
|
*.zip
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ pub async fn add_comment(
|
|||||||
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
|
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Same reason as the caption path: a NUL is the one character Postgres will not store,
|
||||||
|
// and without this the INSERT below fails as an anonymous 500.
|
||||||
|
crate::handlers::upload::reject_nul(text, "Kommentar")?;
|
||||||
|
|
||||||
// Insert the comment and link its hashtags atomically, so a crash mid-loop
|
// Insert the comment and link its hashtags atomically, so a crash mid-loop
|
||||||
// can't leave a committed comment with only some of its tags indexed.
|
// can't leave a committed comment with only some of its tags indexed.
|
||||||
|
|||||||
@@ -17,6 +17,29 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
const MAX_CAPTION_LENGTH: usize = 2000;
|
const MAX_CAPTION_LENGTH: usize = 2000;
|
||||||
|
|
||||||
|
/// Reject free text carrying a NUL byte, before it reaches Postgres.
|
||||||
|
///
|
||||||
|
/// A NUL is the one character a `TEXT` column refuses outright: the driver forwards it and
|
||||||
|
/// Postgres answers `invalid byte sequence for encoding "UTF8": 0x00`, which arrives here as an
|
||||||
|
/// anonymous `sqlx::Error`, becomes `AppError::Internal`, and costs the guest a 500 AND the
|
||||||
|
/// photo they just spent a minute uploading — the transaction rolls back with the file already
|
||||||
|
/// streamed to disk.
|
||||||
|
///
|
||||||
|
/// Deliberately narrower than `validate_display_name`, which refuses control characters
|
||||||
|
/// wholesale. A caption legitimately contains newlines and emoji, and every other control
|
||||||
|
/// character stores and renders harmlessly; this rejects exactly the byte that cannot work.
|
||||||
|
///
|
||||||
|
/// Rejected rather than stripped. Silently rewriting what a guest wrote is the worse failure,
|
||||||
|
/// and no real client emits a NUL by accident — it only arrives from a broken or hostile one.
|
||||||
|
pub(crate) fn reject_nul(value: &str, field_de: &str) -> Result<(), AppError> {
|
||||||
|
if value.contains('\0') {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"{field_de} enthält ein ungültiges Zeichen und wurde nicht gespeichert."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Byte ceiling for the caption field, enforced WHILE reading it.
|
/// Byte ceiling for the caption field, enforced WHILE reading it.
|
||||||
///
|
///
|
||||||
/// `Field::text()` buffers the entire field before returning, and this is the one route whose
|
/// `Field::text()` buffers the entire field before returning, and this is the one route whose
|
||||||
@@ -149,6 +172,74 @@ impl Drop for TempFileGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Guarantees the compression job is queued even if this request never finishes.
|
||||||
|
///
|
||||||
|
/// The enqueue is a bare `tokio::spawn` that happens AFTER `tx.commit()`, and the commit is a
|
||||||
|
/// suspension point. If the guest walks out of range inside it, Postgres applies the COMMIT and
|
||||||
|
/// axum drops this future before the spawn is reached: the row lands durably at
|
||||||
|
/// `compression_status = 'pending'` with no job behind it, and nothing in the app ever looks at
|
||||||
|
/// it again — `startup_recovery` only rescues `'processing'`, and the derivative backfill runs
|
||||||
|
/// once at boot. The photo then shows in the feed with no preview, forces every viewer to pull
|
||||||
|
/// the full original, and is skipped by the diashow forever.
|
||||||
|
///
|
||||||
|
/// This is the same hazard `TempFileGuard` covers for the file, reached one line later, and it
|
||||||
|
/// gets the same answer: `Drop` runs on cancellation, so arming before the commit and disarming
|
||||||
|
/// after the spawn closes the window. It also covers the indeterminate-commit error path, which
|
||||||
|
/// returns `Err` on a row that may well be live.
|
||||||
|
///
|
||||||
|
/// Enqueuing a job for a row that did NOT commit is harmless: `set_compression_status` matches
|
||||||
|
/// zero rows and `begin_derivative_attempt` returns `None`, so the task retires immediately.
|
||||||
|
/// A guard is still not sufficient on its own — nothing in-process survives a SIGKILL — which is
|
||||||
|
/// why `CompressionWorker::requeue_stuck_pending` sweeps for the same state on a timer.
|
||||||
|
struct EnqueueGuard {
|
||||||
|
/// `None` once the job has actually been handed to the worker.
|
||||||
|
armed: Option<(crate::services::compression::CompressionWorker, Uuid, String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnqueueGuard {
|
||||||
|
fn new(
|
||||||
|
worker: crate::services::compression::CompressionWorker,
|
||||||
|
upload_id: Uuid,
|
||||||
|
original_path: String,
|
||||||
|
mime_type: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
armed: Some((worker, upload_id, original_path, mime_type)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The job is queued; stand down.
|
||||||
|
fn disarm(&mut self) {
|
||||||
|
self.armed = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnqueueGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some((worker, upload_id, path, mime)) = self.armed.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// `process` calls `tokio::spawn`, which panics without a runtime. During a normal
|
||||||
|
// cancellation we are still on the runtime that dropped us; during shutdown we may not
|
||||||
|
// be. Log loudly rather than panicking in a destructor — the timed sweeper is the
|
||||||
|
// backstop for exactly this case.
|
||||||
|
if tokio::runtime::Handle::try_current().is_err() {
|
||||||
|
tracing::error!(
|
||||||
|
%upload_id,
|
||||||
|
"upload committed but the compression job could not be queued (no runtime in \
|
||||||
|
Drop); requeue_stuck_pending will pick it up"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
%upload_id,
|
||||||
|
"request ended before the compression job was queued — queueing it from the drop \
|
||||||
|
guard (client most likely disconnected during COMMIT)"
|
||||||
|
);
|
||||||
|
worker.process(upload_id, path, mime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Allowlist of accepted media types, keyed by the MIME that `infer` derives from
|
/// Allowlist of accepted media types, keyed by the MIME that `infer` derives from
|
||||||
/// the file's magic bytes. The detected MIME (not the client-declared one) is what
|
/// the file's magic bytes. The detected MIME (not the client-declared one) is what
|
||||||
/// we trust, store, and hand to the compression pipeline — so a text-based payload
|
/// we trust, store, and hand to the compression pipeline — so a text-based payload
|
||||||
@@ -458,6 +549,12 @@ pub async fn upload(
|
|||||||
MAX_CAPTION_LENGTH
|
MAX_CAPTION_LENGTH
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
if let Some(ref cap) = caption {
|
||||||
|
reject_nul(cap, "Beschreibung")?;
|
||||||
|
}
|
||||||
|
if let Some(ref csv) = hashtags_csv {
|
||||||
|
reject_nul(csv, "Hashtags")?;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine the file type from its magic bytes and require it to be on the
|
// Determine the file type from its magic bytes and require it to be on the
|
||||||
// allowlist. `infer` returns None for text-based payloads (SVG/HTML/JS), so
|
// allowlist. `infer` returns None for text-based payloads (SVG/HTML/JS), so
|
||||||
@@ -593,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 {
|
||||||
@@ -690,7 +793,9 @@ pub async fn upload(
|
|||||||
// Quota accounting, the upload row, and its hashtag links must be atomic: a
|
// Quota accounting, the upload row, and its hashtag links must be atomic: a
|
||||||
// crash between the bytes increment and the insert would permanently charge
|
// crash between the bytes increment and the insert would permanently charge
|
||||||
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
||||||
let tx_result: Result<Upload, AppError> = async {
|
// Carries the enqueue guard out with the row: it is armed inside the block (before the
|
||||||
|
// commit) and can only be stood down after `process()` runs, which happens out here.
|
||||||
|
let tx_result: Result<(Upload, EnqueueGuard), AppError> = async {
|
||||||
let mut tx = state.pool.begin().await?;
|
let mut tx = state.pool.begin().await?;
|
||||||
|
|
||||||
// RE-CHECK THE LOCK, UNDER A ROW LOCK, INSIDE THE COMMIT TX.
|
// RE-CHECK THE LOCK, UNDER A ROW LOCK, INSIDE THE COMMIT TX.
|
||||||
@@ -807,6 +912,15 @@ pub async fn upload(
|
|||||||
// the failure to the recoverable side: if we are dropped mid-commit the bytes leak,
|
// the failure to the recoverable side: if we are dropped mid-commit the bytes leak,
|
||||||
// and leaked bytes under a final name are exactly what the orphan sweeper reclaims.
|
// and leaked bytes under a final name are exactly what the orphan sweeper reclaims.
|
||||||
// A committed row whose file we deleted is unrecoverable. Prefer the leak.
|
// A committed row whose file we deleted is unrecoverable. Prefer the leak.
|
||||||
|
// Armed BEFORE the commit, for the mirror-image of the reason the file guard is
|
||||||
|
// disarmed before it: from here on, a cancellation can leave a live row behind, and a
|
||||||
|
// live row with no compression job is silent, permanent loss. See `EnqueueGuard`.
|
||||||
|
let enqueue_guard = EnqueueGuard::new(
|
||||||
|
state.compression.clone(),
|
||||||
|
upload.id,
|
||||||
|
relative_path.clone(),
|
||||||
|
mime.clone(),
|
||||||
|
);
|
||||||
file_guard.disarm();
|
file_guard.disarm();
|
||||||
if let Err(e) = tx.commit().await {
|
if let Err(e) = tx.commit().await {
|
||||||
// Deliberately do NOT re-arm the guard here.
|
// Deliberately do NOT re-arm the guard here.
|
||||||
@@ -832,7 +946,7 @@ pub async fn upload(
|
|||||||
);
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
Ok(upload)
|
Ok((upload, enqueue_guard))
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -844,8 +958,8 @@ pub async fn upload(
|
|||||||
//
|
//
|
||||||
// The successful-commit case disarmed the guard inside the block, immediately before
|
// The successful-commit case disarmed the guard inside the block, immediately before
|
||||||
// `tx.commit()` — see the comment there for why it cannot be done out here.
|
// `tx.commit()` — see the comment there for why it cannot be done out here.
|
||||||
let upload = match tx_result {
|
let (upload, mut enqueue_guard) = match tx_result {
|
||||||
Ok(u) => u,
|
Ok(v) => v,
|
||||||
// The concurrent duplicate resolved inside the transaction. The winner's row is committed;
|
// The concurrent duplicate resolved inside the transaction. The winner's row is committed;
|
||||||
// answer with it so both retries of the same photo get the same successful reply. The
|
// answer with it so both retries of the same photo get the same successful reply. The
|
||||||
// loser's bytes are reclaimed by the guard when this return drops it.
|
// loser's bytes are reclaimed by the guard when this return drops it.
|
||||||
@@ -888,10 +1002,12 @@ pub async fn upload(
|
|||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn compression task
|
// Spawn compression task. The guard above has covered this call since before the commit;
|
||||||
|
// stand it down only once the job is genuinely with the worker.
|
||||||
state
|
state
|
||||||
.compression
|
.compression
|
||||||
.process(upload.id, relative_path, mime.clone());
|
.process(upload.id, relative_path, mime.clone());
|
||||||
|
enqueue_guard.disarm();
|
||||||
|
|
||||||
// Broadcast SSE event
|
// Broadcast SSE event
|
||||||
let dto = UploadDto {
|
let dto = UploadDto {
|
||||||
@@ -970,6 +1086,14 @@ pub async fn edit_upload(
|
|||||||
"Beschreibung ist zu lang. Maximum: {MAX_CAPTION_LENGTH} Zeichen."
|
"Beschreibung ist zu lang. Maximum: {MAX_CAPTION_LENGTH} Zeichen."
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
if let Some(ref caption) = body.caption {
|
||||||
|
reject_nul(caption, "Beschreibung")?;
|
||||||
|
}
|
||||||
|
if let Some(ref tags) = body.hashtags {
|
||||||
|
for tag in tags {
|
||||||
|
reject_nul(tag, "Hashtags")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
let normalized_tags = body
|
let normalized_tags = body
|
||||||
.hashtags
|
.hashtags
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1308,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
|
||||||
@@ -1721,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.
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ async fn main() -> Result<()> {
|
|||||||
state.rate_limiter.clone(),
|
state.rate_limiter.clone(),
|
||||||
state.sse_tickets.clone(),
|
state.sse_tickets.clone(),
|
||||||
config.media_path.clone(),
|
config.media_path.clone(),
|
||||||
|
state.compression.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
|
|||||||
@@ -411,6 +411,66 @@ impl CompressionWorker {
|
|||||||
/// `derivative_attempts` stops a fatal row being replayed on every boot, `BACKFILL_BATCH`
|
/// `derivative_attempts` stops a fatal row being replayed on every boot, `BACKFILL_BATCH`
|
||||||
/// stops one start queueing unbounded work, and the whole thing runs as ONE task walking
|
/// stops one start queueing unbounded work, and the whole thing runs as ONE task walking
|
||||||
/// the rows sequentially rather than N tasks racing for the same semaphore.
|
/// the rows sequentially rather than N tasks racing for the same semaphore.
|
||||||
|
/// How long an upload may sit at `'pending'` before the sweeper assumes its job was lost.
|
||||||
|
///
|
||||||
|
/// Must comfortably exceed the deepest real queue wait, or the sweeper re-enqueues photos
|
||||||
|
/// that are merely waiting their turn. The live path makes that easy to bound: a task's
|
||||||
|
/// FIRST action after taking a permit is to flip the row to `'processing'`, so a row still
|
||||||
|
/// `'pending'` after this long is either behind that much work or genuinely lost. The
|
||||||
|
/// event simulation peaked at 120 queued with a p99 of 97s while being fed thirty times a
|
||||||
|
/// real event's arrival rate, so ten minutes is a wide margin rather than a tight one.
|
||||||
|
const PENDING_GRACE: chrono::Duration = chrono::Duration::minutes(10);
|
||||||
|
|
||||||
|
/// Re-queue uploads whose compression job was lost between the commit and the spawn.
|
||||||
|
///
|
||||||
|
/// `EnqueueGuard` closes that window for a cancelled request, but nothing in-process
|
||||||
|
/// survives a SIGKILL or an OOM kill, and `startup_recovery` only rescues `'processing'`
|
||||||
|
/// — so a row orphaned at `'pending'` had no path back at all. Without this, the failure is
|
||||||
|
/// silent and permanent: the photo keeps its feed entry, never gets a derivative, and is
|
||||||
|
/// dropped from the diashow for the rest of the event.
|
||||||
|
///
|
||||||
|
/// Re-entering the live `process` path (rather than regenerating derivatives inline, as
|
||||||
|
/// `backfill_stale_derivatives` does) is deliberate: it reuses the status transitions, the
|
||||||
|
/// retry ladder and the SSE broadcast, so a rescued photo behaves exactly like one that was
|
||||||
|
/// never lost.
|
||||||
|
pub async fn requeue_stuck_pending(&self) {
|
||||||
|
let cutoff = chrono::Utc::now() - Self::PENDING_GRACE;
|
||||||
|
let rows = sqlx::query_as::<_, (Uuid, String, String)>(
|
||||||
|
"SELECT id, original_path, mime_type FROM upload
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND compression_status = 'pending'
|
||||||
|
AND original_path <> ''
|
||||||
|
AND created_at < $1
|
||||||
|
AND derivative_attempts < $2
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(cutoff)
|
||||||
|
.bind(Self::MAX_DERIVATIVE_ATTEMPTS)
|
||||||
|
.bind(Self::BACKFILL_BATCH)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await;
|
||||||
|
let rows = match rows {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "stuck-pending sweep query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// WARN, not INFO: reaching this means the guard did not run, which is worth noticing
|
||||||
|
// even though the photo is being rescued.
|
||||||
|
tracing::warn!(
|
||||||
|
count = rows.len(),
|
||||||
|
"found upload(s) stuck at 'pending' with no compression job; re-queueing"
|
||||||
|
);
|
||||||
|
for (id, original_path, mime_type) in rows {
|
||||||
|
self.process(id, original_path, mime_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn backfill_stale_derivatives(&self) {
|
pub async fn backfill_stale_derivatives(&self) {
|
||||||
// `original_path IS NOT NULL` was dead — the column is NOT NULL. What actually needs
|
// `original_path IS NOT NULL` was dead — the column is NOT NULL. What actually needs
|
||||||
// excluding is the blanked path `cleanup_deleted_media` leaves behind.
|
// excluding is the blanked path `cleanup_deleted_media` leaves behind.
|
||||||
|
|||||||
@@ -144,7 +144,36 @@ pub fn spawn_periodic_tasks(
|
|||||||
rate_limiter: RateLimiter,
|
rate_limiter: RateLimiter,
|
||||||
sse_tickets: SseTicketStore,
|
sse_tickets: SseTicketStore,
|
||||||
media_path: PathBuf,
|
media_path: PathBuf,
|
||||||
|
compression: crate::services::compression::CompressionWorker,
|
||||||
) {
|
) {
|
||||||
|
// Rescuing a lost compression job gets its OWN loop rather than a line in the hourly one.
|
||||||
|
// Cadence is the whole point: an upload stuck at 'pending' is a photo missing from the
|
||||||
|
// diashow and showing without a preview, so an hour of that during a five-hour party is a
|
||||||
|
// guest-visible hole. Ten minutes bounds it while still sitting far above the deepest
|
||||||
|
// observed queue wait (see `PENDING_GRACE`). Supervised for the same reason as the loop
|
||||||
|
// below: silently dying is how this class of safety net stops existing.
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let worker = compression.clone();
|
||||||
|
let inner = tokio::spawn(async move {
|
||||||
|
// `interval` fires its first tick immediately, and that is deliberate here:
|
||||||
|
// a restart is exactly when stranded rows exist (the drop guard cannot
|
||||||
|
// survive a SIGKILL), and `startup_recovery` rescues only 'processing'. The
|
||||||
|
// grace window makes the boot pass safe — nothing uploaded in the last ten
|
||||||
|
// minutes can match, so this can never race a job that is merely queued.
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(600));
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
worker.requeue_stuck_pending().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match inner.await {
|
||||||
|
Ok(()) => tracing::error!("stuck-pending sweeper returned; restarting it"),
|
||||||
|
Err(e) => tracing::error!(error = ?e, "stuck-pending sweeper died; restarting it"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
// Supervised, because this one task carries EVERY piece of recurring hygiene in the app:
|
// Supervised, because this one task carries EVERY piece of recurring hygiene in the app:
|
||||||
// session pruning, media reclamation, the orphan-temp sweep, and the rate-limiter and
|
// session pruning, media reclamation, the orphan-temp sweep, and the rate-limiter and
|
||||||
// SSE-ticket maps. As a bare `tokio::spawn` with no retained handle, a single panic anywhere
|
// SSE-ticket maps. As a bare `tokio::spawn` with no retained handle, a single panic anywhere
|
||||||
|
|||||||
@@ -226,6 +226,14 @@ services:
|
|||||||
# produces `https://` here and collapses the Caddyfile's site block below, so the stack
|
# produces `https://` here and collapses the Caddyfile's site block below, so the stack
|
||||||
# comes up with no TLS and no site and the only symptom is a browser error.
|
# comes up with no TLS and no site and the only symptom is a browser error.
|
||||||
ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}"
|
ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}"
|
||||||
|
# In-app camera switch, read at RUNTIME by adapter-node — so flipping it is this line
|
||||||
|
# plus `docker compose up -d frontend`, not a rebuild. Set it to "false" when
|
||||||
|
# `getUserMedia` misbehaves on the guests' phones (front/back switching throwing
|
||||||
|
# "Kamera konnte nicht gestartet werden", video capture failing its permission prompt).
|
||||||
|
# Guests then upload through the gallery picker, which still reaches the phone's own
|
||||||
|
# camera app and handles video. Lives on the FRONTEND service, not `app`: the backend
|
||||||
|
# cannot tell a camera upload from a gallery upload and has no stake in the choice.
|
||||||
|
PUBLIC_CAMERA_ENABLED: "${PUBLIC_CAMERA_ENABLED:-true}"
|
||||||
# V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured:
|
# V8 sizes its old-space heap from the cgroup limit, but lands on ~101% of it (measured:
|
||||||
# heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the
|
# heap_size_limit 259 MB inside a 256M container). So the JS heap ceiling sits ABOVE the
|
||||||
# container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response
|
# container's entire budget — before base RSS (~60-90 MB), the C++ heap, or SSR response
|
||||||
|
|||||||
27
e2e/Caddyfile.sim
Normal file
27
e2e/Caddyfile.sim
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Caddyfile for the EVENT SIMULATION stack (e2e/docker-compose.sim.yml).
|
||||||
|
# Identical in behaviour to Caddyfile.test — same compression carve-out for SSE,
|
||||||
|
# same security headers, same export framing rules — but on :3102 so the
|
||||||
|
# simulation can run without colliding with the :3101 Playwright stack.
|
||||||
|
|
||||||
|
:3102 {
|
||||||
|
# Mirror prod: exclude the SSE stream from compression so buffering doesn't
|
||||||
|
# delay real-time events.
|
||||||
|
@compressible not path /api/v1/stream
|
||||||
|
encode @compressible zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@framable path /api/v1/export/zip /api/v1/export/html
|
||||||
|
@not_framable not path /api/v1/export/zip /api/v1/export/html
|
||||||
|
header @framable X-Frame-Options "SAMEORIGIN"
|
||||||
|
header @not_framable X-Frame-Options "DENY"
|
||||||
|
|
||||||
|
reverse_proxy /api/* app:3000
|
||||||
|
reverse_proxy /media/* app:3000
|
||||||
|
reverse_proxy /health app:3000
|
||||||
|
|
||||||
|
reverse_proxy frontend:3001
|
||||||
|
}
|
||||||
156
e2e/docker-compose.sim.yml
Normal file
156
e2e/docker-compose.sim.yml
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# EventSnap EVENT SIMULATION stack — models the real production box, not CI.
|
||||||
|
#
|
||||||
|
# Difference from docker-compose.test.yml (which is tuned for fast, unconstrained
|
||||||
|
# Playwright runs): this file reproduces the CX22 the event actually runs on —
|
||||||
|
# * 2 vCPU total, shared by all four services
|
||||||
|
# * 4 GB RAM, split by the same per-service limits production ships
|
||||||
|
# * a REAL 30 GB filesystem for media + exports (loopback ext4), so statvfs
|
||||||
|
# inside the container returns true numbers and the disk gate / 507 path is
|
||||||
|
# exercised for real rather than simulated.
|
||||||
|
#
|
||||||
|
# Why cpuset on every service: production's per-service `cpus` ceilings sum to
|
||||||
|
# 1.5 + 1.2 + 0.6 + 0.5 = 3.8 on a box that has 2. That oversubscription is the
|
||||||
|
# point — the ceilings only bind when something else is competing, and cpu_shares
|
||||||
|
# decides who wins. Reproducing that on a 12-core workstation requires confining
|
||||||
|
# every container to the SAME two cores; without cpuset each service would get its
|
||||||
|
# ceiling simultaneously and the contention under test would never happen.
|
||||||
|
#
|
||||||
|
# Bring up: docker compose -f docker-compose.sim.yml up -d --build
|
||||||
|
# Tear down: docker compose -f docker-compose.sim.yml down -v
|
||||||
|
#
|
||||||
|
# The 30 GB volume is created out-of-band (see e2e/loadtest/sim-disk.sh) because a
|
||||||
|
# loopback device must be attached by root; it is declared `external` here.
|
||||||
|
|
||||||
|
x-cpuset: &cpuset '0,1'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
cpuset: *cpuset
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: eventsnap_test
|
||||||
|
POSTGRES_PASSWORD: eventsnap_test
|
||||||
|
POSTGRES_DB: eventsnap_test
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U eventsnap_test -d eventsnap_test']
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
ports:
|
||||||
|
- '55433:5432'
|
||||||
|
volumes:
|
||||||
|
# Named (not anonymous) so a `down -v` really wipes it and so its on-disk size
|
||||||
|
# can be measured against the 10 GB DISK_RESERVE that is meant to cover it.
|
||||||
|
- sim_pgdata:/var/lib/postgresql/data
|
||||||
|
# Production values, verbatim (docker-compose.yml db service).
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpu_shares: 2048
|
||||||
|
memswap_limit: 1152m
|
||||||
|
|
||||||
|
app:
|
||||||
|
# The SHIPPED release image, not a local build. Verified identical to HEAD:
|
||||||
|
# `git diff v0.17.5 HEAD -- backend/` is empty, and v0.17.5/v0.17.6 share one
|
||||||
|
# app digest on purpose (the v0.17.6 bump was frontend-only). Running the real
|
||||||
|
# artifact means the simulation tests what the event will actually run.
|
||||||
|
image: ${SIM_APP_IMAGE:-registry.mc02.dev/eventsnap/app:v0.17.5}
|
||||||
|
cpuset: *cpuset
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://eventsnap_test:eventsnap_test@db:5432/eventsnap_test
|
||||||
|
JWT_SECRET: 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
|
||||||
|
# bcrypt("admin-test-pw"), cost 4. $ doubled to escape compose interpolation.
|
||||||
|
ADMIN_PASSWORD_HASH: $$2b$$04$$XKJJkNX6BOi6y3S42DA5JOWwk4oxc8DHPL6.MrPfJI2vpnccZjP32
|
||||||
|
EVENT_SLUG: sim-wedding
|
||||||
|
EVENT_NAME: Hochzeit Simulation
|
||||||
|
APP_PORT: '3000'
|
||||||
|
# Both live on the SAME 30 GB filesystem, as they do on the real VPS — but in
|
||||||
|
# sibling directories, because config.rs::validate requires EXPORT_PATH outside
|
||||||
|
# MEDIA_PATH (a keepsake archive contains every photo in the event).
|
||||||
|
MEDIA_PATH: /disk/media
|
||||||
|
EXPORT_PATH: /disk/exports
|
||||||
|
SESSION_EXPIRY_DAYS: '30'
|
||||||
|
# Production pins these; the CI stack leaves them at code defaults. Sized to 2 vCPU.
|
||||||
|
DATABASE_MAX_CONNECTIONS: '15'
|
||||||
|
COMPRESSION_WORKER_CONCURRENCY: '2'
|
||||||
|
# Production disables comments for this event (docker-compose.yml, product decision).
|
||||||
|
COMMENTS_ENABLED: 'false'
|
||||||
|
# Mirrors the deployed setting so the harness can audit against the real gate.
|
||||||
|
KEEPSAKE_ENABLED: ${SIM_KEEPSAKE:-true}
|
||||||
|
# The ONE deviation from production: enables /admin/__truncate so the harness can
|
||||||
|
# reset between runs. Never set on the real box.
|
||||||
|
EVENTSNAP_TEST_MODE: '1'
|
||||||
|
RUST_LOG: eventsnap_backend=info,tower_http=warn
|
||||||
|
volumes:
|
||||||
|
- sim_disk:/disk
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
cpus: '1.2'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpu_shares: 512
|
||||||
|
memswap_limit: 1152m
|
||||||
|
expose:
|
||||||
|
- '3000'
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
# Shipped release image; `git diff v0.17.6 HEAD -- frontend/` is empty.
|
||||||
|
image: ${SIM_FE_IMAGE:-registry.mc02.dev/eventsnap/frontend:v0.17.6}
|
||||||
|
cpuset: *cpuset
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
environment:
|
||||||
|
PORT: '3001'
|
||||||
|
HOST: '0.0.0.0'
|
||||||
|
ORIGIN: 'http://localhost:3102'
|
||||||
|
PUBLIC_CAMERA_ENABLED: ${SIM_CAMERA:-true}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.6'
|
||||||
|
cpu_shares: 256
|
||||||
|
memswap_limit: 320m
|
||||||
|
expose:
|
||||||
|
- '3001'
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
cpuset: *cpuset
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
- frontend
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile.sim:/etc/caddy/Caddyfile:ro
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.5'
|
||||||
|
cpu_shares: 1024
|
||||||
|
memswap_limit: 320m
|
||||||
|
ports:
|
||||||
|
- '3102:3102'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# 30 GB loopback ext4, created by e2e/loadtest/sim-disk.sh. Holds media AND exports,
|
||||||
|
# exactly as one VPS disk holds both.
|
||||||
|
sim_disk:
|
||||||
|
external: true
|
||||||
|
name: eventsnap_sim_media
|
||||||
|
# Postgres data stays on the host disk, NOT on the 30 GB volume. Scoping decision:
|
||||||
|
# the 30 GB budget under test is the one the APP manages and measures (statvfs on
|
||||||
|
# MEDIA_PATH drives the disk gate). Putting PG on the same volume would test
|
||||||
|
# filesystem exhaustion instead, and a full disk under Postgres risks ending the run
|
||||||
|
# for an infrastructural reason rather than an application one. Its growth is sampled
|
||||||
|
# separately and reported against DISK_RESERVE_BYTES (10 GB), which exists to cover it.
|
||||||
|
sim_pgdata:
|
||||||
178
e2e/loadtest/acceptance-audit.mjs
Normal file
178
e2e/loadtest/acceptance-audit.mjs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Acceptance audit: push EVERY file in the pool through the real upload validator
|
||||||
|
* and report exactly what the server refuses, and why.
|
||||||
|
*
|
||||||
|
* Answers one question — "with these settings, which of my photos would be turned
|
||||||
|
* away?" — and answers it empirically rather than by reasoning about limits. Size
|
||||||
|
* caps are only one of the paths that can refuse a file: the magic-byte allowlist,
|
||||||
|
* the decode budget (12000 px axis / 256 MiB alloc, both code constants), the disk
|
||||||
|
* gate and the per-user quota all reject too, and only the running server knows the
|
||||||
|
* interaction between them.
|
||||||
|
*
|
||||||
|
* Deliberately NOT a load test: no personas, no viewers, no think-time. It measures
|
||||||
|
* admission, so it does not wait for the compression backlog to drain.
|
||||||
|
*
|
||||||
|
* SIM_UPLOADERS=8 node e2e/loadtest/acceptance-audit.mjs
|
||||||
|
*/
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
|
||||||
|
const API = `${BASE}/api/v1`;
|
||||||
|
const POOL = process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool';
|
||||||
|
const META = process.env.SIM_POOL_META ?? '/tmp/eventsnap-pool.json';
|
||||||
|
const ADMIN_PW = process.env.SIM_ADMIN_PW ?? 'admin-test-pw';
|
||||||
|
const CONC = parseInt(process.env.AUDIT_CONC ?? '4', 10);
|
||||||
|
// Spread across a few accounts, as a real event does — a single uploader would hit
|
||||||
|
// the per-user quota and hourly limit for reasons unrelated to the files themselves.
|
||||||
|
const UPLOADERS = parseInt(process.env.SIM_UPLOADERS ?? '8', 10);
|
||||||
|
|
||||||
|
const j = async (path, opts = {}) => {
|
||||||
|
const res = await fetch(`${API}${path}`, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, body };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const meta = JSON.parse(await readFile(META, 'utf8'));
|
||||||
|
console.log(`[pool] ${meta.length} files, ${(meta.reduce((a, f) => a + f.bytes, 0) / 1e9).toFixed(2)} GB`);
|
||||||
|
|
||||||
|
const admin = (
|
||||||
|
await j('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: ADMIN_PW }),
|
||||||
|
})
|
||||||
|
).body.jwt;
|
||||||
|
|
||||||
|
const cfg = (await j('/admin/config', { headers: { Authorization: `Bearer ${admin}` } })).body;
|
||||||
|
const shown = [
|
||||||
|
'max_image_size_mb',
|
||||||
|
'max_video_size_mb',
|
||||||
|
'upload_rate_per_hour',
|
||||||
|
'storage_quota_enabled',
|
||||||
|
'quota_enabled',
|
||||||
|
];
|
||||||
|
console.log(`[config] ${shown.map((k) => `${k}=${cfg[k]}`).join(' ')}`);
|
||||||
|
const stats = (await j('/admin/stats', { headers: { Authorization: `Bearer ${admin}` } })).body;
|
||||||
|
console.log(
|
||||||
|
`[disk] ${(stats.disk_free_bytes / 1e9).toFixed(1)} GB free of ${(stats.disk_total_bytes / 1e9).toFixed(1)} GB`
|
||||||
|
);
|
||||||
|
|
||||||
|
const guests = [];
|
||||||
|
for (let i = 0; i < UPLOADERS; i++) {
|
||||||
|
const r = await j('/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: `Audit ${i} ${randomUUID().slice(0, 4)}` }),
|
||||||
|
});
|
||||||
|
guests.push(r.body.jwt);
|
||||||
|
}
|
||||||
|
console.log(`[join] ${guests.length} uploaders\n`);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
let done = 0;
|
||||||
|
const queue = [...meta];
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const worker = async (slot) => {
|
||||||
|
while (queue.length) {
|
||||||
|
const f = queue.shift();
|
||||||
|
if (!f) break;
|
||||||
|
const jwt = guests[slot % guests.length];
|
||||||
|
let buf;
|
||||||
|
try {
|
||||||
|
buf = await readFile(join(POOL, f.name));
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ ...f, status: -1, msg: `read error: ${e}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([buf], { type: f.magic }), f.name);
|
||||||
|
form.append('client_upload_id', randomUUID());
|
||||||
|
let status, body;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
status = res.status;
|
||||||
|
const t = await res.text();
|
||||||
|
try {
|
||||||
|
body = JSON.parse(t);
|
||||||
|
} catch {
|
||||||
|
body = t;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status = 0;
|
||||||
|
body = { message: String(e).slice(0, 80) };
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
name: f.name,
|
||||||
|
bytes: f.bytes,
|
||||||
|
magic: f.magic,
|
||||||
|
w: f.w,
|
||||||
|
h: f.h,
|
||||||
|
status,
|
||||||
|
code: body?.code ?? body?.error,
|
||||||
|
msg: status >= 400 ? String(body?.message ?? '').slice(0, 110) : undefined,
|
||||||
|
});
|
||||||
|
if (++done % 100 === 0) {
|
||||||
|
const ok = results.filter((r) => r.status === 201).length;
|
||||||
|
console.log(
|
||||||
|
` ${done}/${meta.length} accepted ${ok} refused ${done - ok} (${((Date.now() - t0) / 1000).toFixed(0)}s)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: CONC }, (_, i) => worker(i)));
|
||||||
|
|
||||||
|
// ── Report ────────────────────────────────────────────────────────────────
|
||||||
|
const ok = results.filter((r) => r.status === 201);
|
||||||
|
const bad = results.filter((r) => r.status !== 201);
|
||||||
|
const byReason = {};
|
||||||
|
for (const r of bad) {
|
||||||
|
const key = `${r.status} ${r.msg ?? r.code ?? '?'}`;
|
||||||
|
(byReason[key] ??= []).push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '═'.repeat(74));
|
||||||
|
console.log('ACCEPTANCE AUDIT');
|
||||||
|
console.log('═'.repeat(74));
|
||||||
|
console.log(
|
||||||
|
`accepted ${ok.length}/${results.length} (${(ok.reduce((a, r) => a + r.bytes, 0) / 1e9).toFixed(2)} GB)`
|
||||||
|
);
|
||||||
|
console.log(`refused ${bad.length}\n`);
|
||||||
|
for (const [reason, rows] of Object.entries(byReason).sort((a, b) => b[1].length - a[1].length)) {
|
||||||
|
const sizes = rows.map((r) => r.bytes / 1024 / 1024);
|
||||||
|
console.log(` ${rows.length} x ${reason}`);
|
||||||
|
console.log(
|
||||||
|
` sizes ${Math.min(...sizes).toFixed(1)}–${Math.max(...sizes).toFixed(1)} MB · types ${[...new Set(rows.map((r) => r.magic))].join(', ')}`
|
||||||
|
);
|
||||||
|
console.log(` e.g. ${rows.slice(0, 3).map((r) => r.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
if (!bad.length) console.log(' ✓ nothing was refused');
|
||||||
|
|
||||||
|
const outDir = join(__dirname, 'results');
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
const out = join(outDir, `acceptance-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
||||||
|
await writeFile(out, JSON.stringify({ config: cfg, stats, results }, null, 2));
|
||||||
|
console.log(`\nfull detail → ${out}`);
|
||||||
|
console.log('═'.repeat(74));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('audit failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
214
e2e/loadtest/browser-check.mjs
Normal file
214
e2e/loadtest/browser-check.mjs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Real-browser pass over the event AFTER the load simulation has filled it.
|
||||||
|
*
|
||||||
|
* `event-sim.mjs` drives the HTTP API directly — deliberately, because 150 real
|
||||||
|
* browsers would bottleneck the test box rather than the server. That leaves two
|
||||||
|
* things unmeasured, and both are guest-visible:
|
||||||
|
*
|
||||||
|
* 1. The SvelteKit frontend container, which the API driver never touches.
|
||||||
|
* 2. Whether the frontend ESCAPES the XSS caption the API stored verbatim. The
|
||||||
|
* backend stores captions raw by design (`upload.rs` length-checks only), so
|
||||||
|
* the entire defence is the renderer. The abuse suite proved the payload is
|
||||||
|
* in the database; only a browser can prove it is inert.
|
||||||
|
*
|
||||||
|
* Runs three real engines against the loaded gallery and reports load timings,
|
||||||
|
* console errors, and the XSS verdict.
|
||||||
|
*
|
||||||
|
* node e2e/loadtest/browser-check.mjs
|
||||||
|
*/
|
||||||
|
import { chromium, firefox, webkit, devices } from '@playwright/test';
|
||||||
|
import { writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
|
||||||
|
const API = `${BASE}/api/v1`;
|
||||||
|
const OUT = join(__dirname, 'results', 'browser');
|
||||||
|
|
||||||
|
const XSS_CAPTION = '<script>alert(document.cookie)</script><img src=x onerror=alert(1)>';
|
||||||
|
const XSS_MARKERS = ['<script>alert(', 'onerror=alert(1)', 'onerror=alert('];
|
||||||
|
|
||||||
|
async function joinGuest(name) {
|
||||||
|
const res = await fetch(`${API}/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`join failed ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the guest session the way the app itself does, so we land on the gallery. */
|
||||||
|
async function seedSession(context, acct, name) {
|
||||||
|
await context.addInitScript(
|
||||||
|
([jwt, uid, dn]) => {
|
||||||
|
localStorage.setItem('eventsnap_jwt', jwt);
|
||||||
|
localStorage.setItem('eventsnap_user_id', uid);
|
||||||
|
localStorage.setItem('eventsnap_display_name', dn);
|
||||||
|
// Skip the first-run guide. It is a modal over the feed, and leaving it up
|
||||||
|
// means the feed never lazy-loads — which silently turns the XSS check into
|
||||||
|
// "the payload wasn't on screen", not "the payload was neutralised".
|
||||||
|
localStorage.setItem('eventsnap_guide_seen', '1');
|
||||||
|
},
|
||||||
|
[acct.jwt, acct.user_id, name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a fresh XSS caption so the payload is the NEWEST item and therefore on the
|
||||||
|
* first page of the feed. The load test's payload is real but buried behind ~780
|
||||||
|
* newer uploads, and a check that never renders the payload proves nothing.
|
||||||
|
*/
|
||||||
|
async function seedXssUpload(jwt) {
|
||||||
|
const { readdir, readFile } = await import('node:fs/promises');
|
||||||
|
const dir = process.env.SIM_POOL_DIR ?? '/tmp/eventsnap-realpool';
|
||||||
|
const files = (await readdir(dir)).filter((f) => f.toLowerCase().endsWith('.jpg') || f.toLowerCase().endsWith('.jpeg'));
|
||||||
|
const buf = await readFile(join(dir, files[0]));
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([buf], { type: 'image/jpeg' }), 'x.jpg');
|
||||||
|
form.append('caption', XSS_CAPTION);
|
||||||
|
const res = await fetch(`${API}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`xss seed upload failed ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||||
|
return (await res.json()).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEngine(engineName, launcher, deviceProfile) {
|
||||||
|
const result = { engine: engineName, device: deviceProfile ?? 'desktop', pages: {}, consoleErrors: [], dialogs: [], xss: null };
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await launcher.launch();
|
||||||
|
} catch (e) {
|
||||||
|
// A missing system library on the test box is not a finding about the app.
|
||||||
|
// Skip the engine and say so, rather than failing the whole pass.
|
||||||
|
result.skipped = `could not launch: ${String(e).split('\n')[0].slice(0, 120)}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const context = await browser.newContext(deviceProfile ? devices[deviceProfile] : {});
|
||||||
|
const acct = await joinGuest(`Browser ${engineName} ${Date.now() % 10000}`);
|
||||||
|
await seedSession(context, acct, `Browser ${engineName}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// An executed payload would surface as a dialog. Nothing should ever fire.
|
||||||
|
page.on('dialog', async (d) => {
|
||||||
|
result.dialogs.push({ type: d.type(), message: d.message() });
|
||||||
|
await d.dismiss();
|
||||||
|
});
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (m.type() === 'error') result.consoleErrors.push(m.text().slice(0, 200));
|
||||||
|
});
|
||||||
|
page.on('pageerror', (e) => result.consoleErrors.push(`pageerror: ${String(e).slice(0, 200)}`));
|
||||||
|
|
||||||
|
for (const [label, path] of [
|
||||||
|
['gallery', '/'],
|
||||||
|
['diashow', '/diashow'],
|
||||||
|
]) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
await page.goto(`${BASE}${path}`, { waitUntil: 'load', timeout: 60000 });
|
||||||
|
const loadMs = Date.now() - t0;
|
||||||
|
await page.waitForTimeout(6000); // let the feed + images settle
|
||||||
|
const imgCount = await page.locator('img').count();
|
||||||
|
const shot = join(OUT, `${engineName}-${label}.png`);
|
||||||
|
await page.screenshot({ path: shot, fullPage: false });
|
||||||
|
result.pages[label] = { loadMs, settledMs: Date.now() - t0, imgCount, screenshot: shot };
|
||||||
|
} catch (e) {
|
||||||
|
result.pages[label] = { error: String(e).slice(0, 200) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XSS verdict ───────────────────────────────────────────────────────────
|
||||||
|
// Walk the gallery looking for the stored payload. It must appear as TEXT and
|
||||||
|
// never as a live <script> element or an img with an onerror handler.
|
||||||
|
try {
|
||||||
|
await seedXssUpload(acct.jwt);
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'load', timeout: 60000 });
|
||||||
|
await page.waitForTimeout(6000);
|
||||||
|
const verdict = await page.evaluate((markers) => {
|
||||||
|
const bodyText = document.body.innerText ?? '';
|
||||||
|
const html = document.body.innerHTML ?? '';
|
||||||
|
const asText = markers.some((m) => bodyText.includes(m));
|
||||||
|
// Any script tag that isn't a real app/module script would be injected.
|
||||||
|
const injectedScripts = [...document.querySelectorAll('script')]
|
||||||
|
.filter((s) => (s.textContent ?? '').includes('alert('))
|
||||||
|
.map((s) => (s.textContent ?? '').slice(0, 80));
|
||||||
|
const onerrorImgs = [...document.querySelectorAll('img[onerror]')].map((i) => i.getAttribute('onerror'));
|
||||||
|
return {
|
||||||
|
payloadVisibleAsText: asText,
|
||||||
|
injectedScripts,
|
||||||
|
onerrorImgs,
|
||||||
|
// escaped entities are the positive signal that the renderer did its job
|
||||||
|
escapedMarkup: html.includes('<script>') || html.includes('<img'),
|
||||||
|
};
|
||||||
|
}, XSS_MARKERS);
|
||||||
|
result.xss = verdict;
|
||||||
|
} catch (e) {
|
||||||
|
result.xss = { error: String(e).slice(0, 200) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
await mkdir(OUT, { recursive: true });
|
||||||
|
const results = [];
|
||||||
|
results.push(await runEngine('chromium', chromium, 'Pixel 7'));
|
||||||
|
results.push(await runEngine('webkit', webkit, 'iPhone 14'));
|
||||||
|
results.push(await runEngine('firefox', firefox, null));
|
||||||
|
|
||||||
|
const out = join(__dirname, 'results', `browser-check-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
||||||
|
await writeFile(out, JSON.stringify(results, null, 2));
|
||||||
|
|
||||||
|
console.log('\n' + '═'.repeat(70));
|
||||||
|
console.log('REAL BROWSER CHECK');
|
||||||
|
console.log('═'.repeat(70));
|
||||||
|
let xssFail = false;
|
||||||
|
let inconclusive = false;
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`\n${r.engine} (${r.device})`);
|
||||||
|
if (r.skipped) {
|
||||||
|
console.log(` SKIPPED — ${r.skipped}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(r.pages))
|
||||||
|
console.log(
|
||||||
|
` ${k.padEnd(8)} ${v.error ? 'ERROR ' + v.error : `load ${v.loadMs}ms, ${v.imgCount} <img> after settle`}`
|
||||||
|
);
|
||||||
|
console.log(` console errors: ${r.consoleErrors.length}${r.consoleErrors.length ? ' → ' + r.consoleErrors[0] : ''}`);
|
||||||
|
console.log(` dialogs fired : ${r.dialogs.length}`);
|
||||||
|
const x = r.xss ?? {};
|
||||||
|
const bad = (x.injectedScripts?.length ?? 0) > 0 || (x.onerrorImgs?.length ?? 0) > 0 || r.dialogs.length > 0;
|
||||||
|
// Absence of an explosion only counts if the payload actually reached the DOM.
|
||||||
|
const rendered = !!(x.payloadVisibleAsText || x.escapedMarkup);
|
||||||
|
if (bad) xssFail = true;
|
||||||
|
if (!bad && !rendered) inconclusive = true;
|
||||||
|
const label = bad ? '✗ PAYLOAD LIVE' : rendered ? '✓ inert (rendered as text)' : '? INCONCLUSIVE — payload never reached the DOM';
|
||||||
|
console.log(
|
||||||
|
` XSS : ${label} ` +
|
||||||
|
`(as text: ${x.payloadVisibleAsText}, escaped markup: ${x.escapedMarkup}, ` +
|
||||||
|
`injected scripts: ${x.injectedScripts?.length ?? 0}, onerror imgs: ${x.onerrorImgs?.length ?? 0})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`\nverdict: ${
|
||||||
|
xssFail
|
||||||
|
? '✗ stored XSS EXECUTES in a real browser'
|
||||||
|
: inconclusive
|
||||||
|
? '? INCONCLUSIVE in at least one engine — payload never rendered, so nothing was proven'
|
||||||
|
: '✓ stored payload rendered as inert text in every engine that ran'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
console.log(`report → ${out}`);
|
||||||
|
console.log('═'.repeat(70));
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('browser check failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
1556
e2e/loadtest/event-sim.mjs
Normal file
1556
e2e/loadtest/event-sim.mjs
Normal file
File diff suppressed because it is too large
Load Diff
150
e2e/loadtest/upload-integrity-check.mjs
Normal file
150
e2e/loadtest/upload-integrity-check.mjs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Drives a REAL upload through the composer's file picker and proves the bytes survived.
|
||||||
|
*
|
||||||
|
* Written during the v0.18.3–v0.18.6 run of hotfixes, which were all one failure wearing
|
||||||
|
* different masks: the browser handing the queue something that was not the file. First an
|
||||||
|
* empty body (WebKit stores a picked `File` as a reference to an OS file iOS then deletes),
|
||||||
|
* then — once the bytes were copied — the risk of a SHORT one, because that copy is a
|
||||||
|
* multi-second chunked loop for a video and the same purge can land partway through it.
|
||||||
|
*
|
||||||
|
* Every check that missed those bugs shared one shortcut: it asserted the upload was
|
||||||
|
* ACCEPTED. A truncated file is accepted. It passes the magic-byte sniff, the size cap and the
|
||||||
|
* decode budget, is stored, gets a preview, and shows in the gallery — and is still not the
|
||||||
|
* guest's video. So this asserts the only thing that actually settles it: fetch the stored
|
||||||
|
* original back and compare its SHA-256 to the file on disk.
|
||||||
|
*
|
||||||
|
* Two paths matter and they are not the same code:
|
||||||
|
* * at or below MATERIALISE_CHUNK_BYTES (4 MB) the copy is a single `arrayBuffer()`
|
||||||
|
* * above it, a chunked loop — the one that can produce a short blob
|
||||||
|
* Pass at least one file of each, or the run proves half of what it claims.
|
||||||
|
*
|
||||||
|
* Requires the sim stack (e2e/docker-compose.sim.yml) on :3102.
|
||||||
|
*
|
||||||
|
* node e2e/loadtest/upload-integrity-check.mjs photo.jpg big-video.mp4
|
||||||
|
*/
|
||||||
|
import { chromium, devices } from '@playwright/test';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
|
||||||
|
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
|
||||||
|
const API = `${BASE}/api/v1`;
|
||||||
|
/** Mirrors MATERIALISE_CHUNK_BYTES in frontend/src/lib/upload-queue.ts. */
|
||||||
|
const CHUNK_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
const sha = (buf) => createHash('sha256').update(buf).digest('hex');
|
||||||
|
|
||||||
|
const files = process.argv.slice(2);
|
||||||
|
if (!files.length) {
|
||||||
|
console.error('usage: upload-integrity-check.mjs <file> [file...] (include one >4 MB)');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext({ ...devices['Pixel 7'] });
|
||||||
|
// The guide is a modal over the composer; leaving it up means the picker is never reached.
|
||||||
|
await context.addInitScript(() => localStorage.setItem('eventsnap_guide_seen', '1'));
|
||||||
|
const page = await context.newPage();
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on('pageerror', (e) => pageErrors.push(String(e).slice(0, 140)));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/join`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.fill('input[type=text]', `Integrity ${Date.now() % 100000}`);
|
||||||
|
await page.locator('button[type=submit]').first().click();
|
||||||
|
await page.waitForTimeout(4500);
|
||||||
|
for (const label of ['Weiter', 'Verstanden', 'Los geht']) {
|
||||||
|
const btn = page.getByRole('button', { name: new RegExp(label, 'i') });
|
||||||
|
if ((await btn.count()) && (await btn.first().isVisible())) {
|
||||||
|
await btn.first().click();
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('eventsnap_jwt'));
|
||||||
|
|
||||||
|
// Report which upload entries the sheet offers, so a run also records whether
|
||||||
|
// PUBLIC_CAMERA_ENABLED was in effect — the composer path differs with it.
|
||||||
|
await page.locator('button[aria-label="Hochladen"]').last().click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const sheet = await page.locator('body').innerText();
|
||||||
|
console.log(
|
||||||
|
`[sheet] Galerie=${sheet.includes('Foto oder Video wählen')} ` +
|
||||||
|
`Kamera=${sheet.includes('Jetzt aufnehmen')}\n`
|
||||||
|
);
|
||||||
|
// Close it again. The FAB TOGGLES, so leaving the sheet open here makes the loop's first
|
||||||
|
// "open the sheet" click close it instead — and the file chooser then never fires.
|
||||||
|
await page.getByRole('button', { name: /Abbrechen/i }).first().click();
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const path of files) {
|
||||||
|
const name = basename(path);
|
||||||
|
const local = await readFile(path);
|
||||||
|
const size = (await stat(path)).size;
|
||||||
|
const caption = `integrity ${name} ${Date.now()}`;
|
||||||
|
const chunked = size > CHUNK_BYTES;
|
||||||
|
|
||||||
|
// Open the sheet fresh for every file — submitting returns to the feed, and the
|
||||||
|
// invariant this relies on is simply that the sheet is CLOSED at the top of each pass.
|
||||||
|
await page.locator('button[aria-label="Hochladen"]').last().click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const chooser = page.waitForEvent('filechooser');
|
||||||
|
await page.getByText('Foto oder Video wählen').click();
|
||||||
|
(await chooser).setFiles(path);
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.locator('textarea').fill(caption);
|
||||||
|
await page.getByRole('button', { name: /^Hochladen$/ }).first().click();
|
||||||
|
// Generous: the queue retries, and a large file on a throttled box takes its time.
|
||||||
|
await page.waitForTimeout(Math.max(9000, (size / 1e6) * 900));
|
||||||
|
|
||||||
|
// Find it server-side. The feed is the guest's own view, so this also proves the photo
|
||||||
|
// is actually visible rather than merely stored.
|
||||||
|
const feed = await fetch(`${API}/feed?limit=50`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}).then((r) => r.json());
|
||||||
|
const row = feed.uploads?.find((u) => u.caption === caption);
|
||||||
|
if (!row) {
|
||||||
|
results.push({ name, size, chunked, ok: false, why: 'never appeared in the feed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The whole point: compare the bytes that came BACK, not the status that went out.
|
||||||
|
const served = Buffer.from(
|
||||||
|
await fetch(`${API}/upload/${row.id}/original`).then((r) => r.arrayBuffer())
|
||||||
|
);
|
||||||
|
const ok = served.length === size && sha(served) === sha(local);
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
chunked,
|
||||||
|
ok,
|
||||||
|
why: ok ? '' : `stored ${served.length} of ${size} bytes / hash mismatch`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log('═'.repeat(66));
|
||||||
|
console.log('UPLOAD INTEGRITY');
|
||||||
|
console.log('═'.repeat(66));
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(
|
||||||
|
` ${r.ok ? '✓' : '✗'} ${r.name.padEnd(24)} ${(r.size / 1e6).toFixed(1).padStart(6)} MB ` +
|
||||||
|
`${r.chunked ? 'chunked copy' : 'single read '} ${r.why}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!results.some((r) => r.chunked))
|
||||||
|
console.log('\n ⚠ no file above 4 MB — the chunked copy path was NOT exercised');
|
||||||
|
if (pageErrors.length) console.log(`\n page errors: ${pageErrors[0]}`);
|
||||||
|
const failed = results.filter((r) => !r.ok).length;
|
||||||
|
console.log(`\n${failed ? `✗ ${failed} of ${results.length} corrupted` : `✓ all ${results.length} byte-identical`}`);
|
||||||
|
console.log('═'.repeat(66));
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('integrity check failed:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -6,6 +6,7 @@
|
|||||||
import { scrollLock } from '$lib/actions/scroll-lock';
|
import { scrollLock } from '$lib/actions/scroll-lock';
|
||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
|
import { hasSeenGuide, markGuideSeen } from '$lib/onboarding';
|
||||||
|
import { cameraEnabled } from '$lib/feature-flags';
|
||||||
|
|
||||||
type Step =
|
type Step =
|
||||||
| { kind: 'text'; icon: string; title: string; body: string }
|
| { kind: 'text'; icon: string; title: string; body: string }
|
||||||
@@ -30,7 +31,13 @@
|
|||||||
kind: 'text',
|
kind: 'text',
|
||||||
icon: '⬆️',
|
icon: '⬆️',
|
||||||
title: 'Fotos & Videos hochladen',
|
title: 'Fotos & Videos hochladen',
|
||||||
body: 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
// The second half is conditional: with the in-app camera switched off, promising
|
||||||
|
// "direkt mit der Kamera aufnehmen" describes a button that is not there. The
|
||||||
|
// gallery picker still reaches the phone's camera app on both iOS and Android, so
|
||||||
|
// the capability survives — only the in-app shortcut is gone.
|
||||||
|
body: cameraEnabled
|
||||||
|
? 'Tippe auf den Kamera-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
||||||
|
: 'Tippe auf den Kamera-Button unten in der Mitte und wähle Fotos oder Videos aus deiner Galerie. Frisch aufnehmen kannst du direkt in der Auswahl deines Handys. Mehrere Dateien auf einmal sind kein Problem!'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
@@ -41,8 +48,19 @@
|
|||||||
{
|
{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
icon: '👆',
|
icon: '👆',
|
||||||
title: 'Lange tippen für mehr',
|
title: 'Mehr zu einem Foto',
|
||||||
body: 'Tippe lange auf ein Bild im Feed, um zusätzliche Aktionen zu öffnen — zum Beispiel das Original anzeigen oder eigene Beiträge löschen.'
|
// Names the VISIBLE control first. Long-press was the only affordance mentioned here,
|
||||||
|
// and it is invisible — a guest has to already know it exists. The three-dot button
|
||||||
|
// is on every card in list view and is what most people will find.
|
||||||
|
//
|
||||||
|
// Both views are named on purpose: the button exists only in the LIST view
|
||||||
|
// (FeedListCard). The grid tiles have no button at all, only `use:longpress`, so a
|
||||||
|
// guest browsing in grid mode would be stranded by a menu-only instruction.
|
||||||
|
//
|
||||||
|
// "anzeigen und speichern", not "herunterladen": the action opens the original in a
|
||||||
|
// new tab (`Content-Disposition: inline`, deliberate — it is the only playable video
|
||||||
|
// source), so the guest saves it from there rather than getting a download.
|
||||||
|
body: 'Tippe oben rechts am Beitrag auf die drei Punkte (⋯), um weitere Aktionen zu öffnen: das Original in voller Auflösung anzeigen und speichern oder eigene Beiträge löschen. In der Kachel-Ansicht tippst du stattdessen lange auf ein Bild.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'theme',
|
kind: 'theme',
|
||||||
@@ -52,9 +70,18 @@
|
|||||||
{
|
{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
icon: '🔑',
|
icon: '🔑',
|
||||||
title: 'Deinen PIN merken!',
|
title: 'Name und PIN merken!',
|
||||||
|
// Recovery needs BOTH the display name and the PIN (`POST /recover` takes
|
||||||
|
// {display_name, pin}), but this step only ever mentioned the PIN — so a guest who
|
||||||
|
// memorised four digits and forgot whether they typed "Anna" or "Anna M." still
|
||||||
|
// could not get back in.
|
||||||
|
//
|
||||||
|
// "solange du auf diesem Gerät angemeldet bleibst" replaces "ist immer … zu finden",
|
||||||
|
// which was false in exactly the case that matters: /account renders the PIN from
|
||||||
|
// local storage and falls back to "PIN nicht gespeichert", so it is NOT there on a
|
||||||
|
// new device — which is the only reason anyone needs it.
|
||||||
body:
|
body:
|
||||||
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto“ zu finden.' +
|
'Beim Registrieren hast du einen 4-stelligen PIN bekommen. Für die Anmeldung auf einem anderen Gerät brauchst du beides: genau den Namen, den du hier eingegeben hast, und diesen PIN. Notiere dir am besten beides — solange du auf diesem Gerät angemeldet bleibst, findest du sie unter „Mein Konto“.' +
|
||||||
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '')
|
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto“.' : '')
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { PendingFile } from '$lib/pending-upload-store';
|
import type { PendingFile } from '$lib/pending-upload-store';
|
||||||
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
||||||
import { commentsEnabled } from '$lib/event-config-store';
|
import { commentsEnabled } from '$lib/event-config-store';
|
||||||
|
import { cameraEnabled } from '$lib/feature-flags';
|
||||||
import { isBanned } from '$lib/ban-store';
|
import { isBanned } from '$lib/ban-store';
|
||||||
|
|
||||||
// A ban closes uploads just as hard as an event lock does — the backend refuses every
|
// A ban closes uploads just as hard as an event lock does — the backend refuses every
|
||||||
@@ -150,8 +151,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Camera (rendered outside sheet so it gets full viewport) -->
|
<!-- Camera (rendered outside sheet so it gets full viewport).
|
||||||
{#if showCamera}
|
`cameraEnabled` is checked here as well as on the button: `showCamera` is ordinary
|
||||||
|
component state, and a belt-and-braces guard means no future entry point (a deep link, a
|
||||||
|
restored state, a stray keyboard shortcut) can mount the capture UI while it is switched
|
||||||
|
off for the event. -->
|
||||||
|
{#if showCamera && cameraEnabled}
|
||||||
<CameraCapture
|
<CameraCapture
|
||||||
oncapture={handleCapture}
|
oncapture={handleCapture}
|
||||||
onclose={handleCameraClose}
|
onclose={handleCameraClose}
|
||||||
@@ -262,7 +267,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Camera option -->
|
<!-- Camera option. Hidden entirely when PUBLIC_CAMERA_ENABLED=false: on some devices
|
||||||
|
`getUserMedia` fails when switching front/back or when asked for video, and a
|
||||||
|
button that throws an error modal is worse than no button. The gallery entry
|
||||||
|
above still reaches the OS camera and handles video. -->
|
||||||
|
{#if cameraEnabled}
|
||||||
<button
|
<button
|
||||||
onclick={openCamera}
|
onclick={openCamera}
|
||||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||||
@@ -294,6 +303,7 @@
|
|||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- The in-app-browser escape hatch.
|
<!-- The in-app-browser escape hatch.
|
||||||
The join link travels through WhatsApp groups, and a link tapped inside one opens
|
The join link travels through WhatsApp groups, and a link tapped inside one opens
|
||||||
@@ -305,8 +315,14 @@
|
|||||||
line of text and is never wrong. It lives here rather than in the root layout
|
line of text and is never wrong. It lives here rather than in the root layout
|
||||||
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
|
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
|
||||||
off — a banner there would never render on the composer. -->
|
off — a banner there would never render on the composer. -->
|
||||||
|
<!-- "diese Seite", not "den Link": the guest is already inside the app when they read
|
||||||
|
this, so there is no link on screen for "den Link" to refer to. Naming the app
|
||||||
|
they most likely arrived from, and the menu entry that gets them out, turns a
|
||||||
|
hint they cannot act on into an instruction they can. -->
|
||||||
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
|
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
Nichts passiert beim Tippen? Öffne den Link in Safari oder Chrome.
|
Nichts passiert beim Tippen? Dann bist du wahrscheinlich im Browser von WhatsApp o. Ä.
|
||||||
|
Öffne diese Seite in Safari oder Chrome — dort funktioniert die Auswahl. (Im Menü des
|
||||||
|
In-App-Browsers: „In Safari öffnen“ bzw. „Im Browser öffnen“.)
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
36
frontend/src/lib/feature-flags.ts
Normal file
36
frontend/src/lib/feature-flags.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-independent feature switches read from the frontend container's environment.
|
||||||
|
*
|
||||||
|
* Deliberately NOT routed through the backend's `/api/v1/event` payload the way
|
||||||
|
* `comments_enabled` is. That flag describes the EVENT (whether guests may comment at all);
|
||||||
|
* this one describes what the CLIENT can do on the device in front of it. The backend has no
|
||||||
|
* stake in how bytes were captured — an upload from the camera and an upload from the gallery
|
||||||
|
* arrive on the same endpoint, indistinguishable — so putting the switch on the server would
|
||||||
|
* add a schema, a DTO field and a release of the app image to answer a question only the
|
||||||
|
* browser can ask.
|
||||||
|
*
|
||||||
|
* `$env/dynamic/public` is read at RUNTIME by adapter-node, so this is a compose variable and
|
||||||
|
* a restart, not a rebuild.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Interpret a flag the same way `config.rs` does, so operators only learn one convention. */
|
||||||
|
function flag(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === undefined || value.trim() === '') return fallback;
|
||||||
|
return !['false', '0', 'no', 'off'].includes(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the in-app camera is offered (`PUBLIC_CAMERA_ENABLED`, default true).
|
||||||
|
*
|
||||||
|
* Turned off for events where `getUserMedia` misbehaves on the guests' actual phones —
|
||||||
|
* switching between front and back cameras throwing "Kamera konnte nicht gestartet werden",
|
||||||
|
* or video capture failing the permission prompt outright. Those failures are per-device and
|
||||||
|
* cannot be diagnosed mid-event, so the switch removes the broken path rather than leaving
|
||||||
|
* guests to discover it.
|
||||||
|
*
|
||||||
|
* Nothing is lost by disabling it: the gallery picker reaches the same OS camera through
|
||||||
|
* `capture`-less `<input type="file">`, handles video, and is the path most guests use anyway.
|
||||||
|
*/
|
||||||
|
export const cameraEnabled = flag(env.PUBLIC_CAMERA_ENABLED, true);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
classifyUploadStatus,
|
classifyUploadStatus,
|
||||||
|
isIncompleteBody,
|
||||||
isReversibleLock,
|
isReversibleLock,
|
||||||
entryToQueueItem,
|
entryToQueueItem,
|
||||||
shouldAbortForStall,
|
shouldAbortForStall,
|
||||||
@@ -54,6 +55,91 @@ describe('classifyUploadStatus', () => {
|
|||||||
* reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either
|
* reopen and the photo resumes) or PURGES it (permanent ban / quota). Getting this wrong either
|
||||||
* loses a photo the guest expected to survive a reopen, or lets a banned device retry forever.
|
* loses a photo the guest expected to survive a reopen, or lets a banned device retry forever.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Regression guard for the data loss this was written for: an iPhone guest on the live event
|
||||||
|
* got "Error parsing `multipart/form-data` request", the item went terminal, and the ONLY copy
|
||||||
|
* of the photo was purged from IndexedDB with no retry offered.
|
||||||
|
*
|
||||||
|
* The first attempt at the fix keyed on the envelope (`body.error !== 'bad_request'`) and was
|
||||||
|
* inert, because the backend wraps the multipart error in its own `bad_request` envelope. These
|
||||||
|
* cases are transcribed from real responses captured against the running backend, so they fail
|
||||||
|
* if that reasoning is ever reverted.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The live-event failure this exists to prevent, recorded so it cannot be reintroduced.
|
||||||
|
*
|
||||||
|
* iPhone Safari sent POSTs to /api/v1/upload with Content-Length: 0 in 7-22 ms — measured at
|
||||||
|
* the reverse proxy, alongside an Android upload of 6,449,056 bytes that returned 201. Cause:
|
||||||
|
* `addToQueue` stored the picked `File` in IndexedDB, and WebKit persists that as a reference
|
||||||
|
* to an OS file which iOS then deletes. The File keeps its name and size and reads as nothing,
|
||||||
|
* and `xhr.send()` does not throw — it puts an empty body on the wire.
|
||||||
|
*
|
||||||
|
* Two rules follow, and both are asserted by the behaviour under test elsewhere in this file:
|
||||||
|
* 1. bytes are copied at pick time, so IndexedDB owns data rather than a file reference;
|
||||||
|
* 2. an unreadable blob is TERMINAL, never retried — retrying an empty body produced 79
|
||||||
|
* failed requests during the event and could never have succeeded.
|
||||||
|
*
|
||||||
|
* Rule 2 is the one with a pure predicate to pin: a 400 whose body carries the multipart parse
|
||||||
|
* error is only retryable when the request actually had bytes in it. `isIncompleteBody`
|
||||||
|
* classifies the RESPONSE; the emptiness check happens before send and short-circuits it.
|
||||||
|
*/
|
||||||
|
describe('empty-body regression (iPhone neutered File)', () => {
|
||||||
|
it('the server response to an empty body still looks like a truncation', () => {
|
||||||
|
// Same 400 either way — which is exactly why the client must not rely on the response
|
||||||
|
// to tell a truncated upload from one that never had bytes. The pre-send readability
|
||||||
|
// probe is what separates them.
|
||||||
|
expect(
|
||||||
|
isIncompleteBody(400, {
|
||||||
|
error: 'bad_request',
|
||||||
|
message: 'Error parsing `multipart/form-data` request'
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isIncompleteBody', () => {
|
||||||
|
const parseError = 'Error parsing `multipart/form-data` request';
|
||||||
|
|
||||||
|
it('the exact live failure: bad_request envelope carrying the parse error → incomplete', () => {
|
||||||
|
expect(isIncompleteBody(400, { error: 'bad_request', message: parseError, status: 400 })).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the same error raised mid-file, with the German prefix → incomplete', () => {
|
||||||
|
expect(
|
||||||
|
isIncompleteBody(400, {
|
||||||
|
error: 'bad_request',
|
||||||
|
message: `Datei konnte nicht gelesen werden: ${parseError}`,
|
||||||
|
status: 400
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unparseable body (plain-text rejection, proxy, WAF) → incomplete', () => {
|
||||||
|
expect(isIncompleteBody(400, null)).toBe(true);
|
||||||
|
expect(isIncompleteBody(400, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a real verdict on the file → NOT incomplete, so it still purges', () => {
|
||||||
|
expect(
|
||||||
|
isIncompleteBody(400, { error: 'bad_request', message: 'Datei ist zu groß. Maximum: 500 MB.' })
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isIncompleteBody(400, { error: 'bad_request', message: 'Keine Datei hochgeladen.' })
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isIncompleteBody(400, { error: 'bad_request', message: 'Dateityp wird nicht unterstützt.' })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only applies to 400 — other statuses keep their own rules', () => {
|
||||||
|
expect(isIncompleteBody(413, { error: 'quota_exceeded' })).toBe(false);
|
||||||
|
expect(isIncompleteBody(403, null)).toBe(false);
|
||||||
|
expect(isIncompleteBody(500, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isReversibleLock', () => {
|
describe('isReversibleLock', () => {
|
||||||
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
it('an `uploads_locked` code is reversible at any status (event closed / released)', () => {
|
||||||
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
expect(isReversibleLock(403, 'uploads_locked')).toBe(true);
|
||||||
|
|||||||
@@ -628,6 +628,14 @@ class TerminalError extends Error {
|
|||||||
*/
|
*/
|
||||||
class NetworkError extends Error {}
|
class NetworkError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blob is in IndexedDB but its bytes are unreadable — iOS purged the OS file behind a
|
||||||
|
* stored `File`. Deliberately NOT a NetworkError: retrying cannot bring the bytes back, and
|
||||||
|
* treating it as transient is what produced an empty-POST retry storm during the event. The
|
||||||
|
* guest has to re-pick the photo, and the message says so.
|
||||||
|
*/
|
||||||
|
class UnreadableBlobError extends Error {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError
|
* The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError
|
||||||
* subclass because the transport outcome is identical — but it must NOT stop the batch or
|
* subclass because the transport outcome is identical — but it must NOT stop the batch or
|
||||||
@@ -701,6 +709,48 @@ export function classifyUploadStatus(status: number): UploadOutcome {
|
|||||||
return 'transient';
|
return 'transient';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Within the `terminal` bucket, is this 400 a TRUNCATED REQUEST rather than a verdict on the
|
||||||
|
* file? Keep the blob and retry if so. Pure + exported for the same reason as
|
||||||
|
* `isReversibleLock`: it decides whether a guest keeps their photo.
|
||||||
|
*
|
||||||
|
* Keyed on the MESSAGE, not the envelope. The obvious rule — "an app-raised 400 carries
|
||||||
|
* `bad_request`, so a 400 without it is Axum's plain-text rejection" — does not hold, and was
|
||||||
|
* verified against the running backend rather than reasoned about:
|
||||||
|
*
|
||||||
|
* stream breaks between parts → 400 application/json
|
||||||
|
* {"error":"bad_request","message":"Error parsing `multipart/…"}
|
||||||
|
* stream breaks mid-file → 400 application/json, same code, message prefixed
|
||||||
|
* "Datei konnte nicht gelesen werden: …"
|
||||||
|
* no boundary in Content-Type → 400 text/plain "Invalid `boundary` for `multipart/…"
|
||||||
|
*
|
||||||
|
* Only the third is Axum's own extractor rejection. The first two — the ones a webview or a
|
||||||
|
* dropping mobile link actually produce — never reach it: the handler pulls the fields itself
|
||||||
|
* and wraps `MultipartError` in `AppError::BadRequest` (`upload.rs` field loop and chunk loop),
|
||||||
|
* so they arrive as an ordinary `bad_request` envelope, indistinguishable by code from "file too
|
||||||
|
* large" or "caption too long". An envelope check therefore never fires for the case this
|
||||||
|
* exists to catch. Confirmed live: the log line for a real guest failure and for a synthetic
|
||||||
|
* truncation are byte-identical.
|
||||||
|
*
|
||||||
|
* Retrying is safe: nothing was parsed, so nothing was stored and no quota was charged, and
|
||||||
|
* `X-Client-Upload-Id` makes a duplicate impossible even if the server did see it.
|
||||||
|
*
|
||||||
|
* The substring is Axum's `MultipartError` Display text and is therefore an UPSTREAM contract
|
||||||
|
* this file does not own — an axum upgrade could reword it and silently re-open the data loss.
|
||||||
|
* The durable fix is a distinct backend code (e.g. `body_incomplete`) that this can prefer once
|
||||||
|
* it exists; the match is kept as the fallback because it needs no app-image release.
|
||||||
|
*/
|
||||||
|
export function isIncompleteBody(status: number, body: unknown): boolean {
|
||||||
|
if (status !== 400) return false;
|
||||||
|
const envelope = body as { error?: unknown; message?: unknown } | null | undefined;
|
||||||
|
// An unparseable body (proxy, WAF, captive portal) cannot be a considered rejection either.
|
||||||
|
if (!envelope || envelope.error !== 'bad_request') return true;
|
||||||
|
return (
|
||||||
|
typeof envelope.message === 'string' &&
|
||||||
|
envelope.message.includes('Error parsing `multipart/form-data` request')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
|
* Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
|
||||||
* park retryable for a host reopen) rather than a permanent rejection (purge the blob).
|
* park retryable for a host reopen) rather than a permanent rejection (purge the blob).
|
||||||
@@ -839,7 +889,49 @@ export async function releaseResolvedParks(state: {
|
|||||||
|
|
||||||
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
||||||
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
||||||
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
export type EnqueueResult = 'queued' | 'duplicate' | 'full' | 'unreadable';
|
||||||
|
|
||||||
|
/** Chunk size for `materialise`. Bounds peak JS heap, not total copy size. */
|
||||||
|
const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a picked file's bytes into a Blob this origin owns, so IndexedDB stores DATA rather
|
||||||
|
* than a reference to an OS file that iOS will delete. See the call site in `addToQueue` for
|
||||||
|
* why that reference is the bug.
|
||||||
|
*
|
||||||
|
* Chunked deliberately. `new Blob([await file.arrayBuffer()])` is one line and correct for a
|
||||||
|
* 3 MB photo, but it pulls the whole file into the JS heap — and this queue accepts videos up
|
||||||
|
* to 500 MB, where that would very likely get the tab killed by the OS. Trading a crash for a
|
||||||
|
* failed upload is not a fix. Reading a slice at a time and letting each chunk become its own
|
||||||
|
* Blob keeps peak heap at one chunk; the browser's blob store owns the accumulated parts and
|
||||||
|
* can spill them to disk, which is exactly where a half-gigabyte video should live.
|
||||||
|
*/
|
||||||
|
async function materialise(file: File): Promise<Blob> {
|
||||||
|
let out: Blob;
|
||||||
|
if (file.size <= MATERIALISE_CHUNK_BYTES) {
|
||||||
|
out = new Blob([await file.arrayBuffer()], { type: file.type });
|
||||||
|
} else {
|
||||||
|
const parts: Blob[] = [];
|
||||||
|
for (let offset = 0; offset < file.size; offset += MATERIALISE_CHUNK_BYTES) {
|
||||||
|
const slice = file.slice(offset, offset + MATERIALISE_CHUNK_BYTES);
|
||||||
|
parts.push(new Blob([await slice.arrayBuffer()]));
|
||||||
|
}
|
||||||
|
out = new Blob(parts, { type: file.type });
|
||||||
|
}
|
||||||
|
// Verify the copy. The purge this whole function exists to defeat can also land PARTWAY
|
||||||
|
// THROUGH the loop above: a 500 MB video is many seconds of reading, and once the OS file
|
||||||
|
// is gone the remaining slices read as nothing. `new Blob` is happy to build a short blob
|
||||||
|
// out of them, and short is far worse than absent — it uploads, the server stores it, and
|
||||||
|
// the guest gets a truncated video that looks like it worked. A read that returns fewer
|
||||||
|
// bytes than the file claims is never legitimate, so refuse it here, while the guest is
|
||||||
|
// still holding the phone and can pick the file again.
|
||||||
|
if (out.size !== file.size) {
|
||||||
|
throw new UnreadableBlobError(
|
||||||
|
'Diese Datei konnte nicht vollständig gelesen werden — bitte wähle sie noch einmal aus.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function addToQueue(
|
export async function addToQueue(
|
||||||
file: File,
|
file: File,
|
||||||
@@ -887,6 +979,31 @@ export async function addToQueue(
|
|||||||
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
|
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
|
||||||
// exactly ONCE per file here and reused by every retry — see uploadItem.
|
// exactly ONCE per file here and reused by every retry — see uploadItem.
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
// MATERIALISE THE BYTES. Do not store the `File` itself.
|
||||||
|
//
|
||||||
|
// WebKit persists a File in IndexedDB as a REFERENCE to the OS backing file rather than a
|
||||||
|
// copy of its contents. iOS purges that file soon after the picker closes, which leaves a
|
||||||
|
// "neutered File": `.name` and `.size` still read correctly, so nothing looks wrong, but
|
||||||
|
// the bytes are gone. WebKit then does NOT throw on `xhr.send()` — the note at the send
|
||||||
|
// site assumed it would — it puts the request on the wire with an EMPTY BODY, the server
|
||||||
|
// cannot parse a multipart with no parts, and the guest sees a 400.
|
||||||
|
//
|
||||||
|
// Measured on the live event rather than inferred: every failing iPhone upload reached
|
||||||
|
// Caddy with `Content-Length: 0` in 7-22 ms, while an Android upload in the same minute
|
||||||
|
// sent 6,449,056 bytes and got a 201.
|
||||||
|
//
|
||||||
|
// Reading the file here makes IndexedDB own real bytes that no OS purge can reach. It
|
||||||
|
// costs one full read at pick time, which is also the moment the file is guaranteed still
|
||||||
|
// readable — the picker has only just handed it over.
|
||||||
|
// A file the browser cannot fully read is not a queueable item. Returning a result rather
|
||||||
|
// than throwing keeps the composer's per-file loop intact: one bad photo out of five must
|
||||||
|
// not abandon the other four, which is what an exception here would do.
|
||||||
|
let blob: Blob;
|
||||||
|
try {
|
||||||
|
blob = await materialise(file);
|
||||||
|
} catch {
|
||||||
|
return 'unreadable';
|
||||||
|
}
|
||||||
const entry: QueueEntry = {
|
const entry: QueueEntry = {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
@@ -897,7 +1014,7 @@ export async function addToQueue(
|
|||||||
caption,
|
caption,
|
||||||
hashtags,
|
hashtags,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
blob: file
|
blob
|
||||||
};
|
};
|
||||||
await storePut(entry);
|
await storePut(entry);
|
||||||
|
|
||||||
@@ -1070,6 +1187,11 @@ async function processQueue(): Promise<void> {
|
|||||||
// NetworkError, which it extends.)
|
// NetworkError, which it extends.)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (e instanceof UnreadableBlobError) {
|
||||||
|
// This one photo is unrecoverable, but the others in the queue may be fine
|
||||||
|
// (a re-picked copy, or one taken after the fix). Keep draining.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (e instanceof NetworkError) {
|
if (e instanceof NetworkError) {
|
||||||
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
||||||
// and the `online` listener resumes it; if the failure hit while nominally
|
// and the `online` listener resumes it; if the failure hit while nominally
|
||||||
@@ -1120,7 +1242,26 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
|
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
|
||||||
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
||||||
formData.append('client_upload_id', entry.id);
|
formData.append('client_upload_id', entry.id);
|
||||||
formData.append('file', entry.blob, entry.fileName);
|
// Never send a body we cannot read. `entry.blob.size` is NOT sufficient on WebKit: a
|
||||||
|
// neutered File keeps its metadata and reports the original size while reading as
|
||||||
|
// nothing. Only an actual read tells the truth, so probe one byte.
|
||||||
|
//
|
||||||
|
// This covers items queued BEFORE the materialise-on-pick fix above, which are still
|
||||||
|
// sitting in IndexedDB holding a dead File reference. Without it those retry until the
|
||||||
|
// budget is spent, every attempt an empty POST — 79 of them during the event.
|
||||||
|
const blob = entry.blob;
|
||||||
|
let readable = false;
|
||||||
|
try {
|
||||||
|
readable = (await blob.slice(0, 1).arrayBuffer()).byteLength > 0;
|
||||||
|
} catch {
|
||||||
|
readable = false;
|
||||||
|
}
|
||||||
|
if (!readable && entry.fileSize > 0) {
|
||||||
|
throw new UnreadableBlobError(
|
||||||
|
'Dieses Foto ist auf dem Gerät nicht mehr lesbar — bitte wähle es noch einmal aus.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
formData.append('file', blob, entry.fileName);
|
||||||
if (entry.caption) formData.append('caption', entry.caption);
|
if (entry.caption) formData.append('caption', entry.caption);
|
||||||
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
||||||
|
|
||||||
@@ -1252,6 +1393,15 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'terminal': {
|
case 'terminal': {
|
||||||
|
// A truncated body is a transport failure, not a verdict on the file, so it
|
||||||
|
// must not purge the blob. See `isIncompleteBody` for why the envelope alone
|
||||||
|
// cannot decide this.
|
||||||
|
if (isIncompleteBody(xhr.status, body)) {
|
||||||
|
settle(() =>
|
||||||
|
reject(new NetworkError('Übertragung unvollständig — bitte erneut versuchen'))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
// A REVERSIBLE lock (event closed / gallery released) is tagged
|
// A REVERSIBLE lock (event closed / gallery released) is tagged
|
||||||
// `uploads_locked` by the backend — keep the blob and park it retryable so
|
// `uploads_locked` by the backend — keep the blob and park it retryable so
|
||||||
// a host reopen resumes it, instead of purging it like a permanent 4xx.
|
// a host reopen resumes it, instead of purging it like a permanent 4xx.
|
||||||
@@ -1429,6 +1579,20 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
if (e instanceof UnreadableBlobError) {
|
||||||
|
// The bytes are gone from the browser's storage (iOS purged the OS file behind a
|
||||||
|
// stored `File`). No retry can recover them, so this is terminal — but unlike a
|
||||||
|
// server rejection the photo itself is fine and still in the camera roll, so the
|
||||||
|
// message asks for a re-pick rather than reporting the file as refused. Dropping
|
||||||
|
// the dead blob also frees the queue slot for the re-picked copy.
|
||||||
|
delete entry.blob;
|
||||||
|
entry.status = 'blocked';
|
||||||
|
entry.error = e.message;
|
||||||
|
await storePut(entry);
|
||||||
|
updateItemStatus(id, 'blocked', e.message);
|
||||||
|
toast(`${entry.fileName}: ${e.message}`, 'error', 8000);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
if (e instanceof TerminalError) {
|
if (e instanceof TerminalError) {
|
||||||
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
||||||
// so the UI shows a clear reason and offers no retry.
|
// so the UI shows a clear reason and offers no retry.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
|
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
|
||||||
import { initTheme } from '$lib/theme-store';
|
import { initTheme } from '$lib/theme-store';
|
||||||
@@ -227,9 +226,11 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<!-- NO `<svelte:head><link rel="icon">` here. There used to be one, pointing at
|
||||||
<link rel="icon" href={favicon} />
|
`$lib/assets/favicon.svg` — the orange Svelte logo from the project skeleton. Vite inlined it
|
||||||
</svelte:head>
|
as a data URI into the layout bundle and this block applied it AFTER hydration, so it beat the
|
||||||
|
branded `<link rel="icon">` in app.html and the tab showed the framework's logo on every page.
|
||||||
|
app.html is now the single source for the icon; the skeleton asset is deleted. -->
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -309,8 +309,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Normal join form -->
|
<!-- Normal join form -->
|
||||||
|
<!-- The article is HARDCODED to match this event's name ("Hochzeit von …"), so the
|
||||||
|
lead-in and the <h1> below read as one sentence: "Willkommen bei der Hochzeit
|
||||||
|
von …". That couples this string to EVENT_NAME's grammatical gender — it is
|
||||||
|
wrong for "Willkommen bei der Sommerfest". Deliberate for a single event; if
|
||||||
|
this app is reused, either make the article configurable alongside EVENT_NAME
|
||||||
|
or drop back to an article-free lead-in ("Herzlich willkommen!"), which works
|
||||||
|
with any name. The fallback below must keep agreeing with whatever is chosen. -->
|
||||||
<p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
<p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
Willkommen bei
|
Willkommen bei der
|
||||||
</p>
|
</p>
|
||||||
{#if eventName}
|
{#if eventName}
|
||||||
<h1
|
<h1
|
||||||
@@ -320,8 +327,10 @@
|
|||||||
{eventName}
|
{eventName}
|
||||||
</h1>
|
</h1>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- "Feier", not "dem Event": the lead-in above now carries the article, so the
|
||||||
|
old fallback would render "Willkommen bei der dem Event". -->
|
||||||
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
|
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
dem Event
|
Feier
|
||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
|
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
|
||||||
|
|||||||
@@ -163,6 +163,16 @@
|
|||||||
}
|
}
|
||||||
const result = await addToQueue(sf.file, caption, hashtagsString);
|
const result = await addToQueue(sf.file, caption, hashtagsString);
|
||||||
if (result === 'full') full++;
|
if (result === 'full') full++;
|
||||||
|
// The browser could not read this file's bytes (iOS purges the OS file behind a
|
||||||
|
// picked photo). Named per file rather than counted like `full`: the guest has to
|
||||||
|
// find and re-pick this specific one, so a bare number would not be actionable.
|
||||||
|
if (result === 'unreadable') {
|
||||||
|
toast(
|
||||||
|
`„${sf.file.name}“ konnte nicht gelesen werden. Bitte wähle das Foto noch einmal aus.`,
|
||||||
|
'error',
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Don't let a full queue silently swallow photos the user thinks were queued.
|
// Don't let a full queue silently swallow photos the user thinks were queued.
|
||||||
if (full > 0) {
|
if (full > 0) {
|
||||||
@@ -308,6 +318,18 @@
|
|||||||
rows="4"
|
rows="4"
|
||||||
class="input resize-none text-sm"
|
class="input resize-none text-sm"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<!-- Persistent helper, NOT a second placeholder line. Two reasons: a `placeholder`
|
||||||
|
attribute may not contain line breaks (Safari collapses them outright), and a
|
||||||
|
placeholder disappears the moment the guest starts typing — which is exactly when
|
||||||
|
they need to read what to write. This stays visible while they type.
|
||||||
|
The hashtag is written plain on purpose: `#fotoaufgabe3` would be a DIFFERENT tag
|
||||||
|
from `#fotoaufgabe`, so twelve tasks would produce twelve unfilterable tags. The
|
||||||
|
number belongs in the prose, the hashtag stays one word. -->
|
||||||
|
<p class="mt-1.5 text-xs leading-snug text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="font-semibold text-gray-600 dark:text-gray-300">Fotoaufgabe?</span> Schreib
|
||||||
|
die Aufgabe dazu — die Nummer steht unten rechts auf dem Kärtchen — und setze den Hashtag
|
||||||
|
#fotoaufgabe.
|
||||||
|
</p>
|
||||||
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
|
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
|
||||||
{caption.length} / {MAX_CAPTION_LENGTH}
|
{caption.length} / {MAX_CAPTION_LENGTH}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="EventSnap">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Hochzeit von Julia & Jonas">
|
||||||
<!-- Full-bleed brand background keeps the icon "maskable": safe content sits within the centre 80%. -->
|
<!-- Full-bleed brand background keeps the icon "maskable": safe content sits within the centre 80%
|
||||||
<rect width="512" height="512" fill="#2563eb"/>
|
(51.2-460.8). Rotated, the rings span 102.9-409.1 on BOTH axes including stroke, so an Android
|
||||||
<g fill="none" stroke="#ffffff" stroke-width="22" stroke-linejoin="round" stroke-linecap="round">
|
circle crop cannot clip them. The stricter circular test is unaffected by the rotation: the
|
||||||
<!-- Camera body -->
|
farthest painted point is 51 (centre offset) + 117 (radius + half stroke) = 168 from the
|
||||||
<rect x="116" y="172" width="280" height="200" rx="34"/>
|
middle, comfortably inside the 204.8 safe radius, and rotating about that same middle does
|
||||||
<!-- Viewfinder bump -->
|
not change any point's distance from it. -->
|
||||||
<path d="M212 172l24-34h40l24 34" fill="#ffffff" stroke="none"/>
|
<rect width="512" height="512" fill="#8a6a2b"/>
|
||||||
<!-- Lens -->
|
<!-- Two interlocking bands. Deliberately just the two rings: no gem, no shine, no inner bevel.
|
||||||
<circle cx="256" cy="276" r="58"/>
|
This renders at 16px in a browser tab, where any detail smaller than the stroke width turns
|
||||||
|
into a smudge — the same reason the camera icon this replaces was three shapes and no more.
|
||||||
|
|
||||||
|
The -45° tilt lifts the right band above the left. It is negative because SVG's y axis points
|
||||||
|
down, so a positive angle would rotate the other way and drop the right band instead. Applying
|
||||||
|
it to the GROUP rather than to each shape is what keeps the interlock intact — the circles and
|
||||||
|
the overlap arc below are all still authored on the horizontal axis and rotate together, so
|
||||||
|
the crossing geometry never has to be recomputed. -->
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-width="26" transform="rotate(-45 256 256)">
|
||||||
|
<circle cx="205" cy="256" r="104"/>
|
||||||
|
<circle cx="307" cy="256" r="104"/>
|
||||||
|
<!-- The interlock. Both circles above are drawn whole, so the right band covers the left one at
|
||||||
|
BOTH crossings and they read as merely overlapping. Redrawing this arc of the left band —
|
||||||
|
the segment through the upper intersection at (256, 165.4) — puts it back on top there
|
||||||
|
while the right stays on top at the lower crossing, which is what makes them look linked
|
||||||
|
rather than stacked. Keep it in sync if either circle moves. -->
|
||||||
|
<path d="M214.1 152.4A104 104 0 0 1 290.2 196.3"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 2.2 KiB |
@@ -8,7 +8,7 @@
|
|||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#2563eb",
|
"theme_color": "#8a6a2b",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "/icon.svg",
|
||||||
|
|||||||
Reference in New Issue
Block a user