Files
EventSnap/backend/src/handlers/host.rs
fabi 498b4e256b fix(security): critical — repair PIN reset + enforce real prod secrets
C1: reset_user_pin wrote to a non-existent column (pin_failed_attempts);
    the real column is failed_pin_attempts, so every PIN reset 500'd. Fixed
    the column name; new e2e (pin-reset.spec.ts) proves a reset returns a
    usable PIN and the target can recover with it.

C2: config.rs::validate_secrets now rejects placeholder-ish secrets
    (change_me/dev_secret/placeholder), enforces len>=32 in prod, and
    requires a real ADMIN_PASSWORD_HASH. docker-compose.yml sets
    APP_ENV=production so the guard actually runs. Corrected the false
    "fixed" claim in SECURITY-BACKLOG.md. .env.example documents the rule.

Riders in these files (documented here since git can't split hunks):
- host.rs also carries the event-scoped ban_user fix and the
  close_event/open_event no-op broadcast guard (medium).
- docker-compose.yml also adds ORIGIN (H6), app/frontend healthchecks with
  Caddy waiting on health, and per-service memory limits (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:25:39 +02:00

419 lines
12 KiB
Rust

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::middleware::RequireHost;
use crate::error::AppError;
use crate::models::comment::Comment;
use crate::models::event::Event;
use crate::models::upload::Upload;
use crate::models::user::UserRole;
use crate::state::{AppState, SseEvent};
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize, sqlx::FromRow)]
pub struct UserSummary {
pub id: Uuid,
pub display_name: String,
pub role: String,
pub is_banned: bool,
pub uploads_hidden: bool,
pub upload_count: i64,
pub total_upload_bytes: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct EventStatus {
pub name: String,
pub is_active: bool,
pub uploads_locked: bool,
pub export_released: bool,
}
#[derive(Deserialize)]
pub struct BanRequest {
pub hide_uploads: bool,
}
#[derive(Deserialize)]
pub struct SetRoleRequest {
pub role: String,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
pub async fn get_event_status(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<Json<EventStatus>, AppError> {
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
Ok(Json(EventStatus {
name: event.name,
is_active: event.is_active,
uploads_locked: event.uploads_locked_at.is_some(),
export_released: event.export_released_at.is_some(),
}))
}
pub async fn list_users(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<Json<Vec<UserSummary>>, AppError> {
let rows = sqlx::query_as::<_, UserSummary>(
"SELECT u.id,
u.display_name,
u.role::text AS role,
u.is_banned,
u.uploads_hidden,
COALESCE(COUNT(up.id), 0) AS upload_count,
u.total_upload_bytes,
u.created_at
FROM \"user\" u
LEFT JOIN upload up ON up.user_id = u.id AND up.deleted_at IS NULL
WHERE u.event_id = $1
GROUP BY u.id
ORDER BY u.created_at ASC",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
pub async fn ban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<BanRequest>,
) -> Result<StatusCode, AppError> {
// Cannot ban yourself or another host/admin
if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
}
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 sperren.".into()));
}
sqlx::query(
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1 AND event_id = $3",
)
.bind(user_id)
.bind(body.hide_uploads)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
hide_uploads = body.hide_uploads,
"host: ban_user"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn unban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let result = sqlx::query(
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Benutzer nicht gefunden.".into()));
}
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
"host: unban_user"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn set_role(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<SetRoleRequest>,
) -> Result<StatusCode, AppError> {
if user_id == auth.user_id {
return Err(AppError::BadRequest(
"Du kannst deine eigene Rolle nicht ändern.".into(),
));
}
let new_role = match body.role.as_str() {
"guest" => "guest",
"host" => "host",
_ => {
return Err(AppError::BadRequest(
"Ungültige Rolle. Erlaubt: guest, host.".into(),
))
}
};
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
// promote guests and demote *other* hosts (the user explicitly requested this
// expansion). Hosts may not touch admins. Admins may do anything (except change
// themselves, blocked above).
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" {
return Err(AppError::Forbidden(
"Admins können nicht geändert werden.".into(),
));
}
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
.bind(user_id)
.bind(new_role)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
old_role = %target.0,
new_role,
"host: set_role"
);
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize)]
pub struct PinResetResponse {
/// Plaintext PIN — shown to the operator **once**. Never persisted client-side.
pub pin: String,
}
/// Generate a fresh PIN for another user, returning the plaintext exactly once.
///
/// Authorisation:
/// - Host caller → may reset **guest** PINs only.
/// - Admin caller → may reset **guest** and **host** PINs (never another admin).
/// - Target ≠ caller.
pub async fn reset_user_pin(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<Json<PinResetResponse>, AppError> {
use rand::Rng;
if user_id == auth.user_id {
return Err(AppError::BadRequest(
"Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(),
));
}
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()))?;
match (auth.role.clone(), target.0.as_str()) {
(UserRole::Admin, "guest" | "host") => {}
(UserRole::Host, "guest") => {}
_ => {
return Err(AppError::Forbidden(
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
))
}
}
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)))?;
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $1,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
)
.bind(&pin_hash)
.bind(user_id)
.execute(&state.pool)
.await?;
// Notify the *recipient* device(s) if they happen to be online so they can clear
// their cached local PIN. They'll save the new one on the next /recover.
let _ = state.sse_tx.send(SseEvent::new(
"pin-reset",
serde_json::json!({ "user_id": user_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
"host: reset_user_pin"
);
Ok(Json(PinResetResponse { pin }))
}
pub async fn host_delete_upload(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
}
let _ = state.sse_tx.send(SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload.id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
upload_id = %upload.id,
"host: host_delete_upload"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn host_delete_comment(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let deleted =
Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
}
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
comment_id = %comment_id,
"host: host_delete_comment"
);
Ok(StatusCode::NO_CONTENT)
}
pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Only broadcast when this call actually flipped the lock — closing an
// already-closed event is a no-op and shouldn't spam listeners.
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1 AND uploads_locked_at IS NOT NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.export_released_at.is_some() {
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
}
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Enqueue export jobs
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
ON CONFLICT (event_id, type) DO NOTHING",
)
.bind(event.id)
.bind(export_type)
.execute(&state.pool)
.await?;
}
// Spawn export workers
crate::services::export::spawn_export_jobs(
event.id,
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.sse_tx.clone(),
);
Ok(StatusCode::NO_CONTENT)
}