diff --git a/Lib/BackupFile.php b/Lib/BackupFile.php
index 43d4645..124d36f 100644
--- a/Lib/BackupFile.php
+++ b/Lib/BackupFile.php
@@ -20,8 +20,11 @@
namespace FacturaScripts\Plugins\Backup\Lib;
use FacturaScripts\Core\Tools;
+use FilesystemIterator;
+use RecursiveCallbackFilterIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
+use SplFileInfo;
use Throwable;
use ZipStream\Option\Archive;
use ZipStream\ZipStream;
@@ -31,6 +34,21 @@
*/
class BackupFile
{
+ /**
+ * Carpetas (relativas a la raíz, con '/' final) que no se incluyen en la copia.
+ * El '/' final evita excluir otras carpetas con el mismo prefijo.
+ *
+ * @var string[]
+ */
+ protected const EXCLUDED_FOLDERS = ['MyFiles/Backups/', 'MyFiles/Cache/', 'MyFiles/Tmp/', 'Dinamic/'];
+
+ /**
+ * Genera una copia de seguridad de los archivos en MyFiles/Backups.
+ *
+ * @param string $channel Canal de log donde se registran los mensajes.
+ *
+ * @return bool
+ */
public static function generate(string $channel = ''): bool
{
$folder = Tools::folder('MyFiles', 'Backups');
@@ -41,7 +59,7 @@ public static function generate(string $channel = ''): bool
// 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, $channel)) {
Tools::log($channel)->error('record-save-error');
return false;
}
@@ -54,7 +72,17 @@ public static function generate(string $channel = ''): bool
return true;
}
- protected static function zipFolder(string $fileName): bool
+ /**
+ * Comprime la carpeta de FacturaScripts (o la indicada) en un zip, saltando las carpetas
+ * excluidas sin entrar en ellas y las carpetas o archivos que no se puedan leer.
+ *
+ * @param string $fileName Ruta del zip de destino.
+ * @param string $channel Canal de log donde se registran los errores.
+ * @param string|null $sourceFolder Carpeta a comprimir. Por defecto, FS_FOLDER.
+ *
+ * @return bool
+ */
+ protected static function zipFolder(string $fileName, string $channel = '', ?string $sourceFolder = null): bool
{
// abrimos un stream de escritura hacia el archivo destino
$outputStream = fopen($fileName, 'wb');
@@ -79,9 +107,33 @@ protected static function zipFolder(string $fileName): bool
);
}
+ $root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($sourceFolder ?? FS_FOLDER, '/\\'));
+
+ // SKIP_DOTS evita visitar '.' y '..' (que con open_basedir provoca un error)
+ $directory = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS);
+
+ // podamos las carpetas excluidas sin entrar en ellas, y saltamos lo que no se pueda leer
+ $filtered = new RecursiveCallbackFilterIterator(
+ $directory,
+ function (SplFileInfo $current) use ($root): bool {
+ if (false === is_readable($current->getPathname())) {
+ return false;
+ }
+
+ if ($current->isDir()) {
+ $relative = substr(str_replace(DIRECTORY_SEPARATOR, '/', $current->getPathname()), strlen($root) + 1);
+ return false === in_array($relative . '/', static::EXCLUDED_FOLDERS, true);
+ }
+
+ return true;
+ }
+ );
+
+ // CATCH_GET_CHILD salta las subcarpetas que lancen excepción al abrirlas
$files = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator(FS_FOLDER),
- RecursiveIteratorIterator::LEAVES_ONLY
+ $filtered,
+ RecursiveIteratorIterator::LEAVES_ONLY,
+ RecursiveIteratorIterator::CATCH_GET_CHILD
);
foreach ($files as $file) {
@@ -89,22 +141,14 @@ protected static function zipFolder(string $fileName): bool
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;
- }
- }
-
+ $filePath = $file->getPathname();
+ $relativePath = substr(str_replace(DIRECTORY_SEPARATOR, '/', $filePath), strlen($root) + 1);
$zip->addFileFromPath($relativePath, $filePath);
}
$zip->finish();
} catch (Throwable $e) {
+ Tools::log($channel)->error('backup-files-error', ['%error%' => $e->getMessage()]);
fclose($outputStream);
return false;
}
diff --git a/Test/main/BackupFileUnreadableFoldersTest.php b/Test/main/BackupFileUnreadableFoldersTest.php
new file mode 100644
index 0000000..70d2e70
--- /dev/null
+++ b/Test/main/BackupFileUnreadableFoldersTest.php
@@ -0,0 +1,151 @@
+
+ *
+ * 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\Plugins\Backup\Lib\BackupFile;
+use PHPUnit\Framework\TestCase;
+use ReflectionMethod;
+use ZipArchive;
+
+/**
+ * Comprueba que la copia de archivos poda las carpetas excluidas y salta las que no se pueden leer.
+ *
+ * @author Esteban Sánchez Martínez
+ */
+final class BackupFileUnreadableFoldersTest extends TestCase
+{
+ /** @var string Carpeta temporal que hace de raíz de FacturaScripts. */
+ private string $root = '';
+
+ /** @var string Ruta del zip generado. */
+ private string $zipPath = '';
+
+ public static function setUpBeforeClass(): void
+ {
+ // sin el autoloader del vendor del plugin no se encuentra ZipStream
+ if (false === class_exists(\ZipStream\ZipStream::class)) {
+ require_once FS_FOLDER . '/Plugins/Backup/vendor/autoload.php';
+ }
+ }
+
+ protected function setUp(): void
+ {
+ $base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'backup_test_' . uniqid();
+ $this->root = $base . DIRECTORY_SEPARATOR . 'root';
+ $this->zipPath = $base . DIRECTORY_SEPARATOR . 'out.zip';
+
+ // estructura: archivos normales, carpetas excluidas y una carpeta sin permisos
+ $files = [
+ 'visible.txt',
+ 'sub/visible2.txt',
+ 'MyFiles/user.txt',
+ 'MyFiles/Backups/old.zip',
+ 'MyFiles/Cache/cache.txt',
+ 'MyFiles/Tmp/tmp.txt',
+ 'MyFilesExtra/keep.txt',
+ 'Dinamic/dinamic.php',
+ 'locked/secret.txt',
+ ];
+ foreach ($files as $file) {
+ $path = $this->root . '/' . $file;
+ mkdir(dirname($path), 0777, true);
+ file_put_contents($path, 'contenido ' . $file);
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ // restauramos los permisos para poder borrar la carpeta bloqueada
+ @chmod($this->root . '/locked', 0777);
+
+ $base = dirname($this->root);
+ if (is_dir($base)) {
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($items as $item) {
+ $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
+ }
+ @rmdir($base);
+ }
+ }
+
+ public function testExcludedFoldersAreNotZipped(): void
+ {
+ $this->assertTrue($this->zipFolder(), 'zipFolder() debería devolver true');
+
+ $names = $this->zipNames();
+ $this->assertContains('visible.txt', $names);
+ $this->assertContains('sub/visible2.txt', $names);
+ $this->assertContains('MyFiles/user.txt', $names);
+ $this->assertContains('MyFilesExtra/keep.txt', $names, 'No debe excluirse una carpeta con el mismo prefijo');
+
+ foreach ($names as $name) {
+ $this->assertStringStartsNotWith('MyFiles/Backups/', $name);
+ $this->assertStringStartsNotWith('MyFiles/Cache/', $name);
+ $this->assertStringStartsNotWith('MyFiles/Tmp/', $name);
+ $this->assertStringStartsNotWith('Dinamic/', $name);
+ }
+ }
+
+ public function testUnreadableFolderIsSkipped(): void
+ {
+ chmod($this->root . '/locked', 0000);
+
+ // en Windows o ejecutando como root chmod no impide leer: no se puede simular
+ if (is_readable($this->root . '/locked')) {
+ $this->markTestSkipped('No se pueden quitar los permisos de lectura en este entorno.');
+ }
+
+ $this->assertTrue($this->zipFolder(), 'zipFolder() debería devolver true con una carpeta sin permisos');
+
+ $names = $this->zipNames();
+ $this->assertContains('visible.txt', $names);
+ $this->assertNotContains('locked/secret.txt', $names);
+ }
+
+ /**
+ * Ejecuta BackupFile::zipFolder() sobre la carpeta temporal.
+ */
+ private function zipFolder(): bool
+ {
+ $method = new ReflectionMethod(BackupFile::class, 'zipFolder');
+ $method->setAccessible(true);
+ return $method->invoke(null, $this->zipPath, 'test', $this->root);
+ }
+
+ /**
+ * Devuelve los nombres de las entradas del zip generado.
+ *
+ * @return string[]
+ */
+ private function zipNames(): array
+ {
+ $zip = new ZipArchive();
+ $this->assertTrue($zip->open($this->zipPath), 'No se pudo abrir el zip');
+ $names = [];
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $names[] = $zip->getNameIndex($i);
+ }
+ $zip->close();
+ return $names;
+ }
+}
diff --git a/Translation/es_ES.json b/Translation/es_ES.json
index 36a248c..6f9b734 100644
--- a/Translation/es_ES.json
+++ b/Translation/es_ES.json
@@ -5,6 +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-files-error": "Error al comprimir los archivos de la copia de seguridad: %error%",
"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-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.",