- 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
455 lines
16 KiB
PHP
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];
|
|
}
|
|
}
|