ogni file del disco `public`, con lo stesso percorso * relativo che sta in pdf_path/thumbnail_path/logo_path * piantine/ 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} * 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 */ 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 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); } }