Files
EventSnap/backend/src/handlers/social.rs
fabi 9666d74a46 fix(export): close all 13 findings from the multi-agent review
A 6-lens multi-agent review (103 agents; every finding adversarially verified by three
refute-by-default skeptics) confirmed the epoch redesign is sound — the core invariant
holds — and found 13 real defects around it. All are fixed here.

The redesign's invariant was re-verified and stands: an accepted upload is always in the
keepsake, and a downloadable keepsake always reflects the most recent release.

But a WEAKER adjacent invariant did NOT hold — "a downloadable keepsake reflects the
current content" — and that is the theme of the biggest fixes.

CONTENT REMOVAL NOW ALWAYS REACHES THE KEEPSAKE
  Regeneration was wired only to host deletes. Ban, unban, guest self-delete and guest
  comment-delete did not trigger it — yet the export query filters `is_banned = FALSE`,
  so the pipeline already agreed that content must not be there. Ban someone for abusive
  content after release and every guest kept downloading an archive containing it.
  All five removal paths now invalidate and rebuild. `query_comments` also gained the
  banned/hidden filter it was missing entirely (the ZIP had it; the viewer did not).

  Each removal is now ATOMIC with its invalidation (one tx, models made executor-generic).
  Previously the delete committed in its own tx and the regeneration in a second: a dropped
  handler future between them left the taken-down photo permanently downloadable, and
  nothing could notice — the keepsake still looked complete and the host could no longer
  even find the upload to retry.

NO MORE TAKEDOWN STORM
  Every removal spawned two uncancellable full exports. A superseded worker was INERT, not
  STOPPED: it still ran every ffmpeg spawn and image resize and wrote a whole archive before
  discovering it had lost. Five deletes left ten workers alive, each holding a
  full-gallery-sized temp, in a 1 GB container.
  Now: a debounce before `claim_job` (a superseded worker fails its claim and does ZERO
  work, collapsing a burst into one export), plus `update_progress` — which already ran
  exactly the right liveness predicate and threw the answer away — returning it so both file
  loops bail the instant they are retired. Moderating a comment no longer rebuilds the
  multi-GB ZIP either: the ZIP is media-only, so it is carried forward, with prune taught to
  protect any file a live row still references.

AN ESCAPE HATCH
  A failed export was terminal at runtime: release_gallery refuses an already-released event,
  recovery only runs at boot, and there was no retry route — the only outs were restarting the
  container or reopening uploads to every guest. Added POST /host/export/rebuild. Also widened
  mark_failed's guard to `status IN ('running','pending')`: when claim_job itself ERRORED, the
  row was still `pending`, so the failure was never recorded and the job sat at 0% forever.

SILENT INVALIDATION
  Retiring the epoch 404s the download instantly, but nothing told the clients — guests kept
  seeing an enabled download button through the whole rebuild. Now broadcasts an invalidation.
  And the download was a top-level <a href>: a 404 (or a 429 from the per-IP export limit that
  everyone on the venue WiFi shares) NAVIGATED THE USER OUT OF THE PWA onto raw JSON. It now
  targets a hidden iframe, so an error response is harmless.

ALSO
  - The HTML export still had the exists()-then-open TOCTOU the ZIP path was fixed to remove,
    at three sites, two with a hard `?` that failed the ENTIRE viewer. It is reachable: the
    compression worker hard-deletes an original when a transcode fails and can still be running
    when the gallery is released.
  - Crash-orphaned .tmp files were immortal (prune deliberately skips .tmp). Swept at boot,
    where it is unambiguously safe.
  - export_status read the epoch in one statement and used it in the next; now one query.
  - DiskCache::snapshot IGNORED its path argument on a cache hit, returning whatever filesystem
    was measured last. Latent only because both callers pass media_path — it would have silently
    misreported the moment anyone measured the exports volume. Now keyed by path.
  - Corrected two comments that asserted safety properties the code does not have (claim_job
    does NOT prevent a retired-epoch claim — retirement is enforced at READ time; and a
    multi-replica reap is NOT contained, it can corrupt the keepsake). Added a rollback runbook
    to migration 014, the repo's first destructive migration.

TESTS
  The regression guard for the headline FOR SHARE fix was probabilistic — it could pass on
  broken code if the interleaving fell the wrong way. It is now STRUCTURAL: the upload is held
  open mid-body with its pre-flight check already passed, so the release provably lands inside
  the exact window. Verified 5/5 FAILING (201 instead of 403) with the fix reverted, 5/5
  passing with it.

Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:35:47 +02:00

245 lines
9.3 KiB
Rust

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::models::comment::{Comment, CommentDto};
use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
#[derive(Serialize)]
pub struct LikeResponse {
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
/// this rather than blind-inverting local state — otherwise a second device (same
/// recovered user) drifts, since the `like-update` broadcast only carries `like_count`.
pub liked: bool,
/// Fresh like count, or `null` if the (best-effort) count query hiccuped. The client
/// keeps its current count when this is null rather than adopting a wrong number.
pub like_count: Option<i64>,
}
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<Json<LikeResponse>, AppError> {
// Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Event-scope: the upload must belong to the caller's event (404 otherwise),
// matching the host handlers' find_by_id_and_event pattern.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// NOTE: liking is intentionally allowed while the event is locked. Locking
// ("Event schließen") freezes *new uploads* only — likes, comments and
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
// Try to insert; if conflict, delete (toggle). `liked` = the caller's state afterwards.
let result = sqlx::query(
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
ON CONFLICT (upload_id, user_id) DO NOTHING",
)
.bind(upload_id)
.bind(auth.user_id)
.execute(&state.pool)
.await?;
let liked = result.rows_affected() > 0;
if !liked {
// Already liked — remove
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
.bind(upload_id)
.bind(auth.user_id)
.execute(&state.pool)
.await?;
}
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The like
// itself is already committed, so a failed count must not fail the request — but we
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
// client until the next event). On error we skip the broadcast and return null.
let like_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
)
.bind(upload_id)
.fetch_one(&state.pool)
.await
.ok();
if let Some(count) = like_count {
// Broadcast the new count so other clients patch their card. Only `like_count` is
// shared — each client's own `liked_by_me` only changes via its own toggle (which
// now reads it straight from this response).
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "like_count": count }).to_string(),
});
}
Ok(Json(LikeResponse { liked, like_count }))
}
#[derive(Deserialize, Default)]
pub struct ListCommentsQuery {
/// RFC3339 timestamp — return only comments older than this. Pass the
/// `created_at` of the oldest currently-loaded comment to fetch the next
/// older page.
pub before: Option<DateTime<Utc>>,
}
const COMMENT_PAGE_SIZE: i64 = 50;
pub async fn list_comments(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<CommentDto>>, AppError> {
// Event-scope: only list comments for an upload in the caller's event.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let comments =
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
Ok(Json(comments))
}
#[derive(Deserialize)]
pub struct AddCommentRequest {
pub body: String,
}
pub async fn add_comment(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
Json(body): Json<AddCommentRequest>,
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Event-scope: only comment on an upload that belongs to the caller's event.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// NOTE: commenting is intentionally allowed while the event is locked. Locking
// freezes *new uploads* only — likes, comments and browsing stay open
// (USER_JOURNEYS §9.3, FEATURES capability matrix).
let text = body.body.trim();
let text_chars = text.chars().count();
if text_chars == 0 || text_chars > 500 {
return Err(AppError::BadRequest(
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
));
}
// 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.
let tags = hashtag::extract_hashtags(text);
let mut tx = state.pool.begin().await?;
let comment = Comment::create(&mut *tx, upload_id, auth.user_id, text).await?;
for tag in &tags {
let h = Hashtag::upsert(&mut *tx, auth.event_id, tag).await?;
sqlx::query(
"INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(comment.id)
.bind(h.id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.comment_count = COUNT(DISTINCT c.id); COUNT(*)
// over the same deleted_at filter is identical since comment.id is the PK). The
// count + broadcast are a UI optimisation — the comment is already committed, so a
// failure here must not fail the request. Swallow the error and skip the broadcast.
if let Ok(comment_count) = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM comment WHERE upload_id = $1 AND deleted_at IS NULL",
)
.bind(upload_id)
.fetch_one(&state.pool)
.await
{
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "new-comment".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "comment_count": comment_count })
.to_string(),
});
}
let dto = CommentDto {
id: comment.id,
upload_id,
user_id: auth.user_id,
uploader_name: user.display_name,
body: comment.body,
created_at: comment.created_at,
};
Ok((StatusCode::CREATED, Json(dto)))
}
pub async fn delete_comment(
State(state): State<AppState>,
auth: AuthUser,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Banned users keep read access but cannot mutate (USER_JOURNEYS §10).
if auth.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let comment = Comment::find_by_id(&state.pool, comment_id)
.await?
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
if comment.user_id != auth.user_id {
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
}
// Event-scope: soft_delete_in_event only matches comments whose upload is in
// the caller's event, so a cross-event comment_id resolves to a 404 here.
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()));
}
// Comments live only in the HTML viewer, so the ZIP is carried forward, not rebuilt.
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
crate::services::export::Affects::ViewerOnly,
)
.await?;
tx.commit().await?;
if let Some(r) = regen {
crate::handlers::host::start_regen(&state, r);
}
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)
}