//! Routes API REST du dashboard — proxy vers Imago v2 use axum::{ Router, routing::{get, post, put}, extract::{Multipart, Path, Query, State}, http::HeaderMap, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::path::PathBuf; use std::collections::HashSet; use tokio::sync::Mutex; use crate::imago_client::{ImagoClient, ImageSummary}; /// État partagé de l'application pub struct AppState { pub imago: Arc, pub base_url: String, pub api_key: String, pub ws_hub: crate::server::ws::WsHub, /// Chemin vers la DB de dédoublonnage (partagée avec le watcher) pub dedup_db_path: PathBuf, /// Uploads en cours — empêche les doublons concurrents pub in_flight: Mutex>>, } /// Crée un ImagoClient — utilise la clé fournie par l'utilisateur si présente, /// sinon la clé configurée par défaut (admin). fn get_client(state: &AppState, headers: &HeaderMap) -> anyhow::Result { let key = headers .get("x-api-key") .and_then(|v| v.to_str().ok()) .filter(|k| !k.is_empty()) .unwrap_or(&state.api_key); ImagoClient::new(state.base_url.clone(), key.to_string()) } /// Helper pour retourner une erreur JSON fn error_response(status: u16, msg: &str) -> (axum::http::StatusCode, Json) { ( axum::http::StatusCode::from_u16(status).unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR), Json(serde_json::json!({"error": msg})), ) } /// Paramètres de pagination #[derive(Deserialize)] pub struct Pagination { pub page: Option, pub per_page: Option, } /// Paramètres de recherche #[derive(Deserialize)] pub struct SearchQuery { pub q: Option, pub page: Option, pub per_page: Option, } /// Requête de validation de clé API #[derive(Deserialize)] pub struct AuthRequest { pub api_key: String, } /// Réponse de validation #[derive(Serialize)] pub struct AuthResponse { pub valid: bool, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// Convertit ImageSummary en JSON frontend-friendly fn image_to_json(img: &ImageSummary) -> serde_json::Value { serde_json::json!({ "id": img.id, "uuid": img.uuid, "filename": img.original_name, "mime_type": img.mime_type, "width": img.width, "height": img.height, "size_bytes": img.file_size, "tags": img.ai_tags, "description": img.ai_description, "created_at": img.uploaded_at, "status": img.processing_status, "thumbnail_url": img.thumbnail_path, }) } // ─── Route builder ───────────────────────────────────────────────────────── pub fn api_routes(state: AppState) -> Router { let state = Arc::new(state); Router::new() .route("/api/health", get(health_check)) .route("/api/ping", get(ping)) .route("/api/auth/validate", post(validate_api_key)) .route("/api/upload", post(upload_image)) .route("/api/images", get(list_images)) .route("/api/images/search", get(search_images)) .route("/api/images/{id}", get(get_image).delete(delete_image)) .route("/api/images/{id}/tags", put(update_tags)) .route("/api/images/{id}/thumbnail", get(get_thumbnail)) .route("/api/images/batch-delete", post(batch_delete)) .route("/api/stats", get(stats)) .route("/api/version", get(version)) .with_state(state) } // ─── Handlers ────────────────────────────────────────────────────────────── async fn health_check() -> &'static str { "OK" } /// Test de connectivité minimal async fn ping() -> Json { Json(serde_json::json!({"pong": true, "timestamp": chrono::Utc::now().to_rfc3339()})) } /// Retourne la version de l'application et des infos utiles async fn version() -> Json { Json(serde_json::json!({ "version": env!("IMAGOD_BUILD_VERSION"), "name": env!("CARGO_PKG_NAME"), "description": env!("CARGO_PKG_DESCRIPTION"), "repository": env!("CARGO_PKG_REPOSITORY"), "rustc": option_env!("RUSTC_VERSION").unwrap_or("unknown"), "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, "target_os": std::env::consts::OS, "target_arch": std::env::consts::ARCH, })) } /// Valide une clé API utilisateur contre l'API Imago. /// Si valide, retourne le nom du client (trouvé via les images existantes). async fn validate_api_key( State(state): State>, Json(body): Json, ) -> impl IntoResponse { let client = match ImagoClient::new(state.base_url.clone(), body.api_key.clone()) { Ok(c) => c, Err(e) => return Json(serde_json::json!({ "valid": false, "error": format!("Erreur client : {}", e) })), }; match client.list_images(1, 1).await { Ok(response) => { // Récupère le client_name de la première image si elle existe let name = response.items.first() .and_then(|img| img.client_name.clone()); Json(serde_json::json!({ "valid": true, "name": name })) } Err(e) => { let err_msg = format!("{:#}", e); let is_auth_error = err_msg.to_lowercase().contains("401") || err_msg.to_lowercase().contains("403") || err_msg.to_lowercase().contains("unauthorized"); if is_auth_error { Json(serde_json::json!({ "valid": false, "error": "Clé API invalide" })) } else { Json(serde_json::json!({ "valid": true, "name": null, "warning": format!("Clé acceptée mais l'API est inaccessible : {}", err_msg) })) } } } } /// Upload d'image : reçoit un fichier et le transmet à l'API Imago async fn upload_image( State(state): State>, headers: HeaderMap, mut multipart: Multipart, ) -> impl IntoResponse { let mut data: Vec = Vec::new(); let mut filename = String::from("upload.png"); let mut mime_type = String::from("image/png"); while let Ok(Some(mut field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "file" { if let Some(fn_) = field.file_name() { filename = fn_.to_string(); } if let Some(mt) = field.content_type() { mime_type = mt.to_string(); } while let Ok(Some(chunk)) = field.chunk().await { data.extend_from_slice(&chunk); } } } if data.is_empty() { return ( axum::http::StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Aucun fichier reçu"})), ); } // Validation MIME basique côté dashboard if !mime_type.starts_with("image/") { return ( axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, Json(serde_json::json!({"error": format!("Type MIME non supporté : {}", mime_type)})), ); } let client = match get_client(&state, &headers) { Ok(c) => c, Err(e) => return error_response(500, &format!("Erreur client : {}", e)), }; // Calculer le hash AVANT de consommer `data` (upload_image prend ownership) let file_hash = crate::dedup::Dedup::hash_bytes(&data); // ═══ Déduplication SQLite : vérifier si ce hash a déjà été uploadé ═══ if let Ok(dedup) = crate::dedup::Dedup::open(&state.dedup_db_path, 1000) { if dedup.already_seen(&file_hash).unwrap_or(false) { tracing::info!( "🔄 Fichier déjà connu — hash {}... ignoré (uploadeur : {})", hex::encode(&file_hash[..8.min(file_hash.len())]), filename ); return ( axum::http::StatusCode::OK, Json(serde_json::json!({ "id": null, "uuid": null, "filename": filename, "status": "duplicate", "message": "Image déjà existante (hash identique)" })), ); } } // ═══ Garde anti-doublon : vérifier qu'aucun upload concurrent n'est en cours pour ce hash ═══ { let mut in_flight = state.in_flight.lock().await; if in_flight.contains(&file_hash) { tracing::warn!( "🛑 Upload concurrent détecté pour {} ({} octets) — requête rejetée", filename, data.len() ); return ( axum::http::StatusCode::CONFLICT, Json(serde_json::json!({"error": "Upload déjà en cours pour ce fichier", "duplicate": true})), ); } in_flight.insert(file_hash.clone()); } tracing::info!( "📤 Upload reçu : {} ({} octets, hash: {}...)", filename, data.len(), hex::encode(&file_hash[..8.min(file_hash.len())]) ); match client.upload_image(data, &filename, None).await { Ok(response) => { // Marquer le hash dans la DB de dédoublonnage pour que le watcher // ne ré-upload pas ce fichier if let Ok(dedup) = crate::dedup::Dedup::open(&state.dedup_db_path, 1000) { let _ = dedup.mark_seen(&file_hash, &response.id.to_string()); } // Nettoyer le lock in-flight state.in_flight.lock().await.remove(&file_hash); ( axum::http::StatusCode::CREATED, Json(serde_json::json!({ "id": response.id, "uuid": response.uuid, "filename": response.original_name, "status": response.status, })), ) } Err(e) => { // Nettoyer le lock in-flight même en cas d'échec state.in_flight.lock().await.remove(&file_hash); tracing::error!("Upload vers Imago échoué : {:#}", e); ( axum::http::StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error": format!("Échec de l'upload : {:#}", e)})), ) } } } async fn list_images( State(state): State>, headers: HeaderMap, Query(pagination): Query, ) -> impl IntoResponse { let page = pagination.page.unwrap_or(1).max(1); let per_page = pagination.per_page.unwrap_or(24).min(100); let client = match get_client(&state, &headers) { Ok(c) => c, Err(e) => return Json(serde_json::json!({ "images": [], "total": 0, "page": page, "per_page": per_page, "error": format!("Erreur client : {}", e) })), }; match client.list_images(page, per_page).await { Ok(response) => { let images: Vec = response.items.iter().map(image_to_json).collect(); Json(serde_json::json!({ "images": images, "total": response.total, "page": response.page, "per_page": response.page_size })) } Err(e) => { tracing::error!("Erreur API Imago (list) : {:#}", e); Json(serde_json::json!({ "images": [], "total": 0, "page": page, "per_page": per_page, "error": format!("{:#}", e) })) } } } async fn search_images( State(state): State>, headers: HeaderMap, Query(params): Query, ) -> impl IntoResponse { let query = params.q.unwrap_or_default(); let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.unwrap_or(24).min(100); let client = match get_client(&state, &headers) { Ok(c) => c, Err(e) => return Json(serde_json::json!({ "images": [], "total": 0, "page": page, "per_page": per_page, "error": format!("Erreur client : {}", e) })), }; if query.is_empty() { match client.list_images(page, per_page).await { Ok(response) => { let images: Vec = response.items.iter().map(image_to_json).collect(); return Json(serde_json::json!({ "images": images, "total": response.total, "page": response.page, "per_page": response.page_size })); } Err(e) => { tracing::error!("Erreur API Imago (list from search) : {:#}", e); return Json(serde_json::json!({ "images": [], "total": 0, "page": page, "per_page": per_page, "error": format!("{:#}", e) })); } } } match client.search(&query, per_page, page).await { Ok(response) => { let images: Vec = response.items.iter().map(image_to_json).collect(); Json(serde_json::json!({ "images": images, "total": response.total, "page": response.page, "per_page": response.page_size, "query": query })) } Err(e) => { tracing::error!("Erreur API Imago (search) : {:#}", e); Json(serde_json::json!({ "images": [], "total": 0, "page": page, "per_page": per_page, "query": query, "error": format!("{:#}", e) })) } } } async fn get_image( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> impl IntoResponse { let img_id: i64 = match id.parse() { Ok(n) => n, Err(_) => { return Json(serde_json::json!({ "error": "ID invalide", "id": id })); } }; let client = match get_client(&state, &headers) { Ok(c) => c, Err(e) => return Json(serde_json::json!({ "error": format!("Erreur client : {}", e), "id": id })), }; match client.get_image(img_id).await { Ok(detail) => { Json(serde_json::json!({ "id": detail.id, "uuid": detail.uuid, "filename": detail.original_name, "status": detail.processing_status, "width": detail.width, "height": detail.height, "size_bytes": detail.file_size, "tags": detail.ai.as_ref().and_then(|a| a.tags.clone()), "description": detail.ai.as_ref().and_then(|a| a.description.clone()), "ocr_text": detail.ocr.as_ref().and_then(|o| o.text.clone()), "exif": { "camera": detail.exif.as_ref().and_then(|e| e.camera.as_ref().map(|c| c.model.clone())).flatten(), "lens": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.lens.clone())), "iso": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.iso)), "aperture": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.aperture)), "shutter": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.shutter_speed.clone())), "focal_length": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.focal_length.clone())), "gps": detail.exif.as_ref().and_then(|e| e.gps.as_ref().map(|g| { serde_json::json!({"lat": g.latitude, "lon": g.longitude}) })), "taken_at": detail.exif.as_ref().and_then(|e| e.camera.as_ref().and_then(|c| c.taken_at.clone())) }, "created_at": detail.uploaded_at, })) } Err(e) => { tracing::warn!("Image non trouvée (id={}) : {:#}", id, e); Json(serde_json::json!({ "error": "Image non trouvée", "id": id })) } } } async fn get_thumbnail( State(state): State>, Path(id): Path, Query(params): Query>, ) -> impl IntoResponse { let img_id: i64 = match id.parse() { Ok(n) => n, Err(_) => { return axum::http::Response::builder() .status(axum::http::StatusCode::BAD_REQUEST) .header("Content-Type", "text/plain") .body(axum::body::Body::from("ID invalide")).unwrap(); } }; let full = params.get("full").map(|v| v == "1").unwrap_or(false); // Utilise TOUJOURS la clé serveur (config) pour les thumbnails, // car les balises du navigateur ne peuvent pas envoyer X-API-Key let client = match ImagoClient::new(state.base_url.clone(), state.api_key.clone()) { Ok(c) => c, Err(e) => { return axum::http::Response::builder() .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) .header("Content-Type", "text/plain") .body(axum::body::Body::from(format!("Erreur client : {}", e))).unwrap(); } }; let result = if full { client.get_file(img_id).await } else { match client.get_thumbnail(img_id).await { Ok(t) => Ok(t), Err(_) => { tracing::info!("Thumbnail fallback -> fichier original pour id={}", id); client.get_file(img_id).await } } }; match result { Ok((data, content_type)) => { axum::http::Response::builder() .header("Content-Type", content_type) .header("Cache-Control", "public, max-age=3600") .body(axum::body::Body::from(data)).unwrap() } Err(e) => { tracing::warn!("Thumbnail indisponible (id={}) : {:#}", id, e); axum::http::Response::builder() .status(axum::http::StatusCode::NOT_FOUND) .header("Content-Type", "text/plain") .body(axum::body::Body::from("Thumbnail non disponible")).unwrap() } } } async fn delete_image( State(state): State>, headers: HeaderMap, Path(id): Path, ) -> impl IntoResponse { let img_id: i64 = match id.parse() { Ok(n) => n, Err(_) => return error_response(400, "ID invalide"), }; let client = match get_client(&state, &headers) { Ok(c) => c, Err(e) => return error_response(500, &format!("Erreur client : {}", e)), }; match client.delete_image(img_id).await { Ok(()) => ( axum::http::StatusCode::OK, Json(serde_json::json!({"deleted": true, "id": img_id})), ), Err(e) => { let err_msg = format!("{:#}", e); let status = if err_msg.contains("403") || err_msg.contains("401") { 403 } else { 500 }; error_response(status, &format!("Échec suppression : {}", err_msg)) } } } /// Requête de mise à jour des tags #[derive(Deserialize)] pub struct TagsUpdate { pub tags: Vec, } /// Requête de suppression multiple #[derive(Deserialize)] pub struct BatchDeleteRequest { pub ids: Vec, } /// Réponse de suppression multiple #[derive(Serialize)] pub struct BatchDeleteResponse { pub deleted: Vec, pub failed: Vec, pub total_requested: usize, pub total_deleted: usize, } /// Suppression en lot : supprime plusieurs images en une seule requête async fn batch_delete( State(state): State>, headers: HeaderMap, Json(body): Json, ) -> impl IntoResponse { if body.ids.is_empty() { return error_response(400, "Aucun ID fourni"); } if body.ids.len() > 100 { return error_response(400, "Maximum 100 images par lot"); } let total_requested = body.ids.len(); let mut deleted: Vec = Vec::new(); let mut failed: Vec = Vec::new(); let client = match get_client(&state, &headers) { Ok(c) => c, Err(e) => return error_response(500, &format!("Erreur client : {:#}", e)), }; tracing::info!("Suppression batch de {} images...", total_requested); for &id in &body.ids { match client.delete_image(id).await { Ok(()) => { deleted.push(id); tracing::debug!(" ✅ Image {} supprimée", id); } Err(e) => { failed.push(serde_json::json!({ "id": id, "error": format!("{:#}", e) })); tracing::warn!(" ❌ Échec suppression image {}: {:#}", id, e); } } } tracing::info!( "Batch delete terminé: {}/{} supprimées, {} échecs", deleted.len(), total_requested, failed.len() ); // Notifier les clients WebSocket if !deleted.is_empty() { let _ = state.ws_hub.broadcast(&serde_json::json!({ "type": "images_deleted", "ids": &deleted }).to_string()); } let status = if failed.is_empty() { axum::http::StatusCode::OK } else if deleted.is_empty() { axum::http::StatusCode::INTERNAL_SERVER_ERROR } else { axum::http::StatusCode::MULTI_STATUS }; ( status, Json(serde_json::json!({ "deleted": deleted, "failed": failed, "total_requested": total_requested, "total_deleted": deleted.len() })), ) } async fn update_tags( State(state): State>, headers: HeaderMap, Path(id): Path, Json(body): Json, ) -> impl IntoResponse { let img_id: i64 = match id.parse() { Ok(n) => n, Err(_) => return error_response(400, "ID invalide"), }; let client = match get_client(&state, &headers) { Ok(c) => c, Err(e) => return error_response(500, &format!("Erreur client : {}", e)), }; match client.update_tags(img_id, body.tags.clone()).await { Ok(()) => ( axum::http::StatusCode::OK, Json(serde_json::json!({"updated": true, "id": img_id, "tags": body.tags})), ), Err(e) => { let err_msg = format!("{:#}", e); error_response(500, &format!("Échec mise à jour tags : {}", err_msg)) } } } async fn stats( State(state): State>, headers: HeaderMap, ) -> impl IntoResponse { let client = match get_client(&state, &headers) { Ok(c) => c, Err(_) => { return Json(serde_json::json!({ "total_images": 0, "total_storage_bytes": 0, "unique_tags": 0, "recent_uploads": 0, "ws_clients": state.ws_hub.client_count(), "status": "error", "error": "Erreur client API" })); } }; let (total_images, storage_mb, unique_tags) = match client.list_images(1, 1).await { Ok(response) => { let tags = if !response.items.is_empty() { response.items[0].ai_tags.as_ref().map(|t| t.len()).unwrap_or(0) .saturating_mul(response.total).saturating_div(2) } else { 0 }; (response.total, response.storage_used_mb.unwrap_or(0.0), tags) } Err(_) => (0, 0.0, 0), }; Json(serde_json::json!({ "total_images": total_images, "total_storage_bytes": (storage_mb * 1_048_576.0) as u64, "unique_tags": unique_tags, "recent_uploads": 0, "ws_clients": state.ws_hub.client_count(), "status": "ok" })) }