Config::MAX_FILE_SIZE) { $sizeFormatted = Config::formatBytes($fileSize); $maxFormatted = Config::formatBytes(Config::MAX_FILE_SIZE); Config::log("Fichier ZIP trop volumineux : {$sizeFormatted} > {$maxFormatted}", 'ERROR'); throw new Exception("Fichier ZIP trop volumineux ({$sizeFormatted}). Maximum : {$maxFormatted}"); } // Valider que c'est bien un fichier ZIP (magic bytes) if (!$this->isValidZip($zipPath)) { Config::log("Fichier non-ZIP ou corrompu : {$zipPath}", 'ERROR'); throw new Exception("Le fichier n'est pas un ZIP valide"); } // Ouvrir le ZIP $zip = new ZipArchive(); $result = $zip->open($zipPath, ZipArchive::RDONLY); if ($result !== true) { Config::log("Échec ouverture ZIP : {$zipPath} (code: {$result})", 'ERROR'); throw new Exception("Impossible d'ouvrir le fichier ZIP (code erreur: {$result})"); } Config::log("Extraction structure ZIP : {$zipPath} ({$zip->numFiles} fichiers)"); // Vérifier le nombre de fichiers if ($zip->numFiles > Config::MAX_FILES_PER_ZIP) { $zip->close(); Config::log("Trop de fichiers dans le ZIP : {$zip->numFiles} > " . Config::MAX_FILES_PER_ZIP, 'ERROR'); throw new Exception("Le ZIP contient trop de fichiers (" . $zip->numFiles . "). Maximum : " . Config::MAX_FILES_PER_ZIP); } // Extraire la liste des fichiers $filesList = []; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); if ($stat === false) { Config::log("Impossible de lire l'entrée {$i} du ZIP", 'WARNING'); continue; } $name = $stat['name']; $size = $stat['size']; $isDir = substr($name, -1) === '/'; // Sanitize le nom $nameSanitized = Config::sanitizePath($name); // Vérifier la profondeur $depth = substr_count($nameSanitized, '/'); if ($depth > Config::MAX_TREE_DEPTH) { Config::log("Profondeur excessive ignorée : {$nameSanitized} (depth: {$depth})", 'WARNING'); continue; } // Vérifier les extensions interdites if (!$isDir && Config::isForbiddenExtension($nameSanitized)) { Config::log("Extension interdite ignorée : {$nameSanitized}", 'WARNING'); continue; } $filesList[] = [ 'name' => $nameSanitized, 'size' => $size, 'is_dir' => $isDir, 'index' => $i ]; } $zip->close(); Config::log("Structure extraite : " . count($filesList) . " éléments valides"); return [ 'files' => $filesList, 'total_files' => count($filesList), 'total_size' => array_sum(array_column($filesList, 'size')), 'zip_path' => $zipPath ]; } /** * Vérifie si un fichier est un ZIP valide (magic bytes) * * @param string $filePath Chemin vers le fichier * @return bool True si c'est un ZIP valide */ private function isValidZip(string $filePath): bool { $handle = fopen($filePath, 'rb'); if (!$handle) { return false; } // Lire les 4 premiers octets $bytes = fread($handle, 4); fclose($handle); if ($bytes === false || strlen($bytes) < 4) { return false; } // Convertir en hex $hex = bin2hex($bytes); // Vérifier contre les signatures ZIP connues foreach (Config::ZIP_MAGIC_BYTES as $magicByte) { if (strcasecmp($hex, $magicByte) === 0) { return true; } } return false; } /** * Fusionne des fichiers de 2 ZIP selon la sélection utilisateur * * @param string $leftZipPath Chemin vers le ZIP de gauche * @param string $rightZipPath Chemin vers le ZIP de droite * @param array $selection Map path => 'left'|'right'|null * @param string $outputPath Chemin de sortie pour le ZIP fusionné * @return string Chemin du ZIP créé * @throws Exception Si erreur lors de la fusion */ public function merge(string $leftZipPath, string $rightZipPath, array $selection, string $outputPath): string { Config::log("Début fusion : left={$leftZipPath}, right={$rightZipPath}, output={$outputPath}"); // Ouvrir les ZIP sources $leftZip = new ZipArchive(); $rightZip = new ZipArchive(); $outputZip = new ZipArchive(); if ($leftZip->open($leftZipPath, ZipArchive::RDONLY) !== true) { throw new Exception("Impossible d'ouvrir le ZIP de gauche"); } if ($rightZip->open($rightZipPath, ZipArchive::RDONLY) !== true) { $leftZip->close(); throw new Exception("Impossible d'ouvrir le ZIP de droite"); } // Créer le ZIP de sortie if ($outputZip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { $leftZip->close(); $rightZip->close(); throw new Exception("Impossible de créer le ZIP de sortie"); } $addedCount = 0; $skippedCount = 0; $addedPaths = []; // Pour éviter les doublons // Parcourir la sélection foreach ($selection as $path => $side) { if ($side === null) { // Aucun côté sélectionné, ignorer $skippedCount++; continue; } // Déterminer le ZIP source $sourceZip = ($side === 'left') ? $leftZip : $rightZip; // Vérifier si c'est un dossier (se termine par /) $isFolder = substr($path, -1) === '/'; if ($isFolder) { // C'est un dossier : ajouter tous les fichiers qui commencent par ce chemin $folderFileCount = 0; for ($i = 0; $i < $sourceZip->numFiles; $i++) { $filename = $sourceZip->getNameIndex($i); // Vérifier si ce fichier est dans le dossier if (strpos($filename, $path) === 0) { // Éviter les doublons if (isset($addedPaths[$filename])) { continue; } // Vérifier si c'est un dossier (entrée ZIP vide) $stat = $sourceZip->statIndex($i); if ($stat !== false && substr($stat['name'], -1) === '/') { // C'est un dossier vide, on peut l'ignorer ou l'ajouter // Pour l'instant on l'ignore car on ajoute déjà les fichiers continue; } // Récupérer le contenu $content = $sourceZip->getFromIndex($i); if ($content === false) { Config::log("Impossible de lire : {$filename}", 'WARNING'); $skippedCount++; continue; } // Ajouter au ZIP de sortie if ($outputZip->addFromString($filename, $content)) { $addedCount++; $folderFileCount++; $addedPaths[$filename] = true; // Flush tous les 50 fichiers pour éviter les timeouts if ($folderFileCount % 50 === 0) { @ob_flush(); @flush(); } } else { Config::log("Échec ajout : {$filename}", 'WARNING'); $skippedCount++; } } } Config::log("Dossier {$path} : {$folderFileCount} fichiers ajoutés"); } else { // C'est un fichier individuel // Éviter les doublons if (isset($addedPaths[$path])) { continue; } // Rechercher l'index du fichier dans le ZIP source $index = $sourceZip->locateName($path); if ($index === false) { Config::log("Fichier introuvable : {$path} (côté: {$side})", 'WARNING'); $skippedCount++; continue; } // Récupérer le contenu $content = $sourceZip->getFromIndex($index); if ($content === false) { Config::log("Impossible de lire : {$path}", 'WARNING'); $skippedCount++; continue; } // Ajouter au ZIP de sortie if ($outputZip->addFromString($path, $content)) { $addedCount++; $addedPaths[$path] = true; } else { Config::log("Échec ajout : {$path}", 'WARNING'); $skippedCount++; } } } // Fermer les ZIP $leftZip->close(); $rightZip->close(); $outputZip->close(); Config::log("Fusion terminée : {$addedCount} fichiers ajoutés, {$skippedCount} ignorés"); return $outputPath; } /** * Extrait un fichier spécifique d'un ZIP * * @param string $zipPath Chemin vers le ZIP * @param string $filePath Chemin du fichier dans le ZIP * @param string $outputPath Chemin de sortie * @return bool True si succès */ public function extractFile(string $zipPath, string $filePath, string $outputPath): bool { $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) { Config::log("Impossible d'ouvrir le ZIP : {$zipPath}", 'ERROR'); return false; } $content = $zip->getFromName($filePath); if ($content === false) { Config::log("Fichier introuvable dans le ZIP : {$filePath}", 'ERROR'); $zip->close(); return false; } $result = file_put_contents($outputPath, $content); $zip->close(); if ($result === false) { Config::log("Échec écriture fichier : {$outputPath}", 'ERROR'); return false; } Config::log("Fichier extrait : {$filePath} -> {$outputPath}"); return true; } /** * Prévisualise le contenu d'un fichier texte dans un ZIP * * @param string $zipPath Chemin vers le ZIP * @param string $filePath Chemin du fichier dans le ZIP * @param int $maxLength Longueur maximale à retourner * @return string|null Contenu du fichier ou null si erreur */ public function previewFile(string $zipPath, string $filePath, int $maxLength = 10000): ?string { $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) { Config::log("Impossible d'ouvrir le ZIP pour prévisualisation : {$zipPath}", 'ERROR'); return null; } $content = $zip->getFromName($filePath); $zip->close(); if ($content === false) { Config::log("Fichier introuvable pour prévisualisation : {$filePath}", 'ERROR'); return null; } // Limiter la taille if (strlen($content) > $maxLength) { $content = substr($content, 0, $maxLength) . "\n\n... (tronqué)"; } // Vérifier que c'est du texte (pas du binaire) if (!mb_check_encoding($content, 'UTF-8') && !mb_check_encoding($content, 'ASCII')) { return "[Fichier binaire - prévisualisation impossible]"; } return $content; } /** * Obtient des informations détaillées sur un ZIP * * @param string $zipPath Chemin vers le ZIP * @return array|null Informations ou null si erreur */ public function getZipInfo(string $zipPath): ?array { if (!file_exists($zipPath)) { return null; } $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) { return null; } $info = [ 'file_path' => $zipPath, 'file_size' => filesize($zipPath), 'file_size_formatted' => Config::formatBytes(filesize($zipPath)), 'num_files' => $zip->numFiles, 'comment' => $zip->comment, 'is_valid' => $this->isValidZip($zipPath) ]; $zip->close(); return $info; } /** * Valide un fichier uploadé ZIP * * @param array $uploadedFile Tableau $_FILES['file'] * @return array ['valid' => bool, 'error' => string|null] */ public function validateUpload(array $uploadedFile): array { // Vérifier les erreurs d'upload if ($uploadedFile['error'] !== UPLOAD_ERR_OK) { $errorMessages = [ UPLOAD_ERR_INI_SIZE => 'Fichier trop volumineux (php.ini)', UPLOAD_ERR_FORM_SIZE => 'Fichier trop volumineux (formulaire)', UPLOAD_ERR_PARTIAL => 'Upload partiel', UPLOAD_ERR_NO_FILE => 'Aucun fichier uploadé', UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire manquant', UPLOAD_ERR_CANT_WRITE => 'Échec écriture disque', UPLOAD_ERR_EXTENSION => 'Extension PHP a stoppé l\'upload' ]; $error = $errorMessages[$uploadedFile['error']] ?? 'Erreur inconnue'; Config::log("Erreur upload : {$error} (code: {$uploadedFile['error']})", 'ERROR'); return ['valid' => false, 'error' => $error]; } // Vérifier la taille if ($uploadedFile['size'] > Config::MAX_FILE_SIZE) { $sizeFormatted = Config::formatBytes($uploadedFile['size']); $maxFormatted = Config::formatBytes(Config::MAX_FILE_SIZE); return [ 'valid' => false, 'error' => "Fichier trop volumineux ({$sizeFormatted}). Maximum : {$maxFormatted}" ]; } // Vérifier le type MIME $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $uploadedFile['tmp_name']); finfo_close($finfo); if (!Config::isAllowedMimeType($mimeType)) { Config::log("Type MIME non autorisé : {$mimeType}", 'WARNING'); return [ 'valid' => false, 'error' => "Type de fichier non autorisé ({$mimeType}). ZIP uniquement." ]; } // Vérifier les magic bytes if (!$this->isValidZip($uploadedFile['tmp_name'])) { Config::log("Magic bytes invalides pour fichier uploadé", 'WARNING'); return [ 'valid' => false, 'error' => "Le fichier n'est pas un ZIP valide" ]; } Config::log("Fichier ZIP validé : {$uploadedFile['name']} ({$uploadedFile['size']} octets)"); return ['valid' => true, 'error' => null]; } }