Merge branch 'fix/audit-2026-07-07'

This commit is contained in:
fabi
2026-07-07 07:28:32 +02:00
16 changed files with 220 additions and 62 deletions

View File

@@ -238,6 +238,10 @@ pub struct AdminLoginRequest {
#[derive(Serialize)]
pub struct AdminLoginResponse {
pub jwt: String,
/// The admin's user id + display name, so the client can populate a real identity
/// (own-post affordances, a name on the Account page) instead of a blank session.
pub user_id: Uuid,
pub display_name: String,
}
pub async fn admin_login(
@@ -325,7 +329,11 @@ pub async fn admin_login(
let expires_at = Utc::now() + chrono::Duration::days(1);
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
Ok(Json(AdminLoginResponse { jwt: token }))
Ok(Json(AdminLoginResponse {
jwt: token,
user_id: admin_user.id,
display_name: admin_user.display_name,
}))
}
pub async fn logout(

View File

@@ -266,6 +266,10 @@ pub async fn export_ticket(
State(state): State<AppState>,
auth: crate::auth::middleware::AuthUser,
) -> Json<serde_json::Value> {
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
// released — Spec design choice"). The export is read-only, so it stays available
// to them, consistent with the read-only-ban model.
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket }))
}

View File

@@ -146,6 +146,26 @@ pub async fn unban_user(
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Mirror the ban guard: a host may only lift bans on guests, never on hosts or
// admins. Without this a host could override an admin's ban of another host,
// which is asymmetric with `ban_user` and lets a host escalate a peer back in.
let target = sqlx::query_as::<_, (String,)>(
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin"
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
{
return Err(AppError::Forbidden(
"Du kannst diesen Benutzer nicht entsperren.".into(),
));
}
let result = sqlx::query(
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
)
@@ -275,10 +295,11 @@ pub async fn reset_user_pin(
SET recovery_pin_hash = $1,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
WHERE id = $2 AND event_id = $3",
)
.bind(&pin_hash)
.bind(user_id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;

View File

@@ -2,6 +2,7 @@ pub mod admin;
pub mod feed;
pub mod host;
pub mod me;
pub mod public;
pub mod social;
pub mod sse;
pub mod test_admin;

View File

@@ -0,0 +1,24 @@
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::state::AppState;
#[derive(Serialize)]
pub struct PublicEventDto {
pub name: String,
pub slug: String,
}
/// Public event identity, used by the pre-auth join/recover screens so a guest can
/// see *which* event they're joining. Only the display name and slug are exposed —
/// nothing user-scoped — so this is safe without a token. Served straight from the
/// instance config (no DB round-trip needed).
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
Json(PublicEventDto {
name: state.config.event_name.clone(),
slug: state.config.event_slug.clone(),
})
}

View File

@@ -12,18 +12,6 @@ use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
/// Reject the request when the event's uploads (and, by extension, social
/// interaction) are locked. Mirrors the guard in the upload handler.
async fn require_event_open(state: &AppState) -> Result<(), AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.uploads_locked_at.is_some() {
return Err(AppError::Forbidden("Das Event ist geschlossen.".into()));
}
Ok(())
}
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
@@ -43,8 +31,9 @@ pub async fn toggle_like(
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler.
require_event_open(&state).await?;
// 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)
let result = sqlx::query(
@@ -135,8 +124,9 @@ pub async fn add_comment(
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// A closed event freezes social interaction too, matching the upload handler.
require_event_open(&state).await?;
// 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();

View File

@@ -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()))?;

View File

@@ -57,6 +57,7 @@ async fn main() -> Result<()> {
let api = Router::new()
// Auth
.route("/api/v1/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
@@ -144,6 +145,16 @@ async fn main() -> Result<()> {
let router = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
// Block direct HTTP access to originals. They live under `media_path` (so the
// compression worker and export can read them off disk) but must NOT be pullable
// straight from `/media/originals/**` — that would bypass the visibility checks in
// `get_original` (soft-delete + ban-hide). Every legitimate original fetch goes
// through `/api/v1/upload/{id}/original`; previews/thumbnails under `/media` stay
// public. The more specific nest takes precedence over `/media` below.
.nest_service(
"/media/originals",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.nest_service("/media", media_service)
.layer(TraceLayer::new_for_http())
.with_state(state);

View File

@@ -74,6 +74,22 @@ impl Upload {
.await
}
/// Like [`Self::find_by_id`] but also excludes uploads whose owner has been
/// ban-hidden (`user.uploads_hidden`). Used by the public original-file alias
/// so that moderation which hides a user's content from the feed/export also
/// stops their full-resolution originals from being pulled by UUID. Mirrors the
/// `uploads_hidden` filter that `v_feed` already applies to the feed.
pub async fn find_by_id_visible(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT up.* FROM upload up
JOIN \"user\" u ON u.id = up.user_id
WHERE up.id = $1 AND up.deleted_at IS NULL AND u.uploads_hidden = false",
)
.bind(id)
.fetch_optional(pool)
.await
}
/// Event-scoped lookup used by host endpoints so a host of event A cannot
/// reach uploads belonging to event B.
pub async fn find_by_id_and_event(

View File

@@ -38,15 +38,14 @@ test.describe('Upload — gallery path', () => {
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
});
test.fixme('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
// The full UI flow (BottomNav FAB → UploadSheet → /upload page → handleSubmit →
// upload-queue.ts XHR) does not currently complete within the test window in
// Playwright. The XHR doesn't appear in backend logs. Suspected cause: the
// queue worker fires after the page navigates from /upload to /feed via
// SvelteKit's goto(), but the blob/IDB chain may not survive the unmount/
// remount cycle in Playwright's headless Chromium. Needs deeper
// investigation; tracked as a fixme for now. API-driven tests above cover
// the data contract.
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
// `upgrade` callback opened a *new* transaction, which throws during a
// version-change transaction and aborted the whole upgrade, so the `queue`
// object store was never created and the worker could never persist an item.
// Fixed in upload-queue.ts by reusing the callback's version-change
// transaction. This test guards against regressing that.
const h = await guest('UploaderUI');
await signIn(page, h);
const feed = new FeedPage(page);

View File

@@ -34,10 +34,11 @@ test.describe('Host — event lock', () => {
// and flip fixme to test once it lands.
});
// Regression for the review: likes/comments used to ignore uploads_locked_at,
// so social writes still landed on a closed event. They now share the upload
// handler's lock guard.
test('a closed event rejects likes and comments', async ({ api, host, guest }) => {
// Locking is uploads-only: likes, comments and browsing stay open on a closed
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
// rejected. (An earlier revision froze social interaction too; that contradicted
// the documented behavior and was reverted.)
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const g = await guest('SocialLocked');
@@ -55,17 +56,26 @@ test.describe('Host — event lock', () => {
await api.closeEvent(host.jwt);
// Likes stay open on a locked event.
const likeRes = await fetch(`${BASE}/api/v1/upload/${id}/like`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(likeRes.status).toBe(403);
expect(likeRes.status).toBe(204);
// Comments stay open on a locked event.
const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: 'sollte blockiert sein' }),
body: JSON.stringify({ body: 'darf durchgehen' }),
});
expect(commentRes.status).toBe(403);
expect(commentRes.status).toBe(201);
// New uploads, however, are rejected while locked.
const blockedUpload = await uploadRaw(g.jwt, readFileSync(sample), {
filename: 'y.jpg',
contentType: 'image/jpeg',
});
expect(blockedUpload.status).toBe(403);
});
});

View File

@@ -33,16 +33,23 @@ async function getDb(): Promise<IDBPDatabase> {
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
// persisted across logouts before this version.
db = await openDB(DB_NAME, 2, {
upgrade(database, oldVersion) {
if (oldVersion < 1) {
// Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade
// opened a *new* transaction inside the callback, which throws InvalidStateError
// ("A version change transaction is running") and aborts the whole upgrade —
// leaving some browsers at version 2 with NO 'queue' object store (so every queue
// write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for
// those installs; the contains() guard recreates the missing store instead of
// assuming createObjectStore only ever runs on a brand-new DB.
db = await openDB(DB_NAME, 3, {
upgrade(database, oldVersion, _newVersion, transaction) {
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
if (oldVersion < 2) {
// Wipe any pre-v2 entries — they have no userId field and would belong
// to a now-indeterminate user. Safer to drop than to misattribute.
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).clear();
} else if (oldVersion < 2) {
// Existing v1 store: its entries predate the `userId` field, so drop them
// rather than misattribute them to whoever is signed in now. Reuse the
// active version-change transaction (never open a new one here — see above).
// Skipped when we just created the store, which is already empty.
transaction.objectStore(STORE_NAME).clear();
}
}
});

View File

@@ -18,9 +18,14 @@
loading = true;
error = '';
try {
const res = await api.post<{ jwt: string }>('/admin/login', { password });
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN
setAuth(res.jwt, null, '');
const res = await api.post<{ jwt: string; user_id: string; display_name: string }>(
'/admin/login',
{ password }
);
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN.
// Persist the real user id + name so the admin has an identity (own-post
// affordances on the feed, a name on the Account page rather than "Unbekannt").
setAuth(res.jwt, null, res.user_id, res.display_name);
goto('/admin');
} catch (e) {
if (e instanceof ApiError) {

View File

@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { getToken } from '$lib/auth';
import { showBottomNav } from '$lib/ui-store';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { onSseEvent } from '$lib/sse';
@@ -151,6 +152,13 @@
}
onMount(() => {
// Auth guard — mirror the other protected routes. Without this an
// unauthenticated visitor lands on a permanently-loading empty slideshow
// instead of being sent to /join.
if (!getToken()) {
goto('/join');
return;
}
showBottomNav.set(false);
void acquireWakeLock();
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { getToken, getRole, getUserId } from '$lib/auth';
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { onMount } from 'svelte';
@@ -57,6 +57,7 @@
let pinModal = $state<{ name: string; pin: string } | null>(null);
const myRole = getRole();
const myUserId = getUserId();
// Generic confirm-then-run for the irreversible / privilege-changing actions
// (promote, demote, unban, release gallery) that previously fired on one tap.
@@ -486,7 +487,11 @@
>
Entsperren
</button>
{:else}
{:else if user.id !== myUserId}
<!-- Never render target-actions (promote/demote/PIN/ban) on the
caller's own row: the backend rejects every self-action
(self-ban / self-demote / self-PIN) with a 400, so the button
would only ever fail. -->
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
<button
onclick={() => (confirmAction = {

View File

@@ -1,9 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth } from '$lib/auth';
import { focusTrap } from '$lib/actions/focus-trap';
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
let eventName = $state('');
onMount(async () => {
try {
const ev = await api.get<{ name: string; slug: string }>('/event');
eventName = ev.name;
} catch {
// Non-fatal — fall back to the generic heading if the lookup fails.
}
});
let displayName = $state('');
let error = $state('');
let loading = $state(false);
@@ -164,6 +176,9 @@
{:else}
<!-- Normal join form -->
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>
{#if eventName}
<p class="mb-1 text-center text-lg font-semibold text-blue-600 dark:text-blue-400" data-testid="join-event-name">{eventName}</p>
{/if}
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen ein, um dem Event beizutreten.</p>
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>