fix: anti-doublon upload — lock mémoire in-flight + garde click/drag frontend

- Backend: Mutex<HashSet> pour bloquer les uploads concurrents du même hash (409 Conflict)
- Backend: logs tracing pour tracer chaque upload entrant
- Frontend: flag isDragging pour éviter le double déclenchement click+drag
- Dépendance: ajout hex = 0.4 pour log du hash

Corrige le bug où un seul fichier uploadé générait 4 copies sur Imago.
This commit is contained in:
2026-06-25 14:23:55 -04:00
parent 3254c33ae5
commit 5a2e12da01
7 changed files with 58 additions and 9 deletions
Generated
+7
View File
@@ -1013,6 +1013,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.4.2"
@@ -1276,6 +1282,7 @@ dependencies = [
"clap",
"dashmap",
"futures-util",
"hex",
"image",
"libc",
"notify",
+1
View File
@@ -23,6 +23,7 @@ serde_json = "1"
toml = "0.8"
# Hashing
sha2 = "0.10"
hex = "0.4"
# SQLite
rusqlite = { version = "0.32", features = ["bundled"] }
# Logging
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Changelog — imagod-web v0.2.0+build.21-e867dad-dirty</title>
<title>Changelog — imagod-web v0.2.0+build.22-3254c33-dirty</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🖼️</text></svg>">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
@@ -37,7 +37,7 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
</head>
<body>
<header>
<h1>📋 imagod-web <span class="version-badge">v0.2.0+build.21-e867dad-dirty</span></h1>
<h1>📋 imagod-web <span class="version-badge">v0.2.0+build.22-3254c33-dirty</span></h1>
<nav>
<a href="/">🖼️ Galerie</a>
<a href="/changelog" class="active">📋 Changelog</a>
@@ -107,6 +107,6 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
<h2>Légende des tags</h2>
</main>
<footer>imagod-web v0.2.0+build.21-e867dad-dirty — <a href="https://git.dracodev.net/Projets/imagod-web">Gitea</a></footer>
<footer>imagod-web v0.2.0+build.22-3254c33-dirty — <a href="https://git.dracodev.net/Projets/imagod-web">Gitea</a></footer>
</body>
</html>
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aide & Roadmap — imagod-web v0.2.0+build.21-e867dad-dirty</title>
<title>Aide & Roadmap — imagod-web v0.2.0+build.22-3254c33-dirty</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🖼️</text></svg>">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
@@ -37,7 +37,7 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
</head>
<body>
<header>
<h1>❓ imagod-web <span class="version-badge">v0.2.0+build.21-e867dad-dirty</span></h1>
<h1>❓ imagod-web <span class="version-badge">v0.2.0+build.22-3254c33-dirty</span></h1>
<nav>
<a href="/">🖼️ Galerie</a>
<a href="/changelog">📋 Changelog</a>
@@ -169,6 +169,6 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
<p>5. <strong>Corriger le backend Imago</strong> (thumbnails, upload, stockage)</p>
</main>
<footer>imagod-web v0.2.0+build.21-e867dad-dirty — <a href="https://git.dracodev.net/Projets/imagod-web">Gitea</a></footer>
<footer>imagod-web v0.2.0+build.22-3254c33-dirty — <a href="https://git.dracodev.net/Projets/imagod-web">Gitea</a></footer>
</body>
</html>
+7 -3
View File
@@ -465,7 +465,7 @@
<button class="btn" id="clear-btn" style="display:none">✕</button>
</div>
<button class="btn accent" id="header-upload-btn" onclick="document.getElementById('drop-zone').scrollIntoView({behavior:'smooth'}); document.getElementById('file-input').click()">📤 Upload</button>
<span class="version-badge" id="version-badge">v0.2.0+build.21-e867dad-dirty</span>
<span class="version-badge" id="version-badge">v0.2.0+build.22-3254c33-dirty</span>
</div>
<!-- ─── Navigation ─── -->
@@ -492,7 +492,7 @@
</div>
<!-- Drop Zone -->
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div class="drop-zone" id="drop-zone" onclick="if(!isDragging)document.getElementById('file-input').click()">
<div class="drop-zone-icon">📁</div>
<div class="drop-zone-text">Glissez-déposez des images ici</div>
<div class="drop-zone-hint">ou cliquez pour sélectionner — PNG, JPG, GIF, WebP</div>
@@ -542,7 +542,7 @@
</div>
<!-- ─── Floating Upload (mobile) ─── -->
<button class="fab-upload" id="fab-upload" onclick="document.getElementById('file-input').click()" title="Upload">+</button>
<button class="fab-upload" id="fab-upload" onclick="if(!isDragging)document.getElementById('file-input').click()" title="Upload">+</button>
<script>
// ─── State ───
@@ -964,11 +964,13 @@ async function uploadFiles(files) {
// ─── Drag & Drop ───
const dropZone = document.getElementById('drop-zone');
let isDragging = false; // Empêche le click+drag simultané (exposé sur window pour le onclick inline)
['dragenter', 'dragover'].forEach(evt => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
isDragging = true;
dropZone.classList.add('drag-over');
});
});
@@ -977,11 +979,13 @@ const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
isDragging = false;
dropZone.classList.remove('drag-over');
});
});
dropZone.addEventListener('drop', (e) => {
isDragging = false;
const files = e.dataTransfer.files;
if (files.length > 0) uploadFiles(files);
});
+3
View File
@@ -9,6 +9,8 @@ use axum::Router;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use std::sync::Arc;
use std::collections::HashSet;
use tokio::sync::Mutex;
use crate::imago_client::ImagoClient;
use crate::config::Config;
@@ -49,6 +51,7 @@ pub async fn serve_with_config(host: &str, port: u16, config: &Config) -> anyhow
api_key: config.imago.api_key.clone(),
ws_hub: ws_hub.clone(),
dedup_db_path,
in_flight: Mutex::new(HashSet::new()),
};
// Router principal
+34
View File
@@ -10,6 +10,8 @@ use axum::{
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
@@ -20,6 +22,8 @@ pub struct AppState {
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,
@@ -223,6 +227,29 @@ async fn upload_image(
// Calculer le hash AVANT de consommer `data` (upload_image prend ownership)
let file_hash = crate::dedup::Dedup::hash_bytes(&data);
// ═══ 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
@@ -230,6 +257,10 @@ async fn upload_image(
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!({
@@ -241,6 +272,9 @@ async fn upload_image(
)
}
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,