diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs index 5fb85ff..ffc15cb 100644 --- a/backend/src/handlers/host.rs +++ b/backend/src/handlers/host.rs @@ -403,20 +403,41 @@ pub async fn release_gallery( .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())); + // Atomically claim the release (M8). Two concurrent presses can no longer + // both spawn export workers racing on the same files: only the request whose + // UPDATE matches a row proceeds. Re-release is allowed when the gallery was + // never released, OR every existing export job terminally failed (e.g. a + // crash mid-export) — otherwise the headline deliverable would be + // permanently unrecoverable. + let claim = sqlx::query( + "UPDATE event SET export_released_at = NOW() + WHERE slug = $1 + AND ( + export_released_at IS NULL + OR NOT EXISTS ( + SELECT 1 FROM export_job j + WHERE j.event_id = event.id AND j.status <> 'failed' + ) + )", + ) + .bind(&state.config.event_slug) + .execute(&state.pool) + .await?; + + if claim.rows_affected() == 0 { + 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 + // Enqueue export jobs, resetting any prior terminally-failed rows so the + // workers re-run cleanly. for export_type in ["zip", "html"] { sqlx::query( "INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type) - ON CONFLICT (event_id, type) DO NOTHING", + ON CONFLICT (event_id, type) + DO UPDATE SET status = 'pending', progress_pct = 0, + error_message = NULL, completed_at = NULL", ) .bind(event.id) .bind(export_type) diff --git a/backend/src/handlers/social.rs b/backend/src/handlers/social.rs index 4c96e7b..1dd615e 100644 --- a/backend/src/handlers/social.rs +++ b/backend/src/handlers/social.rs @@ -9,6 +9,7 @@ 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; pub async fn toggle_like( @@ -16,13 +17,12 @@ pub async fn toggle_like( auth: AuthUser, Path(upload_id): Path, ) -> Result { - // Check if user is banned - let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id) + // Ban is already rejected by the AuthUser extractor. Scope the target to the + // caller's event and reject deleted uploads (M1: no cross-event IDOR, no + // likes on soft-deleted posts). + Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) .await? - .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; - if user.is_banned { - return Err(AppError::Forbidden("Du bist gesperrt.".into())); - } + .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; // Try to insert; if conflict, delete (toggle) let result = sqlx::query( @@ -64,10 +64,16 @@ const COMMENT_PAGE_SIZE: i64 = 50; pub async fn list_comments( State(state): State, - _auth: AuthUser, + auth: AuthUser, Path(upload_id): Path, Query(q): Query, ) -> Result>, AppError> { + // M1: a pure read behind any valid token must still be scoped to the + // caller's event and must not leak comments on soft-deleted uploads. + 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)) @@ -84,12 +90,15 @@ pub async fn add_comment( Path(upload_id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { + // M1: scope the target upload to the caller's event and reject deleted + // posts. (Ban is already handled by the AuthUser extractor.) + Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) + .await? + .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; + 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())); - } let text = body.body.trim(); let text_chars = text.chars().count(); diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index fb94d91..cb392c9 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -68,14 +68,11 @@ pub async fn upload( } } - // Check if user is banned + // Ban is already rejected by the AuthUser extractor. We still load the user + // for the uploader's display name in the response DTO. let user = User::find_by_id(&state.pool, auth.user_id) .await? .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; - if user.is_banned { - drain_multipart(multipart).await; - return Err(AppError::Forbidden("Du bist gesperrt.".into())); - } // Check if uploads are locked let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)