diff --git a/backend/src/handlers/host.rs b/backend/src/handlers/host.rs new file mode 100644 index 0000000..e918053 --- /dev/null +++ b/backend/src/handlers/host.rs @@ -0,0 +1,260 @@ +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::state::AppState; + +// ── 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", + ) + .bind(user_id) + .bind(body.hide_uploads) + .execute(&state.pool) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn unban_user( + State(state): State, + RequireHost(_auth): RequireHost, + Path(user_id): Path, +) -> Result { + sqlx::query("UPDATE \"user\" SET is_banned = FALSE WHERE id = $1") + .bind(user_id) + .execute(&state.pool) + .await?; + 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())), + }; + 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?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn host_delete_upload( + State(state): State, + RequireHost(_auth): RequireHost, + Path(upload_id): Path, +) -> Result { + let upload = Upload::find_by_id(&state.pool, upload_id) + .await? + .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; + + Upload::soft_delete(&state.pool, upload_id).await?; + + let _ = state.sse_tx.send(crate::state::SseEvent { + event_type: "upload-deleted".to_string(), + data: serde_json::json!({ "upload_id": upload.id }).to_string(), + }); + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn host_delete_comment( + State(state): State, + RequireHost(_auth): RequireHost, + Path(comment_id): Path, +) -> Result { + Comment::find_by_id(&state.pool, comment_id) + .await? + .ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?; + + Comment::soft_delete(&state.pool, comment_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn close_event( + State(state): State, + RequireHost(_auth): RequireHost, +) -> 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?; + + let _ = state.sse_tx.send(crate::state::SseEvent { + event_type: "event-closed".to_string(), + data: "{}".to_string(), + }); + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn open_event( + State(state): State, + RequireHost(_auth): RequireHost, +) -> Result { + sqlx::query( + "UPDATE event SET uploads_locked_at = NULL WHERE slug = $1", + ) + .bind(&state.config.event_slug) + .execute(&state.pool) + .await?; + + let _ = state.sse_tx.send(crate::state::SseEvent { + event_type: "event-opened".to_string(), + data: "{}".to_string(), + }); + + 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 (processed by the export worker in a later step) + 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?; + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 82971d0..ff02441 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod feed; +pub mod host; pub mod social; pub mod sse; pub mod upload; diff --git a/backend/src/main.rs b/backend/src/main.rs index 39c27c1..f0b47c0 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -58,7 +58,18 @@ async fn main() -> Result<()> { ) .route("/api/v1/comment/{id}", delete(handlers::social::delete_comment)) // SSE - .route("/api/v1/stream", get(handlers::sse::stream)); + .route("/api/v1/stream", get(handlers::sse::stream)) + // Host Dashboard + .route("/api/v1/host/event", get(handlers::host::get_event_status)) + .route("/api/v1/host/event/close", post(handlers::host::close_event)) + .route("/api/v1/host/event/open", post(handlers::host::open_event)) + .route("/api/v1/host/gallery/release", post(handlers::host::release_gallery)) + .route("/api/v1/host/users", get(handlers::host::list_users)) + .route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user)) + .route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user)) + .route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role)) + .route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload)) + .route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment)); // Serve media files from disk let media_service = ServeDir::new(&config.media_path); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index fe9f961..6546c85 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -38,6 +38,17 @@ export function clearAuth(): void { isAuthenticated.set(false); } +export function getRole(): 'guest' | 'host' | 'admin' | null { + const token = getToken(); + if (!token) return null; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return payload.role ?? null; + } catch { + return null; + } +} + export function initAuth(): void { if (!browser) return; isAuthenticated.set(!!getToken()); diff --git a/frontend/src/routes/host/+page.svelte b/frontend/src/routes/host/+page.svelte new file mode 100644 index 0000000..3d63a6a --- /dev/null +++ b/frontend/src/routes/host/+page.svelte @@ -0,0 +1,318 @@ + + + +{#if banTarget} +
+
+

Benutzer sperren

+

+ Was soll mit den Uploads von {banTarget.display_name} passieren? +

+ +
+ + +
+
+
+{/if} + + +{#if toast} +
+ {toast} +
+{/if} + +
+ +
+
+
+

Host Dashboard

+ {#if event} +

{event.name}

+ {/if} +
+ Zur Galerie +
+
+ +
+ {#if loading} +
Laden…
+ {:else if error} +
{error}
+ {:else if event} + +
+

Veranstaltung

+
+ + +
+
+ + + Uploads {event.uploads_locked ? 'gesperrt' : 'offen'} + + + + Export {event.export_released ? 'freigegeben' : 'gesperrt'} + +
+
+ + +
+
+

Gäste ({users.length})

+
+ {#if users.length === 0} +

Noch keine Gäste.

+ {:else} +
+ {#each users as user} +
+
+
+ {user.display_name} + {#if user.role === 'host'} + Host + {:else if user.role === 'admin'} + Admin + {/if} + {#if user.is_banned} + Gesperrt + {/if} +
+

+ {user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)} +

+
+
+ {#if user.role !== 'admin'} + {#if user.is_banned} + + {:else} + {#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')} + + {/if} + {#if user.role === 'host' && myRole === 'admin'} + + {/if} + + {/if} + {/if} +
+
+ {/each} +
+ {/if} +
+ {/if} +
+