Initial Commit

This commit is contained in:
2026-04-09 23:23:31 +02:00
commit 9223e4d35f
94 changed files with 15173 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
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<AppState>,
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = 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/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
"target_common_name": r.try_get::<Option<String>, _>("target_common_name").unwrap_or_default(),
})
}).collect();
Ok(Json(serde_json::json!({ "items": items })))
}
pub async fn list_gallery(
State(state): State<AppState>,
Path(catalog_id): Path<String>,
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = 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/{}/{}", catalog_id, filename),
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
})
}).collect();
Ok(Json(serde_json::json!({ "items": items })))
}
pub async fn upload_image(
State(state): State<AppState>,
Path(catalog_id): Path<String>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, AppError> {
let mut image_bytes: Option<Vec<u8>> = None;
let mut orig_filename = String::from("image.jpg");
let mut caption: Option<String> = None;
let mut log_id: Option<i32> = 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/{}/{}", catalog_id, filename),
})))
}
pub async fn delete_image(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<Json<serde_json::Value>, 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<Vec<u8>> {
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)
}