Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 0 additions & 60 deletions Controller/Backup.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
use FacturaScripts\Dinamic\Lib\BackupSQL;
use FacturaScripts\Dinamic\Model\User;
use PDO;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Throwable;
use ZipArchive;

Expand Down Expand Up @@ -139,10 +137,6 @@ public function privateCore(&$response, $user, $permissions)
case 'switch-db-charset':
$this->switchDbCharsetAction();
break;

default:
$this->defaultChecks();
break;
}

$this->loadBackupFiles();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
173 changes: 150 additions & 23 deletions Lib/BackupFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
namespace FacturaScripts\Plugins\Backup\Lib;

use FacturaScripts\Core\Tools;
use Generator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Throwable;
Expand All @@ -31,6 +32,27 @@
*/
class BackupFile
{
/** @var array<string> 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');
Expand All @@ -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;
}
Expand All @@ -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<string, string> $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<string, string> $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<string, string> $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');
Expand All @@ -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);
}

Expand All @@ -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<string, string>
*/
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;
}
}
}
2 changes: 1 addition & 1 deletion Translation/es_ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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%')",
Expand Down