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
136 changes: 115 additions & 21 deletions Lib/BackupFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <contacto@danielfg.es>
*/
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');
Expand All @@ -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<string>
*/
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<string, string>
*/
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');
Expand All @@ -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);
}

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down
123 changes: 123 additions & 0 deletions Test/main/BackupFileZipArchiveTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?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\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 <esteban@factura.city>
*/
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;
}
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
}
],
"config": {
"platform-check": false,
"platform": {
"php": "8.1"
}
Expand Down
Loading