Merge branch 'chore/deploy-readiness-2026-07-14'
This commit is contained in:
73
.github/workflows/audit.yml
vendored
Normal file
73
.github/workflows/audit.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Dependency advisory scan.
|
||||||
|
#
|
||||||
|
# Lives in .github/workflows/ because Gitea Actions scans BOTH .gitea/workflows and
|
||||||
|
# .github/workflows, and e2e.yml already proves this instance resolves `actions/*` from GitHub
|
||||||
|
# (DEFAULT_ACTIONS_URL). Keeping one directory avoids a split where half the CI is invisible
|
||||||
|
# depending on which convention you look under.
|
||||||
|
#
|
||||||
|
# Unlike the GitHub-hosted runners this workflow does NOT assume a preinstalled Rust toolchain —
|
||||||
|
# the common Gitea runner images (gitea/runner-images, catthehacker/ubuntu) ship Node and Docker
|
||||||
|
# but not cargo. So we install it explicitly instead of relying on the ambient environment.
|
||||||
|
name: Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
# New advisories land against unchanged code, so a push-only trigger would never see them.
|
||||||
|
- cron: '0 6 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cargo-audit:
|
||||||
|
name: cargo audit (backend)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
run: |
|
||||||
|
if ! command -v cargo > /dev/null; then
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/bin
|
||||||
|
key: cargo-audit-${{ hashFiles('backend/Cargo.lock') }}
|
||||||
|
restore-keys: cargo-audit-
|
||||||
|
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit --locked || true
|
||||||
|
|
||||||
|
# `rsa` 0.9 (RUSTSEC-2023-0071, "Marvin") is a transitive dep of the SQLx MySQL driver that
|
||||||
|
# this app never exercises: auth is HS256 + bcrypt, and the only database is Postgres. There
|
||||||
|
# is no patched release, so failing the build on it would mean a permanently red pipeline
|
||||||
|
# that everyone learns to ignore — which is worse than not scanning at all. Ignore it
|
||||||
|
# SPECIFICALLY, so that any OTHER advisory still fails the job.
|
||||||
|
- name: Audit
|
||||||
|
working-directory: ./backend
|
||||||
|
run: cargo audit --deny warnings --ignore RUSTSEC-2023-0071
|
||||||
|
|
||||||
|
npm-audit:
|
||||||
|
name: npm audit (frontend)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npm install
|
||||||
|
# Production dependencies only: a dev-only advisory (build tooling, test runners) can't be
|
||||||
|
# reached by a guest at the party, and gating merges on it just trains people to skip the gate.
|
||||||
|
- name: Audit
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: npm audit --omit=dev --audit-level=high
|
||||||
195
backend/scripts/rehearse-014.sh
Executable file
195
backend/scripts/rehearse-014.sh
Executable file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Dress rehearsal for migration 014 (export_epoch) — the repo's first DESTRUCTIVE migration.
|
||||||
|
#
|
||||||
|
# 014 DROPs event.export_zip_ready / export_html_ready and rewrites export_job.release_seq into an
|
||||||
|
# epoch. The dangerous part is not the DDL, it's the BACKFILL: it has to carry the old notion of
|
||||||
|
# "downloadable" across exactly. Get it wrong and a released event's keepsake silently 404s for
|
||||||
|
# every guest, on a dataset you cannot re-create.
|
||||||
|
#
|
||||||
|
# This script proves the backfill preserves downloadability, against a scratch copy of a REAL dump:
|
||||||
|
#
|
||||||
|
# ./rehearse-014.sh /path/to/prod-dump.sql # rehearse against production data
|
||||||
|
# ./rehearse-014.sh # synthetic: build a pre-014 DB covering every case
|
||||||
|
#
|
||||||
|
# It asserts:
|
||||||
|
# 1. up: {(event,type) downloadable BEFORE} == {(event,type) downloadable AFTER}
|
||||||
|
# 2. down: the old ready flags come back identical (so a rollback is actually a rollback)
|
||||||
|
# 3. up again: still identical (so a roll-forward after a rollback is safe)
|
||||||
|
#
|
||||||
|
# It NEVER touches your real database. Everything runs in a throwaway container.
|
||||||
|
#
|
||||||
|
# NOTE ON ROLLBACK (see the runbook header in 014_export_epoch.up.sql): sqlx runs migrations before
|
||||||
|
# serving and errors with VersionMissing on an unknown version, so the PREVIOUS image will not boot
|
||||||
|
# against the 014 schema — it crash-loops. Rolling back means running 014_export_epoch.down.sql BY
|
||||||
|
# HAND FIRST, then deploying the old image. Assertion 2 is what makes that safe. Rehearse it before
|
||||||
|
# you need it, not while the party is happening.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DUMP="${1:-}"
|
||||||
|
MIG_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../migrations" && pwd)"
|
||||||
|
CONTAINER="eventsnap-rehearse-014"
|
||||||
|
PGPASSWORD="rehearse"
|
||||||
|
DB="eventsnap_rehearsal"
|
||||||
|
|
||||||
|
cleanup() { docker rm -f "$CONTAINER" > /dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
echo "▸ Starting a throwaway postgres:16 (nothing here touches your real DB)…"
|
||||||
|
docker run --rm -d --name "$CONTAINER" \
|
||||||
|
-e POSTGRES_PASSWORD="$PGPASSWORD" -e POSTGRES_DB="$DB" \
|
||||||
|
postgres:16-alpine > /dev/null
|
||||||
|
|
||||||
|
psql() { docker exec -i -e PGPASSWORD="$PGPASSWORD" "$CONTAINER" psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 "$@"; }
|
||||||
|
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if docker exec "$CONTAINER" pg_isready -U postgres > /dev/null 2>&1; then break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$DUMP" ]]; then
|
||||||
|
echo "▸ Restoring $DUMP …"
|
||||||
|
psql -q < "$DUMP"
|
||||||
|
else
|
||||||
|
echo "▸ No dump given — building a synthetic pre-014 DB (migrations 001→013)…"
|
||||||
|
for f in "$MIG_DIR"/0[0-1][0-9]_*.up.sql; do
|
||||||
|
[[ "$(basename "$f")" == 014_* ]] && continue
|
||||||
|
psql -q < "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Every state the backfill has to classify. If 014 mishandles ANY of these, assertion 1 fails.
|
||||||
|
# Every state the backfill has to classify. export_job is UNIQUE (event_id, type), so each event
|
||||||
|
# has at most one job per type — the cases are distinguished by event, not by piling up rows.
|
||||||
|
# A: released, both flags, both done → both stay downloadable
|
||||||
|
# B: released, ZIP flag only → ZIP downloadable, HTML not (a half-built keepsake)
|
||||||
|
# C: released, jobs done, NO flags → NOT downloadable (a superseded worker's finished job:
|
||||||
|
# the exact state the old ready-flag model used to leak)
|
||||||
|
# D: never released, jobs done → NOT downloadable (reopened, or built before release)
|
||||||
|
# E: released, ZIP flag set but the job is FAILED/RUNNING → NOT downloadable (flag/job disagree,
|
||||||
|
# which the old two-source model made representable at all)
|
||||||
|
echo "▸ Seeding: released+both, zip-only, done-but-stale, unreleased, flag/job-disagreement…"
|
||||||
|
psql -q <<'SQL'
|
||||||
|
INSERT INTO event (id, slug, name, export_released_at, export_zip_ready, export_html_ready) VALUES
|
||||||
|
('aaaaaaaa-0000-0000-0000-000000000001','ev-a','A', now(), TRUE, TRUE),
|
||||||
|
('aaaaaaaa-0000-0000-0000-000000000002','ev-b','B', now(), TRUE, FALSE),
|
||||||
|
('aaaaaaaa-0000-0000-0000-000000000003','ev-c','C', now(), FALSE, FALSE),
|
||||||
|
('aaaaaaaa-0000-0000-0000-000000000004','ev-d','D', NULL, FALSE, FALSE),
|
||||||
|
('aaaaaaaa-0000-0000-0000-000000000005','ev-e','E', now(), TRUE, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO export_job (event_id, type, status, progress_pct, file_path, release_seq)
|
||||||
|
SELECT e.id, t::export_type, 'done'::export_status, 100, '/x/' || e.slug || '.' || t, 0
|
||||||
|
FROM event e CROSS JOIN (VALUES ('zip'),('html')) AS v(t);
|
||||||
|
|
||||||
|
-- E: the flags claim ready, the jobs say otherwise. Old model required BOTH, so neither is
|
||||||
|
-- downloadable — the migration must not "trust the flag" and resurrect them.
|
||||||
|
UPDATE export_job SET status = 'failed', file_path = NULL, progress_pct = 40
|
||||||
|
WHERE event_id = 'aaaaaaaa-0000-0000-0000-000000000005' AND type = 'zip';
|
||||||
|
UPDATE export_job SET status = 'running', file_path = NULL, progress_pct = 70
|
||||||
|
WHERE event_id = 'aaaaaaaa-0000-0000-0000-000000000005' AND type = 'html';
|
||||||
|
SQL
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▸ Snapshotting what is downloadable under the OLD model…"
|
||||||
|
psql -q <<'SQL'
|
||||||
|
CREATE TABLE _rehearsal_before AS
|
||||||
|
SELECT j.event_id, j.type
|
||||||
|
FROM export_job j JOIN event e ON e.id = j.event_id
|
||||||
|
WHERE e.export_released_at IS NOT NULL
|
||||||
|
AND j.status = 'done'
|
||||||
|
AND ((j.type = 'zip' AND e.export_zip_ready) OR (j.type = 'html' AND e.export_html_ready));
|
||||||
|
|
||||||
|
-- For assertion 2: the exact flags a rollback has to reproduce.
|
||||||
|
CREATE TABLE _rehearsal_flags AS
|
||||||
|
SELECT id, export_zip_ready, export_html_ready FROM event;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
before=$(psql -tAc "SELECT count(*) FROM _rehearsal_before")
|
||||||
|
echo " → $before (event,type) pair(s) downloadable before."
|
||||||
|
|
||||||
|
# The assertion. A symmetric difference of zero means the backfill carried the old notion of
|
||||||
|
# "downloadable" across EXACTLY: nothing gained (a stale keepsake resurrected), nothing lost (a
|
||||||
|
# guest's keepsake silently 404s).
|
||||||
|
assert_up_preserves() {
|
||||||
|
local label="$1" diff
|
||||||
|
diff=$(psql -tAc "
|
||||||
|
SELECT count(*) FROM (
|
||||||
|
(SELECT event_id, type FROM _rehearsal_before
|
||||||
|
EXCEPT SELECT event_id, type FROM export_current WHERE status = 'done')
|
||||||
|
UNION ALL
|
||||||
|
(SELECT event_id, type FROM export_current WHERE status = 'done'
|
||||||
|
EXCEPT SELECT event_id, type FROM _rehearsal_before)
|
||||||
|
) d")
|
||||||
|
if [[ "$diff" != "0" ]]; then
|
||||||
|
echo "✗ FAIL ($label): $diff (event,type) pair(s) changed downloadability across the migration."
|
||||||
|
psql -c "
|
||||||
|
(SELECT 'LOST — guests can no longer download this' AS problem, event_id, type FROM _rehearsal_before
|
||||||
|
EXCEPT ALL SELECT 'LOST — guests can no longer download this', event_id, type FROM export_current WHERE status='done')
|
||||||
|
UNION ALL
|
||||||
|
(SELECT 'GAINED — a stale keepsake became downloadable', event_id, type FROM export_current WHERE status='done'
|
||||||
|
EXCEPT ALL SELECT 'GAINED — a stale keepsake became downloadable', event_id, type FROM _rehearsal_before)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ $label: downloadability preserved exactly ($before pair(s))."
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "▸ Applying 014 (up)…"
|
||||||
|
psql -q < "$MIG_DIR/014_export_epoch.up.sql"
|
||||||
|
assert_up_preserves "up"
|
||||||
|
|
||||||
|
echo "▸ Applying 014 (down) — the rollback path…"
|
||||||
|
psql -q < "$MIG_DIR/014_export_epoch.down.sql"
|
||||||
|
|
||||||
|
# The down migration is an inverse UP TO DRIFT, not a bit-exact inverse — deliberately.
|
||||||
|
#
|
||||||
|
# The old model let a ready flag disagree with its job row (flag TRUE over a running/failed job):
|
||||||
|
# the flag was a CACHED copy of a derivation, and keeping a cache in agreement with its source
|
||||||
|
# across concurrent workers is precisely what it kept failing at. The down migration re-derives the
|
||||||
|
# flags from the epoch state, so such a pair comes back FALSE. That HEALS the drift rather than
|
||||||
|
# faithfully restoring corruption, and it costs nothing: a flag over a non-done job had no file to
|
||||||
|
# serve (file_path is NULL until the worker finishes), so the old code 404'd on it anyway.
|
||||||
|
#
|
||||||
|
# What a rollback must NOT do is drop a flag for a keepsake that was genuinely downloadable
|
||||||
|
# (flag set AND the job actually done). That is the assertion.
|
||||||
|
lost=$(psql -tAc "
|
||||||
|
SELECT count(*) FROM _rehearsal_before b
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM event e
|
||||||
|
WHERE e.id = b.event_id
|
||||||
|
AND ((b.type = 'zip' AND e.export_zip_ready) OR (b.type = 'html' AND e.export_html_ready)))")
|
||||||
|
if [[ "$lost" != "0" ]]; then
|
||||||
|
echo "✗ FAIL (down): $lost downloadable keepsake(s) lost their ready flag — the rollback is LOSSY."
|
||||||
|
psql -c "
|
||||||
|
SELECT b.event_id, b.type, 'was downloadable, flag not restored' AS problem
|
||||||
|
FROM _rehearsal_before b
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM event e
|
||||||
|
WHERE e.id = b.event_id
|
||||||
|
AND ((b.type = 'zip' AND e.export_zip_ready) OR (b.type = 'html' AND e.export_html_ready)))"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ down: every downloadable keepsake kept its flag — the old image sees a working state."
|
||||||
|
|
||||||
|
healed=$(psql -tAc "
|
||||||
|
SELECT count(*) FROM event e JOIN _rehearsal_flags f ON f.id = e.id
|
||||||
|
WHERE (e.export_zip_ready, e.export_html_ready) IS DISTINCT FROM (f.export_zip_ready, f.export_html_ready)")
|
||||||
|
if [[ "$healed" != "0" ]]; then
|
||||||
|
echo " ℹ $healed event(s) came back with a CLEARED flag that had drifted from its job row."
|
||||||
|
echo " Not a loss — those had no file to serve. The rollback normalises them; the old code"
|
||||||
|
echo " will rebuild on the next release. Listing them so the behaviour is not a surprise:"
|
||||||
|
psql -c "SELECT e.id, f.export_zip_ready AS was_zip, e.export_zip_ready AS now_zip,
|
||||||
|
f.export_html_ready AS was_html, e.export_html_ready AS now_html
|
||||||
|
FROM event e JOIN _rehearsal_flags f ON f.id = e.id
|
||||||
|
WHERE (e.export_zip_ready, e.export_html_ready)
|
||||||
|
IS DISTINCT FROM (f.export_zip_ready, f.export_html_ready)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▸ Re-applying 014 (up) — roll forward after a rollback…"
|
||||||
|
psql -q < "$MIG_DIR/014_export_epoch.up.sql"
|
||||||
|
assert_up_preserves "up (after rollback)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "✓ Rehearsal passed. 014 is safe to deploy against this dataset."
|
||||||
|
[[ -z "$DUMP" ]] && echo " (synthetic data — re-run with a real pg_dump before you deploy for real)"
|
||||||
|
exit 0
|
||||||
@@ -446,4 +446,69 @@ test.describe('Flow re-review — reopen→re-release export integrity + stale-k
|
|||||||
|
|
||||||
sse.stop();
|
sse.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a FAILED keepsake is recoverable via rebuild — without reopening uploads to guests', async ({
|
||||||
|
host,
|
||||||
|
}) => {
|
||||||
|
// The escape hatch. A failed export used to be TERMINAL at runtime: `release_gallery` 409s on an
|
||||||
|
// already-released event, `recover_exports` only runs at boot, and nothing else retried. The
|
||||||
|
// host's only outs were restarting the container or REOPENING the event — which unlocks uploads
|
||||||
|
// to every guest and retracts the release, i.e. the cure was worse than the disease. (The host
|
||||||
|
// page literally used to instruct exactly that.)
|
||||||
|
//
|
||||||
|
// What this pins is not just "rebuild works", but that recovery is NON-DESTRUCTIVE: the event
|
||||||
|
// stays released and uploads stay locked across it. If a future refactor implements rebuild as
|
||||||
|
// an internal reopen+re-release, those two assertions fail.
|
||||||
|
await seedUpload(host.jwt, { caption: 'keepsake' });
|
||||||
|
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
|
||||||
|
await waitExportDone(host.jwt);
|
||||||
|
|
||||||
|
// Simulate the worker having died: force the job terminal-failed, the state the host is stuck in.
|
||||||
|
await pgQuery(
|
||||||
|
`UPDATE export_job ej SET status = 'failed', file_path = NULL, progress_pct = 40
|
||||||
|
FROM event e WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
|
||||||
|
);
|
||||||
|
|
||||||
|
const failed = await exportStatus(host.jwt);
|
||||||
|
expect(failed.released).toBe(true);
|
||||||
|
expect(failed.zip.status).toBe('failed');
|
||||||
|
|
||||||
|
// Stuck: the keepsake is not downloadable and no amount of re-releasing helps.
|
||||||
|
const ticket = await mintTicket(host.jwt);
|
||||||
|
expect((await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket))).status).toBe(404);
|
||||||
|
// ("Galerie wurde bereits freigegeben." — this is the dead end the rebuild endpoint exists for.)
|
||||||
|
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(400);
|
||||||
|
|
||||||
|
const epochBefore = await eventEpoch();
|
||||||
|
|
||||||
|
// The escape hatch.
|
||||||
|
expect((await post('/api/v1/host/export/rebuild', host.jwt)).status).toBe(204);
|
||||||
|
await waitExportDone(host.jwt);
|
||||||
|
|
||||||
|
// Recovered — and the keepsake is real, not an empty shell.
|
||||||
|
expect(await downloadZipEntries(host.jwt)).not.toHaveLength(0);
|
||||||
|
|
||||||
|
// ...and it cost the guests nothing: still released, still locked.
|
||||||
|
const [ev] = await pgQuery<{ released: boolean; locked: boolean }>(
|
||||||
|
`SELECT export_released_at IS NOT NULL AS released, uploads_locked_at IS NOT NULL AS locked
|
||||||
|
FROM event WHERE slug = '${SLUG}'`
|
||||||
|
);
|
||||||
|
expect(ev.released).toBe(true);
|
||||||
|
expect(ev.locked).toBe(true);
|
||||||
|
|
||||||
|
// A rebuild is a new generation, so any worker still alive from the failed one is inert.
|
||||||
|
expect(await eventEpoch()).toBeGreaterThan(epochBefore);
|
||||||
|
expect(await zipJobEpoch()).toBe(await eventEpoch());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rebuild is rejected when the gallery was never released', async ({ host }) => {
|
||||||
|
// Nothing to rebuild — and, more to the point, rebuild must not become a back door that
|
||||||
|
// publishes a keepsake for an event the host never released.
|
||||||
|
const res = await post('/api/v1/host/export/rebuild', host.jwt);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const [ev] = await pgQuery<{ released: boolean }>(
|
||||||
|
`SELECT export_released_at IS NOT NULL AS released FROM event WHERE slug = '${SLUG}'`
|
||||||
|
);
|
||||||
|
expect(ev.released).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
let users = $state<UserSummary[]>([]);
|
let users = $state<UserSummary[]>([]);
|
||||||
let pinResetRequests = $state<PinResetRequest[]>([]);
|
let pinResetRequests = $state<PinResetRequest[]>([]);
|
||||||
let exportInfo = $state<ExportStatusDto | null>(null);
|
let exportInfo = $state<ExportStatusDto | null>(null);
|
||||||
|
let rebuilding = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -291,6 +292,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The escape hatch for a keepsake that is failed or stale. Without this the only recovery is
|
||||||
|
// reopening uploads (which unlocks the gallery to every guest and retracts the release) or a
|
||||||
|
// container restart — neither of which a host at 2am can reasonably be asked to do.
|
||||||
|
async function rebuildExport() {
|
||||||
|
rebuilding = true;
|
||||||
|
try {
|
||||||
|
await api.post('/host/export/rebuild', {});
|
||||||
|
toast('Keepsake wird neu erstellt…', 'success');
|
||||||
|
await refreshExportStatus();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
rebuilding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openBanModal(user: UserSummary) {
|
function openBanModal(user: UserSummary) {
|
||||||
banTarget = user;
|
banTarget = user;
|
||||||
}
|
}
|
||||||
@@ -598,10 +615,38 @@
|
|||||||
{:else if exportReady}
|
{:else if exportReady}
|
||||||
<p class="flex items-center justify-between gap-2">
|
<p class="flex items-center justify-between gap-2">
|
||||||
<span class="font-medium text-green-700 dark:text-green-300">Keepsake ist bereit.</span>
|
<span class="font-medium text-green-700 dark:text-green-300">Keepsake ist bereit.</span>
|
||||||
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400">Zum Download</a>
|
<span class="flex items-center gap-3">
|
||||||
|
<!-- Rebuilding a READY keepsake is disruptive: guests who tap Download during the
|
||||||
|
rebuild get nothing until it finishes. Worth a confirm, unlike the failed case. -->
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmAction = {
|
||||||
|
title: 'Keepsake neu erstellen?',
|
||||||
|
message:
|
||||||
|
'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
|
||||||
|
'Während der Erstellung können Gäste es nicht herunterladen.',
|
||||||
|
confirmLabel: 'Neu erstellen',
|
||||||
|
tone: 'danger',
|
||||||
|
run: rebuildExport
|
||||||
|
})}
|
||||||
|
disabled={rebuilding}
|
||||||
|
data-testid="export-rebuild"
|
||||||
|
class="font-medium text-gray-500 underline disabled:opacity-50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Neu erstellen
|
||||||
|
</button>
|
||||||
|
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400">Zum Download</a>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen. Öffne die Uploads erneut und gib die Galerie neu frei.</p>
|
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
|
||||||
|
<button
|
||||||
|
onclick={rebuildExport}
|
||||||
|
disabled={rebuilding}
|
||||||
|
data-testid="export-rebuild"
|
||||||
|
class="mt-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400"
|
||||||
|
>
|
||||||
|
{rebuilding ? 'Wird gestartet…' : 'Erneut versuchen'}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user