diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d290213..bf8d4218 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -7,6 +7,15 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - web/**
+ - windows/**
+ - macos/**
+ - linux/**
include: package:flutter_lints/flutter.yaml
linter:
diff --git a/android/gradle.properties b/android/gradle.properties
index fbee1d8c..d5da7278 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/backendia/dashboard/src/app/layout.tsx b/backendia/dashboard/src/app/layout.tsx
index 8cd59f6c..a3696e5e 100644
--- a/backendia/dashboard/src/app/layout.tsx
+++ b/backendia/dashboard/src/app/layout.tsx
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
-import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react";
+import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
@@ -41,6 +41,13 @@ export default function RootLayout({
Contributeurs
+
+
+ Logs d'Uploads
+
diff --git a/backendia/dashboard/src/app/logs/LogsManager.tsx b/backendia/dashboard/src/app/logs/LogsManager.tsx
new file mode 100644
index 00000000..52bc96f8
--- /dev/null
+++ b/backendia/dashboard/src/app/logs/LogsManager.tsx
@@ -0,0 +1,900 @@
+"use client";
+
+import { useState, useMemo, useEffect } from "react";
+import {
+ ScrollText,
+ Search,
+ Download,
+ RefreshCw,
+ CheckCircle2,
+ XCircle,
+ Copy,
+ Check,
+ Trash2,
+ Smartphone,
+ Crosshair,
+ Calendar,
+ HardDrive,
+ Users,
+ ExternalLink,
+ Info,
+ Filter,
+ Eye,
+ ShieldAlert,
+ ShieldCheck,
+ Ban,
+ AlertTriangle
+} from "lucide-react";
+import { API_BASE_URL } from "@/lib/api";
+import Link from "next/link";
+
+interface UploadLog {
+ id: number;
+ timestamp: string;
+ session_id: string | null;
+ wallet_hash: string | null;
+ image_filename: string | null;
+ json_filename: string | null;
+ file_size: number | null;
+ ip_address: string | null;
+ user_agent: string | null;
+ device_model: string | null;
+ device_os: string | null;
+ target_type: string | null;
+ weapon: string | null;
+ distance_meters: number | null;
+ impacts_count: number;
+ status: string;
+ target_valid?: number;
+ target_status?: string;
+ target_confidence?: number;
+ target_rings_count?: number;
+ target_details?: string | null;
+ error_message: string | null;
+ raw_metadata: string | null;
+}
+
+interface BannedWallet {
+ wallet_hash: string;
+ reason: string;
+ banned_at: string;
+ banned_by: string;
+}
+
+interface LogStats {
+ total_uploads: number;
+ success_count: number;
+ failed_count: number;
+ today_uploads: number;
+ unique_wallets: number;
+ total_bytes: number;
+ latest_upload: string | null;
+}
+
+export default function LogsManager({
+ initialLogs,
+ initialStats
+}: {
+ initialLogs: UploadLog[];
+ initialStats: LogStats | null;
+}) {
+ const [logs, setLogs] = useState
(initialLogs);
+ const [stats, setStats] = useState(initialStats);
+ const [bannedWallets, setBannedWallets] = useState([]);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [statusFilter, setStatusFilter] = useState<"ALL" | "SUCCESS" | "FAILED" | "INVALID_TARGET" | "BANNED">("ALL");
+ const [copiedWallet, setCopiedWallet] = useState(null);
+ const [selectedLog, setSelectedLog] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [showClearConfirm, setShowClearConfirm] = useState(false);
+
+ // Moderation state
+ const [banModalWallet, setBanModalWallet] = useState(null);
+ const [banReason, setBanReason] = useState("Images non conformes / Fausse cible");
+ const [isSubmittingBan, setIsSubmittingBan] = useState(false);
+
+ const fetchBannedWallets = async () => {
+ try {
+ const res = await fetch(`${API_BASE_URL}/api/moderation/banned`, { cache: 'no-store' });
+ if (res.ok) {
+ const data = await res.json();
+ setBannedWallets(data.banned || []);
+ }
+ } catch (e) {
+ console.error("Erreur récupération wallets bannis:", e);
+ }
+ };
+
+ useEffect(() => {
+ fetchBannedWallets();
+ }, []);
+
+ const fetchLogs = async () => {
+ setIsLoading(true);
+ try {
+ const [logsRes, statsRes] = await Promise.all([
+ fetch(`${API_BASE_URL}/api/logs?limit=200`, { cache: 'no-store' }),
+ fetch(`${API_BASE_URL}/api/logs/stats`, { cache: 'no-store' }),
+ fetchBannedWallets()
+ ]);
+
+ if (logsRes.ok) {
+ const logsData = await logsRes.json();
+ setLogs(logsData.logs || []);
+ }
+ if (statsRes.ok) {
+ const statsData = await statsRes.json();
+ setStats(statsData.stats || null);
+ }
+ } catch (e) {
+ console.error("Erreur lors de la récupération des logs:", e);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text);
+ setCopiedWallet(text);
+ setTimeout(() => setCopiedWallet(null), 2000);
+ };
+
+ const isWalletBanned = (walletHash: string | null) => {
+ if (!walletHash) return false;
+ return bannedWallets.some(b => b.wallet_hash === walletHash);
+ };
+
+ const getBannedInfo = (walletHash: string | null) => {
+ if (!walletHash) return null;
+ return bannedWallets.find(b => b.wallet_hash === walletHash) || null;
+ };
+
+ const handleBanWallet = async () => {
+ if (!banModalWallet) return;
+ setIsSubmittingBan(true);
+ try {
+ const res = await fetch(`${API_BASE_URL}/api/moderation/ban`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ wallet_hash: banModalWallet,
+ reason: banReason,
+ banned_by: 'Admin Dashboard'
+ })
+ });
+ if (res.ok) {
+ setBanModalWallet(null);
+ await fetchBannedWallets();
+ await fetchLogs();
+ } else {
+ alert("Erreur lors du bannissement.");
+ }
+ } catch (e) {
+ console.error("Erreur ban:", e);
+ } finally {
+ setIsSubmittingBan(false);
+ }
+ };
+
+ const handleUnbanWallet = async (walletHash: string) => {
+ if (!confirm(`Voulez-vous vraiment débannir le wallet ${walletHash.substring(0, 10)}... ?`)) return;
+ try {
+ const res = await fetch(`${API_BASE_URL}/api/moderation/ban/${walletHash}`, {
+ method: 'DELETE'
+ });
+ if (res.ok) {
+ await fetchBannedWallets();
+ await fetchLogs();
+ }
+ } catch (e) {
+ console.error("Erreur unban:", e);
+ }
+ };
+
+ const handleDeleteLog = async (id: number) => {
+ if (!confirm("Voulez-vous vraiment supprimer ce log ?")) return;
+ try {
+ const res = await fetch(`${API_BASE_URL}/api/logs/${id}`, { method: 'DELETE' });
+ if (res.ok) {
+ setLogs(prev => prev.filter(l => l.id !== id));
+ fetchLogs();
+ }
+ } catch (e) {
+ console.error("Erreur suppression:", e);
+ }
+ };
+
+ const handleClearAll = async () => {
+ try {
+ const res = await fetch(`${API_BASE_URL}/api/logs`, { method: 'DELETE' });
+ if (res.ok) {
+ setLogs([]);
+ setShowClearConfirm(false);
+ fetchLogs();
+ }
+ } catch (e) {
+ console.error("Erreur vidage logs:", e);
+ }
+ };
+
+ const formatFileSize = (bytes: number | null) => {
+ if (!bytes || bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+
+ const filteredLogs = useMemo(() => {
+ return logs.filter(log => {
+ // Filter status
+ if (statusFilter === "SUCCESS" && log.status !== "SUCCESS") return false;
+ if (statusFilter === "FAILED" && log.status !== "FAILED") return false;
+ if (statusFilter === "INVALID_TARGET" && log.target_status === "VALID") return false;
+ if (statusFilter === "BANNED" && !isWalletBanned(log.wallet_hash)) return false;
+
+ // Filter search
+ if (!searchTerm.trim()) return true;
+ const term = searchTerm.toLowerCase();
+ return (
+ (log.wallet_hash && log.wallet_hash.toLowerCase().includes(term)) ||
+ (log.session_id && log.session_id.toLowerCase().includes(term)) ||
+ (log.image_filename && log.image_filename.toLowerCase().includes(term)) ||
+ (log.device_model && log.device_model.toLowerCase().includes(term)) ||
+ (log.weapon && log.weapon.toLowerCase().includes(term)) ||
+ (log.target_type && log.target_type.toLowerCase().includes(term)) ||
+ (log.target_status && log.target_status.toLowerCase().includes(term)) ||
+ (log.ip_address && log.ip_address.toLowerCase().includes(term))
+ );
+ });
+ }, [logs, searchTerm, statusFilter, bannedWallets]);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
Journal & Contrôle des Uploads
+
+ Historique des transferts, diagnostic OpenCV (détection cibles) et modération des wallets.
+
+
+
+
+
+
+
+
+ Actualiser
+
+
+
+
+ Exporter CSV
+
+
+ {!showClearConfirm ? (
+
setShowClearConfirm(true)}
+ className="flex items-center gap-2 px-3 py-2.5 bg-slate-900 border border-slate-800 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 rounded-xl text-sm transition-all"
+ title="Purger tous les logs"
+ >
+
+
+ ) : (
+
+ Effacer tout ?
+
+ Oui
+
+ setShowClearConfirm(false)}
+ className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition-all"
+ >
+ Non
+
+
+ )}
+
+
+
+ {/* KPI Stats Cards */}
+
+
+
+
Total Uploads
+
+
{stats?.total_uploads ?? logs.length}
+ {stats && stats.total_uploads > 0 && (
+
+ {Math.round(((stats.success_count || 0) / stats.total_uploads) * 100)}% succès
+
+ )}
+
+
+
+
+
+
+
+
+
+
Aujourd'hui
+
{stats?.today_uploads ?? 0}
+
+
+
+
+
+
+
+
+
Wallets Bannis
+
+
{bannedWallets.length}
+
/ {stats?.unique_wallets ?? 0} total
+
+
+
+
+
+
+
+
+
+
Volume Transféré
+
+ {formatFileSize(stats?.total_bytes || logs.reduce((acc, l) => acc + (l.file_size || 0), 0))}
+
+
+
+
+
+
+
+
+ {/* Filters & Search Toolbar */}
+
+ {/* Search */}
+
+
+ setSearchTerm(e.target.value)}
+ placeholder="Rechercher par Wallet, Session, Cible, Image..."
+ className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-xl pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none transition-all"
+ />
+ {searchTerm && (
+ setSearchTerm("")}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-300"
+ >
+ Effacer
+
+ )}
+
+
+ {/* Status & OpenCV Filter */}
+
+
+
+ Filtre:
+
+
+ setStatusFilter("ALL")}
+ className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
+ statusFilter === "ALL"
+ ? "bg-slate-800 text-white shadow-sm"
+ : "text-slate-400 hover:text-slate-200"
+ }`}
+ >
+ Tous ({logs.length})
+
+ setStatusFilter("SUCCESS")}
+ className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
+ statusFilter === "SUCCESS"
+ ? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
+ : "text-slate-400 hover:text-emerald-400"
+ }`}
+ >
+ Succès
+
+ setStatusFilter("INVALID_TARGET")}
+ className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
+ statusFilter === "INVALID_TARGET"
+ ? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
+ : "text-slate-400 hover:text-amber-400"
+ }`}
+ title="Photos non reconnues comme cible par OpenCV"
+ >
+ ⚠ Cibles Douteuses
+
+ setStatusFilter("BANNED")}
+ className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
+ statusFilter === "BANNED"
+ ? "bg-rose-500/20 text-rose-300 border border-rose-500/30"
+ : "text-slate-400 hover:text-rose-400"
+ }`}
+ >
+ Bannis ({bannedWallets.length})
+
+
+
+
+
+ {/* Logs Table */}
+
+
+
+
+
+ Date & Heure
+ Diagnostic OpenCV
+ Wallet Contributeur
+ Session / Cible
+ Appareil
+ Fichier
+ Actions
+
+
+
+ {filteredLogs.map((log) => {
+ const dateObj = new Date(log.timestamp);
+ const isValidDate = !isNaN(dateObj.getTime());
+ const formattedDate = isValidDate
+ ? dateObj.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })
+ : log.timestamp;
+ const formattedTime = isValidDate
+ ? dateObj.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
+ : "";
+
+ const banned = isWalletBanned(log.wallet_hash);
+
+ return (
+
+ {/* Timestamp */}
+
+
+ {formattedDate}
+ {formattedTime}
+
+
+
+ {/* OpenCV Diagnosis & Upload Status */}
+
+
+ {log.status === "FAILED" ? (
+
+
+ UPLOAD ÉCHEC
+
+ ) : log.target_status === "VALID" ? (
+
+
+ CIBLE CERTIFIÉE ({Math.round((log.target_confidence || 1) * 100)}%)
+
+ ) : log.target_status === "SUSPICIOUS" ? (
+
+
+ DOUTEUSE (1 ANNEAU)
+
+ ) : (
+
+
+ NON RECONNUE
+
+ )}
+
+ {log.target_rings_count ? (
+
+ {log.target_rings_count} anneaux concentriques
+
+ ) : null}
+
+
+
+ {/* Wallet Hash & Ban Badge */}
+
+ {log.wallet_hash ? (
+
+
+ setSearchTerm(log.wallet_hash || "")}
+ className="font-mono text-xs text-indigo-300 hover:text-indigo-200 bg-indigo-500/5 px-2 py-1 rounded border border-indigo-500/10 hover:border-indigo-500/30 cursor-pointer transition-colors"
+ title="Cliquer pour filtrer par ce wallet"
+ >
+ {log.wallet_hash.length > 16
+ ? `${log.wallet_hash.substring(0, 8)}...${log.wallet_hash.substring(log.wallet_hash.length - 8)}`
+ : log.wallet_hash}
+
+ copyToClipboard(log.wallet_hash!)}
+ className="text-slate-500 hover:text-slate-300 transition-colors p-1"
+ title="Copier le hash du wallet"
+ >
+ {copiedWallet === log.wallet_hash ? (
+
+ ) : (
+
+ )}
+
+
+
+ {banned && (
+
+
+ WALLET BANNI
+
+ )}
+
+ ) : (
+ Anonyme / Non fourni
+ )}
+
+
+ {/* Session & Target */}
+
+
+ {log.session_id && (
+
+ ID: {log.session_id}
+
+ )}
+
+ {log.target_type && (
+
+ {log.target_type}
+
+ )}
+ {log.weapon && (
+
+ {log.weapon}
+
+ )}
+ {log.impacts_count > 0 && (
+
+
+ {log.impacts_count} imp.
+
+ )}
+
+
+
+
+ {/* Device & IP */}
+
+
+
+
+ {log.device_model || log.device_os || "Inconnu"}
+
+ {log.ip_address && (
+
+ {log.ip_address.replace("::ffff:", "")}
+
+ )}
+
+
+
+ {/* File & Size */}
+
+
+ {log.image_filename ? (
+
+ {log.image_filename}
+
+
+ ) : (
+ Aucun fichier
+ )}
+
+ {formatFileSize(log.file_size)}
+
+
+
+
+ {/* Actions */}
+
+
+ {/* Ban / Unban Button */}
+ {log.wallet_hash && (
+ banned ? (
+ handleUnbanWallet(log.wallet_hash!)}
+ className="p-1.5 text-slate-400 hover:text-emerald-400 hover:bg-slate-800 rounded-lg transition-colors"
+ title="Débannir ce wallet"
+ >
+
+
+ ) : (
+ setBanModalWallet(log.wallet_hash)}
+ className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
+ title="Bannir définitivement ce wallet"
+ >
+
+
+ )
+ )}
+
+ setSelectedLog(log)}
+ className="p-1.5 text-slate-400 hover:text-indigo-300 hover:bg-slate-800 rounded-lg transition-colors"
+ title="Voir les détails complets"
+ >
+
+
+ handleDeleteLog(log.id)}
+ className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
+ title="Supprimer ce log"
+ >
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Empty state */}
+ {filteredLogs.length === 0 && (
+
+
+
+
+
Aucun log trouvé
+
+ {searchTerm || statusFilter !== "ALL"
+ ? "Aucun enregistrement ne correspond à vos critères de recherche."
+ : "Les logs apparaîtront ici dès que des photos ou des sessions seront uploadées."}
+
+
+ )}
+
+
+ {/* Details Modal */}
+ {selectedLog && (
+
+
+ {/* Modal Header */}
+
+
+
+
+
+
+
Détails du Log #{selectedLog.id}
+
{new Date(selectedLog.timestamp).toLocaleString()}
+
+
+
setSelectedLog(null)}
+ className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-xl transition-colors"
+ >
+ ✕
+
+
+
+ {/* Modal Content */}
+
+ {/* OpenCV Diagnostic Banner */}
+
+
+
+ {selectedLog.target_status === "VALID" ?
:
}
+ Diagnostic OpenCV : {selectedLog.target_status || "VALID"}
+
+
+ Confiance : {Math.round((selectedLog.target_confidence || 1) * 100)}%
+
+
+
{selectedLog.target_details || "Cible détectée avec succès."}
+
+
+ {/* General Grid */}
+
+
+
Wallet Hash
+
+ {selectedLog.wallet_hash || "Non spécifié"}
+
+ {isWalletBanned(selectedLog.wallet_hash) && (
+
+ Banni ({getBannedInfo(selectedLog.wallet_hash)?.reason})
+
+ )}
+
+
+
+
Session ID
+
+ {selectedLog.session_id || "N/A"}
+
+
+
+
+
Appareil
+
+ {selectedLog.device_model || "Inconnu"} ({selectedLog.device_os || "OS inconnu"})
+
+
+
+
+
Adresse IP
+
+ {selectedLog.ip_address || "Inconnue"}
+
+
+
+
+
Cible & Arme
+
+ Type: {selectedLog.target_type || "N/A"} | Arme: {selectedLog.weapon || "N/A"} | {selectedLog.distance_meters ? `${selectedLog.distance_meters}m` : ""}
+
+
+
+
+
Fichiers & Taille
+
+ Image: {selectedLog.image_filename || "N/A"} ({formatFileSize(selectedLog.file_size)})
+
+
+
+
+ {/* Raw Metadata JSON */}
+ {selectedLog.raw_metadata && (
+
+
Données Brutes (JSON)
+
+ {(() => {
+ try {
+ return JSON.stringify(JSON.parse(selectedLog.raw_metadata), null, 2);
+ } catch {
+ return selectedLog.raw_metadata;
+ }
+ })()}
+
+
+ )}
+
+
+ {/* Modal Footer */}
+
+
+ {selectedLog.image_filename && (
+
+
+ Ouvrir la session
+
+ )}
+ {selectedLog.wallet_hash && (
+ isWalletBanned(selectedLog.wallet_hash) ? (
+ handleUnbanWallet(selectedLog.wallet_hash!)}
+ className="px-3 py-2 bg-slate-800 hover:bg-emerald-600/30 text-emerald-400 rounded-xl text-xs font-bold transition-all border border-emerald-500/20"
+ >
+ Débannir ce wallet
+
+ ) : (
+ {
+ setSelectedLog(null);
+ setBanModalWallet(selectedLog.wallet_hash);
+ }}
+ className="px-3 py-2 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20"
+ >
+ Bannir ce wallet
+
+ )
+ )}
+
+
+
setSelectedLog(null)}
+ className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
+ >
+ Fermer
+
+
+
+
+ )}
+
+ {/* Ban Confirmation Modal */}
+ {banModalWallet && (
+
+
+
+
+
+
+
+
Bannir un Wallet
+
Bloquer définitivement tout upload futur de ce compte.
+
+
+
+
+ {banModalWallet}
+
+
+
+
+ Motif du bannissement
+
+ setBanReason(e.target.value)}
+ className="w-full bg-slate-950 border border-slate-800 focus:border-rose-500 rounded-xl px-3 py-2 text-sm text-slate-200 outline-none"
+ >
+ Images non conformes / Fausse cible
+ Spam / Uploads abusifs répétés
+ Contenu inapproprié ou illicite
+ Tentative de manipulation des données
+
+
+
+
+ setBanModalWallet(null)}
+ className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
+ >
+ Annuler
+
+
+
+ {isSubmittingBan ? "Bannissement..." : "Confirmer le Bannissement"}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/backendia/dashboard/src/app/logs/page.tsx b/backendia/dashboard/src/app/logs/page.tsx
new file mode 100644
index 00000000..8493b5aa
--- /dev/null
+++ b/backendia/dashboard/src/app/logs/page.tsx
@@ -0,0 +1,30 @@
+import { fetchApi } from "@/lib/api";
+import LogsManager from "./LogsManager";
+
+export const metadata = {
+ title: "Logs d'Uploads | Bully IA Dashboard",
+ description: "Journal complet des téléversements de sessions et métadonnées",
+};
+
+export default async function LogsPage() {
+ let logs = [];
+ let stats = null;
+
+ try {
+ const [logsRes, statsRes] = await Promise.all([
+ fetchApi('/api/logs?limit=100'),
+ fetchApi('/api/logs/stats')
+ ]);
+ logs = logsRes.logs || [];
+ stats = statsRes.stats || null;
+ } catch (error) {
+ console.error("Erreur chargement initial des logs:", error);
+ }
+
+ return (
+
+ );
+}
diff --git a/backendia/package-lock.json b/backendia/package-lock.json
index 42cb8dc1..58af382a 100644
--- a/backendia/package-lock.json
+++ b/backendia/package-lock.json
@@ -9,14 +9,526 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "@techstark/opencv-js": "^5.0.0-release.1",
"adm-zip": "^0.5.17",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"multer": "^2.1.1",
+ "sharp": "^0.35.3",
"sqlite3": "^6.0.1"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -29,6 +541,12 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@techstark/opencv-js": {
+ "version": "5.0.0-release.1",
+ "resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz",
+ "integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==",
+ "license": "Apache-2.0"
+ },
"node_modules/abbrev": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
@@ -1264,9 +1782,9 @@
"license": "MIT"
},
"node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1326,6 +1844,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1581,6 +2148,13 @@
"node": ">=0.6"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
diff --git a/backendia/package.json b/backendia/package.json
index 8ca25ea6..1858be48 100644
--- a/backendia/package.json
+++ b/backendia/package.json
@@ -12,11 +12,13 @@
"author": "",
"license": "ISC",
"dependencies": {
+ "@techstark/opencv-js": "^5.0.0-release.1",
"adm-zip": "^0.5.17",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"multer": "^2.1.1",
+ "sharp": "^0.35.3",
"sqlite3": "^6.0.1"
}
}
diff --git a/backendia/server.js b/backendia/server.js
index 337f19c3..7ce080a5 100644
--- a/backendia/server.js
+++ b/backendia/server.js
@@ -7,6 +7,8 @@ const sqlite3 = require('sqlite3').verbose();
const AdmZip = require('adm-zip');
+const TargetValidator = require('./services/target_validator');
+
const app = express();
const PORT = process.env.PORT || 3000;
@@ -39,9 +41,119 @@ const db = new sqlite3.Database(dbPath, (err) => {
photo_count INTEGER DEFAULT 0,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
+
+ db.run(`CREATE TABLE IF NOT EXISTS banned_wallets (
+ wallet_hash TEXT PRIMARY KEY,
+ reason TEXT,
+ banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ banned_by TEXT DEFAULT 'Admin'
+ )`);
+
+ db.run(`CREATE TABLE IF NOT EXISTS upload_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
+ session_id TEXT,
+ wallet_hash TEXT,
+ image_filename TEXT,
+ json_filename TEXT,
+ file_size INTEGER,
+ ip_address TEXT,
+ user_agent TEXT,
+ device_model TEXT,
+ device_os TEXT,
+ target_type TEXT,
+ weapon TEXT,
+ distance_meters INTEGER,
+ impacts_count INTEGER DEFAULT 0,
+ status TEXT DEFAULT 'SUCCESS',
+ target_valid INTEGER DEFAULT 1,
+ target_status TEXT DEFAULT 'VALID',
+ target_confidence REAL DEFAULT 1.0,
+ target_rings_count INTEGER DEFAULT 0,
+ target_details TEXT,
+ error_message TEXT,
+ raw_metadata TEXT
+ )`);
+
+ // Migration safe des colonnes OpenCV si la table existait déjà
+ const migrations = [
+ "ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1",
+ "ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'",
+ "ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0",
+ "ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0",
+ "ALTER TABLE upload_logs ADD COLUMN target_details TEXT"
+ ];
+ migrations.forEach(sql => db.run(sql, () => {}));
+
+ db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`);
+ db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`);
}
});
+// Helper pour insérer un log d'upload
+function logUploadEntry({
+ sessionId = null,
+ walletHash = null,
+ imageFilename = null,
+ jsonFilename = null,
+ fileSize = null,
+ ipAddress = null,
+ userAgent = null,
+ deviceModel = null,
+ deviceOs = null,
+ targetType = null,
+ weapon = null,
+ distanceMeters = null,
+ impactsCount = 0,
+ status = 'SUCCESS',
+ targetValid = 1,
+ targetStatus = 'VALID',
+ targetConfidence = 1.0,
+ targetRingsCount = 0,
+ targetDetails = null,
+ errorMessage = null,
+ rawMetadata = null
+}) {
+ const query = `
+ INSERT INTO upload_logs (
+ session_id, wallet_hash, image_filename, json_filename, file_size,
+ ip_address, user_agent, device_model, device_os, target_type,
+ weapon, distance_meters, impacts_count, status,
+ target_valid, target_status, target_confidence, target_rings_count, target_details,
+ error_message, raw_metadata
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `;
+ db.run(query, [
+ sessionId,
+ walletHash,
+ imageFilename,
+ jsonFilename,
+ fileSize,
+ ipAddress,
+ userAgent,
+ deviceModel,
+ deviceOs,
+ targetType,
+ weapon,
+ distanceMeters,
+ impactsCount,
+ status,
+ targetValid ? 1 : 0,
+ targetStatus,
+ targetConfidence,
+ targetRingsCount,
+ targetDetails,
+ errorMessage,
+ rawMetadata ? JSON.stringify(rawMetadata) : null
+ ], function(err) {
+ if (err) {
+ console.error("Erreur lors de l'insertion dans upload_logs:", err.message);
+ } else {
+ console.log(`[LOG] Upload enregistré (ID: ${this.lastID}) - Statut: ${status} - OpenCV: ${targetStatus}`);
+ }
+ });
+}
+
// Configuration de multer pour le stockage des fichiers
const storage = multer.diskStorage({
destination: function (req, file, cb) {
@@ -81,10 +193,22 @@ app.get('/api/health', (req, res) => {
// Route pour l'upload de photo + données JSON
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
-app.post('/api/upload', upload.single('photo'), (req, res) => {
+app.post('/api/upload', upload.single('photo'), async (req, res) => {
+ const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
+ const userAgent = req.headers['user-agent'] || '';
+
try {
if (!req.file) {
- return res.status(400).json({ error: 'Aucune photo fournie' });
+ logUploadEntry({
+ ipAddress,
+ userAgent,
+ status: 'FAILED',
+ errorMessage: 'Aucune photo fournie'
+ });
+ return res.status(400).json({
+ code: 'MISSING_PHOTO',
+ error: 'Aucune photo fournie dans la requête'
+ });
}
let plottingData = {};
@@ -93,10 +217,75 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
plottingData = JSON.parse(req.body.plotting);
} catch (e) {
console.error("Erreur parsing JSON:", e);
- return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' });
+ logUploadEntry({
+ imageFilename: req.file.filename,
+ fileSize: req.file.size,
+ ipAddress,
+ userAgent,
+ status: 'FAILED',
+ errorMessage: 'Le champ plotting doit être un JSON valide'
+ });
+ return res.status(400).json({
+ code: 'INVALID_PLOTTING_JSON',
+ error: 'Le champ plotting contient un format JSON invalide'
+ });
}
}
+ const walletHash = plottingData.wallet_hash || null;
+ const sessionId = plottingData.session_id || null;
+
+ // 1. Vérification du bannissement de wallet
+ if (walletHash) {
+ const bannedEntry = await new Promise((resolve) => {
+ db.get('SELECT * FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, row) => {
+ resolve(row || null);
+ });
+ });
+
+ if (bannedEntry) {
+ // Supprimer le fichier image temporaire pour ne pas consommer d'espace
+ if (req.file.path && fs.existsSync(req.file.path)) {
+ try { fs.unlinkSync(req.file.path); } catch (e) {}
+ }
+ const banReason = bannedEntry.reason || 'Non-respect des règles de contribution / Image non conforme';
+ logUploadEntry({
+ sessionId,
+ walletHash,
+ imageFilename: req.file.filename,
+ fileSize: req.file.size,
+ ipAddress,
+ userAgent,
+ status: 'FAILED',
+ errorMessage: `Upload bloqué : wallet banni (${banReason})`
+ });
+ console.warn(`[MODÉRATION] Upload rejeté pour wallet banni: ${walletHash}`);
+ return res.status(403).json({
+ code: 'WALLET_BANNED',
+ error: 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
+ reason: banReason,
+ banned_at: bannedEntry.banned_at
+ });
+ }
+ }
+
+ // 2. Validation de la cible par OpenCV
+ let validation = {
+ isValid: true,
+ status: 'VALID',
+ confidence: 1.0,
+ ringsCount: 0,
+ bestCenter: null,
+ details: 'Analyse non effectuée'
+ };
+
+ try {
+ validation = await TargetValidator.validateTarget(req.file.path);
+ console.log(`[OPENCV] Diagnostic cible pour ${req.file.filename}: ${validation.status} (${Math.round(validation.confidence * 100)}% conf, ${validation.ringsCount} anneaux)`);
+ } catch (cvErr) {
+ console.error("[OPENCV] Erreur analyse cible:", cvErr);
+ }
+
// Nom de base sans l'extension
const baseFilename = path.parse(req.file.filename).name;
const jsonFilename = `${baseFilename}.json`;
@@ -105,8 +294,13 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
// Sauvegarde du JSON dans uploads/data/
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
+ // Extraction des métadonnées
+ const deviceInfo = plottingData.device_info || {};
+ const targetMeta = plottingData.target_metadata || {};
+ const impacts = plottingData.plotting?.impacts || [];
+ const impactsCount = Array.isArray(impacts) ? impacts.length : 0;
+
// Mise à jour de la BDD si on a un wallet_hash
- const walletHash = plottingData.wallet_hash;
if (walletHash) {
db.run(`
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
@@ -122,12 +316,37 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
}
});
}
+
+ // Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
+ logUploadEntry({
+ sessionId,
+ walletHash,
+ imageFilename: req.file.filename,
+ jsonFilename,
+ fileSize: req.file.size,
+ ipAddress,
+ userAgent,
+ deviceModel: deviceInfo.model || null,
+ deviceOs: deviceInfo.os || null,
+ targetType: targetMeta.type || null,
+ weapon: targetMeta.weapon || null,
+ distanceMeters: targetMeta.distance_meters || null,
+ impactsCount,
+ status: 'SUCCESS',
+ targetValid: validation.isValid ? 1 : 0,
+ targetStatus: validation.status,
+ targetConfidence: validation.confidence,
+ targetRingsCount: validation.ringsCount,
+ targetDetails: validation.details,
+ rawMetadata: plottingData
+ });
console.log(`Données reçues et sauvegardées:`);
console.log(`- Image: uploads/images/${req.file.filename}`);
console.log(`- JSON : uploads/data/${jsonFilename}`);
res.status(200).json({
+ code: 'UPLOAD_SUCCESS',
message: 'Photo et données uploadées avec succès',
file: {
filename: req.file.filename,
@@ -136,14 +355,254 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
},
data: {
filename: jsonFilename
- }
+ },
+ target_validation: validation
});
} catch (error) {
console.error('Erreur lors de l\'upload:', error);
- res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
+ logUploadEntry({
+ imageFilename: req.file ? req.file.filename : null,
+ fileSize: req.file ? req.file.size : null,
+ ipAddress,
+ userAgent,
+ status: 'FAILED',
+ errorMessage: error.message || 'Erreur interne lors de l\'upload'
+ });
+ res.status(500).json({
+ code: 'SERVER_ERROR',
+ error: error.message || 'Erreur interne du serveur lors de l\'upload'
+ });
}
});
+// Routes de Modération : Gestion des bannissements de wallets
+app.get('/api/moderation/banned', (req, res) => {
+ db.all('SELECT * FROM banned_wallets ORDER BY banned_at DESC', [], (err, rows) => {
+ if (err) {
+ console.error("Erreur lecture wallets bannis:", err);
+ return res.status(500).json({ error: 'Erreur lecture wallets bannis' });
+ }
+ res.json({ banned: rows || [] });
+ });
+});
+
+app.post('/api/moderation/ban', (req, res) => {
+ const { wallet_hash, reason, banned_by } = req.body;
+ if (!wallet_hash) {
+ return res.status(400).json({ error: 'wallet_hash obligatoire' });
+ }
+
+ db.run(
+ `INSERT INTO banned_wallets (wallet_hash, reason, banned_by, banned_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(wallet_hash) DO UPDATE SET
+ reason = excluded.reason,
+ banned_at = CURRENT_TIMESTAMP`,
+ [wallet_hash, reason || 'Contenu invalide ou non conforme aux règles', banned_by || 'Admin'],
+ function(err) {
+ if (err) {
+ console.error("Erreur bannissement:", err);
+ return res.status(500).json({ error: 'Erreur lors du bannissement du wallet' });
+ }
+ console.log(`[MODÉRATION] Wallet ${wallet_hash} banni (Motif: ${reason})`);
+ res.json({ message: 'Wallet banni avec succès', wallet_hash });
+ }
+ );
+});
+
+app.delete('/api/moderation/ban/:wallet_hash', (req, res) => {
+ const wallet_hash = req.params.wallet_hash;
+ db.run('DELETE FROM banned_wallets WHERE wallet_hash = ?', [wallet_hash], function(err) {
+ if (err) {
+ console.error("Erreur débannissement:", err);
+ return res.status(500).json({ error: 'Erreur lors du débannissement' });
+ }
+ console.log(`[MODÉRATION] Wallet ${wallet_hash} débanni`);
+ res.json({ message: 'Wallet débanni avec succès', wallet_hash });
+ });
+});
+
+// Route pour relancer l'analyse OpenCV sur une photo déjà existante
+app.post('/api/moderation/verify/:filename', async (req, res) => {
+ const filename = req.params.filename;
+ const imgPath = path.join(imagesDir, filename);
+ if (!fs.existsSync(imgPath)) {
+ return res.status(404).json({ error: 'Image non trouvée' });
+ }
+ try {
+ const result = await TargetValidator.validateTarget(imgPath);
+ res.json({ filename, validation: result });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// Route pour lister les logs avec filtres, recherche et pagination
+app.get('/api/logs', (req, res) => {
+ const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
+ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
+ const wallet = req.query.wallet || req.query.wallet_hash;
+ const status = req.query.status;
+ const search = req.query.search;
+
+ let whereClauses = [];
+ let params = [];
+
+ if (wallet) {
+ whereClauses.push("wallet_hash LIKE ?");
+ params.push(`%${wallet}%`);
+ }
+ if (status) {
+ whereClauses.push("status = ?");
+ params.push(status);
+ }
+ if (search) {
+ whereClauses.push("(session_id LIKE ? OR wallet_hash LIKE ? OR device_model LIKE ? OR weapon LIKE ? OR target_type LIKE ? OR image_filename LIKE ?)");
+ const s = `%${search}%`;
+ params.push(s, s, s, s, s, s);
+ }
+
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
+
+ db.get(`SELECT COUNT(*) as total FROM upload_logs ${whereSql}`, params, (err, countRow) => {
+ if (err) {
+ console.error("Erreur comptage logs:", err);
+ return res.status(500).json({ error: 'Erreur lors du comptage des logs' });
+ }
+
+ const total = countRow ? countRow.total : 0;
+ const query = `
+ SELECT * FROM upload_logs
+ ${whereSql}
+ ORDER BY timestamp DESC, id DESC
+ LIMIT ? OFFSET ?
+ `;
+
+ db.all(query, [...params, limit, offset], (err, rows) => {
+ if (err) {
+ console.error("Erreur lecture logs:", err);
+ return res.status(500).json({ error: 'Erreur lors de la lecture des logs' });
+ }
+ res.json({
+ logs: rows,
+ total,
+ limit,
+ offset
+ });
+ });
+ });
+});
+
+// Route pour les statistiques des logs
+app.get('/api/logs/stats', (req, res) => {
+ const statsQuery = `
+ SELECT
+ COUNT(*) as total_uploads,
+ SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
+ SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_count,
+ SUM(CASE WHEN date(timestamp, 'localtime') = date('now', 'localtime') THEN 1 ELSE 0 END) as today_uploads,
+ COUNT(DISTINCT wallet_hash) as unique_wallets,
+ SUM(COALESCE(file_size, 0)) as total_bytes,
+ MAX(timestamp) as latest_upload
+ FROM upload_logs
+ `;
+
+ db.get(statsQuery, [], (err, stats) => {
+ if (err) {
+ console.error("Erreur stats logs:", err);
+ return res.status(500).json({ error: 'Erreur calcul statistiques' });
+ }
+ res.json({
+ status: 'ok',
+ stats: stats || {
+ total_uploads: 0,
+ success_count: 0,
+ failed_count: 0,
+ today_uploads: 0,
+ unique_wallets: 0,
+ total_bytes: 0,
+ latest_upload: null
+ }
+ });
+ });
+});
+
+// Route pour exporter les logs (CSV ou JSON)
+app.get('/api/logs/export', (req, res) => {
+ const format = req.query.format || 'csv';
+
+ db.all('SELECT * FROM upload_logs ORDER BY timestamp DESC, id DESC', [], (err, rows) => {
+ if (err) {
+ console.error("Erreur export logs:", err);
+ return res.status(500).json({ error: 'Erreur export logs' });
+ }
+
+ if (format === 'json') {
+ res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.json`);
+ res.setHeader('Content-Type', 'application/json');
+ return res.send(JSON.stringify(rows, null, 2));
+ }
+
+ // CSV Export
+ const headers = [
+ 'ID', 'Date/Heure', 'Statut', 'Wallet Hash', 'Session ID',
+ 'Image', 'JSON', 'Taille (octets)', 'Cible', 'Arme', 'Distance (m)',
+ 'Impacts', 'Modèle Appareil', 'OS', 'IP', 'Erreur'
+ ];
+
+ const csvLines = [headers.join(';')];
+
+ rows.forEach(r => {
+ const line = [
+ r.id,
+ `"${r.timestamp || ''}"`,
+ `"${r.status || ''}"`,
+ `"${r.wallet_hash || ''}"`,
+ `"${r.session_id || ''}"`,
+ `"${r.image_filename || ''}"`,
+ `"${r.json_filename || ''}"`,
+ r.file_size || 0,
+ `"${r.target_type || ''}"`,
+ `"${r.weapon || ''}"`,
+ r.distance_meters || '',
+ r.impacts_count || 0,
+ `"${r.device_model || ''}"`,
+ `"${r.device_os || ''}"`,
+ `"${r.ip_address || ''}"`,
+ `"${(r.error_message || '').replace(/"/g, '""')}"`
+ ];
+ csvLines.push(line.join(';'));
+ });
+
+ res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.csv`);
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.send('\uFEFF' + csvLines.join('\r\n'));
+ });
+});
+
+// Route pour supprimer un log spécifique
+app.delete('/api/logs/:id', (req, res) => {
+ const id = req.params.id;
+ db.run('DELETE FROM upload_logs WHERE id = ?', [id], function(err) {
+ if (err) {
+ console.error("Erreur suppression log:", err);
+ return res.status(500).json({ error: 'Erreur lors de la suppression' });
+ }
+ res.json({ message: 'Log supprimé avec succès', deletedId: id });
+ });
+});
+
+// Route pour vider les logs
+app.delete('/api/logs', (req, res) => {
+ db.run('DELETE FROM upload_logs', [], function(err) {
+ if (err) {
+ console.error("Erreur vidage logs:", err);
+ return res.status(500).json({ error: 'Erreur lors de la réinitialisation des logs' });
+ }
+ res.json({ message: 'Tous les logs ont été effacés', changes: this.changes });
+ });
+});
+
// Route pour récupérer toutes les photos disponibles
app.get('/api/photos', (req, res) => {
try {
@@ -164,20 +623,42 @@ app.get('/api/photos', (req, res) => {
}
});
-// Route pour récupérer les statistiques d'un wallet_hash
+// Route pour récupérer les statistiques d'un wallet_hash + statut de modération
app.get('/api/stats/:wallet_hash', (req, res) => {
const walletHash = req.params.wallet_hash;
- db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => {
+ db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, statRow) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
}
- if (row) {
- res.json({ status: 'ok', stats: row });
- } else {
- res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } });
+ db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (bErr, bannedRow) => {
+ const isBanned = !!bannedRow;
+ const stats = statRow || { photo_count: 0, last_upload: null };
+ res.json({
+ status: 'ok',
+ stats,
+ is_banned: isBanned,
+ ban_reason: bannedRow ? bannedRow.reason : null,
+ banned_at: bannedRow ? bannedRow.banned_at : null
+ });
+ });
+ });
+});
+
+// Route directe pour vérifier l'état de modération d'un wallet
+app.get('/api/moderation/status/:wallet_hash', (req, res) => {
+ const walletHash = req.params.wallet_hash;
+ db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, bannedRow) => {
+ if (err) {
+ return res.status(500).json({ error: 'Erreur lecture statut modération' });
}
+ res.json({
+ wallet_hash: walletHash,
+ is_banned: !!bannedRow,
+ ban_reason: bannedRow ? bannedRow.reason : null,
+ banned_at: bannedRow ? bannedRow.banned_at : null
+ });
});
});
diff --git a/backendia/services/target_validator.js b/backendia/services/target_validator.js
new file mode 100644
index 00000000..a71994d8
--- /dev/null
+++ b/backendia/services/target_validator.js
@@ -0,0 +1,230 @@
+const cvPromise = require('@techstark/opencv-js');
+const sharp = require('sharp');
+
+let cvInstance = null;
+
+async function getCV() {
+ if (!cvInstance) {
+ cvInstance = await cvPromise;
+ }
+ return cvInstance;
+}
+
+/**
+ * Service de validation OpenCV pour vérifier si une image reçue est bien une cible de tir.
+ */
+class TargetValidator {
+ /**
+ * Analyse une image et renvoie le diagnostic OpenCV
+ * @param {string|Buffer} imageInput - Chemin du fichier ou Buffer de l'image
+ * @returns {Promise<{
+ * isValid: boolean,
+ * status: 'VALID' | 'SUSPICIOUS' | 'INVALID',
+ * confidence: number,
+ * ringsCount: number,
+ * bestCenter: { x: number, y: number, radius: number } | null,
+ * details: string
+ * }>}
+ */
+ static async validateTarget(imageInput) {
+ const cv = await getCV();
+
+ let src = null;
+ let gray = null;
+ let blurred = null;
+ let circles = null;
+
+ try {
+ // 1. Charger et décoder l'image avec Sharp pour obtenir les pixels bruts RGBA
+ const { data, info } = await sharp(imageInput)
+ .ensureAlpha() // 4 canaux RGBA
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ const width = info.width;
+ const height = info.height;
+ const minDim = Math.min(width, height);
+
+ // 2. Créer une Mat OpenCV à partir des pixels RGBA
+ src = new cv.Mat(height, width, cv.CV_8UC4);
+ src.data.set(data);
+
+ // 3. Conversion en niveaux de gris
+ gray = new cv.Mat();
+ cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
+
+ // 4. Flou Gaussien pour réduire le bruit
+ blurred = new cv.Mat();
+ const ksize = new cv.Size(9, 9);
+ cv.GaussianBlur(gray, blurred, ksize, 2, 2, cv.BORDER_DEFAULT);
+
+ // 5. Détection multi-bandes pour capturer tous les anneaux concentriques
+ // (Puisque minDist dans HoughCircles empêche la détection de cercles concentriques sur un seul passage)
+ const detectedCircles = [];
+ const bands = [
+ { min: Math.floor(minDim * 0.04), max: Math.floor(minDim * 0.18), p2: 45 },
+ { min: Math.floor(minDim * 0.18), max: Math.floor(minDim * 0.35), p2: 48 },
+ { min: Math.floor(minDim * 0.35), max: Math.floor(minDim * 0.55), p2: 50 }
+ ];
+
+ circles = new cv.Mat();
+
+ for (const band of bands) {
+ cv.HoughCircles(
+ blurred,
+ circles,
+ cv.HOUGH_GRADIENT,
+ 1,
+ Math.max(Math.floor(minDim * 0.05), 10),
+ 100,
+ band.p2,
+ band.min,
+ band.max
+ );
+
+ if (circles.cols > 0 && circles.data32F) {
+ for (let i = 0; i < circles.cols; i++) {
+ const x = circles.data32F[i * 3];
+ const y = circles.data32F[i * 3 + 1];
+ const r = circles.data32F[i * 3 + 2];
+ if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
+ detectedCircles.push({ x, y, r });
+ }
+ }
+ }
+ }
+
+ // Si aucun cercle dans les bandes, tentative globale tolérante
+ if (detectedCircles.length === 0) {
+ cv.HoughCircles(
+ blurred,
+ circles,
+ cv.HOUGH_GRADIENT,
+ 1,
+ Math.max(Math.floor(minDim * 0.08), 10),
+ 100,
+ 35,
+ Math.floor(minDim * 0.05),
+ Math.floor(minDim * 0.55)
+ );
+
+ if (circles.cols > 0 && circles.data32F) {
+ for (let i = 0; i < circles.cols; i++) {
+ const x = circles.data32F[i * 3];
+ const y = circles.data32F[i * 3 + 1];
+ const r = circles.data32F[i * 3 + 2];
+ if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
+ detectedCircles.push({ x, y, r });
+ }
+ }
+ }
+ }
+
+ // Si aucun cercle détecté
+ if (detectedCircles.length === 0) {
+ return {
+ isValid: false,
+ status: 'INVALID',
+ confidence: 0.02,
+ ringsCount: 0,
+ bestCenter: null,
+ details: 'Aucun motif circulaire ou cible détecté sur cette photo.'
+ };
+ }
+
+ // 6. Clustering des cercles pour trouver les anneaux concentriques
+ const tolerance = minDim * 0.07; // Tolérance de 7% pour le décalage de centre
+ const clusters = [];
+
+ for (const circle of detectedCircles) {
+ let added = false;
+ for (const cluster of clusters) {
+ // Prendre le centre moyen actuel du cluster
+ const avgClusterX = cluster.reduce((sum, c) => sum + c.x, 0) / cluster.length;
+ const avgClusterY = cluster.reduce((sum, c) => sum + c.y, 0) / cluster.length;
+ const dist = Math.sqrt(Math.pow(circle.x - avgClusterX, 2) + Math.pow(circle.y - avgClusterY, 2));
+
+ if (dist < tolerance) {
+ // Vérifier que le rayon n'est pas un doublon exact (< 10px de différence)
+ const isDuplicate = cluster.some(c => Math.abs(c.r - circle.r) < minDim * 0.03);
+ if (!isDuplicate) {
+ cluster.push(circle);
+ }
+ added = true;
+ break;
+ }
+ }
+ if (!added) {
+ clusters.push([circle]);
+ }
+ }
+
+ // Trier les clusters par nombre de cercles concentriques décroissant
+ clusters.sort((a, b) => b.length - a.length);
+ const bestCluster = clusters[0];
+ const ringsCount = bestCluster.length;
+
+ // Calculer le centre moyen du meilleur cluster
+ const avgX = bestCluster.reduce((acc, c) => acc + c.x, 0) / ringsCount;
+ const avgY = bestCluster.reduce((acc, c) => acc + c.y, 0) / ringsCount;
+ const maxR = Math.max(...bestCluster.map(c => c.r));
+
+ // 7. Évaluation du statut et de la confiance
+ if (ringsCount >= 3) {
+ return {
+ isValid: true,
+ status: 'VALID',
+ confidence: Math.min(0.92 + (ringsCount - 3) * 0.02, 0.99),
+ ringsCount,
+ bestCenter: { x: avgX, y: avgY, radius: maxR },
+ details: `Cible certifiée : ${ringsCount} anneaux concentriques identifiés.`
+ };
+ } else if (ringsCount === 2) {
+ return {
+ isValid: true,
+ status: 'VALID',
+ confidence: 0.85,
+ ringsCount,
+ bestCenter: { x: avgX, y: avgY, radius: maxR },
+ details: `Cible confirmée : ${ringsCount} anneaux concentriques détectés.`
+ };
+ } else if (ringsCount === 1) {
+ return {
+ isValid: true,
+ status: 'SUSPICIOUS',
+ confidence: 0.50,
+ ringsCount: 1,
+ bestCenter: { x: avgX, y: avgY, radius: maxR },
+ details: 'Un seul anneau détecté, confirmation visuelle recommandée.'
+ };
+ } else {
+ return {
+ isValid: false,
+ status: 'INVALID',
+ confidence: 0.1,
+ ringsCount: 0,
+ bestCenter: null,
+ details: 'Motif de cible non conforme.'
+ };
+ }
+ } catch (error) {
+ console.error('Erreur TargetValidator OpenCV:', error);
+ return {
+ isValid: false,
+ status: 'INVALID',
+ confidence: 0.0,
+ ringsCount: 0,
+ bestCenter: null,
+ details: `Erreur d'analyse OpenCV: ${error.message}`
+ };
+ } finally {
+ // 8. Nettoyage mémoire
+ if (src && !src.isDeleted()) src.delete();
+ if (gray && !gray.isDeleted()) gray.delete();
+ if (blurred && !blurred.isDeleted()) blurred.delete();
+ if (circles && !circles.isDeleted()) circles.delete();
+ }
+ }
+}
+
+module.exports = TargetValidator;
diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart
index a9b0a13e..d2773f07 100644
--- a/lib/features/analysis/analysis_provider.dart
+++ b/lib/features/analysis/analysis_provider.dart
@@ -241,15 +241,18 @@ class AnalysisProvider extends ChangeNotifier {
/// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
- Future exportToAiBackend({
+ Future exportToAiBackend({
String? sessionId,
int? distance,
String? weapon,
}) async {
if (_imagePath == null || _targetType == null) {
- _errorMessage = "Impossible d'export : image ou type de cible manquant.";
+ _errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
notifyListeners();
- return false;
+ return AiExportResult.error(
+ code: 'MISSING_DATA',
+ message: "Impossible d'exporter : image ou type de cible manquant.",
+ );
}
final service = AiExportService();
@@ -257,7 +260,7 @@ class AnalysisProvider extends ChangeNotifier {
_state = AnalysisState.loading;
notifyListeners();
- final success = await service.exportData(
+ final result = await service.exportData(
imagePath: _imagePath!,
sessionId: sessionId ?? 'export',
targetType: _targetType!,
@@ -270,11 +273,11 @@ class AnalysisProvider extends ChangeNotifier {
);
_state = AnalysisState.success;
- if (!success) {
- _errorMessage = "Échec de l'export vers le serveur IA.";
+ if (!result.isSuccess) {
+ _errorMessage = result.message;
}
notifyListeners();
- return success;
+ return result;
}
/// Save the session
diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart
index 347db502..00e0550c 100644
--- a/lib/features/analysis/analysis_screen.dart
+++ b/lib/features/analysis/analysis_screen.dart
@@ -19,6 +19,7 @@ import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart';
import '../../services/wallet_identity_service.dart';
+import '../../services/ai_export_service.dart';
import '../session/session_provider.dart';
import 'analysis_provider.dart';
import 'impact_editor_screen.dart';
@@ -933,12 +934,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
date: sessionProvider.sessionDate,
);
- bool? exportSucceeded;
+ AiExportResult? exportResult;
if (export) {
messenger.showSnackBar(
- const SnackBar(content: Text('Exportation en cours...')),
+ const SnackBar(content: Text('Exportation vers le serveur IA en cours...')),
);
- exportSucceeded = await provider.exportToAiBackend(
+ exportResult = await provider.exportToAiBackend(
sessionId: sessionProvider.activeSessionId,
distance: sessionProvider.distance,
weapon: sessionProvider.currentWeapon,
@@ -954,19 +955,68 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
openMainTab(mainTabStats);
}
- if (exportSucceeded != null) {
- messenger.showSnackBar(
- SnackBar(
- content: Text(
- exportSucceeded
- ? 'Export réussi vers le backend IA !'
- : (provider.errorMessage ?? 'Erreur d\'export'),
+ if (exportResult != null) {
+ if (exportResult.isBanned) {
+ messenger.showSnackBar(
+ SnackBar(
+ content: Row(
+ children: [
+ const Icon(Icons.block, color: Colors.white),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ 'Participation IA suspendue : ${exportResult.reason ?? "Non-respect des règles"}',
+ style: const TextStyle(fontWeight: FontWeight.bold),
+ ),
+ ),
+ ],
+ ),
+ backgroundColor: AppTheme.errorColor,
+ duration: const Duration(seconds: 6),
),
- backgroundColor: exportSucceeded
- ? AppTheme.successColor
- : AppTheme.errorColor,
- ),
- );
+ );
+ } else if (exportResult.isSuccess) {
+ final targetStatus = exportResult.targetValidation?['status'];
+ final isCertified = targetStatus == 'VALID';
+ messenger.showSnackBar(
+ SnackBar(
+ content: Row(
+ children: [
+ Icon(
+ isCertified ? Icons.verified : Icons.cloud_done,
+ color: Colors.white,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ isCertified
+ ? 'Export réussi ! Cible certifiée par l\'IA.'
+ : exportResult.message,
+ ),
+ ),
+ ],
+ ),
+ backgroundColor: AppTheme.successColor,
+ duration: const Duration(seconds: 4),
+ ),
+ );
+ } else {
+ messenger.showSnackBar(
+ SnackBar(
+ content: Row(
+ children: [
+ const Icon(Icons.warning_amber_rounded, color: Colors.white),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text('Échec de l\'export : ${exportResult.message}'),
+ ),
+ ],
+ ),
+ backgroundColor: AppTheme.errorColor,
+ duration: const Duration(seconds: 4),
+ ),
+ );
+ }
}
} catch (e) {
messenger.showSnackBar(
diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart
index ae9471e0..f0876b76 100644
--- a/lib/features/settings/settings_screen.dart
+++ b/lib/features/settings/settings_screen.dart
@@ -22,7 +22,11 @@ class _SettingsScreenState extends State {
String? _identityPhrase;
int? _photoCount;
bool _isLoadingStats = false;
+ bool _isCheckingStatus = false;
bool _isUploadEnabled = false;
+ bool _isBanned = false;
+ String? _banReason;
+ String _serverUrl = 'http://localhost:3000';
@override
void initState() {
@@ -32,11 +36,17 @@ class _SettingsScreenState extends State {
Future _loadIdentity() async {
final phrase = await _walletService.getIdentityPhrase();
+ final serverUrl = await _walletService.getServerBaseUrl();
+ final isBanned = await _walletService.isBanned();
+ final banReason = await _walletService.getBanReason();
final isEnabled = await _walletService.isUploadEnabled();
if (mounted) {
setState(() {
_identityPhrase = phrase;
- _isUploadEnabled = isEnabled;
+ _serverUrl = serverUrl;
+ _isBanned = isBanned;
+ _banReason = banReason;
+ _isUploadEnabled = isEnabled && !isBanned;
});
_fetchStats(phrase);
}
@@ -47,19 +57,18 @@ class _SettingsScreenState extends State {
try {
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
-
- // Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
- final baseUrl = Theme.of(context).platform == TargetPlatform.android
- ? 'http://10.0.2.2:3000'
- : 'http://localhost:3000';
+ final baseUrl = await _walletService.getServerBaseUrl();
- final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
+ final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
+ const Duration(seconds: 4),
+ );
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (mounted) {
setState(() {
_photoCount = data['stats']['photo_count'] ?? 0;
+ _serverUrl = baseUrl;
_isLoadingStats = false;
});
}
@@ -72,6 +81,72 @@ class _SettingsScreenState extends State {
}
}
+ /// Action manuelle de l'utilisateur pour vérifier et synchroniser son statut auprès du serveur
+ Future _refreshAccountStatus() async {
+ setState(() => _isCheckingStatus = true);
+ try {
+ final isBanned = await _walletService.syncBanStatus();
+ final banReason = await _walletService.getBanReason();
+ final isEnabled = await _walletService.isUploadEnabled();
+
+ if (_identityPhrase != null) {
+ await _fetchStats(_identityPhrase!);
+ }
+
+ if (!mounted) return;
+
+ setState(() {
+ _isBanned = isBanned;
+ _banReason = banReason;
+ _isUploadEnabled = isEnabled && !isBanned;
+ _isCheckingStatus = false;
+ });
+
+ if (isBanned) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Row(
+ children: [
+ const Icon(Icons.block, color: Colors.white),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text('Compte toujours suspendu : ${_banReason ?? "Non-respect des règles"}'),
+ ),
+ ],
+ ),
+ backgroundColor: AppTheme.errorColor,
+ duration: const Duration(seconds: 5),
+ ),
+ );
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Row(
+ children: [
+ Icon(Icons.check_circle, color: Colors.white),
+ SizedBox(width: 10),
+ Expanded(
+ child: Text('Statut actualisé : votre compte est actif et autorisé !'),
+ ),
+ ],
+ ),
+ backgroundColor: AppTheme.successColor,
+ duration: Duration(seconds: 4),
+ ),
+ );
+ }
+ } catch (e) {
+ if (!mounted) return;
+ setState(() => _isCheckingStatus = false);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Impossible de joindre le serveur : $e'),
+ backgroundColor: AppTheme.errorColor,
+ ),
+ );
+ }
+ }
+
void _copyToClipboard() {
if (_identityPhrase != null) {
Clipboard.setData(ClipboardData(text: _identityPhrase!));
@@ -182,8 +257,34 @@ class _SettingsScreenState extends State {
}
void _showOptInDisclaimer(bool value) {
+ if (_isBanned) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Row(
+ children: [
+ Icon(Icons.block, color: AppTheme.errorColor),
+ SizedBox(width: 10),
+ Expanded(child: Text('Programme IA Suspendu')),
+ ],
+ ),
+ content: Text(
+ 'Votre participation au programme d\'entraînement a été suspendue par la modération pour le motif suivant :\n\n'
+ '« ${_banReason ?? "Envois non conformes ou inappropriés"} »\n\n'
+ 'L\'envoi de photos pour l\'entraînement est définitivement désactivé sur cet appareil.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Compris'),
+ ),
+ ],
+ ),
+ );
+ return;
+ }
+
if (!value) {
- // Si on désactive, pas besoin de disclaimer, on le fait direct.
_walletService.setUploadEnabled(false);
setState(() {
_isUploadEnabled = false;
@@ -196,27 +297,68 @@ class _SettingsScreenState extends State {
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
- content: const Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
- SizedBox(height: 16),
- Text(
- 'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
- style: TextStyle(fontWeight: FontWeight.bold),
- textAlign: TextAlign.center,
- ),
- SizedBox(height: 12),
- Text(
- '🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
- style: TextStyle(fontSize: 13),
- ),
- SizedBox(height: 12),
- Text(
- '🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
- style: TextStyle(fontSize: 13),
- ),
- ],
+ content: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Center(
+ child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ 'En activant cette option, vous acceptez d\'envoyer vos photos de cibles au serveur d\'entraînement IA.',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 14),
+ const Text(
+ '🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.',
+ style: TextStyle(fontSize: 13),
+ ),
+ const SizedBox(height: 10),
+ const Text(
+ '🎁 Récompenses : Vos contributions sont enregistrées pour vous donner accès à des fonctionnalités exclusives.',
+ style: TextStyle(fontSize: 13),
+ ),
+ const SizedBox(height: 14),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: AppTheme.errorColor.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.4)),
+ ),
+ child: const Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 20),
+ SizedBox(width: 8),
+ Text(
+ 'Règles strictes & Bannissement',
+ style: TextStyle(
+ color: AppTheme.errorColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 13,
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: 6),
+ Text(
+ 'Vous vous engagez à n\'envoyer que de réelles cibles de tir conformes. '
+ 'Tout envoi de photos non conformes, fausses cibles, images floues ou contenu inapproprié '
+ 'entraînera le bannissement immédiat et définitif de votre compte. '
+ 'L\'application perdra définitivement la possibilité d\'envoyer des photos.',
+ style: TextStyle(fontSize: 12, height: 1.4),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
),
actions: [
TextButton(
@@ -238,7 +380,70 @@ class _SettingsScreenState extends State {
),
);
},
- child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
+ child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showEditServerUrlDialog() {
+ final urlController = TextEditingController(text: _serverUrl);
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Adresse du Serveur IA'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Indiquez l\'adresse IP ou l\'URL du serveur backend IA (port 3000) :',
+ style: TextStyle(fontSize: 13, color: Colors.grey),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: urlController,
+ decoration: const InputDecoration(
+ hintText: 'Ex: http://192.168.1.50:3000',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ '💡 Sur émulateur : http://10.0.2.2:3000\n💡 Sur smartphone réel : IP locale de votre PC (ex: http://192.168.1.X:3000)',
+ style: TextStyle(fontSize: 11, color: Colors.grey),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Annuler'),
+ ),
+ ElevatedButton(
+ style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
+ onPressed: () async {
+ final newUrl = urlController.text.trim();
+ if (newUrl.isNotEmpty) {
+ await _walletService.setServerBaseUrl(newUrl);
+ setState(() {
+ _serverUrl = newUrl;
+ });
+ if (_identityPhrase != null) {
+ _fetchStats(_identityPhrase!);
+ }
+ }
+ if (!context.mounted) return;
+ Navigator.pop(context);
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Adresse du serveur IA mise à jour'),
+ backgroundColor: AppTheme.successColor,
+ ),
+ );
+ },
+ child: const Text('Enregistrer', style: TextStyle(color: Colors.white)),
),
],
),
@@ -363,51 +568,126 @@ class _SettingsScreenState extends State {
),
const SizedBox(height: 24),
- _buildSectionHeader('Configuration Serveur IA'),
- Card(
- elevation: 0,
- color: Colors.transparent,
- margin: const EdgeInsets.only(bottom: 8.0),
- child: SwitchListTile(
- contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
- title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
- subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
- secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
- value: _isUploadEnabled,
- activeThumbColor: AppTheme.primaryColor,
+ _buildSectionHeader('Programme d\'Entraînement IA'),
+ if (_isBanned) ...[
+ Card(
+ elevation: 0,
+ color: AppTheme.errorColor.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
- side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
+ side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Row(
+ children: [
+ Icon(Icons.block, color: AppTheme.errorColor, size: 22),
+ SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ 'Participation au programme IA suspendue',
+ style: TextStyle(
+ color: AppTheme.errorColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Motif : ${_banReason ?? "Envois d'images non appropriées ou fausses cibles."}',
+ style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 6),
+ const Text(
+ 'Suite à des envois non conformes, la possibilité de participer au programme d\'entraînement et d\'envoyer des photos a été révoquée pour cet identifiant.',
+ style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.3),
+ ),
+ const SizedBox(height: 14),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ icon: _isCheckingStatus
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
+ )
+ : const Icon(Icons.refresh, size: 18),
+ label: Text(
+ _isCheckingStatus ? 'Vérification...' : 'ACTUALISER MON STATUT',
+ style: const TextStyle(fontWeight: FontWeight.bold),
+ ),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppTheme.errorColor,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
+ ),
+ ),
+ ],
+ ),
),
- onChanged: _showOptInDisclaimer,
),
- ),
- if (_isUploadEnabled)
- _buildSettingsTile(
- context: context,
- icon: Icons.cloud_outlined,
- title: 'Adresse du Serveur IA',
- subtitle: 'http://localhost:3000/api/upload',
- onTap: () {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Changement d\'adresse à venir')),
- );
- },
+ ] else ...[
+ Card(
+ elevation: 0,
+ color: Colors.transparent,
+ margin: const EdgeInsets.only(bottom: 8.0),
+ child: SwitchListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
+ title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
+ subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)),
+ secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
+ value: _isUploadEnabled,
+ activeThumbColor: AppTheme.primaryColor,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12.0),
+ side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
+ ),
+ onChanged: _showOptInDisclaimer,
+ ),
),
- if (_isUploadEnabled)
- _buildSettingsTile(
- context: context,
- icon: Icons.data_usage,
- title: 'Photos Exportées',
- subtitle: _isLoadingStats
- ? 'Chargement...'
- : (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
- onTap: () {
- if (_identityPhrase != null) {
- _fetchStats(_identityPhrase!);
- }
- },
- ),
+ if (_isUploadEnabled)
+ _buildSettingsTile(
+ context: context,
+ icon: Icons.cloud_outlined,
+ title: 'Adresse du Serveur IA',
+ subtitle: _serverUrl,
+ onTap: _showEditServerUrlDialog,
+ ),
+ if (_isUploadEnabled)
+ _buildSettingsTile(
+ context: context,
+ icon: Icons.sync,
+ title: 'Actualiser mon statut',
+ subtitle: _isCheckingStatus ? 'Vérification en cours...' : 'Vérifier l\'état du compte auprès du serveur',
+ onTap: _isCheckingStatus ? () {} : _refreshAccountStatus,
+ ),
+ if (_isUploadEnabled)
+ _buildSettingsTile(
+ context: context,
+ icon: Icons.data_usage,
+ title: 'Photos Exportées',
+ subtitle: _isLoadingStats
+ ? 'Chargement...'
+ : (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
+ onTap: () {
+ if (_identityPhrase != null) {
+ _fetchStats(_identityPhrase!);
+ }
+ },
+ ),
+ ],
const SizedBox(height: 24),
_buildSectionHeader('À propos'),
diff --git a/lib/services/ai_export_service.dart b/lib/services/ai_export_service.dart
index 2d51f543..dff65bd1 100644
--- a/lib/services/ai_export_service.dart
+++ b/lib/services/ai_export_service.dart
@@ -8,16 +8,62 @@ import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import 'wallet_identity_service.dart';
-class AiExportService {
- // Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
- // Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
- static String get _defaultApiUrl {
- if (Platform.isAndroid) {
- return 'http://10.0.2.2:3000/api/upload';
- }
- return 'http://localhost:3000/api/upload';
+/// Résultat détaillé de l'exportation vers le serveur IA
+class AiExportResult {
+ final bool isSuccess;
+ final String code;
+ final String message;
+ final String? reason;
+ final bool isBanned;
+ final Map? targetValidation;
+
+ AiExportResult({
+ required this.isSuccess,
+ required this.code,
+ required this.message,
+ this.reason,
+ this.isBanned = false,
+ this.targetValidation,
+ });
+
+ factory AiExportResult.success({
+ String? message,
+ Map? targetValidation,
+ }) {
+ return AiExportResult(
+ isSuccess: true,
+ code: 'UPLOAD_SUCCESS',
+ message: message ?? 'Export réussi vers le serveur IA !',
+ targetValidation: targetValidation,
+ );
}
+ factory AiExportResult.banned({
+ String? reason,
+ String? message,
+ }) {
+ return AiExportResult(
+ isSuccess: false,
+ code: 'WALLET_BANNED',
+ isBanned: true,
+ reason: reason,
+ message: message ?? 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
+ );
+ }
+
+ factory AiExportResult.error({
+ String? code,
+ required String message,
+ }) {
+ return AiExportResult(
+ isSuccess: false,
+ code: code ?? 'UPLOAD_ERROR',
+ message: message,
+ );
+ }
+}
+
+class AiExportService {
/// Extrait les informations de l'appareil
Future> _getDeviceInfo() async {
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
@@ -45,7 +91,7 @@ class AiExportService {
}
/// Exporte l'image et les données de plotting vers le serveur
- Future exportData({
+ Future exportData({
required String imagePath,
required String sessionId,
required TargetType targetType,
@@ -58,23 +104,23 @@ class AiExportService {
String? apiUrl,
}) async {
try {
- final url = Uri.parse(apiUrl ?? _defaultApiUrl);
+ final walletService = WalletIdentityService();
+ final baseUrl = await walletService.getServerBaseUrl();
+ final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
+ final url = Uri.parse(effectiveUrl);
final request = http.MultipartRequest('POST', url);
// 1. Prepare image
final file = File(imagePath);
if (!await file.exists()) {
- throw Exception('Le fichier image n\'existe pas');
+ return AiExportResult.error(
+ code: 'FILE_NOT_FOUND',
+ message: 'Le fichier image cible est introuvable.',
+ );
}
- // Read image metadata (approximate dimensions since decoding image can be heavy)
- // On the frontend we usually have aspectRatio, here we use generic values if not available.
final deviceData = await _getDeviceInfo();
- // We approximate the target corners from center and radius
- // radius is relative (0 to 1). We need image width/height to get pixels.
- // But we can just pass relative corners as well, or a normalized bounding box.
- // Let's create normalized corners (0 to 1).
final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
@@ -98,7 +144,6 @@ class AiExportService {
}).toList();
// Get and hash the wallet identity
- final walletService = WalletIdentityService();
final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
@@ -113,7 +158,6 @@ class AiExportService {
"type": targetType.name,
"distance_meters": distanceMeters,
"weapon": weaponName,
- // The backend could extract exact width/height from the image.
},
"plotting": {
"target_corners": corners,
@@ -121,29 +165,61 @@ class AiExportService {
}
};
- // Add fields to request
request.fields['plotting'] = jsonEncode(plottingJson);
-
- // Add file
request.files.add(
await http.MultipartFile.fromPath('photo', imagePath),
);
- // Send request
- final response = await request.send();
+ final streamedResponse = await request.send().timeout(
+ const Duration(seconds: 15),
+ onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
+ );
- if (response.statusCode == 200) {
- final responseData = await response.stream.bytesToString();
- debugPrint('Export réussi: $responseData');
- return true;
+ final responseBody = await streamedResponse.stream.bytesToString();
+ Map responseJson = {};
+ try {
+ responseJson = jsonDecode(responseBody);
+ } catch (_) {}
+
+ final statusCode = streamedResponse.statusCode;
+
+ if (statusCode == 200) {
+ debugPrint('Export réussi: $responseBody');
+ return AiExportResult.success(
+ message: responseJson['message'] ?? 'Photo et données exportées avec succès.',
+ targetValidation: responseJson['target_validation'] as Map?,
+ );
+ } else if (statusCode == 403 || responseJson['code'] == 'WALLET_BANNED') {
+ final reason = responseJson['reason'] ?? 'Non-respect des règles de contribution';
+ debugPrint('Export rejeté (banni): $reason');
+ // Persister le bannissement localement et couper l'envoi de photos
+ await walletService.setBanned(true, reason: reason);
+ return AiExportResult.banned(
+ reason: reason,
+ message: responseJson['error'] ?? 'Votre wallet a été suspendu par la modération.',
+ );
+ } else if (statusCode == 400) {
+ return AiExportResult.error(
+ code: responseJson['code'] ?? 'BAD_REQUEST',
+ message: responseJson['error'] ?? 'Requête d\'export invalide.',
+ );
} else {
- final errorData = await response.stream.bytesToString();
- debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
- return false;
+ return AiExportResult.error(
+ code: responseJson['code'] ?? 'SERVER_ERROR',
+ message: responseJson['error'] ?? 'Erreur serveur ($statusCode).',
+ );
}
+ } on SocketException {
+ return AiExportResult.error(
+ code: 'NETWORK_ERROR',
+ message: 'Impossible de joindre le serveur IA. Vérifiez l\'adresse IP ou votre connexion.',
+ );
} catch (e) {
debugPrint('Exception lors de l\'export: $e');
- return false;
+ return AiExportResult.error(
+ code: 'UNKNOWN_ERROR',
+ message: 'Erreur lors de l\'export: $e',
+ );
}
}
}
diff --git a/lib/services/wallet_identity_service.dart b/lib/services/wallet_identity_service.dart
index 67d361d7..3c084e99 100644
--- a/lib/services/wallet_identity_service.dart
+++ b/lib/services/wallet_identity_service.dart
@@ -1,6 +1,7 @@
import 'dart:math';
import 'dart:convert';
import 'package:crypto/crypto.dart';
+import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
@@ -9,6 +10,9 @@ import 'package:flutter/foundation.dart';
class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
+ static const String _bannedKey = 'wallet_is_banned';
+ static const String _banReasonKey = 'wallet_ban_reason';
+ static const String _serverUrlKey = 'ai_server_url';
// A standard list of 256 words (8 bits of entropy per word)
static const List _wordList = [
@@ -40,18 +44,96 @@ class WalletIdentityService {
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
];
- /// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
+ /// Retourne l'URL de base du serveur configuré (ex: http://192.168.1.50:3000 ou http://10.0.2.2:3000)
+ Future getServerBaseUrl() async {
+ final prefs = await SharedPreferences.getInstance();
+ final customUrl = prefs.getString(_serverUrlKey);
+ if (customUrl != null && customUrl.trim().isNotEmpty) {
+ return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
+ }
+ if (Platform.isAndroid) {
+ return 'http://10.0.2.2:3000';
+ }
+ return 'http://localhost:3000';
+ }
+
+ /// Définit une URL personnalisée pour le serveur IA
+ Future setServerBaseUrl(String url) async {
+ final prefs = await SharedPreferences.getInstance();
+ final cleanUrl = url.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
+ await prefs.setString(_serverUrlKey, cleanUrl);
+ }
+
+ /// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA (et n'est pas banni)
Future isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance();
+ final isBanned = prefs.getBool(_bannedKey) ?? false;
+ if (isBanned) return false;
return prefs.getBool(_uploadEnabledKey) ?? false;
}
/// Active ou désactive l'envoi de données
Future setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
+ final isBanned = prefs.getBool(_bannedKey) ?? false;
+ if (isBanned) {
+ await prefs.setBool(_uploadEnabledKey, false);
+ return;
+ }
await prefs.setBool(_uploadEnabledKey, enabled);
}
+ /// Vérifie si ce wallet/utilisateur est banni en local
+ Future isBanned() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(_bannedKey) ?? false;
+ }
+
+ /// Récupère le motif de bannissement enregistré
+ Future getBanReason() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getString(_banReasonKey);
+ }
+
+ /// Enregistre l'état de bannissement et le motif
+ Future setBanned(bool banned, {String? reason}) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_bannedKey, banned);
+ if (banned) {
+ await prefs.setString(_banReasonKey, reason ?? 'Photos non conformes aux règles de tir');
+ await prefs.setBool(_uploadEnabledKey, false);
+ } else {
+ await prefs.remove(_banReasonKey);
+ }
+ }
+
+ /// Synchronise le statut de modération/bannissement avec le serveur backend
+ Future syncBanStatus() async {
+ try {
+ final phrase = await getIdentityPhrase();
+ final phraseBytes = utf8.encode(phrase);
+ final walletHash = sha256.convert(phraseBytes).toString();
+ final baseUrl = await getServerBaseUrl();
+
+ final res = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
+ const Duration(seconds: 4),
+ );
+ if (res.statusCode == 200) {
+ final data = jsonDecode(res.body);
+ final isBannedOnServer = data['is_banned'] == true;
+ if (isBannedOnServer) {
+ await setBanned(true, reason: data['ban_reason']);
+ } else {
+ await setBanned(false);
+ }
+ return isBannedOnServer;
+ }
+ } catch (e) {
+ debugPrint('Erreur synchro ban: $e');
+ }
+ return await isBanned();
+ }
+
/// Gets the unique 15-word identity phrase
Future getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance();