Initial commit: FuZip - Application de fusion interactive de fichiers ZIP
- Backend PHP: architecture MVC avec API REST (upload, merge, preview, extract) - Frontend JavaScript: composants modulaires (arborescence, upload, themes, i18n) - Fonctionnalités: drag&drop, sélection exclusive, détection conflits, persistance état - Sécurité: validation stricte, isolation sessions, sanitization chemins - UI/UX: responsive, thèmes clair/sombre, multi-langue (FR/EN) - Documentation: README complet avec installation et utilisation
This commit is contained in:
454
core/ZipHandler.php
Normal file
454
core/ZipHandler.php
Normal file
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
/**
|
||||
* ZipHandler - Manipulation des fichiers ZIP
|
||||
*
|
||||
* Cette classe gère :
|
||||
* - L'extraction de la structure d'un ZIP
|
||||
* - La validation des fichiers ZIP
|
||||
* - La fusion de fichiers selon sélection utilisateur
|
||||
* - La création du ZIP final
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/Config.php';
|
||||
|
||||
class ZipHandler {
|
||||
/**
|
||||
* Extraction de la structure complète d'un fichier ZIP
|
||||
*
|
||||
* @param string $zipPath Chemin absolu vers le fichier ZIP
|
||||
* @return array Structure hiérarchique du ZIP
|
||||
* @throws Exception Si le fichier est invalide ou trop volumineux
|
||||
*/
|
||||
public function getStructure(string $zipPath): array {
|
||||
// Vérifier que le fichier existe
|
||||
if (!file_exists($zipPath)) {
|
||||
Config::log("Fichier ZIP introuvable : {$zipPath}", 'ERROR');
|
||||
throw new Exception("Fichier ZIP introuvable");
|
||||
}
|
||||
|
||||
// Vérifier la taille du fichier
|
||||
$fileSize = filesize($zipPath);
|
||||
if ($fileSize > 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user