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, } #[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, RequireHost(_auth): RequireHost, ) -> Result, 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, RequireHost(auth): RequireHost, ) -> Result>, 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, RequireHost(auth): RequireHost, Path(user_id): Path, Json(body): Json, ) -> Result { // 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, RequireHost(auth): RequireHost, Path(user_id): Path, ) -> Result { 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, RequireHost(auth): RequireHost, Path(user_id): Path, Json(body): Json, ) -> Result { 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, RequireHost(auth): RequireHost, Path(user_id): Path, ) -> Result, 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, RequireHost(auth): RequireHost, Path(upload_id): Path, ) -> Result { 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, RequireHost(auth): RequireHost, Path(comment_id): Path, ) -> Result { 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, RequireHost(_auth): RequireHost, ) -> Result { 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, RequireHost(_auth): RequireHost, ) -> Result { 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, RequireHost(_auth): RequireHost, ) -> Result { 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) }