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
74 changes: 59 additions & 15 deletions Lib/BackupFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -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;
}
Expand All @@ -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');
Expand All @@ -79,32 +107,48 @@ 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) {
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;
}
}

$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;
}
Expand Down
151 changes: 151 additions & 0 deletions Test/main/BackupFileUnreadableFoldersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php
/**
* This file is part of Backup plugin for FacturaScripts
* Copyright (C) 2026 Carlos Garcia Gomez <carlos@facturascripts.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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 <esteban@factura.city>
*/
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;
}
}
1 change: 1 addition & 0 deletions Translation/es_ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading