fix(upload): a retry after release returns the stored photo instead of refusing it
The idempotency key was only readable as a multipart FIELD, and a field cannot be read until the body is being parsed — which happens after the lock/release pre-flight. So the replay was unreachable in exactly the case it exists for: the photo commits → the response is lost on the way back (the flaky-wifi failure the key was added for) → the host releases the gallery at the end of the night → the phone's retry answers `gallery_released`. The guest is told a photo that is sitting in the gallery was never sent. And the remedy the client offers is destructive: `open_event` clears `export_released_at` AND bumps `export_epoch`, retiring the whole keepsake generation and forcing a multi-GB rebuild on a 2-vCPU box at midnight — to re-send a photo that was never missing. Several guests on one flaky evening make this likely to happen at least once. The key is now also sent as `X-Client-Upload-Id`, which arrives with the request line, so the answer is knowable before anything is decided about locks. The multipart field stays for the concurrent case and as a fallback. Placed ahead of the hourly rate limiter too, which was the same mistake one layer up: a 40-photo burst with two retries apiece exhausted the guest's hour on uploads that had all committed the first time. The body is still drained rather than abandoned — replying before reading it makes the proxy see a broken pipe and turn a clean 200 into a 502. The spec carries its own control: a DIFFERENT photo is asserted to still be refused with `gallery_released` after the release, so the replay cannot be green merely because the gate was open.
This commit is contained in:
@@ -168,8 +168,51 @@ const ALLOWED_MEDIA: &[(&str, &str)] = &[
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
headers: axum::http::HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
|
||||
// REPLAY FIRST — before the rate limit, before the ban check, before the lock/release gate.
|
||||
//
|
||||
// The idempotency key also arrives as a multipart FIELD, and there is a replay for it further
|
||||
// down; but a field cannot be read until the body is being parsed, which is after every gate
|
||||
// below. So the field's replay was unreachable in exactly the situation it matters most:
|
||||
//
|
||||
// the photo committed, the response was lost on the way back (the flaky-wifi failure this
|
||||
// whole mechanism exists for), the host released the gallery at the end of the night, and
|
||||
// the phone's retry then answered `gallery_released` — telling the guest a photo that is
|
||||
// ALREADY IN THE GALLERY had not been sent.
|
||||
//
|
||||
// And the remedy that error suggests is destructive: `open_event` clears `export_released_at`
|
||||
// and BUMPS `export_epoch`, retiring the whole keepsake generation and forcing a multi-GB
|
||||
// rebuild on a 2-vCPU box at midnight — to re-send a photo that was never missing.
|
||||
//
|
||||
// A header arrives with the request line, so the answer is knowable before anything is
|
||||
// decided. Charging the hourly rate limit for a retry of an already-stored photo was the same
|
||||
// mistake one layer up: a 40-photo burst with two retries each exhausted the hour for uploads
|
||||
// that committed the first time.
|
||||
//
|
||||
// The body is still DRAINED rather than abandoned — see `drain_multipart`: replying before
|
||||
// reading the body makes the proxy see a broken pipe and turn a clean 200 into a 502.
|
||||
if let Some(cid) = headers
|
||||
.get("x-client-upload-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| Uuid::parse_str(v.trim()).ok())
|
||||
&& let Some(existing) =
|
||||
Upload::find_by_client_upload_id(&state.pool, auth.user_id, cid).await?
|
||||
{
|
||||
drain_multipart(multipart).await;
|
||||
let uploader_name = User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.map(|u| u.display_name)
|
||||
.unwrap_or_default();
|
||||
tracing::info!(
|
||||
client_upload_id = %cid, upload_id = %existing.id,
|
||||
"upload retry replayed from the header key, before the lock/release gate"
|
||||
);
|
||||
let dto = replay_upload_dto(&state, &existing, &uploader_name).await;
|
||||
return Ok((StatusCode::OK, Json(dto)));
|
||||
}
|
||||
|
||||
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles.
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
|
||||
|
||||
Reference in New Issue
Block a user