//! Scene management API handlers for admin UI. use axum::{ Json, extract::{Path, State}, }; use chattyness_db::{ models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}, queries::{realms, scenes}, }; use chattyness_error::{AppError, OptionExt}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::path::PathBuf; use uuid::Uuid; use crate::auth::AdminConn; // ============================================================================= // Image Processing Helpers // ============================================================================= /// Result of downloading and storing a background image. struct ImageDownloadResult { /// The local path to the stored image (relative to static root, for URL). local_path: String, /// Image dimensions if requested. dimensions: Option<(u32, u32)>, } /// Download an image from a URL and store it locally. /// /// Returns the local path and optionally the dimensions. /// Path format: /static/realm/{realm_id}/scene/{scene_id}/{sha256}.{ext} async fn download_and_store_image( url: &str, realm_id: Uuid, scene_id: Uuid, extract_dimensions: bool, ) -> Result { use sha2::{Digest, Sha256}; // Validate URL if !url.starts_with("http://") && !url.starts_with("https://") { return Err(AppError::Validation( "Image URL must start with http:// or https://".to_string(), )); } // Download the image let client = reqwest::Client::new(); let response = client .get(url) .header( reqwest::header::USER_AGENT, "Chattyness/1.0 (Background image downloader)", ) .header(reqwest::header::ACCEPT, "image/*") .send() .await .map_err(|e| AppError::Internal(format!("Failed to fetch image: {}", e)))?; // Check content type let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); // Determine extension from content type let ext = match content_type { t if t.starts_with("image/jpeg") => "jpg", t if t.starts_with("image/png") => "png", t if t.starts_with("image/gif") => "gif", t if t.starts_with("image/webp") => "webp", _ => { // Try to infer from URL if url.contains(".jpg") || url.contains(".jpeg") { "jpg" } else if url.contains(".png") { "png" } else if url.contains(".gif") { "gif" } else if url.contains(".webp") { "webp" } else { return Err(AppError::Validation(format!( "Unsupported image type: {}", content_type ))); } } }; // Get the image bytes let bytes = response .bytes() .await .map_err(|e| AppError::Internal(format!("Failed to read image data: {}", e)))?; // Compute SHA256 hash of the image content let mut hasher = Sha256::new(); hasher.update(&bytes); let hash = hex::encode(hasher.finalize()); // Extract dimensions if requested let dimensions = if extract_dimensions { let reader = image::ImageReader::new(std::io::Cursor::new(&bytes)) .with_guessed_format() .map_err(|e| AppError::Internal(format!("Failed to detect image format: {}", e)))?; let dims = reader .into_dimensions() .map_err(|e| AppError::Internal(format!("Failed to read image dimensions: {}", e)))?; Some(dims) } else { None }; // Create directory structure: /srv/chattyness/assets/realm/{realm_id}/scene/{scene_id}/ let dir_path = PathBuf::from("/srv/chattyness/assets") .join("realm") .join(realm_id.to_string()) .join("scene") .join(scene_id.to_string()); tokio::fs::create_dir_all(&dir_path) .await .map_err(|e| AppError::Internal(format!("Failed to create directory: {}", e)))?; // Write the file with SHA256 hash as filename let filename = format!("{}.{}", hash, ext); let file_path = dir_path.join(&filename); tokio::fs::write(&file_path, &bytes) .await .map_err(|e| AppError::Internal(format!("Failed to write image file: {}", e)))?; // Return the URL path (relative to server root) let local_path = format!("/static/realm/{}/scene/{}/{}", realm_id, scene_id, filename); Ok(ImageDownloadResult { local_path, dimensions, }) } /// Delete all image files for a scene. async fn delete_scene_images(realm_id: Uuid, scene_id: Uuid) -> Result<(), AppError> { let dir_path = PathBuf::from("/srv/chattyness/assets") .join("realm") .join(realm_id.to_string()) .join("scene") .join(scene_id.to_string()); // Try to remove all files in the directory if let Ok(mut entries) = tokio::fs::read_dir(&dir_path).await { while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.is_file() { tokio::fs::remove_file(&path).await.ok(); } } } Ok(()) } // ============================================================================= // API Types // ============================================================================= /// Query parameters for scene list. #[derive(Debug, Deserialize)] pub struct ListScenesQuery { pub limit: Option, pub offset: Option, } /// List all scenes for a realm. pub async fn list_scenes( State(pool): State, Path(slug): Path, ) -> Result>, AppError> { // Get the realm let realm = realms::get_realm_by_slug(&pool, &slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; let scene_list = scenes::list_scenes_for_realm(&pool, realm.id).await?; Ok(Json(scene_list)) } /// Get a scene by ID. pub async fn get_scene( State(pool): State, Path(scene_id): Path, ) -> Result, AppError> { let scene = scenes::get_scene_by_id(&pool, scene_id) .await? .or_not_found("Scene")?; Ok(Json(scene)) } /// Create scene response. #[derive(Debug, Serialize)] pub struct CreateSceneResponse { pub id: Uuid, pub slug: String, } /// Create a new scene in a realm. pub async fn create_scene( admin_conn: AdminConn, Path(slug): Path, Json(mut req): Json, ) -> Result, AppError> { let conn = admin_conn.0; let mut guard = conn.acquire().await; // Get the realm let realm = realms::get_realm_by_slug(&mut *guard, &slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; // Check if slug is available let available = scenes::is_scene_slug_available(&mut *guard, realm.id, &req.slug).await?; if !available { return Err(AppError::Conflict(format!( "Scene slug '{}' is already taken in this realm", req.slug ))); } // Generate a temporary scene ID for image storage path let scene_id = Uuid::new_v4(); // Handle background image URL - download and store locally if let Some(ref url) = req.background_image_url { if !url.is_empty() { let result = download_and_store_image(url, realm.id, scene_id, req.infer_dimensions_from_image) .await?; req.background_image_path = Some(result.local_path); if let Some((width, height)) = result.dimensions { req.bounds_wkt = Some(format!( "POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", width, width, height, height )); } } } let scene = scenes::create_scene_with_id(&mut *guard, scene_id, realm.id, &req).await?; Ok(Json(CreateSceneResponse { id: scene.id, slug: scene.slug, })) } /// Update a scene. pub async fn update_scene( admin_conn: AdminConn, Path(scene_id): Path, Json(mut req): Json, ) -> Result, AppError> { let conn = admin_conn.0; let mut guard = conn.acquire().await; // Get the existing scene to get realm_id let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id) .await? .or_not_found("Scene")?; // Handle clear background image if req.clear_background_image { delete_scene_images(existing_scene.realm_id, scene_id).await?; req.background_image_path = Some(String::new()); } // Handle new background image URL - download and store locally else if let Some(ref url) = req.background_image_url { if !url.is_empty() { delete_scene_images(existing_scene.realm_id, scene_id).await?; let result = download_and_store_image( url, existing_scene.realm_id, scene_id, req.infer_dimensions_from_image, ) .await?; req.background_image_path = Some(result.local_path); if let Some((width, height)) = result.dimensions { req.bounds_wkt = Some(format!( "POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", width, width, height, height )); } } } let scene = scenes::update_scene(&mut *guard, scene_id, &req).await?; Ok(Json(scene)) } /// Delete a scene. pub async fn delete_scene( admin_conn: AdminConn, Path(scene_id): Path, ) -> Result, AppError> { let conn = admin_conn.0; let mut guard = conn.acquire().await; scenes::delete_scene(&mut *guard, scene_id).await?; Ok(Json(())) }