diff --git a/backend/Dockerfile b/backend/Dockerfile index 39e4159..3953c2f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # --- Build stage --- -FROM rust:1.87-alpine AS builder +FROM rust:1.88-alpine AS builder RUN apk add --no-cache musl-dev pkgconfig openssl-dev @@ -12,6 +12,7 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \ COPY src ./src COPY static ./static +COPY migrations ./migrations RUN touch src/main.rs && cargo build --release # --- Runtime stage --- diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index 1912a82..b8c6749 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -54,6 +54,13 @@ pub async fn join( "Name muss zwischen 1 und 50 Zeichen lang sein.".into(), )); } + // Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers + // see a clean 400 instead of an internal error. + if display_name.contains('\0') { + return Err(AppError::BadRequest( + "Name enthält ungültige Zeichen.".into(), + )); + } let event = Event::find_or_create( &state.pool, diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs index 0af1ac9..99cf235 100644 --- a/backend/src/auth/jwt.rs +++ b/backend/src/auth/jwt.rs @@ -13,6 +13,13 @@ pub struct Claims { pub role: UserRole, pub exp: i64, pub iat: i64, + /// Random per-token identifier. Without it, two `create_token` calls in the + /// same wall-clock second for the same (sub, role, event) produce identical + /// JWT bytes — and identical sha256(token) hashes — which then collide on + /// the `session.token_hash` UNIQUE constraint. The jti is ignored by the + /// verifier but breaks the collision. + #[serde(default)] + pub jti: Uuid, } pub fn create_token( @@ -29,6 +36,7 @@ pub fn create_token( role, iat: now.timestamp(), exp: (now + Duration::days(expiry_days)).timestamp(), + jti: Uuid::new_v4(), }; jsonwebtoken::encode( &Header::default(), diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 652fab2..fbdf47b 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,4 +4,5 @@ pub mod host; pub mod me; pub mod social; pub mod sse; +pub mod test_admin; pub mod upload; diff --git a/backend/src/handlers/test_admin.rs b/backend/src/handlers/test_admin.rs new file mode 100644 index 0000000..f3d0cc4 --- /dev/null +++ b/backend/src/handlers/test_admin.rs @@ -0,0 +1,85 @@ +//! Test-only admin routes. **Compiled in always, but only registered when +//! `EVENTSNAP_TEST_MODE=1` is set in the environment.** The route returns a hard +//! 404 in production builds because [`crate::main`] skips registering the handler. +//! +//! These exist to give the Playwright E2E suite a quick "reset everything" +//! escape hatch without forcing tests to maintain raw SQL fixtures or spin up a +//! fresh database container per test. + +use axum::extract::State; +use axum::http::StatusCode; + +use crate::auth::middleware::RequireAdmin; +use crate::error::AppError; +use crate::state::AppState; + +/// Truncates every event-scoped table, wipes media on disk, and reseeds the +/// `config` table from migration defaults. Requires an admin JWT — even with +/// `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously. +pub async fn truncate_all( + State(state): State, + RequireAdmin(_auth): RequireAdmin, +) -> Result { + // Truncate in dependency order doesn't matter with CASCADE, but listing the + // tables explicitly makes the blast radius obvious in code review. + sqlx::query( + r#"TRUNCATE + comment_hashtag, + upload_hashtag, + hashtag, + "like", + comment, + export_job, + upload, + session, + "user", + event, + config + RESTART IDENTITY CASCADE"#, + ) + .execute(&state.pool) + .await?; + + // Reseed config — mirrors migrations 005 and 009. Kept in sync by hand + // because pulling SQL out of the migration files at runtime is fragile. + sqlx::query( + r#"INSERT INTO config (key, value) VALUES + ('max_image_size_mb', '20'), + ('max_video_size_mb', '500'), + ('upload_rate_per_hour', '10'), + ('feed_rate_per_min', '60'), + ('export_rate_per_day', '3'), + ('quota_tolerance', '0.75'), + ('estimated_guest_count', '100'), + ('compression_concurrency', '2'), + ('rate_limits_enabled', 'false'), + ('upload_rate_enabled', 'false'), + ('feed_rate_enabled', 'false'), + ('export_rate_enabled', 'false'), + ('join_rate_enabled', 'false'), + ('quota_enabled', 'false'), + ('storage_quota_enabled', 'false'), + ('upload_count_quota_enabled', 'false'), + ('privacy_note', '') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"#, + ) + .execute(&state.pool) + .await?; + + // Wipe media directory. Best-effort: if it doesn't exist, that's fine. + let _ = tokio::fs::remove_dir_all(&state.config.media_path).await; + let _ = tokio::fs::create_dir_all(&state.config.media_path).await; + + // The rate limiter holds an in-memory HashMap; clear it so a previous test's + // counters don't leak into the next one. + state.rate_limiter.clear(); + + Ok(StatusCode::NO_CONTENT) +} + +/// Returns whether the truncate endpoint is enabled. Used by the e2e harness +/// during global-setup to fail loud if the test backend was started without +/// `EVENTSNAP_TEST_MODE=1`. +pub fn is_test_mode() -> bool { + std::env::var("EVENTSNAP_TEST_MODE").as_deref() == Ok("1") +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 970bddd..6053027 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -107,6 +107,23 @@ async fn main() -> Result<()> { ) .route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs)); + // Test-only route: a hard reset for the Playwright E2E harness. The handler + // is compiled in always, but the route is only attached when + // `EVENTSNAP_TEST_MODE=1`. In production the call returns 404 — the route + // simply isn't there. + let api = if handlers::test_admin::is_test_mode() { + tracing::warn!( + "EVENTSNAP_TEST_MODE=1 — registering /api/v1/admin/__truncate. \ + DO NOT enable this in production." + ); + api.route( + "/api/v1/admin/__truncate", + post(handlers::test_admin::truncate_all), + ) + } else { + api + }; + // Serve media files from disk let media_service = ServeDir::new(&config.media_path); diff --git a/backend/src/services/rate_limiter.rs b/backend/src/services/rate_limiter.rs index d7593a5..733640a 100644 --- a/backend/src/services/rate_limiter.rs +++ b/backend/src/services/rate_limiter.rs @@ -42,6 +42,12 @@ impl RateLimiter { } } + /// Wipe every tracked window. Used by the test-mode truncate route so a previous + /// test's accumulated counters don't bleed into the next test's rate-limit checks. + pub fn clear(&self) { + self.windows.lock().unwrap().clear(); + } + /// Drop keys whose windows are empty after expiring old timestamps. Called from a /// background task (see [`crate::services::maintenance`]) so that long-lived /// processes don't accumulate one HashMap entry per IP that ever connected.