Files
imagod-web/src/server/routes.rs
T
bruno 4917dad351 feat: route /api/ping + message d'erreur affiche l'URL cible
- Ajout route GET /api/ping pour diagnostic connectivité rapide
- Le message 'serveur injoignable' affiche maintenant l'URL exacte tentée
- Aide à identifier les problèmes de proxy/iframe (mauvaise origine)
2026-06-25 15:28:30 -04:00

731 lines
24 KiB
Rust

//! 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<ImagoClient>,
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<HashSet<Vec<u8>>>,
}
/// 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<ImagoClient> {
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<serde_json::Value>) {
(
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<usize>,
pub per_page: Option<usize>,
}
/// Paramètres de recherche
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
pub page: Option<usize>,
pub per_page: Option<usize>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// 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<serde_json::Value> {
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<serde_json::Value> {
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<Arc<AppState>>,
Json(body): Json<AuthRequest>,
) -> 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<Arc<AppState>>,
headers: HeaderMap,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut data: Vec<u8> = 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<Arc<AppState>>,
headers: HeaderMap,
Query(pagination): Query<Pagination>,
) -> 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<serde_json::Value> = 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<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<SearchQuery>,
) -> 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<serde_json::Value> = 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<serde_json::Value> = 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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> 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 <img> 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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> 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<String>,
}
/// Requête de suppression multiple
#[derive(Deserialize)]
pub struct BatchDeleteRequest {
pub ids: Vec<i64>,
}
/// Réponse de suppression multiple
#[derive(Serialize)]
pub struct BatchDeleteResponse {
pub deleted: Vec<i64>,
pub failed: Vec<serde_json::Value>,
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<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<BatchDeleteRequest>,
) -> 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<i64> = Vec::new();
let mut failed: Vec<serde_json::Value> = 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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<TagsUpdate>,
) -> 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<Arc<AppState>>,
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"
}))
}