++ add reset program

This commit is contained in:
2026-07-29 14:26:36 +02:00
parent c6abef977d
commit 588910b893
14 changed files with 1524 additions and 183 deletions

View File

@@ -45,53 +45,137 @@ class BackupArchive
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.
* 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
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
{
$disk = Storage::disk('public');
$files = $disk->allFiles();
$totale = count($files);
$path = storage_path('app/backup-' . Str::uuid() . '.zip');
$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($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
if ($zip->open($destinazione, 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));
$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));
// 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);
}
if (! $zip->close()) {
@unlink($destinazione);
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
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.
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
$nelLotto++;
$fatti++;
if ($nelLotto >= $perLotto && $fatti < $totale) {
$this->chiudiEriapri($zip, $destinazione);
$nelLotto = 0;
$onProgress && $onProgress($fatti, $totale);
}
}
});
} catch (\Throwable $e) {
@unlink($destinazione);
throw $e;
}
$onProgress && $onProgress($totale, $totale);
return $destinazione;
}
/**
* 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) {
$zip->close();
@unlink($path);
throw $e;
}
if (! $zip->close()) {
@unlink($path);
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
}
/**
* 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
{
if (! $zip->close()) {
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
return $path;
if ($zip->open($path) !== true) {
throw new RuntimeException('Impossibile riaprire il file di backup.');
}
}
/**
@@ -102,9 +186,10 @@ class BackupArchive
* 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): array
public function extract(string $zipPath, ?callable $onProgress = null): array
{
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
@@ -148,6 +233,11 @@ class BackupArchive
$totalBytes = 0;
$extracted = 0;
// Le entry di storage/ sono tutte tranne manifest e data.xml: e' il
// totale su cui si misura l'estrazione.
$daEstrarre = max(0, $zip->numFiles - 2);
$onProgress && $onProgress(0, $daEstrarre);
for ($index = 0; $index < $zip->numFiles; $index++) {
$entryName = $zip->getNameIndex($index);
@@ -208,6 +298,7 @@ class BackupArchive
fclose($target);
$extracted++;
$onProgress && $onProgress($extracted, $daEstrarre);
}
} catch (\Throwable $e) {
$zip->close();
@@ -228,21 +319,25 @@ class BackupArchive
* 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): int
public function restoreFiles(string $staging, ?callable $onProgress = null): int
{
$disk = Storage::disk('public');
foreach ($disk->directories() as $directory) {
$disk->deleteDirectory($directory);
}
$disk->delete($disk->files());
$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)
);
@@ -267,11 +362,56 @@ class BackupArchive
}
$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)) {