Fifth round, all storage. The keepsake could not fit on the documented hardware
and failed halfway through a multi-GB write, leaving the deliverable stuck; the
quota had stopped bounding the disk; and the backup had no restore procedure.
Squashed from 6 commits, original messages preserved below.
──────── fix(export): refuse an export that cannot fit, and stop peaking at two generations
Nothing in export.rs ever asked whether the keepsake would fit. Both archives write
their media `Compression::Stored`, so each is essentially a byte-for-byte second copy
of the originals -- Gallery.zip always, and Memories.zip for every video and every
image at or under 5 MB. On the documented CX33 (80 GB, all three volumes on one
filesystem) the upload quota's fixed point leaves ~40 GB free, and a release spawns
BOTH halves concurrently against it.
The failure is not "the export failed", it is "the deliverable is stuck":
1. ENOSPC lands partway through a multi-GB write.
2. The epoch has already moved, so the job row is `failed` at the CURRENT
generation and readiness (epoch = event.export_epoch AND status = 'done') is
false -- GET /export/zip 404s.
3. The last good archive sits on disk, unreferenced and unreachable.
4. POST /host/export/rebuild, the only escape, re-arms the same doomed write.
Three changes.
Reclaim before building. `prune_stale_export_files` ran only after the new archive
was written, renamed and finalised. That reads as durability but buys nothing: the
moment `invalidate_and_arm` bumps the epoch the old archive is ALREADY unreachable,
so keeping it reserves gigabytes for a download nobody can perform -- and for a
takedown it is content someone explicitly asked to have removed. Peak usage is now
one generation. Narrower than the post-finalize prune on purpose: final archives
only, never a `.tmp` or a `viewer_tmp_` dir, since a superseded worker can still be
streaming into those and at build START is far more likely to be alive.
Preflight the space. SUM(original_size_bytes) over exactly `query_uploads`'
visibility filter, +10% for ZIP overhead, multiplied by the number of armed jobs --
without that multiplier each of the two concurrent halves independently sees "it
fits" and together they don't. Runs AFTER claim_job, not before as reported: bailing
before the claim leaves the row `pending` with no worker and no error, the
spinner-forever state `mark_failed`'s status guard exists to prevent. Fails open when
the mount can't be read, exactly as the upload quota does.
Show the host the reason. /export/status returned {status, progress_pct} and nothing
else, so the host dashboard could only render "fehlgeschlagen" next to the retry
button. The message was written to the row and surfaced solely in the ADMIN job list
-- a different screen, possibly a different person. It now travels with the status,
and only on a failure, so a message left on a since-succeeded row can't appear beside
a green "ist bereit".
Tests: 10 unit (the u128 clamp caught a real bug in the first draft -- saturating_mul
then /100 turns an overflow into a number ~100x too small, the one direction that
authorises the write being guarded against; the carried-forward archive must survive
its own older epoch in the filename), 4 DB-backed (the estimate is asserted against
the row set the archive actually contains, not against a restatement of the WHERE
clause, so the two queries cannot drift), 3 e2e over the four-hop plumbing.
──────── fix(maintenance): reclaim the media of deliberately deleted uploads
The quota stopped bounding the disk. `soft_delete_in_event` stamps `deleted_at` and
refunds `total_upload_bytes`, but nothing ever removed the bytes, and the hourly
sweep reached only `compression_status = 'failed'`. Upload 500 MB, delete, quota back
to zero, upload another 500 MB. Not an attack -- a guest curating their camera roll,
which is what people do. The host then sees guests hitting "Du hast dein Upload-Limit
erreicht" while the admin widget shows a disk full of files no upload row points at,
and the quota message is actively misleading because the space really is gone, just
not to anyone the accounting can name.
Two retention windows, because the two deletes mean different things. A compression
failure keeps its 14 days: the guest didn't ask for it and may not be able to retake
the photo. A deliberate removal gets 24 hours -- 14 days outlives the whole event, so
a deliberate delete would never reclaim anything while it mattered, and a day still
covers a mis-tap.
Wider than reported: ALL FOUR paths are reclaimed, not just the original. Preview,
display and thumbnail are each a separate file, none counted in
`original_size_bytes`, and nothing ever removed them either. That was invisible while
the sweep only saw failed compressions (which produce no derivatives) and becomes
three leaked files per upload the moment it reaches a successful one. A row is
re-selected until every path is cleared, and the columns are cleared only once every
file for that upload is gone -- clearing after a partial success would strand the
survivors in exactly the unowned state this drains.
`backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT
NULL`, which is close enough to the post-sweep state to be worth pinning: it is
guarded on `deleted_at IS NULL`, so it cannot re-decode an original that is no longer
on disk. Covered.
Residual, deliberately: within the 24h window the bytes are still spent and still
unaccounted, so delete-and-re-upload through an eight-hour event can outrun the
sweep. Bounding that means holding the quota until the file is reclaimed rather than
refunding at `deleted_at`. The low-disk warning is the net under it.
Tests: 6 DB-backed, replacing 3. The one asserting an owner-deleted upload IS
reclaimed is the exact inverse of what this file used to assert.
──────── feat(host): warn about low disk before it becomes unrecoverable
Storage visibility existed in exactly one place: a passive Speicherauslastung widget
on the ADMIN dashboard. A host who isn't the admin had no view of it, and nothing
warned anyone. README carried "Low-disk alert (< 10 GB free)" under Planned since v1.
Two things make this a safety net rather than a nice-to-have. postgres_data,
media_data and exports_data are all Docker named volumes on ONE filesystem, so
running out doesn't degrade a subsystem -- Postgres stops being able to write and the
whole event goes down. And the keepsake needs room for two gallery-sized archives,
which the export preflight can only ever refuse AFTER the release, when the event is
over and every remedy is harder.
So the threshold is not a fixed number alone. It fires on the 10 GB floor the README
always named, OR on "you could not build the keepsake right now" -- the trigger a
host can still act on, computed with the same arithmetic the preflight uses. Unknown
free space is NOT low: it fails open like the upload quota and the preflight do,
because a banner that cries wolf on an unreadable mount is a banner nobody reads.
Carried on GET /host/event, which the dashboard already fetches on load and on every
reload -- no new endpoint, no new poll. Rendered above everything else including the
PIN-reset queue, and it names the consequence (the event, not just the download)
rather than only the number.
Also fixes the host page's formatBytes, which topped out at MB: 30 GB free would have
rendered as "30720.0 MB", and a guest with 2 GB of uploads was already being shown
that way in the user list.
Tests: 5 unit on the threshold (including that plenty of free space is still low when
the keepsake wouldn't fit -- the case a fixed threshold misses entirely), 3 e2e.
The e2e drives it through `original_size_bytes` rather than a genuinely full disk:
the estimate is pure SQL over that column, so overstating one row moves the
accounting without touching a byte on disk.
──────── docs: add a restore procedure, fix the backup cadence, and correct quota_tolerance
Four things, all found by the same question: what does an operator standing at the
venue actually need?
A RESTORE PROCEDURE. There was none anywhere, and a backup you have never restored
isn't a backup. Two hazards worth writing down: media must be extracted preserving
ownership (the app runs as uid 100 / gid 101, and a root-owned restore makes every
upload fail with EACCES surfacing as a generic 500), and the app must be STOPPED
first, because migrations run on boot and a live pool will fight the restore.
Both the backup and the restore commands were run against the real stack before being
written down, which caught two that would have failed:
- The plain `pg_dump` did not restore: `psql` aborted on `ERROR: schema
"_sqlx_test" already exists`. pg_dump emits no DROPs without --clean --if-exists,
so the documented dump could only ever be restored into an empty database. Fixed
at the source (the dump is now self-cleaning) and verified end to end: 16 tables
back, exit 0.
- `--same-owner` does not exist in BusyBox tar, which is what `alpine` ships, so
the extract aborted before unpacking anything. `--numeric-owner` plus the
explicit chown, verified to land 100:101.
BACKUP CADENCE. "Weekly offsite" is the wrong shape when every irreplaceable byte is
created in one eight-hour window and nobody can retake a wedding. The backup that
matters runs that night, and again after the release so the keepsake is captured.
Also: take the DB dump and the media tarball back to back, or you get rows pointing
at files the dump doesn't know about.
quota_tolerance WAS DOCUMENTED AS SOMETHING IT ISN'T. .env.example called it "fraction
of disk that triggers the low-storage warning". It is the multiplier in
`floor(free_disk * tolerance / active_uploaders)` -- so an operator who wants "warn me
later" and sets 0.95 is actually authorising guests to fill 95% of the disk, moving
the fixed point from 43% to ~49% and eating the export headroom. The admin UI labelled
it "Toleranz (0-1)" with no explanation at all, which invites exactly that reading;
it is now "Speicher-Anteil für Gäste" with the formula in the hint. Wrong docs on a
tuning knob are worse than no docs.
SIZING. New section with the arithmetic: three volumes on one filesystem, the quota
fixed point at tolerance/(1+tolerance), and the fact the 80 GB baseline does not cover
the keepsake -- both archives are built concurrently and each is roughly a second copy
of every original. Provision ~3x expected media, or give exports its own volume.
Also ticks the low-disk alert off the roadmap, since it now exists.
──────── chore: raise the db memory limit and rate-limit social writes
Two smaller operational items.
POSTGRES 512M -> 1G. DATABASE_MAX_CONNECTIONS is 30 for a ~100-guest event (feed
polling + SSE + uploads at once), and 30 backends plus Postgres 16's default
shared_buffers leaves very little headroom at 512M. An OOM here doesn't degrade one
feature -- every request path touches the database, so it takes the event down.
Memory is the cheaper knob than shrinking the pool back and reintroducing the
queueing it was raised to fix. .env.example now names the pairing explicitly, the way
it already does for COMPRESSION_WORKER_CONCURRENCY.
SOCIAL WRITES WERE UNTHROTTLED. toggle_like, add_comment and delete_comment were the
only mutating endpoints in the app with no limit at all -- upload, join, recover,
export and admin login all carry one. Asymmetric coverage rather than a deliberate
decision.
Low severity, and honestly so: a like fans an SSE broadcast to every client, but the
export regeneration a comment deletion triggers is contained (REGEN_DEBOUNCE 20s,
workers born with their epoch, superseded ones inert). So the ceiling is 120/min --
far above anything a real guest produces. This bounds a script, not an enthusiastic
double-tapper.
ONE bucket across all three actions: separate buckets would let a caller triple the
aggregate write rate by alternating between them. Keyed per USER, matching the feed
and upload limits -- at a venue every guest is behind one NAT, and an IP key is what
made the /join and /feed limits turn guests away in the first place.
Migration 020 seeds both keys, and both are wired into the admin allowlist, the
config UI and the e2e reseed -- the step two earlier per-area toggles missed, which
left switches that existed in code and could never be flipped.
Tests: 4 e2e, including that the shared bucket really is shared (the part most likely
to be lost in a refactor) and that one guest hitting the ceiling doesn't block
another behind the same IP.
──────── fix(e2e): stop the video poster assertion racing the ffmpeg thumbnail
Pre-existing, and it fired for real during the full-suite run on a cold stack.
The lightbox binds `poster={upload.thumbnail_url ?? undefined}`, so the attribute is
absent until compression produces the thumbnail. This test asserted on it immediately
after seeding, never waiting for the worker -- unlike the Range test further down the
same file, which does poll. Against a warm stack the worker usually wins; against a
freshly rebuilt one (`stack:down -v`, cold ffmpeg) it doesn't.
That is the worst possible time for a false failure: the first run after a rebuild is
exactly when you are trying to establish whether a change broke something. Poll for
`compression_status = 'done'` before the poster assertion. The `src` assertion needs
no wait and keeps none.
Verified with --repeat-each=3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
854 lines
32 KiB
Rust
854 lines
32 KiB
Rust
use axum::Json;
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::middleware::RequireHost;
|
|
use crate::error::AppError;
|
|
use crate::models::comment::Comment;
|
|
use crate::models::event::Event;
|
|
use crate::models::session::Session;
|
|
use crate::models::upload::Upload;
|
|
use crate::models::user::UserRole;
|
|
use crate::services::export::Affects;
|
|
use crate::state::{AppState, SseEvent};
|
|
|
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
pub struct UserSummary {
|
|
pub id: Uuid,
|
|
pub display_name: String,
|
|
pub role: String,
|
|
pub is_banned: bool,
|
|
pub uploads_hidden: bool,
|
|
pub upload_count: i64,
|
|
pub total_upload_bytes: i64,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct EventStatus {
|
|
pub name: String,
|
|
pub is_active: bool,
|
|
pub uploads_locked: bool,
|
|
pub export_released: bool,
|
|
/// Free space on the volume the keepsake is written to. `None` when the mount can't be
|
|
/// resolved — the UI hides the widget rather than rendering a confident zero.
|
|
pub disk_free_bytes: Option<u64>,
|
|
/// What a full keepsake build would need right now (both halves).
|
|
pub keepsake_required_bytes: u64,
|
|
/// Whether the host should be warned. See [`disk_is_low`].
|
|
pub disk_low: bool,
|
|
}
|
|
|
|
/// Absolute floor below which free space is worth surfacing regardless of gallery size — the
|
|
/// threshold the README has carried on the roadmap since v1.
|
|
const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
|
|
|
|
/// Is free space low enough that the host needs to know?
|
|
///
|
|
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
|
|
/// `media_data` and `exports_data` are all Docker named volumes on one filesystem, so a full disk
|
|
/// does not degrade one subsystem — it stops Postgres writing and takes the event down. That is
|
|
/// what the absolute floor is for.
|
|
///
|
|
/// The second trigger is the one that actually earns its place: the keepsake needs room for two
|
|
/// gallery-sized archives, and the only moment a host can do anything about that is BEFORE they
|
|
/// release. Warning at "you could not build the keepsake right now" turns a post-event dead end
|
|
/// into a decision someone can still make.
|
|
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
|
|
free < LOW_DISK_FLOOR_BYTES || free < keepsake_required
|
|
}
|
|
|
|
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
|
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
|
|
/// always keeps at least one operator" floor.
|
|
async fn remaining_operators(
|
|
state: &AppState,
|
|
event_id: Uuid,
|
|
excluding: Uuid,
|
|
) -> Result<i64, AppError> {
|
|
let count = sqlx::query_scalar::<_, i64>(
|
|
"SELECT COUNT(*) FROM \"user\"
|
|
WHERE event_id = $1 AND id != $2
|
|
AND role IN ('host', 'admin') AND is_banned = FALSE",
|
|
)
|
|
.bind(event_id)
|
|
.bind(excluding)
|
|
.fetch_one(&state.pool)
|
|
.await?;
|
|
Ok(count)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetRoleRequest {
|
|
pub role: String,
|
|
}
|
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn get_event_status(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<Json<EventStatus>, AppError> {
|
|
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
|
|
|
// Measured on the EXPORT volume, not the media one: that is where the cliff is, and it is a
|
|
// distinct mount point even when both are backed by the same filesystem. The cached reading is
|
|
// right here — this is advisory, polled on every dashboard load, and a 15s-stale number costs
|
|
// nothing (unlike the export preflight, which reads uncached because it is about to write).
|
|
let free = state
|
|
.disk_cache
|
|
.snapshot(&state.config.export_path)
|
|
.map(|d| d.free);
|
|
let keepsake_required_bytes =
|
|
crate::services::export::keepsake_space_required(&state.pool, event.id)
|
|
.await
|
|
.unwrap_or(0);
|
|
|
|
Ok(Json(EventStatus {
|
|
name: event.name,
|
|
is_active: event.is_active,
|
|
uploads_locked: event.uploads_locked_at.is_some(),
|
|
export_released: event.export_released_at.is_some(),
|
|
disk_free_bytes: free,
|
|
keepsake_required_bytes,
|
|
// Unknown free space is NOT low. Fails open, exactly as the upload quota and the export
|
|
// preflight do: a scary banner on an unreadable mount would train the host to ignore it.
|
|
disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required_bytes)),
|
|
}))
|
|
}
|
|
|
|
pub async fn list_users(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
) -> Result<Json<Vec<UserSummary>>, AppError> {
|
|
let rows = sqlx::query_as::<_, UserSummary>(
|
|
"SELECT u.id,
|
|
u.display_name,
|
|
u.role::text AS role,
|
|
u.is_banned,
|
|
u.uploads_hidden,
|
|
COALESCE(COUNT(up.id), 0) AS upload_count,
|
|
u.total_upload_bytes,
|
|
u.created_at
|
|
FROM \"user\" u
|
|
LEFT JOIN upload up ON up.user_id = u.id AND up.deleted_at IS NULL
|
|
WHERE u.event_id = $1
|
|
GROUP BY u.id
|
|
ORDER BY u.created_at ASC",
|
|
)
|
|
.bind(auth.event_id)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
pub async fn ban_user(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
// The ban request carries no body — ban always hides (no per-request options).
|
|
// Cannot ban yourself or another host/admin
|
|
if user_id == auth.user_id {
|
|
return Err(AppError::BadRequest(
|
|
"Du kannst dich nicht selbst sperren.".into(),
|
|
));
|
|
}
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
if target.0 == "admin"
|
|
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
|
|
{
|
|
return Err(AppError::Forbidden(
|
|
"Du kannst diesen Benutzer nicht sperren.".into(),
|
|
));
|
|
}
|
|
|
|
// Floor: never leave the event with zero operators. Banning removes the target from
|
|
// the active-operator pool, so refuse if they're the last non-banned host/admin.
|
|
if target.0 == "host" && remaining_operators(&state, auth.event_id, user_id).await? == 0 {
|
|
return Err(AppError::BadRequest(
|
|
"Der letzte Host kann nicht gesperrt werden.".into(),
|
|
));
|
|
}
|
|
|
|
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
|
|
// views/queries now also filter on `is_banned` (defense in depth), and we set
|
|
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
|
|
// opt-out checkbox is gone — `hide_uploads` in the request is ignored.
|
|
//
|
|
// We deliberately do NOT revoke the banned user's sessions. This is a *read-only ban*
|
|
// by design (USER_JOURNEYS §10.3): the user keeps read access to the feed and can still
|
|
// download the released export — writes and host/admin actions are what the ban blocks
|
|
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
|
|
// contradict that model, break the documented "banned guest can still download the
|
|
// keepsake" flow, and be ineffective anyway (the user could just /recover a new session).
|
|
//
|
|
// The ban and the keepsake invalidation are ONE transaction — see `host_delete_upload`.
|
|
let mut tx = state.pool.begin().await?;
|
|
sqlx::query(
|
|
"UPDATE \"user\"
|
|
SET is_banned = TRUE, uploads_hidden = TRUE, uploads_hidden_at = NOW()
|
|
WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// A ban hides the user's uploads EVERYWHERE — and the keepsake is the place that matters most,
|
|
// because it is the copy people keep. The export already filters `is_banned = FALSE`, so a
|
|
// FUTURE export excludes them; without this, an ALREADY-RELEASED archive would keep serving a
|
|
// banned user's photos forever. Same class as a takedown, so same treatment.
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
|
|
// Evict their content live from every feed + the diashow so it disappears without
|
|
// each viewer having to reload. (Their own SSE stream is separately dropped by the
|
|
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
|
|
// retaining plain read access.)
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"user-hidden",
|
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
|
));
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
"host: ban_user"
|
|
);
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn unban_user(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
// Mirror the ban guard: a host may only lift bans on guests, never on hosts or
|
|
// admins. Without this a host could override an admin's ban of another host,
|
|
// which is asymmetric with `ban_user` and lets a host escalate a peer back in.
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
if target.0 == "admin"
|
|
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
|
|
{
|
|
return Err(AppError::Forbidden(
|
|
"Du kannst diesen Benutzer nicht entsperren.".into(),
|
|
));
|
|
}
|
|
|
|
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
|
|
// `is_banned` would leave their content invisible. Clear all three (the timestamp too,
|
|
// so a future ban stamps a fresh `uploads_hidden_at` the reconnect delta will replay).
|
|
let mut tx = state.pool.begin().await?;
|
|
let result = sqlx::query(
|
|
"UPDATE \"user\"
|
|
SET is_banned = FALSE, uploads_hidden = FALSE, uploads_hidden_at = NULL
|
|
WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
|
|
}
|
|
|
|
// The mirror of the ban case: an unban RESTORES their uploads to the export query, so an
|
|
// already-released keepsake is now missing content it should contain. Rebuild it.
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
"host: unban_user"
|
|
);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Force a keepsake rebuild. The ESCAPE HATCH.
|
|
///
|
|
/// Without this, a failed or stranded export is terminal at runtime: `release_gallery` refuses an
|
|
/// already-released event ("bereits freigegeben"), `recover_exports` only runs at boot, and there is
|
|
/// no other retry path — so the host's only options were restarting the container or reopening the
|
|
/// event (which unlocks uploads to every guest and discards the release). This is also the recovery
|
|
/// path for a keepsake that went stale for any reason we haven't thought of.
|
|
pub async fn rebuild_export(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
let mut tx = state.pool.begin().await?;
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
let Some(r) = regen else {
|
|
return Err(AppError::BadRequest(
|
|
"Die Galerie ist nicht freigegeben — es gibt nichts neu zu erzeugen.".into(),
|
|
));
|
|
};
|
|
|
|
// No debounce: this is an explicit, deliberate host action, not a burst.
|
|
for export_type in ["zip", "html"] {
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"export-progress",
|
|
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
|
|
));
|
|
}
|
|
crate::services::export::spawn_export_jobs(
|
|
r.event_id,
|
|
r.event_name,
|
|
r.epoch,
|
|
state.config.comments_enabled,
|
|
std::time::Duration::ZERO,
|
|
state.pool.clone(),
|
|
state.config.media_path.clone(),
|
|
state.config.export_path.clone(),
|
|
state.sse_tx.clone(),
|
|
);
|
|
|
|
tracing::info!(actor_user_id = %auth.user_id, "host: rebuild_export");
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn set_role(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
Json(body): Json<SetRoleRequest>,
|
|
) -> Result<StatusCode, AppError> {
|
|
if user_id == auth.user_id {
|
|
return Err(AppError::BadRequest(
|
|
"Du kannst deine eigene Rolle nicht ändern.".into(),
|
|
));
|
|
}
|
|
let new_role = match body.role.as_str() {
|
|
"guest" => "guest",
|
|
"host" => "host",
|
|
_ => {
|
|
return Err(AppError::BadRequest(
|
|
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
|
));
|
|
}
|
|
};
|
|
|
|
// Look up the current role so we can apply the host-vs-admin guard. A plain host may
|
|
// promote/demote GUESTS only; it may not change any host's or admin's role (see the
|
|
// guard below — this closes the demote-a-peer-host→ban/PIN-reset takeover chain, F1).
|
|
// Only an admin may change a host's role. Admins may do anything except change
|
|
// themselves (blocked above).
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
// Admins are untouchable by hosts. A plain host also may not demote another
|
|
// *host*: without this guard a host could demote a peer host to guest and then
|
|
// ban / PIN-reset (→ account-takeover via /recover) them — the ban/pin-reset peer
|
|
// guards key off the target's *current* role, so a prior demotion would launder
|
|
// past them. Only an admin may change a host's role.
|
|
if target.0 == "admin" || (target.0 == "host" && auth.role != UserRole::Admin) {
|
|
return Err(AppError::Forbidden(
|
|
"Du darfst die Rolle dieses Benutzers nicht ändern.".into(),
|
|
));
|
|
}
|
|
|
|
// Floor: demoting the last non-banned host/admin to guest would leave the event with
|
|
// no operator. Refuse.
|
|
if new_role == "guest"
|
|
&& target.0 == "host"
|
|
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
|
|
));
|
|
}
|
|
|
|
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
|
.bind(user_id)
|
|
.bind(new_role)
|
|
.bind(auth.event_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
old_role = %target.0,
|
|
new_role,
|
|
"host: set_role"
|
|
);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct PinResetResponse {
|
|
/// Plaintext PIN — shown to the operator **once**. Never persisted client-side.
|
|
pub pin: String,
|
|
}
|
|
|
|
/// Generate a fresh PIN for another user, returning the plaintext exactly once.
|
|
///
|
|
/// Authorisation:
|
|
/// - Host caller → may reset **guest** PINs only.
|
|
/// - Admin caller → may reset **guest** and **host** PINs (never another admin).
|
|
/// - Target ≠ caller.
|
|
pub async fn reset_user_pin(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(user_id): Path<Uuid>,
|
|
) -> Result<Json<PinResetResponse>, AppError> {
|
|
use rand::Rng;
|
|
|
|
if user_id == auth.user_id {
|
|
return Err(AppError::BadRequest(
|
|
"Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(),
|
|
));
|
|
}
|
|
|
|
let target = sqlx::query_as::<_, (String,)>(
|
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
|
)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.fetch_optional(&state.pool)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
match (auth.role.clone(), target.0.as_str()) {
|
|
(UserRole::Admin, "guest" | "host") => {}
|
|
(UserRole::Host, "guest") => {}
|
|
_ => {
|
|
return Err(AppError::Forbidden(
|
|
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
|
let pin_hash = crate::auth::handlers::hash_password(pin.clone(), 12).await?;
|
|
|
|
sqlx::query(
|
|
"UPDATE \"user\"
|
|
SET recovery_pin_hash = $1,
|
|
failed_pin_attempts = 0,
|
|
pin_locked_until = NULL
|
|
WHERE id = $2 AND event_id = $3",
|
|
)
|
|
.bind(&pin_hash)
|
|
.bind(user_id)
|
|
.bind(auth.event_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
|
// existing session so old devices must re-authenticate with the new PIN. This is a
|
|
// security-relevant revoke: if it fails, the old sessions stay valid (sessions are
|
|
// token- not PIN-bound), so surface the error in logs rather than swallowing it
|
|
// silently while reporting success to the host.
|
|
if let Err(e) = Session::delete_all_for_user(&state.pool, user_id).await {
|
|
tracing::error!(error = ?e, user_id = %user_id, "PIN reset: failed to revoke sessions");
|
|
}
|
|
|
|
// Resolve any pending in-app "I forgot my PIN" request for this user.
|
|
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
|
|
.bind(user_id)
|
|
.execute(&state.pool)
|
|
.await;
|
|
|
|
// Notify the *recipient* device(s) if they happen to be online so they can clear
|
|
// their cached local PIN. They'll save the new one on the next /recover.
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"pin-reset",
|
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
|
));
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
target_user_id = %user_id,
|
|
event_id = %auth.event_id,
|
|
"host: reset_user_pin"
|
|
);
|
|
|
|
Ok(Json(PinResetResponse { pin }))
|
|
}
|
|
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
pub struct PinResetRequestSummary {
|
|
pub id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub display_name: String,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// List pending in-app PIN-reset requests so a host can action them (via the existing
|
|
/// `reset_user_pin`, which also clears the request).
|
|
pub async fn list_pin_reset_requests(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
) -> Result<Json<Vec<PinResetRequestSummary>>, AppError> {
|
|
let rows = sqlx::query_as::<_, PinResetRequestSummary>(
|
|
"SELECT r.id, r.user_id, u.display_name, r.created_at
|
|
FROM pin_reset_request r
|
|
JOIN \"user\" u ON u.id = r.user_id
|
|
WHERE r.event_id = $1
|
|
ORDER BY r.created_at ASC",
|
|
)
|
|
.bind(auth.event_id)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
/// Dismiss a PIN-reset request without resetting (e.g. the host couldn't verify the
|
|
/// requester's identity).
|
|
pub async fn dismiss_pin_reset_request(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
sqlx::query("DELETE FROM pin_reset_request WHERE id = $1 AND event_id = $2")
|
|
.bind(id)
|
|
.bind(auth.event_id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Content changed AFTER the gallery was released — regenerate the keepsake.
|
|
///
|
|
/// Deleting a photo used to remove it from the live feed but leave it in the already-generated
|
|
/// archive FOREVER: the old `if ready { continue }` skip guaranteed the export was never rebuilt.
|
|
/// For a takedown ("please remove my photo") that is the one place it most needs to disappear.
|
|
///
|
|
/// Bumping the epoch (while staying released) retires the current generation, so the stale archive
|
|
/// stops being downloadable the instant the delete commits, and a fresh worker rebuilds it without
|
|
/// the removed content. The download 404s in the meantime, which is the correct answer — serving
|
|
/// the old archive would serve the deleted photo.
|
|
/// Start the workers for a regeneration that was armed inside a (now-committed) transaction, and
|
|
/// tell every client the current keepsake just became undownloadable.
|
|
///
|
|
/// The SSE matters: bumping the epoch retires the archive INSTANTLY, so `/export/zip` starts 404ing
|
|
/// the moment the change commits. Without a nudge, a guest sitting on `/export` keeps rendering an
|
|
/// enabled "download" button for the whole rebuild. Both the nav badge and the export page already
|
|
/// refetch `/export/status` on `export-progress`, so a 0% tick is the cheapest correct signal.
|
|
pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRegen) {
|
|
for export_type in ["zip", "html"] {
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"export-progress",
|
|
serde_json::json!({ "type": export_type, "progress_pct": 0 }).to_string(),
|
|
));
|
|
}
|
|
crate::services::export::spawn_export_jobs(
|
|
regen.event_id,
|
|
regen.event_name,
|
|
regen.epoch,
|
|
state.config.comments_enabled,
|
|
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
|
|
// delay lets superseded workers fail their claim and do zero work instead of each building
|
|
// a full archive. See export::REGEN_DEBOUNCE.
|
|
crate::services::export::REGEN_DEBOUNCE,
|
|
state.pool.clone(),
|
|
state.config.media_path.clone(),
|
|
state.config.export_path.clone(),
|
|
state.sse_tx.clone(),
|
|
);
|
|
}
|
|
|
|
pub async fn host_delete_upload(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(upload_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
|
|
|
// The delete and the keepsake invalidation are ONE transaction: if the delete committed and the
|
|
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
|
|
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
|
|
let mut tx = state.pool.begin().await?;
|
|
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
|
|
if !deleted {
|
|
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
|
|
}
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::Both,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"upload-deleted",
|
|
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
|
));
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
event_id = %auth.event_id,
|
|
upload_id = %upload.id,
|
|
"host: host_delete_upload"
|
|
);
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn host_delete_comment(
|
|
State(state): State<AppState>,
|
|
RequireHost(auth): RequireHost,
|
|
Path(comment_id): Path<Uuid>,
|
|
) -> Result<StatusCode, AppError> {
|
|
let mut tx = state.pool.begin().await?;
|
|
let deleted = Comment::soft_delete_in_event(&mut tx, comment_id, auth.event_id).await?;
|
|
if !deleted {
|
|
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
|
}
|
|
// Only the HTML viewer embeds comments — the ZIP is media-only, so it is carried forward rather
|
|
// than rebuilt. Otherwise moderating one comment would 404 the photo download for minutes to
|
|
// change nothing in it.
|
|
let regen = crate::services::export::invalidate_and_arm(
|
|
&mut tx,
|
|
&state.config.event_slug,
|
|
Affects::ViewerOnly,
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
let _ = state.sse_tx.send(SseEvent::new(
|
|
"comment-deleted",
|
|
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
|
));
|
|
if let Some(r) = regen {
|
|
start_regen(&state, r);
|
|
}
|
|
tracing::info!(
|
|
actor_user_id = %auth.user_id,
|
|
event_id = %auth.event_id,
|
|
comment_id = %comment_id,
|
|
"host: host_delete_comment"
|
|
);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn close_event(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
let result = sqlx::query(
|
|
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
|
|
)
|
|
.bind(&state.config.event_slug)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
// Only broadcast when this call actually flipped the lock — closing an
|
|
// already-closed event is a no-op and shouldn't spam listeners.
|
|
if result.rows_affected() > 0 {
|
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
|
}
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn open_event(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
|
|
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
|
|
//
|
|
// ONE statement retires the entire export generation. Bumping `export_epoch` in the same write
|
|
// that clears `export_released_at` instantly invalidates every in-flight worker, every `done`
|
|
// row and all readiness — because readiness is DERIVED from this epoch (migration 014), not
|
|
// stored. There is no export_job row to touch and nothing to keep in sync, so this needs no
|
|
// transaction. Any worker still streaming holds the old epoch and is now inert: its
|
|
// epoch-guarded finalize matches nothing, and it discards its own output.
|
|
let result = sqlx::query(
|
|
"UPDATE event
|
|
SET uploads_locked_at = NULL,
|
|
export_released_at = NULL,
|
|
export_epoch = export_epoch + 1
|
|
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
|
)
|
|
.bind(&state.config.event_slug)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
|
|
if result.rows_affected() > 0 {
|
|
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
|
}
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn release_gallery(
|
|
State(state): State<AppState>,
|
|
RequireHost(_auth): RequireHost,
|
|
) -> Result<StatusCode, AppError> {
|
|
// 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:
|
|
//
|
|
// 1. Cancellation. This handler used to commit `export_released_at` and only THEN await the
|
|
// enqueue. Axum drops the handler future when the client disconnects (closed tab, proxy
|
|
// timeout), which left the event released and uploads locked with ZERO export_job rows and
|
|
// no workers: downloads 404 forever and the host cannot retry, because release_gallery
|
|
// rejects an already-released event ("bereits freigegeben"). Only a restart escaped it.
|
|
// Now nothing is committed until every row is written, and the workers are spawned AFTER
|
|
// the commit (a detached `tokio::spawn` survives cancellation).
|
|
// 2. Atomicity vs. a concurrent reopen. Bumping the epoch in the same statement that sets
|
|
// `export_released_at` means no worker and no reader can ever observe "released again but
|
|
// the generation hasn't moved on yet" — the window every previous fix kept leaving open.
|
|
//
|
|
// Release also locks uploads in the same statement (release ⇒ lock), so the export snapshot is
|
|
// taken against a frozen upload set. `COALESCE` preserves an earlier explicit lock time.
|
|
let mut tx = state.pool.begin().await?;
|
|
|
|
let claimed: Option<(Uuid, String, i64)> = sqlx::query_as(
|
|
"UPDATE event
|
|
SET export_released_at = NOW(),
|
|
uploads_locked_at = COALESCE(uploads_locked_at, NOW()),
|
|
export_epoch = export_epoch + 1
|
|
WHERE slug = $1 AND export_released_at IS NULL
|
|
RETURNING id, name, export_epoch",
|
|
)
|
|
.bind(&state.config.event_slug)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let Some((event_id, event_name, epoch)) = claimed else {
|
|
// Distinguish "no such event" from "already released" for a clean error.
|
|
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.is_some();
|
|
return Err(if exists {
|
|
AppError::BadRequest("Galerie wurde bereits freigegeben.".into())
|
|
} else {
|
|
AppError::NotFound("Event nicht gefunden.".into())
|
|
});
|
|
};
|
|
|
|
// Arm both types at THIS epoch. No "skip the type that's already ready" check any more: the
|
|
// epoch bump above retired every prior generation, so there is nothing to preserve and nothing
|
|
// to be fooled by. (That skip was how a stale ready flag used to suppress regeneration.)
|
|
crate::services::export::enqueue_jobs_at_epoch(&mut tx, event_id, epoch).await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
// Release locks uploads too — tell any open composer to flip to the locked UI live rather than
|
|
// discovering it via a rejected upload.
|
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
|
|
|
// Detached — survives this handler being cancelled.
|
|
crate::services::export::spawn_export_jobs(
|
|
event_id,
|
|
event_name,
|
|
epoch,
|
|
state.config.comments_enabled,
|
|
std::time::Duration::ZERO,
|
|
state.pool.clone(),
|
|
state.config.media_path.clone(),
|
|
state.config.export_path.clone(),
|
|
state.sse_tx.clone(),
|
|
);
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{LOW_DISK_FLOOR_BYTES, disk_is_low};
|
|
|
|
const GB: u64 = 1_000_000_000;
|
|
|
|
#[test]
|
|
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
|
|
assert!(!disk_is_low(40 * GB, 25 * GB));
|
|
}
|
|
|
|
#[test]
|
|
fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() {
|
|
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
|
|
// Postgres stops being able to write and the event goes down. A 1 GB gallery would clear
|
|
// the keepsake test comfortably; the floor is what catches this.
|
|
assert!(disk_is_low(5 * GB, GB));
|
|
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0));
|
|
assert!(!disk_is_low(LOW_DISK_FLOOR_BYTES, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn plenty_of_space_is_still_low_when_the_keepsake_would_not_fit() {
|
|
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near
|
|
// any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this
|
|
// before releasing; after releasing, they cannot.
|
|
assert!(disk_is_low(30 * GB, 66 * GB));
|
|
}
|
|
|
|
#[test]
|
|
fn the_keepsake_trigger_is_exact_at_the_boundary() {
|
|
assert!(!disk_is_low(66 * GB, 66 * GB), "exactly enough is enough");
|
|
assert!(disk_is_low(66 * GB - 1, 66 * GB));
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() {
|
|
assert!(!disk_is_low(11 * GB, 0));
|
|
assert!(disk_is_low(9 * GB, 0));
|
|
}
|
|
}
|