- 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)
731 lines
24 KiB
Rust
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"
|
|
}))
|
|
}
|