diff --git a/Controller/Backup.php b/Controller/Backup.php index 38700bc..9192eb5 100644 --- a/Controller/Backup.php +++ b/Controller/Backup.php @@ -30,8 +30,6 @@ use FacturaScripts\Dinamic\Lib\BackupSQL; use FacturaScripts\Dinamic\Model\User; use PDO; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; use Throwable; use ZipArchive; @@ -139,10 +137,6 @@ public function privateCore(&$response, $user, $permissions) case 'switch-db-charset': $this->switchDbCharsetAction(); break; - - default: - $this->defaultChecks(); - break; } $this->loadBackupFiles(); @@ -264,38 +258,6 @@ protected function createZipAction(): void Tools::log()->error('record-save-error'); } - private function defaultChecks(): void - { - // obtenemos el límite de memoria - $memoryMb = $this->getMemoryLimitMb(); - if ($memoryMb === -1) { - return; - } - - // calculamos el tamaño de la carpeta FS_FOLDER - $folderSize = 0; - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator(FS_FOLDER), - RecursiveIteratorIterator::LEAVES_ONLY - ); - foreach ($files as $file) { - if ($file->isDir()) { - continue; - } - - $folderSize += $file->getSize(); - } - $folderMb = round($folderSize / 1024 / 1024, 2); - - // si la carpeta FS_FOLDER ocupa más que el límite de memoria, mostramos un aviso - if ($folderMb >= $memoryMb) { - Tools::log()->warning('backup-memory-warning', [ - '%size%' => $folderMb, - '%memory%' => $memoryMb - ]); - } - } - private function deleteBackupAction(): void { if ($this->permissions->allowDelete === false) { @@ -403,28 +365,6 @@ private function getBackupFilePath(string $fileName, array $extensions): string return Tools::folder('MyFiles', 'Backups', $fileName); } - private function getMemoryLimitMb(): int - { - $memoryLimit = ini_get('memory_limit'); - if ($memoryLimit === '-1') { - return -1; - } - - switch (substr($memoryLimit, -1)) { - case 'G': - return substr($memoryLimit, 0, -1) * 1024; - - case 'M': - return substr($memoryLimit, 0, -1); - - case 'K': - return round(substr($memoryLimit, 0, -1) / 1024, 2); - - default: - return (int)$memoryLimit; - } - } - protected function loadBackupFiles(): void { // buscamos todos los archivos de la carpeta MyFiles/Backups diff --git a/Lib/BackupFile.php b/Lib/BackupFile.php index 43d4645..9f68b04 100644 --- a/Lib/BackupFile.php +++ b/Lib/BackupFile.php @@ -20,6 +20,7 @@ namespace FacturaScripts\Plugins\Backup\Lib; use FacturaScripts\Core\Tools; +use Generator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use Throwable; @@ -31,6 +32,27 @@ */ class BackupFile { + /** @var array Carpetas que se excluyen de la copia de seguridad. */ + private const EXCLUDED_FOLDERS = ['MyFiles/Backups/', 'MyFiles/Cache/', 'MyFiles/Tmp/', 'Dinamic/']; + + /** + * Tamaño, en bytes, a partir del cual ZipStream deja de cargar el archivo entero en + * memoria y lo procesa por streaming. Coincide con el valor por defecto de la librería. + */ + private const LARGE_FILE_SIZE = 20 * 1024 * 1024; + + /** + * @var int Bytes estimados que ocupa en memoria cada entrada del índice central del ZIP + * (el objeto File de ZipStream más sus tres objetos Bigint internos y el nombre del archivo). + */ + private const CDR_ENTRY_OVERHEAD = 1024; + + /** @var int Margen mínimo de memoria, en bytes, para imprevistos no medibles. */ + private const MIN_SAFETY_MARGIN = 32 * 1024 * 1024; + + /** @var float Colchón extra sobre el total calculado, para absorber gastos no modelados. */ + private const SAFETY_FACTOR = 1.5; + public static function generate(string $channel = ''): bool { $folder = Tools::folder('MyFiles', 'Backups'); @@ -39,9 +61,19 @@ public static function generate(string $channel = ''): bool return false; } + // recorremos el disco una sola vez: la misma lista sirve para estimar la + // memoria necesaria y para generar el ZIP, así no se duplica el recorrido + $files = iterator_to_array(static::scanFolder()); + + // si no hay memoria suficiente y no se ha podido ampliar, abortamos aquí para + // evitar el error fatal de PHP a mitad de la generación del ZIP + if (false === static::ensureEnoughMemory($channel, $files)) { + return false; + } + // creamos un archivo $file_path = Tools::folder('MyFiles', 'Backups', date('Y-m-d_H-i-s') . '.zip'); - if (false === static::zipFolder($file_path)) { + if (false === static::zipFolder($file_path, $files)) { Tools::log($channel)->error('record-save-error'); return false; } @@ -54,7 +86,90 @@ public static function generate(string $channel = ''): bool return true; } - protected static function zipFolder(string $fileName): bool + /** + * Calcula la memoria que necesitará la generación del ZIP (lo ya consumido por la + * petición actual, más el pico que puede provocar el mayor archivo a comprimir, más + * un margen por el índice central que ZipStream mantiene en memoria) y, si el límite + * de PHP no llega, intenta ampliarlo justo para esta operación. + * + * @param array $files ruta absoluta => ruta relativa dentro del ZIP + * @return bool true si hay memoria suficiente (o se ha podido ampliar) para continuar + */ + private static function ensureEnoughMemory(string $channel, array $files): bool + { + $currentLimitBytes = static::memoryLimitToBytes(ini_get('memory_limit')); + if ($currentLimitBytes === -1) { + // sin límite de memoria, no hay nada que comprobar + return true; + } + + $requiredBytes = static::estimateRequiredMemory($files); + if ($requiredBytes <= $currentLimitBytes) { + return true; + } + + // function_exists() evita un error fatal si el hosting ha deshabilitado ini_set() + // por disable_functions, en cuyo caso la función deja de existir para PHP + $increased = function_exists('ini_set') && false !== @ini_set('memory_limit', (string)$requiredBytes); + if ($increased) { + return true; + } + + Tools::log($channel)->warning('backup-memory-warning', [ + '%size%' => round($requiredBytes / 1024 / 1024, 2), + '%memory%' => round($currentLimitBytes / 1024 / 1024, 2) + ]); + return false; + } + + /** + * @param array $files ruta absoluta => ruta relativa dentro del ZIP + */ + private static function estimateRequiredMemory(array $files): int + { + $largestFileSize = 0; + + foreach (array_keys($files) as $filePath) { + $size = filesize($filePath); + if ($size !== false && $size < self::LARGE_FILE_SIZE && $size > $largestFileSize) { + $largestFileSize = $size; + } + } + + // el archivo más grande puede llegar a ocupar varias veces su tamaño en memoria + // mientras se comprime (original, copia comprimida y overhead del gestor de memoria) + $safetyMargin = max(self::MIN_SAFETY_MARGIN, count($files) * self::CDR_ENTRY_OVERHEAD); + $estimated = memory_get_usage(true) + (3 * $largestFileSize) + $safetyMargin; + + // colchón final sobre el total, para absorber cualquier otro gasto no modelado + return (int)round($estimated * self::SAFETY_FACTOR); + } + + private static function memoryLimitToBytes(string $memoryLimit): int + { + if ($memoryLimit === '-1') { + return -1; + } + + switch (strtoupper(substr($memoryLimit, -1))) { + case 'G': + return (int)substr($memoryLimit, 0, -1) * 1024 * 1024 * 1024; + + case 'M': + return (int)substr($memoryLimit, 0, -1) * 1024 * 1024; + + case 'K': + return (int)substr($memoryLimit, 0, -1) * 1024; + + default: + return (int)$memoryLimit; + } + } + + /** + * @param array $files ruta absoluta => ruta relativa dentro del ZIP + */ + protected static function zipFolder(string $fileName, array $files): bool { // abrimos un stream de escritura hacia el archivo destino $outputStream = fopen($fileName, 'wb'); @@ -79,27 +194,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 ($files as $filePath => $relativePath) { $zip->addFileFromPath($relativePath, $filePath); } @@ -112,4 +207,36 @@ protected static function zipFolder(string $fileName): bool fclose($outputStream); return true; } + + /** + * Recorre FS_FOLDER y devuelve, para cada archivo que se incluye en la copia de + * seguridad, su ruta absoluta como clave y su ruta relativa dentro del ZIP como valor. + * + * @return Generator + */ + private static function scanFolder(): Generator + { + $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) + foreach (self::EXCLUDED_FOLDERS as $folder) { + if (strpos($relativePath, $folder) === 0) { + continue 2; + } + } + + yield $filePath => $relativePath; + } + } } diff --git a/Translation/es_ES.json b/Translation/es_ES.json index 36a248c..23e15ae 100644 --- a/Translation/es_ES.json +++ b/Translation/es_ES.json @@ -5,7 +5,7 @@ "backup-charset-error": "La codificación de la copia de seguridad es %db-charset%, mientras que en el config.php es %config-charset%. Pulsa el botón cambiar codificación antes de restaurar.", "backup-charset-mixed-warning": "Advertencia: La copia de seguridad contiene tablas con múltiples codificaciones (%charsets%). Tu configuración actual es %config-charset%.", "backup-file-too-big": "El archivo de copia de seguridad es demasiado grande para subirlo al servidor. El tamaño máximo es %size% MB", - "backup-memory-warning": "Advertencia: la copia de seguridad ocupa %size% MB, pero solamente tienes %memory% MB de memoria RAM disponible. Insuficiente para realizar la copia de seguridad. Como alternativa puedes comprimir la carpeta de FacturaScripts, que es equivalente a una copia de los archivos.", + "backup-memory-warning": "Advertencia: Necesitas más de %size% MB de memoria RAM, pero solamente tienes %memory% MB de memoria RAM disponible. Insuficiente para realizar la copia de seguridad. Como alternativa puedes comprimir la carpeta de FacturaScripts, que es equivalente a una copia de los archivos.", "backup-port-warning": "El puerto de MySQL debe ser el 3306, pero estás usando el %port%", "backup-use-fixer-plugin": "Se recomienda usar el plugin Fixer para corregir las codificaciones de las tablas antes de realizar copias de seguridad.", "config-mysql-charset-error": "Faltan las constantes de codificación en el archivo config.php ('%config-charset%', '%config-collate%')",