Files
fuzip/core/ZipHandler.php
Charles bd6d321ed7 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
2026-01-12 03:29:01 +01:00

455 lines
16 KiB
PHP

<?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];
}
}