add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
317
crates/chattyness-admin-ui/src/api/scenes.rs
Normal file
317
crates/chattyness-admin-ui/src/api/scenes.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
//! Scene management API handlers for admin UI.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chattyness_db::{
|
||||
models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest},
|
||||
queries::{realms, scenes},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
// =============================================================================
|
||||
// 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?
|
||||
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
||||
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(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
Json(mut req): Json<CreateSceneRequest>,
|
||||
) -> Result<Json<CreateSceneResponse>, AppError> {
|
||||
// Get the realm
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
// Check if slug is available
|
||||
let available = scenes::is_scene_slug_available(&pool, 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(&pool, scene_id, realm.id, &req).await?;
|
||||
Ok(Json(CreateSceneResponse {
|
||||
id: scene.id,
|
||||
slug: scene.slug,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update a scene.
|
||||
pub async fn update_scene(
|
||||
State(pool): State<PgPool>,
|
||||
Path(scene_id): Path<Uuid>,
|
||||
Json(mut req): Json<UpdateSceneRequest>,
|
||||
) -> Result<Json<Scene>, AppError> {
|
||||
// Get the existing scene to get realm_id
|
||||
let existing_scene = scenes::get_scene_by_id(&pool, scene_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
||||
|
||||
// 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(&pool, scene_id, &req).await?;
|
||||
Ok(Json(scene))
|
||||
}
|
||||
|
||||
/// Delete a scene.
|
||||
pub async fn delete_scene(
|
||||
State(pool): State<PgPool>,
|
||||
Path(scene_id): Path<Uuid>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
scenes::delete_scene(&pool, scene_id).await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue