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

@@ -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(),
);