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; /** * Costruisce il pacchetto e ne restituisce il percorso assoluto. Il file e' * temporaneo: chi chiama lo consegna al browser e lo cancella. * * @param string $xml Contenuto di data.xml (l'export XML gia' pronto). */ public function create(string $xml): string { $disk = Storage::disk('public'); $files = $disk->allFiles(); $path = storage_path('app/backup-' . Str::uuid() . '.zip'); $zip = new ZipArchive(); if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new RuntimeException('Impossibile creare il file di backup.'); } try { $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' => count($files), ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); // addFile() legge dal disco in fase di close(): i PDF non passano // mai interi per la memoria del processo, per quanto sia grande // l'archivio. foreach ($files as $relativePath) { $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath); } } catch (\Throwable $e) { $zip->close(); @unlink($path); throw $e; } if (! $zip->close()) { @unlink($path); throw new RuntimeException('Scrittura del file di backup non riuscita.'); } return $path; } /** * 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. * * @return array{xml: string, staging: string, files: int} */ public function extract(string $zipPath): 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; 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++; } } 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. */ public function restoreFiles(string $staging): int { $disk = Storage::disk('public'); foreach ($disk->directories() as $directory) { $disk->deleteDirectory($directory); } $disk->delete($disk->files()); if (! is_dir($staging)) { return 0; } $restored = 0; $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++; } return $restored; } 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); } }