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:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user