fix(audit): restore broken upload pipeline + role-based E2E audit fixes
A comprehensive role-based E2E audit (guest/host/admin, across browser sessions) surfaced one critical and several smaller issues; this addresses them and hardens the tests that missed them. Critical - The client upload pipeline was fully broken: the IndexedDB v1->v2 upgrade opened a *new* transaction inside the upgrade callback, which throws during a version-change transaction and aborted the whole upgrade, leaving the queue object store uncreated -- so no UI upload ever fired. Reuse the version-change transaction the callback provides, and bump the DB to v3 with a contains() guard so installs already corrupted by the shipped bug self-heal on next load. Re-enabled the previously-fixme'd UI upload E2E test. High / Medium - Event lock is uploads-only again: likes, comments and browsing stay open while the event is locked (USER_JOURNEYS 9.3 / FEATURES) -- it was wrongly freezing social interaction. Updated the event-lock spec accordingly. - get_original now excludes soft-deleted and ban-hidden uploads, and direct /media/originals/** serving is blocked, so a hidden user's originals can no longer be pulled by UUID (all originals go through the checked alias). - The upload handler reads the file field with an early-abort size cap chosen from the declared content-type, instead of buffering the entire body before the size check. Low - unban_user mirrors the ban role guard (a host can no longer unban a host/admin banned by an admin). - reset_user_pin's UPDATE is event-scoped. - Admin login returns and stores a real identity (user_id + display name) instead of a blank session. - The host user list no longer renders target-actions (ban/promote/demote/PIN) on the caller's own row, where the backend always rejected them. - /diashow gains a client-side auth guard like the other protected routes. - The join page shows the event name via a new public GET /api/v1/event. Verified: backend cargo build clean, frontend svelte-check 0 errors, full Playwright E2E suite 144 passed / 1 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -90,14 +90,21 @@ pub async fn upload(
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
// Note: the client-declared filename and Content-Type are intentionally
|
||||
// ignored — the stored MIME and extension are derived from the file's
|
||||
// magic bytes below, so a mislabelled payload can't influence them.
|
||||
file_data = Some(
|
||||
field.bytes().await
|
||||
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
||||
.to_vec(),
|
||||
);
|
||||
// Note: the client-declared filename and Content-Type do NOT determine
|
||||
// the stored MIME/extension — those come from the file's magic bytes
|
||||
// below. The declared type is used only to pick a memory cap so an
|
||||
// oversized body can't be fully buffered before the size check. A
|
||||
// mislabelled type only makes the cap *stricter* (safe); the
|
||||
// authoritative per-class check still runs on the detected type.
|
||||
let declared = field.content_type().unwrap_or("").to_string();
|
||||
let cap_bytes = if declared.starts_with("video/") {
|
||||
(max_video_mb * 1024 * 1024) as usize
|
||||
} else if declared.starts_with("image/") {
|
||||
(max_image_mb * 1024 * 1024) as usize
|
||||
} else {
|
||||
(max_image_mb.max(max_video_mb) * 1024 * 1024) as usize
|
||||
};
|
||||
file_data = Some(read_field_capped(field, cap_bytes).await?);
|
||||
}
|
||||
"caption" => {
|
||||
caption = Some(
|
||||
@@ -332,6 +339,32 @@ pub async fn delete_upload(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Read a multipart field into memory, aborting with a 400 the moment it exceeds
|
||||
/// `max_bytes`. Without this the whole field is buffered (up to the HTTP body cap)
|
||||
/// before the post-read size check runs, so a request claiming to be a tiny image
|
||||
/// could still force hundreds of MB of allocation. Streaming with an early abort
|
||||
/// bounds peak memory to roughly the applicable per-class limit.
|
||||
async fn read_field_capped(
|
||||
mut field: axum::extract::multipart::Field<'_>,
|
||||
max_bytes: usize,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
||||
{
|
||||
if buf.len().saturating_add(chunk.len()) > max_bytes {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Datei ist zu groß. Maximum: {} MB.",
|
||||
max_bytes / (1024 * 1024)
|
||||
)));
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
|
||||
/// Without draining, the client may still be sending the body after we've sent our response,
|
||||
/// which can corrupt the keep-alive connection for subsequent requests.
|
||||
@@ -407,16 +440,17 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
|
||||
/// Data Mode = Original
|
||||
///
|
||||
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
|
||||
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
|
||||
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
|
||||
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
|
||||
/// and `window.open`, defeating the purpose of having the alias.
|
||||
/// **Auth model:** the route is intentionally unauthenticated so it works from
|
||||
/// `<img src>` / `window.open`, matching how preview + thumbnail variants are served.
|
||||
/// The URL contains the upload's unguessable UUID. Unlike raw `/media` files, this
|
||||
/// alias is the *only* way to fetch an original: direct `/media/originals/**` access is
|
||||
/// blocked in the router, and this handler filters out soft-deleted and ban-hidden
|
||||
/// uploads (via `find_by_id_visible`) so moderation actually removes access to content.
|
||||
pub async fn get_original(
|
||||
State(state): State<AppState>,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
||||
let upload = Upload::find_by_id_visible(&state.pool, upload_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user