fix(authz): event-scope social handlers, atomic gallery release (M1, M3, M8)
M1: toggle_like / add_comment / list_comments now resolve the target via Upload::find_by_id_and_event, returning 404 for uploads outside the caller's event or already soft-deleted — closing the cross-event IDOR and the write-to-deleted-post path. M3: the redundant in-handler is_banned fetches (upload, like, comment) are removed; the AuthUser extractor (WS1) already rejects banned users on every authenticated route, which also covers the previously-ungated edit_upload / delete_upload / delete_comment. M8: release_gallery claims the release with a single conditional UPDATE and only proceeds when it matches a row, so concurrent presses can't spawn duplicate export workers racing on the same files. Re-release is now permitted when all prior export jobs terminally failed (e.g. a crash mid-export), and the job upsert resets failed rows so the workers re-run cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// 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<AppState>,
|
||||
_auth: AuthUser,
|
||||
auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Query(q): Query<ListCommentsQuery>,
|
||||
) -> Result<Json<Vec<CommentDto>>, 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<Uuid>,
|
||||
Json(body): Json<AddCommentRequest>,
|
||||
) -> Result<(StatusCode, Json<CommentDto>), 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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user