diff --git a/Lib/BackupFile.php b/Lib/BackupFile.php index 43d4645..ba008a0 100644 --- a/Lib/BackupFile.php +++ b/Lib/BackupFile.php @@ -23,14 +23,23 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use Throwable; +use ZipArchive; use ZipStream\Option\Archive; use ZipStream\ZipStream; /** + * Genera la copia de seguridad de los archivos en un zip dentro de MyFiles/Backups. + * Usa ZipStream y, en PHP de 32 bits o si ZipStream falla, ZipArchive. + * * @author Daniel Fernández Giménez */ class BackupFile { + /** + * Crea la copia de seguridad de archivos en MyFiles/Backups. + * + * @param string $channel Canal de log donde registrar los errores. + */ public static function generate(string $channel = ''): bool { $folder = Tools::folder('MyFiles', 'Backups'); @@ -54,7 +63,112 @@ public static function generate(string $channel = ''): bool return true; } + /** + * Carpetas que no se incluyen en la copia (con '/' final para no excluir otras con el mismo prefijo). + * + * @var array + */ + protected const EXCLUDED_FOLDERS = ['MyFiles/Backups/', 'MyFiles/Cache/', 'MyFiles/Tmp/', 'Dinamic/']; + + /** + * Indica si se puede usar ZipStream. ZipStream 3.x exige un PHP de 64 bits, + * así que en PHP de 32 bits se usa ZipArchive. + */ + protected static function canUseZipStream(): bool + { + return PHP_INT_SIZE >= 8 && class_exists(ZipStream::class); + } + + /** + * Devuelve las rutas (relativa dentro del zip => absoluta) de los archivos a incluir en la copia. + * + * @return array + */ + protected static function getFilesToZip(): array + { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(FS_FOLDER), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + $result = []; + foreach ($files as $file) { + if ($file->isDir()) { + continue; + } + + $filePath = $file->getRealPath(); + $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen(FS_FOLDER) + 1)); + + foreach (static::EXCLUDED_FOLDERS as $folder) { + if (strpos($relativePath, $folder) === 0) { + continue 2; + } + } + + $result[$relativePath] = $filePath; + } + + return $result; + } + + /** + * Genera el zip con la extensión ZipArchive (requisito del plugin). + * Es la alternativa a ZipStream para PHP de 32 bits. + */ + protected static function zipFolderWithZipArchive(string $fileName): bool + { + // ZipArchive no puede abrir un archivo vacío ya creado, así que lo borramos antes + if (file_exists($fileName)) { + unlink($fileName); + } + + $zip = new ZipArchive(); + if ($zip->open($fileName, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + return false; + } + + try { + foreach (static::getFilesToZip() as $relativePath => $filePath) { + if (false === $zip->addFile($filePath, $relativePath)) { + $zip->close(); + return false; + } + } + } catch (Throwable $e) { + $zip->close(); + return false; + } + + // close() escribe el archivo; si falla, la copia no es válida + return $zip->close(); + } + + /** + * Comprime los archivos de FacturaScripts en el zip indicado: ZipStream si es posible + * y ZipArchive en PHP de 32 bits o si ZipStream falla. + * + * @param string $fileName Ruta completa del zip a crear. + */ protected static function zipFolder(string $fileName): bool + { + // en PHP de 32 bits no se puede usar ZipStream 3.x + if (false === static::canUseZipStream()) { + return static::zipFolderWithZipArchive($fileName); + } + + if (static::zipFolderWithZipStream($fileName)) { + return true; + } + + // si falla ZipStream, lo intentamos con ZipArchive + return static::zipFolderWithZipArchive($fileName); + } + + /** + * Genera el zip con ZipStream (2.x o 3.x) escribiendo directamente al archivo, sin cargarlo en memoria. + */ + protected static function zipFolderWithZipStream(string $fileName): bool { // abrimos un stream de escritura hacia el archivo destino $outputStream = fopen($fileName, 'wb'); @@ -79,27 +193,7 @@ protected static function zipFolder(string $fileName): bool ); } - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator(FS_FOLDER), - RecursiveIteratorIterator::LEAVES_ONLY - ); - - foreach ($files as $file) { - if ($file->isDir()) { - continue; - } - - $filePath = $file->getRealPath(); - $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen(FS_FOLDER) + 1)); - - // excluimos algunas carpetas (con '/' final para no excluir otras con el mismo prefijo) - $exclude = ['MyFiles/Backups/', 'MyFiles/Cache/', 'MyFiles/Tmp/', 'Dinamic/']; - foreach ($exclude as $folder) { - if (strpos($relativePath, $folder) === 0) { - continue 2; - } - } - + foreach (static::getFilesToZip() as $relativePath => $filePath) { $zip->addFileFromPath($relativePath, $filePath); } diff --git a/README.md b/README.md index 6db242d..7cfb190 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Permite crear y restaurar copias de seguridad de la base de datos (MySQL y Postg - Límite del número máximo de copias que se conservan, borrando automáticamente las más antiguas. - Aviso cuando han pasado más de 30 días desde la última copia de seguridad. +## Requisitos +- Extensión PHP `zip`. +- La copia de archivos se genera con ZipStream. **En PHP de 32 bits** (donde ZipStream 3.x no funciona) o si ZipStream falla, se genera automáticamente con ZipArchive, así que el plugin funciona igualmente. El `composer.json` desactiva `platform-check` para que la comprobación de plataforma de Composer no rompa la instalación en esos entornos. + ## Carpeta La carpeta debe llamarse igual que el plugin: **Backup**. diff --git a/Test/main/BackupFileZipArchiveTest.php b/Test/main/BackupFileZipArchiveTest.php new file mode 100644 index 0000000..44c3680 --- /dev/null +++ b/Test/main/BackupFileZipArchiveTest.php @@ -0,0 +1,123 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Test\Plugins; + +use FacturaScripts\Core\Tools; +use FacturaScripts\Plugins\Backup\Lib\BackupFile; +use FacturaScripts\Test\Traits\LogErrorsTrait; +use PHPUnit\Framework\TestCase; +use ZipArchive; + +/** + * Comprueba la alternativa con ZipArchive de BackupFile, usada en PHP de 32 bits, + * donde ZipStream 3.x no funciona. + * + * @author Esteban Sánchez Martínez + */ +final class BackupFileZipArchiveTest extends TestCase +{ + use LogErrorsTrait; + + /** @var string Nombre de la carpeta marcador dentro de MyFiles. */ + private string $markerDir = ''; + + /** @var string Ruta del zip generado durante el test. */ + private string $zipFile = ''; + + protected function setUp(): void + { + // carpeta marcador que debe aparecer en la copia + $this->markerDir = 'BackupZipArchiveTest_' . substr(md5(uniqid('', true)), 0, 8); + $markerPath = Tools::folder('MyFiles', $this->markerDir); + Tools::folderCheckOrCreate($markerPath); + file_put_contents($markerPath . DIRECTORY_SEPARATOR . 'marker.txt', 'zip-archive-marker'); + } + + protected function tearDown(): void + { + if ($this->zipFile && file_exists($this->zipFile)) { + unlink($this->zipFile); + } + + Tools::folderDelete(Tools::folder('MyFiles', $this->markerDir)); + $this->logErrors(); + } + + public function testComposerDoesNotRequire64Bits(): void + { + // el plugin no debe tumbar la instalación en PHP de 32 bits + $platformCheck = dirname(__DIR__, 2) . '/vendor/composer/platform_check.php'; + if (false === file_exists($platformCheck)) { + $this->assertFileDoesNotExist($platformCheck); + return; + } + + $this->assertStringNotContainsString( + 'PHP_INT_SIZE', + file_get_contents($platformCheck), + 'platform_check.php exige un PHP de 64 bits' + ); + } + + public function testGenerateWithZipArchive(): void + { + $folder = Tools::folder('MyFiles', 'Backups'); + $before = is_dir($folder) ? (glob($folder . DIRECTORY_SEPARATOR . '*.zip') ?: []) : []; + + // forzamos el camino de ZipArchive + $this->assertTrue(BackupFileForceZipArchive::generate('test'), 'generate() con ZipArchive ha fallado'); + + $after = glob($folder . DIRECTORY_SEPARATOR . '*.zip') ?: []; + $new = array_diff($after, $before); + $this->assertNotEmpty($new, 'No se generó ningún zip'); + $this->zipFile = reset($new); + + $this->assertGreaterThan(0, filesize($this->zipFile), 'El zip generado está vacío'); + + $zip = new ZipArchive(); + $this->assertTrue($zip->open($this->zipFile, ZipArchive::CHECKCONS), 'El zip generado no es válido'); + + // incluye el archivo marcador con su contenido + $entry = 'MyFiles/' . $this->markerDir . '/marker.txt'; + $this->assertNotFalse($zip->locateName($entry), 'El zip no contiene el archivo marcador'); + $this->assertSame('zip-archive-marker', $zip->getFromName($entry)); + + // respeta las carpetas excluidas + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + foreach (['MyFiles/Backups/', 'MyFiles/Cache/', 'MyFiles/Tmp/', 'Dinamic/'] as $excluded) { + $this->assertStringStartsNotWith($excluded, $name, "El zip contiene una carpeta excluida: $name"); + } + } + + $zip->close(); + } +} + +/** + * BackupFile que simula un PHP de 32 bits: nunca usa ZipStream. + */ +class BackupFileForceZipArchive extends BackupFile +{ + protected static function canUseZipStream(): bool + { + return false; + } +} diff --git a/composer.json b/composer.json index 3158edd..55e92ed 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ } ], "config": { + "platform-check": false, "platform": { "php": "8.1" }