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. +

+
+
+
+ +
+ + + + + Exporter CSV + + + {!showClearConfirm ? ( + + ) : ( +
+ Effacer tout ? + + +
+ )} +
+
+ + {/* 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 && ( + + )} +
+ + {/* Status & OpenCV Filter */} +
+ + + Filtre: + +
+ + + + +
+
+
+ + {/* Logs Table */} +
+
+ + + + + + + + + + + + + + {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 */} + + + {/* OpenCV Diagnosis & Upload Status */} + + + {/* Wallet Hash & Ban Badge */} + + + {/* Session & Target */} + + + {/* Device & IP */} + + + {/* File & Size */} + + + {/* Actions */} + + + ); + })} + +
Date & HeureDiagnostic OpenCVWallet ContributeurSession / CibleAppareilFichierActions
+
+ {formattedDate} + {formattedTime} +
+
+
+ {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} +
+
+ {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} + + +
+ + {banned && ( + + + WALLET BANNI + + )} +
+ ) : ( + Anonyme / Non fourni + )} +
+
+ {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. + + )} +
+
+
+
+
+ + {log.device_model || log.device_os || "Inconnu"} +
+ {log.ip_address && ( + + {log.ip_address.replace("::ffff:", "")} + + )} +
+
+
+ {log.image_filename ? ( + + {log.image_filename} + + + ) : ( + Aucun fichier + )} + + {formatFileSize(log.file_size)} + +
+
+
+ {/* Ban / Unban Button */} + {log.wallet_hash && ( + banned ? ( + + ) : ( + + ) + )} + + + +
+
+
+ + {/* 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()}

+
+
+ +
+ + {/* 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) ? ( + + ) : ( + + ) + )} +
+ + +
+
+
+ )} + + {/* Ban Confirmation Modal */} + {banModalWallet && ( +
+
+
+
+ +
+
+

Bannir un Wallet

+

Bloquer définitivement tout upload futur de ce compte.

+
+
+ +
+ {banModalWallet} +
+ +
+ + +
+ +
+ + +
+
+
+ )} +
+ ); +} 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();