Performance: - Cache the runtime `config` table in-memory (ConfigCache) with synchronous invalidation on every write (admin PATCH + test reseed). Was re-reading each key from Postgres on every request (~8 round-trips per upload). - Stream uploads chunk-by-chunk to a temp file instead of buffering the whole body in RAM (peak was up to the per-class cap, e.g. 500 MB/video); only 512 sniff-bytes are kept for magic-byte detection, then atomic rename into place. - Cache the media-filesystem disk snapshot (DiskCache, 15s TTL) shared by the quota check and admin stats; drop the discarded System::refresh_all(). - HTML export streams video (and small-image) originals straight into the ZIP via a manifest instead of copying them to a temp dir first (removed the transient 2x disk usage) and drops the double directory scan. - Auth extractor resolves session -> live user in one JOIN (was two queries), touching last_seen_at by token hash. Stability: - SSE: on broadcast lag, emit a `resync` event so the client runs a delta fetch instead of silently losing events; frontend reconciles adds, deletions, and (via an in-place refresh) like/comment counts on visible cards. - Storage quota fails OPEN when the disk can't be read (was a 0-byte limit that locked out all uploads). - Graceful shutdown drains in-flight requests on SIGTERM/SIGINT, bounded by a 10s backstop so open SSE streams can't stall a deploy. - Upload removes the persisted file if the DB transaction fails (no orphaned bytes with no row to reclaim them). Tests: - New pure select_disk() with 5 unit tests (longest-prefix, fallbacks, fail-open). - New e2e export-video spec covering the HTML export's video-streaming branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
346 lines
12 KiB
Rust
346 lines
12 KiB
Rust
use std::time::Duration;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use axum::Json;
|
|
use chrono::Utc;
|
|
use rand::Rng;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::jwt;
|
|
use crate::auth::middleware::AuthUser;
|
|
use crate::error::AppError;
|
|
use crate::models::event::Event;
|
|
use crate::models::session::Session;
|
|
use crate::models::user::{User, UserRole};
|
|
use crate::services::config;
|
|
use crate::services::rate_limiter::client_ip;
|
|
use crate::state::AppState;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct JoinRequest {
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct JoinResponse {
|
|
pub jwt: String,
|
|
pub pin: String,
|
|
pub user_id: Uuid,
|
|
pub is_new: bool,
|
|
}
|
|
|
|
pub async fn join(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<JoinRequest>,
|
|
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
|
let ip = client_ip(&headers, "unknown");
|
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
|
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
|
|
if rate_limits_on && join_rate_on
|
|
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
|
{
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let display_name = body.display_name.trim();
|
|
let name_chars = display_name.chars().count();
|
|
if name_chars == 0 || name_chars > 50 {
|
|
return Err(AppError::BadRequest(
|
|
"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,
|
|
&state.config.event_slug,
|
|
&state.config.event_name,
|
|
)
|
|
.await?;
|
|
|
|
// Reject if a user with this name (case-insensitive) already exists
|
|
if User::name_taken(&state.pool, event.id, display_name).await? {
|
|
return Err(AppError::Conflict(format!(
|
|
"Der Name \"{}\" ist bereits vergeben.",
|
|
display_name
|
|
)));
|
|
}
|
|
|
|
// Generate a 4-digit PIN
|
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
|
let pin_hash =
|
|
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
|
|
|
|
let token = jwt::create_token(
|
|
user.id,
|
|
event.id,
|
|
user.role.clone(),
|
|
&state.config.jwt_secret,
|
|
state.config.session_expiry_days,
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
|
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
|
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(JoinResponse {
|
|
jwt: token,
|
|
pin,
|
|
user_id: user.id,
|
|
is_new: true,
|
|
}),
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct RecoverRequest {
|
|
pub display_name: String,
|
|
pub pin: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RecoverResponse {
|
|
pub jwt: String,
|
|
pub user_id: Uuid,
|
|
}
|
|
|
|
pub async fn recover(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<RecoverRequest>,
|
|
) -> Result<Json<RecoverResponse>, AppError> {
|
|
let display_name = body.display_name.trim();
|
|
|
|
// Per-IP+name throttle BEFORE the per-user 3-strike counter. Without this
|
|
// an attacker who knows a display name (they're visible on the feed) can
|
|
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
|
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
|
// softens that into a real cost.
|
|
let ip = client_ip(&headers, "unknown");
|
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
|
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
|
if rate_limits_on && recover_rate_on {
|
|
let name_key = display_name.to_lowercase();
|
|
if !state.rate_limiter.check(
|
|
format!("recover:{ip}:{name_key}"),
|
|
5,
|
|
Duration::from_secs(15 * 60),
|
|
) {
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
|
None,
|
|
));
|
|
}
|
|
}
|
|
|
|
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
|
|
|
let users =
|
|
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
|
|
|
if users.is_empty() {
|
|
return Err(AppError::NotFound(
|
|
"Kein Benutzer mit diesem Namen gefunden.".into(),
|
|
));
|
|
}
|
|
|
|
for user in &users {
|
|
// Check PIN lockout. If the lockout has expired, also reset the failed-attempt
|
|
// counter so the user gets a fresh 3-strike window — otherwise the counter
|
|
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
|
|
// after waiting out the cooldown. Without this reset, a once-locked account
|
|
// is effectively permanently fragile.
|
|
if let Some(locked_until) = user.pin_locked_until {
|
|
if Utc::now() < locked_until {
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
|
None,
|
|
));
|
|
}
|
|
// Lockout window expired — wipe the counter and the timestamp.
|
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
|
}
|
|
|
|
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
|
.unwrap_or(false);
|
|
|
|
if pin_matches {
|
|
// Reset failed attempts on success
|
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
|
|
|
let token = jwt::create_token(
|
|
user.id,
|
|
event.id,
|
|
user.role.clone(),
|
|
&state.config.jwt_secret,
|
|
state.config.session_expiry_days,
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
|
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
|
|
|
return Ok(Json(RecoverResponse {
|
|
jwt: token,
|
|
user_id: user.id,
|
|
}));
|
|
}
|
|
|
|
// Wrong PIN — increment failure count
|
|
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
|
|
tracing::warn!(
|
|
user_id = %user.id,
|
|
event_id = %event.id,
|
|
ip = %ip,
|
|
attempts,
|
|
"recover: wrong PIN"
|
|
);
|
|
if attempts >= 3 {
|
|
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
|
User::lock_pin(&state.pool, user.id, lockout).await?;
|
|
tracing::warn!(
|
|
user_id = %user.id,
|
|
event_id = %event.id,
|
|
ip = %ip,
|
|
"recover: account locked for 15 minutes"
|
|
);
|
|
}
|
|
}
|
|
|
|
Err(AppError::Unauthorized("PIN ist falsch.".into()))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AdminLoginRequest {
|
|
pub password: String,
|
|
}
|
|
|
|
#[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(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<AdminLoginRequest>,
|
|
) -> Result<Json<AdminLoginResponse>, AppError> {
|
|
if state.config.admin_password_hash.is_empty() {
|
|
return Err(AppError::Forbidden(
|
|
"Admin-Login ist nicht konfiguriert.".into(),
|
|
));
|
|
}
|
|
|
|
// Throttle password attempts. The admin password is bcrypt-hashed (slow to
|
|
// verify) but with no IP-level limit a determined attacker can still mount
|
|
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
|
// honest typos.
|
|
let ip = client_ip(&headers, "unknown");
|
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
|
let admin_rate_on = config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
|
if rate_limits_on && admin_rate_on
|
|
&& !state.rate_limiter.check(
|
|
format!("admin_login:{ip}"),
|
|
5,
|
|
Duration::from_secs(60),
|
|
)
|
|
{
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
|
|
.unwrap_or(false);
|
|
|
|
if !valid {
|
|
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
|
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
|
}
|
|
|
|
let event = Event::find_or_create(
|
|
&state.pool,
|
|
&state.config.event_slug,
|
|
&state.config.event_name,
|
|
)
|
|
.await?;
|
|
|
|
// Find or create the admin user for this event
|
|
let admin_name = "Admin";
|
|
let users = User::find_by_event_and_name(&state.pool, event.id, admin_name).await?;
|
|
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
|
|
u
|
|
} else {
|
|
// Admin authenticates via password, but the schema still requires a PIN
|
|
// hash. Generate a random unguessable PIN so the recovery path remains
|
|
// unusable as an escalation route even if the role flag ever got cleared.
|
|
let dummy_pin: String = (0..32)
|
|
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
|
.collect();
|
|
let dummy_hash = bcrypt::hash(&dummy_pin, 4)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
|
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
|
.bind(user.id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
User::find_by_id(&state.pool, user.id)
|
|
.await?
|
|
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))?
|
|
};
|
|
|
|
tracing::info!(user_id = %admin_user.id, event_id = %event.id, ip = %ip, "admin_login: success");
|
|
|
|
let token = jwt::create_token(
|
|
admin_user.id,
|
|
event.id,
|
|
UserRole::Admin,
|
|
&state.config.jwt_secret,
|
|
1, // Admin sessions expire after 1 day
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
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,
|
|
user_id: admin_user.id,
|
|
display_name: admin_user.display_name,
|
|
}))
|
|
}
|
|
|
|
pub async fn logout(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> Result<StatusCode, AppError> {
|
|
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|