diff --git a/src/admin.rs b/src/admin.rs index 11da113..99eb976 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1,6 +1,6 @@ use crate::{language::language, page::Page, AppState}; use axum::{ - extract::State, + extract::{Path, Query, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}, routing::{get, post}, @@ -12,13 +12,33 @@ use axum_extra::extract::{ }; use maud::{html, Markup}; use serde::Deserialize; +use std::collections::HashMap; use time::OffsetDateTime; +use uuid::Uuid; #[derive(Deserialize)] struct LoginForm { password: String, } +#[derive(Deserialize)] +struct CameraForm { + uuid: String, + name: String, + desc: Option, +} + +#[derive(Deserialize)] +struct DeleteCameraForm { + uuid: String, +} + +#[derive(Deserialize)] +struct EditCameraForm { + name: String, + desc: Option, +} + async fn login_page(cookies: CookieJar, headers: HeaderMap) -> Markup { let lang = language(&cookies, &headers); rust_i18n::set_locale(lang.to_locale()); @@ -94,6 +114,11 @@ async fn protected_page( p { "Welcome to the admin area! This is a protected route." } p { "Only authenticated administrators can access this page." } + h2 { "Camera Management" } + p { "Manage cameras in the system." } + a href="/admin/cameras/add" { "Add Camera" } + " | " + a href="/admin/cameras" { "Manage Cameras" } form method="POST" action="/admin/logout" { input type="submit" value="Logout" class="secondary"; @@ -103,10 +128,306 @@ async fn protected_page( markup.into_response() } +async fn add_camera_page( + private_cookies: PrivateCookieJar, + cookies: CookieJar, + headers: HeaderMap, + Query(params): Query>, +) -> Response { + // Check if admin is authenticated + if private_cookies.get("admin_session").is_none() { + return Redirect::to("/admin/login").into_response(); + } + + let lang = language(&cookies, &headers); + rust_i18n::set_locale(lang.to_locale()); + + // Get pre-filled UUID from query params + let prefilled_uuid = params.get("uuid").unwrap_or(&String::new()).clone(); + + let markup = Page::new(lang).content(html! { + h1 { "Add Camera" } + @if !prefilled_uuid.is_empty() { + p.text-muted { "Auto-detected missing camera with UUID: " strong { (prefilled_uuid) } } + } + form method="POST" action="/admin/cameras/add" { + fieldset { + label for="uuid" { "Camera UUID:" } + input + type="text" + name="uuid" + id="uuid" + placeholder="e.g., 123e4567-e89b-12d3-a456-426614174000" + value=(prefilled_uuid) + required; + + label for="name" { "Camera Name:" } + input + type="text" + name="name" + id="name" + placeholder="e.g., Front Entrance Camera" + required; + + label for="desc" { "Description (optional):" } + textarea + name="desc" + id="desc" + placeholder="e.g., Camera monitoring the main entrance" {}; + + input type="submit" value="Add Camera"; + } + } + p { + a href="/protected" { "← Back to Admin Dashboard" } + } + }); + + markup.into_response() +} + +async fn manage_cameras_page( + State(state): State, + private_cookies: PrivateCookieJar, + cookies: CookieJar, + headers: HeaderMap, +) -> Response { + // Check if admin is authenticated + if private_cookies.get("admin_session").is_none() { + return Redirect::to("/admin/login").into_response(); + } + + let lang = language(&cookies, &headers); + rust_i18n::set_locale(lang.to_locale()); + + let cameras = state.backend.get_all_cameras().await; + + let markup = Page::new(lang).content(html! { + h1 { "Manage Cameras" } + p { "Total cameras: " strong { (cameras.len()) } } + + @if cameras.is_empty() { + p.text-muted { "No cameras found in the system." } + } @else { + table { + thead { + tr { + th { "UUID" } + th { "Name" } + th { "Description" } + th { "Actions" } + } + } + tbody { + @for camera in &cameras { + tr { + td { + code { (camera.uuid) } + } + td { (camera.name) } + td { + @if let Some(desc) = &camera.desc { + (desc) + } @else { + em.text-muted { "No description" } + } + } + td { + a href=(format!("/admin/cameras/{}/edit", camera.uuid)) class="secondary" style="margin-right: 0.5rem;" { "Edit" } + form method="POST" action="/admin/cameras/delete" style="display: inline;" { + input type="hidden" name="uuid" value=(camera.uuid); + input + type="submit" + value="Delete" + class="secondary" + onclick="return confirm('Are you sure you want to delete this camera? This will also remove all associated sightings.')"; + } + } + } + } + } + } + } + + p { + a href="/admin/cameras/add" { "Add New Camera" } + " | " + a href="/protected" { "← Back to Admin Dashboard" } + } + }); + + markup.into_response() +} + +async fn add_camera( + State(state): State, + private_cookies: PrivateCookieJar, + Form(form): Form, +) -> Response { + // Check if admin is authenticated + if private_cookies.get("admin_session").is_none() { + return Redirect::to("/admin/login").into_response(); + } + + // Parse UUID + let uuid = match Uuid::parse_str(&form.uuid) { + Ok(uuid) => uuid, + Err(_) => return Redirect::to("/admin/cameras/add?error=invalid_uuid").into_response(), + }; + + // Check if camera already exists + if state.backend.get_camera(&uuid).await.is_some() { + return Redirect::to("/admin/cameras/add?error=already_exists").into_response(); + } + + // Create the camera + let desc = if form + .desc + .as_ref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { + None + } else { + form.desc.as_deref() + }; + + match state.backend.create_camera(&uuid, &form.name, desc).await { + Ok(_) => Redirect::to("/admin/cameras?camera_added=1").into_response(), + Err(_) => Redirect::to("/admin/cameras/add?error=creation_failed").into_response(), + } +} + +async fn edit_camera_page( + State(state): State, + private_cookies: PrivateCookieJar, + cookies: CookieJar, + headers: HeaderMap, + Path(uuid_str): Path, +) -> Response { + // Check if admin is authenticated + if private_cookies.get("admin_session").is_none() { + return Redirect::to("/admin/login").into_response(); + } + + let lang = language(&cookies, &headers); + rust_i18n::set_locale(lang.to_locale()); + + // Parse UUID + let uuid = match Uuid::parse_str(&uuid_str) { + Ok(uuid) => uuid, + Err(_) => return Redirect::to("/admin/cameras?error=invalid_uuid").into_response(), + }; + + // Get camera details + let Some(camera) = state.backend.get_camera(&uuid).await else { + return Redirect::to("/admin/cameras?error=not_found").into_response(); + }; + + let markup = Page::new(lang).content(html! { + h1 { "Edit Camera" } + p.text-muted { "UUID: " code { (camera.uuid) } } + + form method="POST" action=(format!("/admin/cameras/{}/edit", camera.uuid)) { + fieldset { + label for="name" { "Camera Name:" } + input + type="text" + name="name" + id="name" + value=(camera.name) + required; + + label for="desc" { "Description (optional):" } + textarea + name="desc" + id="desc" + placeholder="e.g., Camera monitoring the main entrance" { + @if let Some(desc) = &camera.desc { + (desc) + } + } + + input type="submit" value="Update Camera"; + } + } + p { + a href="/admin/cameras" { "← Back to Camera List" } + } + }); + + markup.into_response() +} + +async fn update_camera( + State(state): State, + private_cookies: PrivateCookieJar, + Path(uuid_str): Path, + Form(form): Form, +) -> Response { + // Check if admin is authenticated + if private_cookies.get("admin_session").is_none() { + return Redirect::to("/admin/login").into_response(); + } + + // Parse UUID + let uuid = match Uuid::parse_str(&uuid_str) { + Ok(uuid) => uuid, + Err(_) => return Redirect::to("/admin/cameras?error=invalid_uuid").into_response(), + }; + + // Process description + let desc = if form + .desc + .as_ref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { + None + } else { + form.desc.as_deref() + }; + + match state.backend.update_camera(&uuid, &form.name, desc).await { + Ok(true) => Redirect::to("/admin/cameras?camera_updated=1").into_response(), + Ok(false) => Redirect::to("/admin/cameras?error=not_found").into_response(), + Err(_) => Redirect::to("/admin/cameras?error=update_failed").into_response(), + } +} + +async fn delete_camera( + State(state): State, + private_cookies: PrivateCookieJar, + Form(form): Form, +) -> Response { + // Check if admin is authenticated + if private_cookies.get("admin_session").is_none() { + return Redirect::to("/admin/login").into_response(); + } + + // Parse UUID + let uuid = match Uuid::parse_str(&form.uuid) { + Ok(uuid) => uuid, + Err(_) => return Redirect::to("/admin/cameras?error=invalid_uuid").into_response(), + }; + + match state.backend.delete_camera(&uuid).await { + Ok(true) => Redirect::to("/admin/cameras?camera_deleted=1").into_response(), + Ok(false) => Redirect::to("/admin/cameras?error=not_found").into_response(), + Err(_) => Redirect::to("/admin/cameras?error=deletion_failed").into_response(), + } +} + pub fn routes() -> Router { Router::new() .route("/admin/login", get(login_page)) .route("/admin/login", post(login)) .route("/admin/logout", post(logout)) .route("/protected", get(protected_page)) + .route("/admin/cameras", get(manage_cameras_page)) + .route("/admin/cameras/add", get(add_camera_page)) + .route("/admin/cameras/add", post(add_camera)) + .route("/admin/cameras/:uuid/edit", get(edit_camera_page)) + .route("/admin/cameras/:uuid/edit", post(update_camera)) + .route("/admin/cameras/delete", post(delete_camera)) } diff --git a/src/game.rs b/src/game.rs index c64a1ae..6bd0818 100644 --- a/src/game.rs +++ b/src/game.rs @@ -143,6 +143,12 @@ async fn game( }; let Some(camera) = backend.get_camera(&uuid).await else { + // Check if user is admin + if cookies.get("admin_session").is_some() { + // Redirect to camera add form with pre-filled UUID + return axum::response::Redirect::to(&format!("/admin/cameras/add?uuid={}", uuid)) + .into_response(); + } return not_found(lang_cookies, headers).await.into_response(); }; diff --git a/src/model/camera.rs b/src/model/camera.rs index 176ac88..841d102 100644 --- a/src/model/camera.rs +++ b/src/model/camera.rs @@ -25,6 +25,69 @@ impl Backend { } } + pub(crate) async fn create_camera(&self, uuid: &Uuid, name: &str, desc: Option<&str>) -> Result<(), sqlx::Error> { + let uuid_str = uuid.to_string(); + match self { + Backend::Sqlite(db) => { + sqlx::query!( + "INSERT INTO camera (uuid, name, desc) VALUES (?, ?, ?)", + uuid_str, + name, + desc + ) + .execute(db) + .await?; + Ok(()) + } + } + } + + pub(crate) async fn update_camera(&self, uuid: &Uuid, name: &str, desc: Option<&str>) -> Result { + let uuid_str = uuid.to_string(); + match self { + Backend::Sqlite(db) => { + let result = sqlx::query!( + "UPDATE camera SET name = ?, desc = ? WHERE uuid = ?", + name, + desc, + uuid_str + ) + .execute(db) + .await?; + Ok(result.rows_affected() > 0) + } + } + } + + pub(crate) async fn delete_camera(&self, uuid: &Uuid) -> Result { + let uuid_str = uuid.to_string(); + match self { + Backend::Sqlite(db) => { + let result = sqlx::query!( + "DELETE FROM camera WHERE uuid = ?", + uuid_str + ) + .execute(db) + .await?; + Ok(result.rows_affected() > 0) + } + } + } + + pub(crate) async fn get_all_cameras(&self) -> Vec { + match self { + Backend::Sqlite(db) => { + sqlx::query_as!( + Camera, + "SELECT uuid, desc, name FROM camera ORDER BY name" + ) + .fetch_all(db) + .await + .unwrap_or_default() + } + } + } + pub(crate) async fn amount_total_cameras(&self) -> i64 { match self { Backend::Sqlite(db) => {