Files
termanager2/app/Services/BackupArchive.php
Francesco Picone 426a95211d Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
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>
2026-10-12 15:01:41 +02:00

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);
}
}