320 lines
9.8 KiB
Rust
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(()))
|
|
}
|