fix(review-2): high — read-only ban model, failed-compression cleanup, live SSE eviction

H1: corrected a round-1 over-reach. Bans are read-only per USER_JOURNEYS §10:
    the base AuthUser extractor no longer rejects banned users (they keep read
    access); Require{Host,Admin} and write handlers enforce the ban via
    auth.is_banned → 403 (incl. self-unban). Demoted hosts still lose powers
    immediately (live DB role) and are bounced off the dashboard. Also fixed the
    login/recover redirect regression on unauthenticated 401.

H2: failed compression now self-heals instead of leaving a permanent broken
    card — refund the quota, delete the orphaned original, soft-delete the row;
    the frontend toasts the uploader and evicts the card on upload-error.

H3: self-delete, ban-with-hide (user-hidden), and comment-deletes now broadcast
    SSE; feed, diashow, and lightbox evict the affected content live instead of
    waiting for a reload. sse-eviction.spec.ts covers it.

Riders: feed/+page.svelte also carries the medium truncated-delta pill; host.rs
also carries the low atomic release_gallery claim; admin/+page.svelte also drops
the dead compression_concurrency control (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-02 22:19:43 +02:00
parent dba4d3f932
commit 80179357a7
13 changed files with 245 additions and 20 deletions

View File

@@ -13,6 +13,10 @@ pub struct AuthUser {
pub user_id: Uuid,
pub event_id: Uuid,
pub role: UserRole,
/// Live ban flag. Banned users keep *read* access (per USER_JOURNEYS §10), so
/// the base extractor does NOT reject them — write handlers and the
/// Require{Host,Admin} extractors enforce the ban instead.
pub is_banned: bool,
pub token_hash: String,
}
@@ -45,15 +49,13 @@ impl FromRequestParts<AppState> for AuthUser {
// Trust *live* DB state, not the JWT: a role/ban stored in the token would
// survive a demote/ban for the full session lifetime (up to 30d). Re-read
// the user row so a demoted host loses host powers and a banned user is
// locked out immediately on their next request.
// the user row so a demoted host loses host powers immediately. We do NOT
// reject banned users here — they retain read access by design; writes and
// host/admin actions enforce the ban downstream.
let user = crate::models::user::User::find_by_id(&state.pool, claims.sub)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Dein Zugang wurde gesperrt.".into()));
}
// Update last_seen_at in the background (fire-and-forget). Failures are
// non-fatal but worth surfacing — silent swallowing hides DB connection
@@ -70,6 +72,7 @@ impl FromRequestParts<AppState> for AuthUser {
user_id: user.id,
event_id: user.event_id,
role: user.role,
is_banned: user.is_banned,
token_hash,
})
}
@@ -86,6 +89,9 @@ impl FromRequestParts<AppState> for RequireHost {
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
match auth.role {
UserRole::Host | UserRole::Admin => Ok(Self(auth)),
_ => Err(AppError::Forbidden("Nur für Hosts und Admins.".into())),
@@ -104,6 +110,9 @@ impl FromRequestParts<AppState> for RequireAdmin {
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
match auth.role {
UserRole::Admin => Ok(Self(auth)),
_ => Err(AppError::Forbidden("Nur für Admins.".into())),

View File

@@ -121,6 +121,15 @@ pub async fn ban_user(
.execute(&state.pool)
.await?;
// If we hid their uploads, evict them live from every feed + the diashow so a
// banned guest's content disappears without each viewer having to reload.
if body.hide_uploads {
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,
@@ -329,6 +338,10 @@ pub async fn host_delete_comment(
if !deleted {
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
}
let _ = state.sse_tx.send(SseEvent::new(
"comment-deleted",
serde_json::json!({ "comment_id": comment_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
@@ -380,19 +393,33 @@ pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
// release calls can't both pass a check-then-set and double-enqueue exports.
// rows_affected == 0 means someone already released.
let result = sqlx::query(
"UPDATE event SET export_released_at = NOW()
WHERE slug = $1 AND export_released_at IS NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
// 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())
});
}
// We won the claim — load the event for its id/name to enqueue export jobs.
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.export_released_at.is_some() {
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
}
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Enqueue export jobs
for export_type in ["zip", "html"] {
sqlx::query(
@@ -411,6 +438,7 @@ pub async fn release_gallery(
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
state.sse_tx.clone(),
);

View File

@@ -213,5 +213,9 @@ pub async fn delete_comment(
if !deleted {
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
}
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"comment-deleted",
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
));
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -313,6 +313,14 @@ pub async fn delete_upload(
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
// Evict the card live on every other feed + the projector diashow — otherwise
// a self-deleted post lingers until each viewer manually reloads. Same event
// the host-delete path already emits and the frontend already handles.
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -42,11 +42,28 @@ impl CompressionWorker {
}
Err(e) => {
tracing::error!("compression failed for upload {upload_id}: {e:#}");
// Auto-cleanup: a failed transcode would otherwise leave a
// permanently broken feed card, silently charge the uploader's
// quota, and orphan the original on disk. Refund + soft-delete
// (one tx, so v_feed excludes it), remove the orphan file, then
// tell the uploader (upload-error toast) and evict the card
// everywhere (upload-deleted, already handled by the feed).
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
}
let orphan = worker.media_path.join(&original_path);
if let Err(rm) = tokio::fs::remove_file(&orphan).await {
tracing::warn!(error = ?rm, path = %orphan.display(), "failed to remove orphaned original");
}
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-error".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
});
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-deleted".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
}
}
});

View File

@@ -97,4 +97,27 @@ test.describe('Host — live role/ban revocation (H1)', () => {
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
).rejects.toThrow(/→ 403/);
});
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
// NOT in the base extractor (which would wrongly block reads too).
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest }) => {
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const target = await guest('BannedRW');
await api.banUser(host.jwt, target.userId, false);
// Reads still succeed.
const read = await fetch(base + '/api/v1/me/context', {
headers: { Authorization: `Bearer ${target.jwt}` },
});
expect(read.status).toBe(200);
// Writes are rejected.
const write = await fetch(base + '/api/v1/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${target.jwt}` },
body: new FormData(),
});
expect(write.status).toBe(403);
});
});

View File

@@ -0,0 +1,51 @@
/**
* Regression for the review's H3: self-deleting an upload and banning a user with
* hide_uploads mutated visibility server-side but broadcast nothing, so the
* content lingered on every other viewer's feed (and the projector diashow) until
* a manual reload. Both now emit SSE so clients evict live.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { SseListener } from '../../helpers/sse-listener';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
test.describe('Host — live SSE eviction (H3)', () => {
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
const g = await guest('SelfDeleter');
const uploadId = await seedUpload(g.jwt, { caption: 'delete me' });
const sse = new SseListener();
await sse.start(g.jwt);
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(res.status).toBe(204);
await sse.waitForEvent(
'upload-deleted',
(e) => e.data.upload_id === uploadId
);
});
test('banning a user with hide_uploads broadcasts user-hidden', async ({
api,
host,
guest,
}) => {
const target = await guest('HideTarget');
await seedUpload(target.jwt, { caption: 'should vanish' });
const sse = new SseListener();
await sse.start(host.jwt);
await api.banUser(host.jwt, target.userId, true);
await sse.waitForEvent(
'user-hidden',
(e) => e.data.user_id === target.userId
);
});
});

View File

@@ -68,6 +68,9 @@ async function request<T>(
}
if (!res.ok) {
// An expired/invalid token (401) clears the dead session. Banned users are
// NOT logged out — they keep read access by design (USER_JOURNEYS §10) and
// simply get a 403 "gesperrt" toast on writes.
if (res.status === 401) {
clearAuth();
}

View File

@@ -73,6 +73,23 @@ export class SlideQueue {
return { wasCurrent: currentId === id };
}
/**
* Remove every slide belonging to a user (banned with hide_uploads). Returns
* true if the current head was one of them so the caller advances immediately.
*/
removeByUser(userId: string, currentId: string | null): { wasCurrent: boolean } {
let wasCurrent = false;
for (const [id, slide] of this.allKnown) {
if (slide.user_id === userId) {
if (id === currentId) wasCurrent = true;
this.allKnown.delete(id);
}
}
this.liveQueue = this.liveQueue.filter((s) => s.user_id !== userId);
this.shuffleQueue = this.shuffleQueue.filter((s) => s.user_id !== userId);
return { wasCurrent };
}
/** Look up a slide by id — for the diashow page to render the current slide. */
get(id: string): FeedUpload | undefined {
return this.allKnown.get(id);

View File

@@ -57,8 +57,9 @@
title: 'Limits & Größen',
fields: [
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' },
{ key: 'compression_concurrency', label: 'Kompressions-Worker', kind: 'number' }
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' }
// compression_concurrency is set via COMPRESSION_WORKER_CONCURRENCY at
// boot, not live — omitted so it isn't a dead no-op control.
]
},
{

View File

@@ -99,6 +99,16 @@
}
}
function handleUserHidden(data: string) {
try {
const payload = JSON.parse(data) as { user_id: string };
const result = queue.removeByUser(payload.user_id, current?.id ?? null);
if (result.wasCurrent) advance();
} catch {
// ignore
}
}
function revealOverlay() {
showOverlay = true;
if (overlayHideTimer) clearTimeout(overlayHideTimer);
@@ -145,6 +155,7 @@
void acquireWakeLock();
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
void loadInitial();
});

View File

@@ -27,6 +27,9 @@
let refreshing = $state(false);
let pullProgress = $state(0); // 01+ during the drag, 0 when idle
let selectedUpload = $state<FeedUpload | null>(null);
// Set when a truncated feed-delta means we missed too much to merge — shows a
// tap-to-refresh pill instead of yanking the user's scroll to page 1.
let feedStale = $state(false);
let sentinel: HTMLDivElement;
let feedObserver: IntersectionObserver | null = null;
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
@@ -196,6 +199,28 @@
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
} catch { /* ignore */ }
}),
// A background transcode failed: the backend already cleaned up (refunded
// quota, removed the row) and an upload-deleted evicts the card. Only the
// uploader gets a toast — registered before upload-deleted so the card is
// still present to check ownership.
onSseEvent('upload-error', (data) => {
try {
const { upload_id } = JSON.parse(data) as { upload_id: string };
const mine = uploads.find((u) => u.id === upload_id && u.user_id === myUserId);
if (mine) {
toast('Ein Upload konnte nicht verarbeitet werden.', 'error');
void refreshQuota();
}
} catch { /* ignore */ }
}),
// A banned user's uploads were hidden — drop all their cards live.
onSseEvent('user-hidden', (data) => {
try {
const { user_id } = JSON.parse(data) as { user_id: string };
uploads = uploads.filter((u) => u.user_id !== user_id);
if (selectedUpload && selectedUpload.user_id === user_id) selectedUpload = null;
} catch { /* ignore */ }
}),
// Patch the single affected card in place from the SSE payload instead of
// refetching page 1 — a busy event fires these constantly and a full reload
// would yank every scrolled-down user back to the top on each reaction.
@@ -210,7 +235,9 @@
// Missed more than the backend delta cap while backgrounded — the
// delta is only the newest slice, so merging would leave a silent gap
// of older-but-still-new uploads. Resync from page 1 instead.
void loadFeed(true);
// Rather than involuntarily yank the user to page 1, surface a
// tap-to-refresh pill so they keep scroll until they resync.
feedStale = true;
return;
}
if (delta.uploads.length) {
@@ -440,6 +467,17 @@
</div>
</div>
{/if}
{#if feedStale}
<div class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center">
<button
type="button"
class="pointer-events-auto rounded-full bg-blue-600 px-4 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-blue-700"
onclick={() => { feedStale = false; void loadFeed(true); }}
>
Neue Beiträge tippen zum Aktualisieren
</button>
</div>
{/if}
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900">
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { onMount } from 'svelte';
import { toast, toastError } from '$lib/toast-store';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
@@ -86,8 +87,22 @@
onMount(async () => {
const token = getToken();
const role = getRole();
if (!token || (role !== 'host' && role !== 'admin')) {
if (!token) {
goto('/join');
return;
}
// Trust the *live* role, not the JWT claim: a mid-session demote leaves a
// stale 'host' in the token, but the backend now 403s every host call. Fetch
// the current role and bounce a demoted host to the feed instead of leaving
// them on a dashboard that errors on load.
try {
const ctx = await api.get<MeContextDto>('/me/context');
if (ctx.role !== 'host' && ctx.role !== 'admin') {
goto('/feed');
return;
}
} catch {
// Expired/invalid session (api.ts cleared it) — send them to re-auth.
goto('/join');
return;
}