chattyness/crates/chattyness-admin-ui/src/api/scenes.rs

320 lines
9.8 KiB
Rust

//! 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<ImageDownloadResult, AppError> {
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<i64>,
pub offset: Option<i64>,
}
/// List all scenes for a realm.
pub async fn list_scenes(
State(pool): State<PgPool>,
Path(slug): Path<String>,
) -> Result<Json<Vec<SceneSummary>>, 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<PgPool>,
Path(scene_id): Path<Uuid>,
) -> Result<Json<Scene>, 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<String>,
Json(mut req): Json<CreateSceneRequest>,
) -> Result<Json<CreateSceneResponse>, 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<Uuid>,
Json(mut req): Json<UpdateSceneRequest>,
) -> Result<Json<Scene>, 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<Uuid>,
) -> Result<Json<()>, AppError> {
let conn = admin_conn.0;
let mut guard = conn.acquire().await;
scenes::delete_scene(&mut *guard, scene_id).await?;
Ok(Json(()))
}