Blocco 2 - Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash che comparivano alla pagina dopo. - PDF, miniature e versioni dei territori non più serviti da nginx su /storage: TerritorioFileController con login, permessi e cache privata. - Ripristino tutto o niente: verifica dell'archivio, una transazione con import, piantine, blocco account e scambio dei file annullabile. - Backup formato 2 con piantine, storico PDF e area KML. - Posto unico preso in un passo (Cache::lock) e rispettato anche da import XML e PDF. - Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui), caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico. - L'import XML elimina i file dei territori che non usa più; comando territori:file-orfani per quelli accumulati prima. Blocco 3 - Password attuale richiesta al cambio volontario; AuthenticateSession chiude le sessioni aperte altrove. - Niente nomi dei proclamatori in chiaro nel registro attività. - Export XML senza remember_token; chi riceve l'hash dal file deve cambiare password. - Campagne: ultimo giorno incluso, percentuale sui territori distinti. - Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru, impostazioni rilette a ogni job. - Azzeramento che cancella anche piantine, area KML e anteprime. - Pulsanti profilo e menu che non si nascondevano; icona del sito. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
801 lines
32 KiB
PHP
801 lines
32 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"
|
|
* piantine.json disegni e storico dei PDF (Piantine\PiantineBackup)
|
|
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
|
|
* relativo che sta in pdf_path/thumbnail_path/logo_path
|
|
* piantine/<percorso> l'area KML delle piantine (storage/app/piantine),
|
|
* che sta accanto al disco `public`, non dentro
|
|
*
|
|
* piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1
|
|
* si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area
|
|
* KML in uso non si tocca (vedi scambiaFile()).
|
|
*/
|
|
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 = 2;
|
|
|
|
private const ENTRY_MANIFEST = 'manifest.json';
|
|
private const ENTRY_DATA = 'data.xml';
|
|
private const ENTRY_PIANTINE = 'piantine.json';
|
|
private const PREFIX_STORAGE = 'storage/';
|
|
private const PREFIX_PIANTINE = 'piantine/';
|
|
|
|
/**
|
|
* Sottocartelle dello staging, una per ogni cartella che il ripristino
|
|
* sostituisce (vedi radiciDaSostituire()).
|
|
*/
|
|
private const STAGING_PUBBLICO = 'pubblico';
|
|
private const STAGING_PIANTINE = 'piantine';
|
|
|
|
/** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */
|
|
private const CARTELLA_PIANTINE = 'piantine';
|
|
|
|
/**
|
|
* 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 array $piantine Contenuto di piantine.json (PiantineBackup::esporta()).
|
|
* Obbligatorio e non facoltativo di proposito: un
|
|
* chiamante che se ne dimentica produrrebbe archivi
|
|
* che al ripristino svuotano le piantine.
|
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
|
*/
|
|
public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string
|
|
{
|
|
$disk = Storage::disk('public');
|
|
$local = Storage::disk('local');
|
|
|
|
// Due cartelle sorgente, un solo elenco di terne: percorso relativo
|
|
// (per i messaggi), percorso sul disco, nome dell'entry.
|
|
$files = [];
|
|
foreach ($disk->allFiles() as $relativePath) {
|
|
$files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath];
|
|
}
|
|
$filePubblici = count($files);
|
|
|
|
// allFiles('piantine') restituisce gia' "piantine/area.kml": il
|
|
// percorso relativo al disco coincide con il nome dell'entry.
|
|
foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) {
|
|
$files[] = [$relativePath, $local->path($relativePath), $relativePath];
|
|
}
|
|
$filePiantine = count($files) - $filePubblici;
|
|
|
|
$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_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
$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' => $filePubblici,
|
|
'piantine_files_count' => $filePiantine,
|
|
], 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 (
|
|
$files, $totale, $perLotto, $onProgress, $destinazione
|
|
) {
|
|
$nelLotto = 0;
|
|
$fatti = 0;
|
|
|
|
foreach ($files as [$relativePath, $sorgente, $entry]) {
|
|
// 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, e
|
|
// l'archivio finirebbe con un buco di cui nessuno si
|
|
// accorge fino al giorno del ripristino.
|
|
//
|
|
// La @ non e' pigrizia. Prima di restituire false,
|
|
// addFile() emette un warning di PHP ("No such file or
|
|
// directory") che il gestore di Laravel trasforma in
|
|
// ErrorException: senza sopprimerlo, il messaggio qui sotto
|
|
// — quello che dice *quale* file e *perche'* — non verrebbe
|
|
// raggiunto mai, e l'amministratore leggerebbe la stringa
|
|
// grezza di PHP. Stesso problema, e stessa scelta, del
|
|
// close() avvolto in chiudi().
|
|
if (! @$zip->addFile($sorgente, $entry)) {
|
|
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, $filePubblici, $filePiantine);
|
|
} 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, int $piantineAttesi): void
|
|
{
|
|
$zip = new ZipArchive();
|
|
|
|
if ($zip->open($path, ZipArchive::RDONLY) !== true) {
|
|
throw new RuntimeException('Il backup appena creato non e\' rileggibile.');
|
|
}
|
|
|
|
$trovati = 0;
|
|
$piantineTrovati = 0;
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$nome = $zip->getNameIndex($i);
|
|
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
|
$trovati++;
|
|
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
|
|
$piantineTrovati++;
|
|
}
|
|
}
|
|
|
|
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
|
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
|
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
|
|
|
|
$zip->close();
|
|
|
|
if (! $haDati || ! $haManifest || ! $haPiantine) {
|
|
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.');
|
|
}
|
|
|
|
if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) {
|
|
throw new RuntimeException(sprintf(
|
|
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
|
$trovati + $piantineTrovati,
|
|
$fileAttesi + $piantineAttesi
|
|
));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* E' anche la verifica completa dell'archivio: ogni entry si estrae per
|
|
* intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un
|
|
* file troncato, un'entry corrotta o il disco che si riempie durante
|
|
* l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso
|
|
* volume dei file veri, quindi se c'e' posto per estrarre c'e' posto
|
|
* anche per lo scambio, che e' solo una serie di rename().
|
|
*
|
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
|
* @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: 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 . '.');
|
|
}
|
|
|
|
// Dal formato 2 piantine.json e' obbligatorio: la sua assenza non
|
|
// e' "nessuna piantina" ma un archivio monco, e ripristinarlo
|
|
// svuoterebbe disegni e storico senza che nessuno l'abbia chiesto.
|
|
// Null resta riservato al formato 1, che le piantine non le aveva.
|
|
$piantine = null;
|
|
if ($formatVersion >= 2) {
|
|
$piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE);
|
|
$piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null;
|
|
|
|
if (! is_array($piantine)) {
|
|
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.');
|
|
}
|
|
}
|
|
|
|
// Le entry che si estraggono, prefisso => sottocartella dello
|
|
// staging. L'area KML solo dal formato 2: un archivio vecchio non
|
|
// la porta, e non deve poterla portare per caso.
|
|
$prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO];
|
|
if ($formatVersion >= 2) {
|
|
$prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE;
|
|
}
|
|
|
|
if ($zip->numFiles > self::MAX_ENTRIES) {
|
|
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
|
}
|
|
|
|
$totalBytes = 0;
|
|
$extracted = 0;
|
|
$estrattiPiantine = 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_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) {
|
|
$daEstrarre++;
|
|
}
|
|
}
|
|
$onProgress && $onProgress(0, $daEstrarre);
|
|
|
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
|
$entryName = $zip->getNameIndex($index);
|
|
|
|
if ($entryName === false || str_ends_with($entryName, '/')) {
|
|
continue;
|
|
}
|
|
|
|
$prefisso = $this->prefissoDi($entryName, $prefissi);
|
|
if ($prefisso === null) {
|
|
continue;
|
|
}
|
|
|
|
$relativePath = substr($entryName, strlen($prefisso));
|
|
$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.
|
|
$stat = $zip->statIndex($index);
|
|
$size = (int) ($stat['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 . '/' . $prefissi[$prefisso] . '/' . $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.');
|
|
}
|
|
|
|
// Il risultato della copia si controlla, ed e' qui che il
|
|
// ripristino diventa sicuro: prima un disco pieno o un'entry
|
|
// corrotta lasciavano nello staging un file troncato senza
|
|
// nessun errore, e quel file finiva al posto del PDF buono.
|
|
// La @ per lo stesso motivo di addFile(): il warning di PHP
|
|
// ("CRC error") arriverebbe all'amministratore al posto del
|
|
// messaggio che dice quale file.
|
|
$copiati = @stream_copy_to_stream($stream, $target);
|
|
fclose($stream);
|
|
$chiuso = fclose($target);
|
|
|
|
if ($copiati === false || $copiati !== $size || ! $chiuso) {
|
|
throw new RuntimeException(sprintf(
|
|
'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.',
|
|
$safePath,
|
|
$copiati === false ? '0' : (string) $copiati,
|
|
$size
|
|
));
|
|
}
|
|
|
|
// Il CRC lo controlla gia' libzip a fine lettura, ma solo se la
|
|
// lettura arriva in fondo e come warning: ricalcolarlo sul file
|
|
// scritto costa una rilettura e non dipende da come libzip
|
|
// segnala l'errore.
|
|
if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) {
|
|
throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.');
|
|
}
|
|
|
|
if ($prefissi[$prefisso] === self::STAGING_PIANTINE) {
|
|
$estrattiPiantine++;
|
|
} else {
|
|
$extracted++;
|
|
}
|
|
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$zip->close();
|
|
$this->deleteDirectory($staging);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
$zip->close();
|
|
|
|
return [
|
|
'xml' => $xml,
|
|
'piantine' => $piantine,
|
|
'formato' => $formatVersion,
|
|
'staging' => $staging,
|
|
'pubblico' => $staging . '/' . self::STAGING_PUBBLICO,
|
|
'files' => $extracted,
|
|
'file_piantine' => $estrattiPiantine,
|
|
];
|
|
}
|
|
|
|
/** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */
|
|
private function prefissoDi(string $entryName, array $prefissi): ?string
|
|
{
|
|
foreach (array_keys($prefissi) as $prefisso) {
|
|
if (str_starts_with($entryName, $prefisso)) {
|
|
return $prefisso;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Mette i file estratti al posto di quelli in uso, tenendo da parte i
|
|
* vecchi finche' il ripristino non e' confermato.
|
|
*
|
|
* 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.
|
|
*
|
|
* Prima si svuotava il disco e poi si ricopiava dallo staging file per
|
|
* file: un errore a meta' (disco pieno, permessi) lasciava il database
|
|
* nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile.
|
|
* Adesso sono solo rename() sullo stesso volume, per voce di primo livello
|
|
* (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno
|
|
* di spazio, e ognuno annotato in un registro che annullaScambio()
|
|
* ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui
|
|
* e si solleva: il chiamante annulla la transazione e l'installazione
|
|
* resta com'era.
|
|
*
|
|
* Si spostano le voci e non la cartella radice: a quella del disco
|
|
* `public` punta il symlink public/storage.
|
|
*
|
|
* @param array $estratto Il risultato di extract().
|
|
* @return array{file: int, accantonati: string, spostamenti: list<array{0: string, 1: string}>}
|
|
* Da passare a confermaScambio() a database confermato, o ad
|
|
* annullaScambio() se qualcosa fallisce dopo.
|
|
*/
|
|
public function scambiaFile(array $estratto): array
|
|
{
|
|
// Fuori da backup-restore/ di proposito: quella cartella la ripulisce
|
|
// backup:cleanup dopo un giorno, e qui dentro, se l'annullamento
|
|
// stesso fallisse, resterebbero gli unici file buoni.
|
|
$scambio = [
|
|
'file' => $estratto['files'] + $estratto['file_piantine'],
|
|
'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()),
|
|
'spostamenti' => [],
|
|
];
|
|
|
|
try {
|
|
foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) {
|
|
$this->preparaCartella($radice);
|
|
$this->preparaCartella($scambio['accantonati'] . '/' . $nome);
|
|
|
|
foreach ($this->vociDi($radice) as $voce) {
|
|
$this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
|
|
}
|
|
|
|
// Una sottocartella assente nello staging vuol dire "nessun
|
|
// file": la radice resta vuota, come nell'installazione
|
|
// salvata.
|
|
$nuovi = $estratto['staging'] . '/' . $nome;
|
|
foreach ($this->vociDi($nuovi) as $voce) {
|
|
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$this->annullaScambio($scambio, $e);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
return $scambio;
|
|
}
|
|
|
|
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
|
|
public function confermaScambio(array $scambio): void
|
|
{
|
|
$this->deleteDirectory($scambio['accantonati']);
|
|
}
|
|
|
|
/**
|
|
* Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli
|
|
* spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro
|
|
* posto.
|
|
*
|
|
* Se anche questo fallisce non si cancella niente: i file precedenti
|
|
* restano nella cartella degli accantonati, e il messaggio dice dove, cosi'
|
|
* si possono rimettere a mano invece di darli per persi.
|
|
*/
|
|
public function annullaScambio(array $scambio, ?\Throwable $causa = null): void
|
|
{
|
|
try {
|
|
foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) {
|
|
if (! @rename($a, $da)) {
|
|
throw new RuntimeException('impossibile rimettere "' . $da . '"');
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
throw new RuntimeException(
|
|
($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '')
|
|
. 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): '
|
|
. 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.',
|
|
0,
|
|
$causa ?? $e
|
|
);
|
|
}
|
|
|
|
$this->deleteDirectory($scambio['accantonati']);
|
|
}
|
|
|
|
/**
|
|
* Le cartelle che il ripristino sostituisce: sottocartella dello staging
|
|
* => cartella vera.
|
|
*
|
|
* L'area KML solo con un archivio di formato 2, che la porta sempre (anche
|
|
* vuota, se l'installazione salvata non ne aveva). Un archivio di formato
|
|
* 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire
|
|
* buttare un file che con i dati ripristinati non ha nessun legame: nessuna
|
|
* riga lo cita, e' la mappa della citta'.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function radiciDaSostituire(int $formato): array
|
|
{
|
|
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
|
|
|
|
if ($formato >= 2) {
|
|
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
|
|
}
|
|
|
|
return $radici;
|
|
}
|
|
|
|
/** @return list<string> Le voci di primo livello di una cartella (vuoto se non esiste). */
|
|
private function vociDi(string $cartella): array
|
|
{
|
|
if (! is_dir($cartella)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_diff(scandir($cartella) ?: [], ['.', '..']));
|
|
}
|
|
|
|
private function preparaCartella(string $cartella): void
|
|
{
|
|
if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
|
|
throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.');
|
|
}
|
|
}
|
|
|
|
private function sposta(string $da, string $a, array &$scambio): void
|
|
{
|
|
if (! @rename($da, $a)) {
|
|
throw new RuntimeException(sprintf(
|
|
'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: '
|
|
. 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.',
|
|
basename($da)
|
|
));
|
|
}
|
|
|
|
$scambio['spostamenti'][] = [$da, $a];
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|