use axum::{ extract::{Multipart, Path, State}, Json, }; use std::path::PathBuf; use super::{AppError, AppState}; const GALLERY_DIR: &str = "/data/gallery"; pub async fn list_all_gallery( State(state): State, ) -> Result, AppError> { let rows = sqlx::query( r#"SELECT g.id, g.catalog_id, g.filename, g.caption, g.created_at, c.name AS target_name, c.common_name AS target_common_name FROM gallery g LEFT JOIN catalog c ON c.id = g.catalog_id ORDER BY g.created_at DESC"#, ) .fetch_all(&state.pool) .await?; let items: Vec = rows.iter().map(|r| { use sqlx::Row; let id: i32 = r.try_get("id").unwrap_or_default(); let catalog_id: String = r.try_get("catalog_id").unwrap_or_default(); let filename: String = r.try_get("filename").unwrap_or_default(); serde_json::json!({ "id": id, "catalog_id": &catalog_id, "filename": &filename, "url": format!("/api/gallery/files/{}/{}", catalog_id, filename), "caption": r.try_get::, _>("caption").unwrap_or_default(), "created_at": r.try_get::("created_at").unwrap_or_default(), "target_name": r.try_get::, _>("target_name").unwrap_or_default(), "target_common_name": r.try_get::, _>("target_common_name").unwrap_or_default(), }) }).collect(); Ok(Json(serde_json::json!({ "items": items }))) } pub async fn list_gallery( State(state): State, Path(catalog_id): Path, ) -> Result, AppError> { let rows = sqlx::query( "SELECT * FROM gallery WHERE catalog_id = ? ORDER BY created_at DESC", ) .bind(&catalog_id) .fetch_all(&state.pool) .await?; let items: Vec = rows.iter().map(|r| { use sqlx::Row; let id: i32 = r.try_get("id").unwrap_or_default(); let filename: String = r.try_get("filename").unwrap_or_default(); serde_json::json!({ "id": id, "catalog_id": catalog_id, "filename": filename, "url": format!("/api/gallery/files/{}/{}", catalog_id, filename), "caption": r.try_get::, _>("caption").unwrap_or_default(), "created_at": r.try_get::("created_at").unwrap_or_default(), }) }).collect(); Ok(Json(serde_json::json!({ "items": items }))) } pub async fn upload_image( State(state): State, Path(catalog_id): Path, mut multipart: Multipart, ) -> Result, AppError> { let mut image_bytes: Option> = None; let mut orig_filename = String::from("image.jpg"); let mut caption: Option = None; let mut log_id: Option = None; while let Some(field) = multipart.next_field().await.map_err(|e| AppError::Internal(e.to_string()))? { let name = field.name().unwrap_or("").to_string(); match name.as_str() { "file" => { orig_filename = field.file_name().unwrap_or("image.jpg").to_string(); let bytes = field.bytes().await.map_err(|e| AppError::Internal(e.to_string()))?; if bytes.len() > 50 * 1024 * 1024 { return Err(AppError::BadRequest("File exceeds 50MB limit".to_string())); } image_bytes = Some(bytes.to_vec()); } "caption" => { caption = Some(field.text().await.map_err(|e| AppError::Internal(e.to_string()))?); } "log_id" => { log_id = field.text().await.ok().and_then(|s| s.parse().ok()); } _ => {} } } let bytes = image_bytes.ok_or_else(|| AppError::BadRequest("No file uploaded".to_string()))?; // Convert TIFF to JPEG if needed, else store as-is let ext = std::path::Path::new(&orig_filename) .extension() .and_then(|e| e.to_str()) .unwrap_or("jpg") .to_lowercase(); let (final_bytes, final_ext) = if ext == "tiff" || ext == "tif" { match convert_tiff_to_jpeg(&bytes) { Ok(jpeg) => (jpeg, "jpg".to_string()), Err(_) => (bytes, ext), } } else { (bytes, ext) }; // Generate unique filename let uid = uuid::Uuid::new_v4(); let filename = format!("{}.{}", uid, final_ext); // Ensure directory exists let dir = PathBuf::from(GALLERY_DIR).join(&catalog_id); tokio::fs::create_dir_all(&dir) .await .map_err(|e| AppError::Internal(format!("Failed to create gallery dir: {}", e)))?; let file_path = dir.join(&filename); tokio::fs::write(&file_path, &final_bytes) .await .map_err(|e| AppError::Internal(format!("Failed to write image: {}", e)))?; let id: i64 = sqlx::query_scalar( "INSERT INTO gallery (catalog_id, log_id, filename, caption) VALUES (?, ?, ?, ?) RETURNING id", ) .bind(&catalog_id) .bind(log_id) .bind(&filename) .bind(&caption) .fetch_one(&state.pool) .await?; Ok(Json(serde_json::json!({ "id": id, "catalog_id": catalog_id, "filename": filename, "url": format!("/api/gallery/files/{}/{}", catalog_id, filename), }))) } pub async fn delete_image( State(state): State, Path(id): Path, ) -> Result, AppError> { let row = sqlx::query("SELECT catalog_id, filename FROM gallery WHERE id = ?") .bind(id) .fetch_optional(&state.pool) .await? .ok_or_else(|| AppError::NotFound(format!("Gallery image {} not found", id)))?; use sqlx::Row; let catalog_id: String = row.try_get("catalog_id").unwrap_or_default(); let filename: String = row.try_get("filename").unwrap_or_default(); let file_path = PathBuf::from(GALLERY_DIR).join(&catalog_id).join(&filename); let _ = tokio::fs::remove_file(&file_path).await; sqlx::query("DELETE FROM gallery WHERE id = ?") .bind(id) .execute(&state.pool) .await?; Ok(Json(serde_json::json!({ "id": id, "status": "deleted" }))) } fn convert_tiff_to_jpeg(bytes: &[u8]) -> anyhow::Result> { let img = image::load_from_memory(bytes)?; let mut output = Vec::new(); let mut cursor = std::io::Cursor::new(&mut output); img.write_to(&mut cursor, image::ImageOutputFormat::Jpeg(90))?; Ok(output) }