570 lines
21 KiB
PHP
570 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use ZipArchive;
|
|
|
|
/**
|
|
* Pacchetto di backup completo: i dati gestionali (XML) e i file caricati
|
|
* (PDF dei territori, miniature, logo) in un unico .zip.
|
|
*
|
|
* L'XML da solo non basta a ricostruire l'installazione: `pdf_path` e
|
|
* `thumbnail_path` sono percorsi sul disco `public`, quindi un ripristino su
|
|
* volumi vuoti lascerebbe ogni territorio con un riferimento a un file che non
|
|
* c'e' — link pubblici a 404 e miniature rotte.
|
|
*
|
|
* Struttura dell'archivio:
|
|
*
|
|
* manifest.json formato, data, conteggi (letto per validare il file)
|
|
* data.xml lo stesso XML dell'export "Esporta XML"
|
|
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
|
|
* relativo che sta in pdf_path/thumbnail_path/logo_path
|
|
*/
|
|
class BackupArchive
|
|
{
|
|
/**
|
|
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
|
|
* quello che sa leggere invece di ripristinarli a meta'.
|
|
*/
|
|
public const FORMAT_VERSION = 1;
|
|
|
|
private const ENTRY_MANIFEST = 'manifest.json';
|
|
private const ENTRY_DATA = 'data.xml';
|
|
private const PREFIX_STORAGE = 'storage/';
|
|
|
|
/**
|
|
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
|
|
* questi controlli un file da pochi MB puo' espandersi fino a riempire il
|
|
* volume o esaurire la memoria del processo (zip bomb).
|
|
*/
|
|
private const MAX_ENTRIES = 20000;
|
|
private const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
|
|
private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024;
|
|
|
|
/**
|
|
* Quante volte l'archivio viene chiuso e riaperto mentre lo si costruisce.
|
|
*
|
|
* ZipArchive non scrive niente finche' non si chiama close(): tutte le
|
|
* addFile() sono solo annotazioni in memoria. Un avanzamento contato sulle
|
|
* addFile() salirebbe al 100% in un istante per poi restare fermo per tutta
|
|
* la compressione — cioe' l'esatto contrario di una barra reale. Chiudendo
|
|
* a lotti si paga una riscrittura dell'archivio per ogni lotto (libzip
|
|
* ricopia le entry gia' presenti, byte a byte, senza ricomprimerle) e in
|
|
* cambio ogni avanzamento corrisponde a byte davvero finiti sul disco.
|
|
*
|
|
* Il numero e' un tetto, non un passo fisso: il lotto si calcola come
|
|
* totale/questo valore, cosi' la ricopiatura resta limitata a ~10 volte la
|
|
* dimensione dell'archivio indipendentemente da quanti file ci sono.
|
|
*/
|
|
private const FLUSH_MASSIMI = 20;
|
|
|
|
/**
|
|
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
|
*
|
|
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
|
*/
|
|
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
|
|
{
|
|
$disk = Storage::disk('public');
|
|
$files = $disk->allFiles();
|
|
$totale = count($files);
|
|
|
|
$cartella = dirname($destinazione);
|
|
if (! is_dir($cartella) && ! mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
|
|
throw new RuntimeException('Impossibile preparare la cartella dei backup.');
|
|
}
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($destinazione, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
|
throw new RuntimeException('Impossibile creare il file di backup.');
|
|
}
|
|
|
|
$zip->addFromString(self::ENTRY_DATA, $xml);
|
|
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
|
'format_version' => self::FORMAT_VERSION,
|
|
'application' => 'TerManager2',
|
|
'generated_at' => now()->toIso8601String(),
|
|
'app_url' => (string) config('app.url'),
|
|
'files_count' => $totale,
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
|
|
try {
|
|
$this->chiudi($zip);
|
|
} catch (\Throwable $e) {
|
|
@unlink($destinazione);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
if ($totale === 0) {
|
|
$onProgress && $onProgress(0, 0);
|
|
|
|
return $destinazione;
|
|
}
|
|
|
|
$perLotto = (int) max(1, ceil($totale / self::FLUSH_MASSIMI));
|
|
|
|
try {
|
|
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
|
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
|
|
) {
|
|
$nelLotto = 0;
|
|
$fatti = 0;
|
|
|
|
foreach ($files as $relativePath) {
|
|
// addFile() legge dal disco durante close(): i PDF non
|
|
// passano mai interi per la memoria del processo.
|
|
//
|
|
// Il valore di ritorno si controlla: se il file e' sparito
|
|
// fra allFiles() e qui — cancellato da un'altra richiesta,
|
|
// o illeggibile per i permessi — addFile() torna false
|
|
// senza sollevare, e l'archivio finirebbe con un buco di
|
|
// cui nessuno si accorge fino al giorno del ripristino.
|
|
if (! $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
|
|
throw new RuntimeException(
|
|
'Impossibile aggiungere "' . $relativePath . '" al backup: '
|
|
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
|
|
);
|
|
}
|
|
|
|
$nelLotto++;
|
|
$fatti++;
|
|
|
|
if ($nelLotto >= $perLotto && $fatti < $totale) {
|
|
$this->chiudiEriapri($zip, $destinazione);
|
|
$nelLotto = 0;
|
|
$onProgress && $onProgress($fatti, $totale);
|
|
}
|
|
}
|
|
});
|
|
} catch (\Throwable $e) {
|
|
@unlink($destinazione);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
try {
|
|
$this->verificaArchivio($destinazione, $totale);
|
|
} catch (\Throwable $e) {
|
|
@unlink($destinazione);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
$onProgress && $onProgress($totale, $totale);
|
|
|
|
return $destinazione;
|
|
}
|
|
|
|
/**
|
|
* Rilegge l'archivio appena scritto e controlla che contenga quello che
|
|
* dichiara.
|
|
*
|
|
* Un backup e' l'unico file di cui non ci si accorge che era rotto finche'
|
|
* non serve. Costa una riapertura e la lettura della directory centrale —
|
|
* non dei contenuti — quindi e' un controllo a buon mercato che copre
|
|
* troncamenti, chiusure a meta' e lotti persi.
|
|
*/
|
|
private function verificaArchivio(string $path, int $fileAttesi): void
|
|
{
|
|
$zip = new ZipArchive();
|
|
|
|
if ($zip->open($path, ZipArchive::RDONLY) !== true) {
|
|
throw new RuntimeException('Il backup appena creato non e\' rileggibile.');
|
|
}
|
|
|
|
$trovati = 0;
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$nome = $zip->getNameIndex($i);
|
|
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
|
$trovati++;
|
|
}
|
|
}
|
|
|
|
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
|
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
|
|
|
$zip->close();
|
|
|
|
if (! $haDati || ! $haManifest) {
|
|
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
|
|
}
|
|
|
|
if ($trovati !== $fileAttesi) {
|
|
throw new RuntimeException(sprintf(
|
|
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
|
$trovati,
|
|
$fileAttesi
|
|
));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apre l'archivio in append, esegue il callback e lo chiude, garantendo la
|
|
* chiusura anche se il callback solleva.
|
|
*/
|
|
private function conArchivioAperto(string $path, callable $callback): void
|
|
{
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($path) !== true) {
|
|
throw new RuntimeException('Impossibile riaprire il file di backup.');
|
|
}
|
|
|
|
try {
|
|
$callback($zip);
|
|
} catch (\Throwable $e) {
|
|
// La chiusura di cortesia non deve poter sostituire l'errore vero:
|
|
// dopo un close() fallito l'oggetto ZipArchive resta inutilizzabile
|
|
// e ogni metodo successivo solleva "Invalid or uninitialized Zip
|
|
// object", che finirebbe sotto gli occhi dell'amministratore al
|
|
// posto del messaggio che spiega davvero cos'e' andato storto.
|
|
try {
|
|
$zip->close();
|
|
} catch (\Throwable) {
|
|
// gia' chiuso o invalido: non c'e' niente da salvare
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
|
|
$this->chiudi($zip);
|
|
}
|
|
|
|
/**
|
|
* Manda su disco le entry accumulate e riapre l'archivio per le prossime.
|
|
* E' qui che avviene il lavoro vero, quindi e' subito dopo questa chiamata
|
|
* che l'avanzamento ha un significato.
|
|
*/
|
|
private function chiudiEriapri(ZipArchive $zip, string $path): void
|
|
{
|
|
$this->chiudi($zip);
|
|
|
|
if ($zip->open($path) !== true) {
|
|
throw new RuntimeException('Impossibile riaprire il file di backup.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Chiude l'archivio riportando il motivo del fallimento.
|
|
*
|
|
* E' in close() che libzip legge davvero i file e scrive l'archivio, quindi
|
|
* e' qui che si manifestano permessi negati, disco pieno e file spariti. Un
|
|
* "scrittura non riuscita" secco lascerebbe l'amministratore senza il pezzo
|
|
* che serve — quale file, e perche' — che sta in getStatusString().
|
|
*/
|
|
private function chiudi(ZipArchive $zip): void
|
|
{
|
|
$motivo = '';
|
|
|
|
try {
|
|
if ($zip->close()) {
|
|
return;
|
|
}
|
|
|
|
$motivo = (string) $zip->getStatusString();
|
|
} catch (\Throwable $e) {
|
|
$motivo = $e->getMessage();
|
|
}
|
|
|
|
throw new RuntimeException(
|
|
'Scrittura del file di backup non riuscita'
|
|
. ($motivo !== '' ? ': ' . rtrim($motivo, '. ') . '.' : '.')
|
|
. ' Cause tipiche: un file non leggibile nel volume storage, o spazio esaurito.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Apre il pacchetto, ne verifica la struttura ed estrae i file in una
|
|
* cartella di staging, restituendo l'XML e il percorso dello staging.
|
|
*
|
|
* I file vengono messi da parte *prima* di toccare il database: se
|
|
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
|
* dati correnti sono ancora intatti.
|
|
*
|
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
|
* @return array{xml: string, staging: string, files: int}
|
|
*/
|
|
public function extract(string $zipPath, ?callable $onProgress = null): array
|
|
{
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($zipPath) !== true) {
|
|
throw new RuntimeException('Il file non e\' un archivio ZIP leggibile.');
|
|
}
|
|
|
|
$staging = storage_path('app/backup-restore/' . Str::uuid());
|
|
|
|
try {
|
|
$manifestRaw = $zip->getFromName(self::ENTRY_MANIFEST);
|
|
if ($manifestRaw === false) {
|
|
throw new RuntimeException(
|
|
'Archivio non riconosciuto: manca ' . self::ENTRY_MANIFEST . '. '
|
|
. 'Carica un pacchetto generato da "Scarica backup completo".'
|
|
);
|
|
}
|
|
|
|
$manifest = json_decode($manifestRaw, true);
|
|
$formatVersion = is_array($manifest) ? (int) ($manifest['format_version'] ?? 0) : 0;
|
|
|
|
if ($formatVersion < 1) {
|
|
throw new RuntimeException('Il manifest del backup non e\' valido.');
|
|
}
|
|
|
|
if ($formatVersion > self::FORMAT_VERSION) {
|
|
throw new RuntimeException(
|
|
'Il backup e\' stato creato con una versione piu\' recente dell\'applicazione '
|
|
. '(formato ' . $formatVersion . ', qui si legge fino al ' . self::FORMAT_VERSION . ').'
|
|
);
|
|
}
|
|
|
|
$xml = $zip->getFromName(self::ENTRY_DATA);
|
|
if ($xml === false || trim($xml) === '') {
|
|
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
|
|
}
|
|
|
|
if ($zip->numFiles > self::MAX_ENTRIES) {
|
|
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
|
}
|
|
|
|
$totalBytes = 0;
|
|
$extracted = 0;
|
|
|
|
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
|
|
// due entry di servizio": un archivio con una entry in piu' (una
|
|
// cartella dichiarata, un file aggiunto a mano) sfalserebbe la
|
|
// barra fino a farla superare il 100%.
|
|
$daEstrarre = 0;
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$nome = $zip->getNameIndex($i);
|
|
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
|
|
$daEstrarre++;
|
|
}
|
|
}
|
|
$onProgress && $onProgress(0, $daEstrarre);
|
|
|
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
|
$entryName = $zip->getNameIndex($index);
|
|
|
|
if ($entryName === false || str_ends_with($entryName, '/')) {
|
|
continue;
|
|
}
|
|
|
|
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
|
|
continue;
|
|
}
|
|
|
|
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
|
|
$safePath = $this->safeRelativePath($relativePath);
|
|
|
|
if ($safePath === null) {
|
|
throw new RuntimeException('Percorso di file non ammesso nell\'archivio: ' . $entryName);
|
|
}
|
|
|
|
// La dimensione dichiarata si legge *prima* di estrarre: e'
|
|
// l'unico momento in cui si puo' rifiutare un'entry che, una
|
|
// volta decompressa, riempirebbe il disco.
|
|
$size = (int) ($zip->statIndex($index)['size'] ?? 0);
|
|
|
|
if ($size > self::MAX_ENTRY_BYTES) {
|
|
throw new RuntimeException(sprintf(
|
|
'Il file "%s" nell\'archivio supera %d MB.',
|
|
$safePath,
|
|
self::MAX_ENTRY_BYTES / 1048576
|
|
));
|
|
}
|
|
|
|
$totalBytes += $size;
|
|
if ($totalBytes > self::MAX_TOTAL_BYTES) {
|
|
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
|
|
}
|
|
|
|
$destination = $staging . '/' . $safePath;
|
|
$directory = dirname($destination);
|
|
|
|
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
|
throw new RuntimeException('Impossibile preparare la cartella di ripristino.');
|
|
}
|
|
|
|
$stream = $zip->getStream($entryName);
|
|
if ($stream === false) {
|
|
throw new RuntimeException('Impossibile leggere "' . $safePath . '" dall\'archivio.');
|
|
}
|
|
|
|
$target = fopen($destination, 'wb');
|
|
if ($target === false) {
|
|
fclose($stream);
|
|
|
|
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
|
|
}
|
|
|
|
stream_copy_to_stream($stream, $target);
|
|
fclose($stream);
|
|
fclose($target);
|
|
|
|
$extracted++;
|
|
$onProgress && $onProgress($extracted, $daEstrarre);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$zip->close();
|
|
$this->deleteDirectory($staging);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
$zip->close();
|
|
|
|
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted];
|
|
}
|
|
|
|
/**
|
|
* Sostituisce il contenuto del disco `public` con quello estratto.
|
|
*
|
|
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
|
|
* righe del database, che il ripristino ha appena riscritto per intero.
|
|
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
|
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
|
*
|
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
|
*/
|
|
public function restoreFiles(string $staging, ?callable $onProgress = null): int
|
|
{
|
|
$disk = Storage::disk('public');
|
|
|
|
$this->svuotaDiscoPubblico();
|
|
|
|
if (! is_dir($staging)) {
|
|
return 0;
|
|
}
|
|
|
|
$restored = 0;
|
|
$totale = iterator_count(new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
|
));
|
|
$onProgress && $onProgress(0, $totale);
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
/** @var \SplFileInfo $file */
|
|
if (! $file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
|
|
|
|
$handle = fopen($file->getPathname(), 'rb');
|
|
if ($handle === false) {
|
|
continue;
|
|
}
|
|
|
|
$disk->writeStream($relativePath, $handle);
|
|
|
|
if (is_resource($handle)) {
|
|
fclose($handle);
|
|
}
|
|
|
|
$restored++;
|
|
$onProgress && $onProgress($restored, $totale);
|
|
}
|
|
|
|
return $restored;
|
|
}
|
|
|
|
/**
|
|
* Svuota il disco `public` (PDF dei territori, miniature, logo) lasciando
|
|
* in piedi la cartella, a cui punta il symlink public/storage.
|
|
*
|
|
* Il conteggio si ricava da un secondo giro sul disco, non dai file
|
|
* trovati all'inizio: Flysystem non solleva se una unlink fallisce, quindi
|
|
* restituire il totale iniziale significherebbe dichiarare cancellati file
|
|
* ancora li'. Succede davvero — basta un file scritto da un processo che
|
|
* gira con un altro utente (l'entrypoint lavora da root, php-fpm e il
|
|
* worker di coda da www-data) perche' il disco resti popolato mentre
|
|
* l'operazione si dichiara riuscita.
|
|
*
|
|
* Se qualcosa sopravvive si solleva: per un ripristino significa che il
|
|
* disco non e' stato davvero sostituito, per un azzeramento che dei dati
|
|
* sono rimasti. In entrambi i casi e' un fallimento, non una nota a
|
|
* margine.
|
|
*
|
|
* @return int File effettivamente rimossi.
|
|
*/
|
|
public function svuotaDiscoPubblico(): int
|
|
{
|
|
$disk = Storage::disk('public');
|
|
$prima = count($disk->allFiles());
|
|
|
|
foreach ($disk->directories() as $directory) {
|
|
$disk->deleteDirectory($directory);
|
|
}
|
|
$disk->delete($disk->files());
|
|
|
|
$rimasti = $disk->allFiles();
|
|
|
|
if ($rimasti !== []) {
|
|
throw new RuntimeException(sprintf(
|
|
'Non e\' stato possibile cancellare %d file dal disco (es. "%s"). '
|
|
. 'Di solito e\' un problema di permessi: controlla che il volume storage '
|
|
. 'appartenga a www-data.',
|
|
count($rimasti),
|
|
$rimasti[0]
|
|
));
|
|
}
|
|
|
|
return $prima;
|
|
}
|
|
|
|
public function deleteDirectory(string $path): void
|
|
{
|
|
if (! is_dir($path)) {
|
|
return;
|
|
}
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
|
|
foreach ($iterator as $item) {
|
|
/** @var \SplFileInfo $item */
|
|
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
|
}
|
|
|
|
@rmdir($path);
|
|
}
|
|
|
|
/**
|
|
* Normalizza il percorso di un'entry e rifiuta tutto cio' che potrebbe
|
|
* scrivere fuori dalla cartella di destinazione (zip slip): percorsi
|
|
* assoluti, lettere di unita' Windows e qualunque segmento "..".
|
|
*/
|
|
private function safeRelativePath(string $relativePath): ?string
|
|
{
|
|
$normalized = str_replace('\\', '/', trim($relativePath));
|
|
|
|
if ($normalized === '' || str_starts_with($normalized, '/') || preg_match('/^[a-zA-Z]:/', $normalized)) {
|
|
return null;
|
|
}
|
|
|
|
$segments = [];
|
|
foreach (explode('/', $normalized) as $segment) {
|
|
if ($segment === '' || $segment === '.') {
|
|
continue;
|
|
}
|
|
|
|
if ($segment === '..') {
|
|
return null;
|
|
}
|
|
|
|
$segments[] = $segment;
|
|
}
|
|
|
|
return $segments === [] ? null : implode('/', $segments);
|
|
}
|
|
}
|