From 7ac454cd250692311d347081074ab7d6d68b40e5 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Wed, 29 Jul 2026 16:34:11 +0200 Subject: [PATCH] =?UTF-8?q?++=20consolidamento=20funzionalit=C3=A0=20Backu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 91 +++++++++++- README.md | 18 ++- app/Console/Commands/BackupCleanup.php | 96 +++++++++++++ .../Auth/PasswordChangeController.php | 15 +- .../Controllers/Settings/BackupController.php | 9 ++ app/Jobs/CreateBackupArchive.php | 19 +++ app/Jobs/ResetApplication.php | 13 ++ app/Jobs/RestoreBackupArchive.php | 29 ++++ app/Livewire/Profile.php | 9 ++ app/Livewire/Settings/BackupManager.php | 59 ++++++++ app/Livewire/Territori/TerritorioCestino.php | 28 ++++ app/Services/BackupArchive.php | 133 ++++++++++++++++-- app/Services/BackupOperationState.php | 62 ++++++++ .../settings/backup-manager.blade.php | 20 ++- routes/console.php | 5 + routes/web.php | 9 +- 16 files changed, 585 insertions(+), 30 deletions(-) create mode 100644 app/Console/Commands/BackupCleanup.php diff --git a/HANDOFF.md b/HANDOFF.md index 39ca0bd..e232d06 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -324,6 +324,35 @@ Scelte non ovvie: sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account: nessuno resta chiuso fuori dall'applicazione. +- **Una sola operazione alla volta, con blocco che si autoripara.** Backup, + ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso + disco: due in parallelo — due schede, due amministratori — non si rallentano + soltanto, si sovrascrivono. `BackupOperationState::corrente()` fa da posto + unico; lo controllano sia il componente Livewire sia + `BackupController::upload()`, che sono due porte d'ingresso separate (la + seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e + `corrente()` lo considera libero se l'operazione puntata non esiste più o è + terminata. In più i tre job hanno un `failed()` — Laravel lo chiama quando il + worker uccide il job per timeout o errore fatale, cioè proprio i casi che + `handle()` non può intercettare — che chiude l'operazione e libera il posto + invece di lasciare una barra ferma per un'ora. + +- **La pagina si riaggancia all'operazione in corso.** `BackupManager::mount()` + legge `corrente()`. Senza, ricaricare la pagina durante un backup lungo + faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di + conferma diventava irraggiungibile: nessuno poteva più né confermarlo né + annullarlo, e il posto restava occupato fino alla scadenza. + +- **L'esito del ripristino si legge senza consumarlo.** Il job non ha una + sessione in cui lasciare un flash, quindi deposita il messaggio in cache a + nome di chi ha avviato il ripristino e la schermata di cambio password lo + mostra. Deve essere un `Cache::get` + `forget` a password cambiata, **non** un + `pull`: quando è il middleware a dirottare una richiesta di Livewire, il + client segue il 302 via `fetch` — ed è quella `fetch` a bruciare il messaggio + — per poi rifare la stessa GET con `window.location`, che troverebbe la cache + già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del + proprio ripristino. + - **Nell'azzeramento i file si cancellano prima del database.** Svuotare il disco è l'unico passo che può fallire per cause esterne (permessi sul volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un @@ -335,6 +364,32 @@ Scelte non ovvie: worker da www-data) perché l'operazione si dichiari riuscita lasciando il disco popolato. +- **L'archivio si controlla prima di consegnarlo.** Un backup è l'unico file di + cui non ci si accorge che era rotto finché non serve, quindi + `BackupArchive::create()` non si fida di aver scritto bene: controlla il + valore di ritorno di ogni `addFile()` (che torna `false`, senza sollevare, se + il file è sparito o è illeggibile — l'archivio finirebbe con un buco + silenzioso), e alla fine riapre l'archivio e verifica che contenga + `data.xml`, `manifest.json` e *esattamente* i file attesi. Se qualcosa non + torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun + backup che un backup che sembra buono. + + Attenzione a una trappola di `ZipArchive`: dopo un `close()` fallito + l'oggetto resta inutilizzabile e ogni metodo successivo solleva `Invalid or + uninitialized Zip object`. Una chiusura di cortesia dentro un `catch` + sostituiva il messaggio vero con quello, e l'amministratore leggeva una + stringa incomprensibile al posto di «permesso negato su questo file». Da qui + `chiudi()`, che cattura `getStatusString()` prima che vada perso, e i + `close()` di pulizia avvolti in un try/catch che non può mascherare l'errore + originale. + +- **Eliminare definitivamente un territorio porta via anche i suoi file.** + `TerritorioCestino::forceDelete()` cancellava la riga e lasciava mappa e + miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da + nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono + *prima* della riga, così se la cancellazione fallisce il territorio resta nel + cestino e l'operazione si può ritentare. + - **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione appena creata ha un amministratore; cancellarli tutti lascerebbe l'applicazione senza nessuno che possa accedere fino al riavvio del container @@ -459,13 +514,37 @@ In ordine di priorità. ### 1. Nessun test automatico -`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste**. Ogni -verifica è manuale. È il debito più rilevante: le regole di business (anno -teocratico, stato territorio, priorità automatica, percentuale campagne) sono -esattamente il tipo di logica che va protetta da test. +`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste** e non +c'è `phpunit.xml`. Ogni verifica è manuale. È il debito più rilevante: le regole +di business (anno teocratico, stato territorio, priorità automatica, percentuale +campagne) sono esattamente il tipo di logica che va protetta da test — e a +maggior ragione backup, ripristino e azzeramento, dove un errore non si vede +finché non è troppo tardi. -Nota: `config/database.php` ridefinisce `connections` e lascia solo `mysql`. -Per usare SQLite in memoria nei test va riaggiunta la connessione `sqlite`. +Tre blocchi concreti da rimuovere prima di poter scrivere il primo test: + +1. **PHPUnit non è nell'immagine.** `docker/php/Dockerfile` installa con + `--no-dev` (giusto, per la produzione), quindi `vendor/phpunit` nel container + non c'è e `docker compose exec app ./vendor/bin/phpunit` non funziona. + Servono o un `composer install` locale sull'host, o uno stage `dev` nel + Dockerfile che tenga le dipendenze di sviluppo. +2. **Manca la connessione SQLite.** `config/database.php` ridefinisce + `connections` e lascia solo `mysql`. Per usare SQLite in memoria va + riaggiunta la connessione `sqlite`. +3. **Manca `phpunit.xml`.** L'autoload `Tests\` in `composer.json` c'è già, il + resto no. + +Dove conviene cominciare, in ordine di rapporto valore/attrito — i primi due +non hanno bisogno del database e quindi si sbloccano risolvendo solo il punto 1: + +- `Services/BackupArchive` con `Storage::fake('public')`: round-trip + crea → verifica → estrai, rifiuto dello zip slip, rifiuto del formato futuro, + fallimento su archivio incompleto. +- `Services/BackupOperationState` con il driver di cache `array`: il posto + unico, il rilascio alla chiusura, il puntatore orfano, il non-rilascio da + parte di un'operazione vecchia. +- `Livewire/Registro::righeS13()`: il ciclo da quattro e la colonna del + riporto, con i casi 2/3/4/5/8/9 assegnazioni (questi sì, con database). ### 2. Race su doppia assegnazione diff --git a/README.md b/README.md index ba873c7..c2f0bbc 100644 --- a/README.md +++ b/README.md @@ -521,10 +521,17 @@ docker exec -it php artisan app:create-initial-admin \ # Pulizia manuale dell'audit log docker exec -it php artisan audit:cleanup +# Pulizia manuale degli archivi di backup residui (default: più vecchi di 24h) +docker exec -it php artisan backup:cleanup +docker exec -it php artisan backup:cleanup --ore=0 # tutti + # Svuotare le cache docker exec -it php artisan optimize:clear ``` +Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i +comandi qui sopra servono solo per anticiparle. + --- ## Troubleshooting @@ -697,7 +704,7 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina. | Tabella | Descrizione | |-------------------|--------------------------------------------------------------| -| `users` | Utenti dell'applicazione | +| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova | | `settings` | Configurazione singleton | | `zone` | Zone territoriali | | `tipologie` | Tipologie di territorio | @@ -771,13 +778,14 @@ quando la soglia è scattata. ``` TerManager2/ ├── app/ -│ ├── Console/Commands/ # audit:cleanup, app:create-initial-admin -│ ├── Http/Controllers/ # PDF pubblici, import, login -│ ├── Jobs/ # Import PDF massivo (in coda) +│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin +│ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password +│ ├── Http/Middleware/ # RequirePasswordChange +│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda) │ ├── Livewire/ # Tutti i componenti di interfaccia │ ├── Models/ # 9 modelli Eloquent │ ├── Providers/ # AppServiceProvider -│ └── Services/ # Miniature PDF, stato import +│ └── Services/ # Miniature PDF, stato import, archivio backup ├── bootstrap/app.php # Middleware, proxy e host fidati ├── config/ # Configurazione Laravel ├── database/ diff --git a/app/Console/Commands/BackupCleanup.php b/app/Console/Commands/BackupCleanup.php new file mode 100644 index 0000000..94b4696 --- /dev/null +++ b/app/Console/Commands/BackupCleanup.php @@ -0,0 +1,96 @@ + '*.zip', + 'backup-restore-in' => '*.zip', + ]; + + public function handle(): int + { + $ore = max(1, (int) $this->option('ore')); + $limite = now()->subHours($ore)->getTimestamp(); + + $rimossi = 0; + $liberati = 0; + + foreach (self::CARTELLE as $cartella => $pattern) { + $percorso = storage_path('app/' . $cartella); + + if (! is_dir($percorso)) { + continue; + } + + foreach (glob($percorso . '/' . $pattern) ?: [] as $file) { + if (filemtime($file) >= $limite) { + continue; + } + + $dimensione = filesize($file) ?: 0; + + if (@unlink($file)) { + $rimossi++; + $liberati += $dimensione; + } + } + } + + // Le cartelle di staging del ripristino: il job le cancella nel + // finally, ma un processo ucciso a meta' non arriva a eseguirlo. + $staging = storage_path('app/backup-restore'); + if (is_dir($staging)) { + foreach (glob($staging . '/*', GLOB_ONLYDIR) ?: [] as $dir) { + if (filemtime($dir) < $limite) { + $this->rimuoviCartella($dir); + $rimossi++; + } + } + } + + $this->info(sprintf( + 'Pulizia backup: %d elementi rimossi, %.1f MB liberati.', + $rimossi, + $liberati / 1048576 + )); + + return self::SUCCESS; + } + + private function rimuoviCartella(string $path): void + { + $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); + } +} diff --git a/app/Http/Controllers/Auth/PasswordChangeController.php b/app/Http/Controllers/Auth/PasswordChangeController.php index 8ac0ee2..a13126e 100644 --- a/app/Http/Controllers/Auth/PasswordChangeController.php +++ b/app/Http/Controllers/Auth/PasswordChangeController.php @@ -29,8 +29,15 @@ class PasswordChangeController extends Controller 'forced' => (bool) $user->password_change_required, // Il ripristino gira in coda e non ha una sessione in cui lasciare // un flash; deposita l'esito in cache a nome di chi l'ha avviato e - // lo si consuma qui, che e' la prima pagina che quella persona vede. - 'esitoRipristino' => Cache::pull(RestoreBackupArchive::CHIAVE_ESITO . $user->id), + // lo si legge qui, che e' la prima pagina che quella persona vede. + // + // Si legge senza consumare, e si cancella solo a password cambiata. + // Con un pull, il messaggio non arriverebbe mai a schermo: quando e' + // il middleware a dirottare una richiesta di Livewire, il client + // segue il 302 via fetch — ed e' quella fetch a bruciare il + // messaggio — per poi rifare la stessa GET con window.location, che + // troverebbe la cache gia' vuota. + 'esitoRipristino' => Cache::get(RestoreBackupArchive::CHIAVE_ESITO . $user->id), ]); } @@ -66,6 +73,10 @@ class PasswordChangeController extends Controller $user->setRememberToken(null); $user->save(); + // L'esito del ripristino ha esaurito il suo scopo: e' stato letto, e la + // password richiesta e' stata impostata. + Cache::forget(RestoreBackupArchive::CHIAVE_ESITO . $user->id); + activity()->causedBy($user)->log('password changed'); return redirect()->route('dashboard') diff --git a/app/Http/Controllers/Settings/BackupController.php b/app/Http/Controllers/Settings/BackupController.php index 08d5a21..d13721f 100644 --- a/app/Http/Controllers/Settings/BackupController.php +++ b/app/Http/Controllers/Settings/BackupController.php @@ -88,6 +88,15 @@ class BackupController extends Controller 'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.', ]); + // Stesso blocco del componente Livewire, ripetuto qui perche' questa e' + // una porta d'ingresso separata: un ripristino avviato mentre gira un + // azzeramento riscriverebbe le tabelle che l'altro sta cancellando. + if ($this->stato->corrente() !== null) { + return response()->json([ + 'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.', + ], 409); + } + $operationId = (string) Str::uuid(); // Il file dell'upload vive in una cartella temporanea che sparisce a diff --git a/app/Jobs/CreateBackupArchive.php b/app/Jobs/CreateBackupArchive.php index 5f0db33..bd62b4f 100644 --- a/app/Jobs/CreateBackupArchive.php +++ b/app/Jobs/CreateBackupArchive.php @@ -87,6 +87,25 @@ class CreateBackupArchive implements ShouldQueue $stato->markCompleted($this->operationId, $risultato); } + /** + * Chiamato da Laravel quando il job muore per cause che handle() non puo' + * intercettare: il timeout del worker (che uccide il processo), un errore + * fatale, una dipendenza non risolvibile. + * + * Senza, l'operazione resterebbe "in corso" e terrebbe occupato il posto + * unico fino alla scadenza del puntatore: un'ora in cui nessuno puo' + * avviare un backup e la pagina mostra una barra che non avanzera' mai. + */ + public function failed(?\Throwable $e): void + { + app(BackupOperationState::class)->markFailed( + $this->operationId, + $e?->getMessage() ?: 'Creazione del backup interrotta (timeout o errore del worker).' + ); + + @unlink(self::percorso($this->operationId)); + } + public static function percorso(string $operationId): string { return storage_path('app/backups/' . $operationId . '.zip'); diff --git a/app/Jobs/ResetApplication.php b/app/Jobs/ResetApplication.php index a29ceec..475f7a3 100644 --- a/app/Jobs/ResetApplication.php +++ b/app/Jobs/ResetApplication.php @@ -150,6 +150,19 @@ class ResetApplication implements ShouldQueue $stato->markCompleted($this->operationId, ['messaggio' => $messaggio]); } + /** + * Vedi CreateBackupArchive::failed(): chiude l'operazione quando il job + * muore per cause che handle() non intercetta, liberando il posto unico. + */ + public function failed(?\Throwable $e): void + { + app(BackupOperationState::class)->markFailed( + $this->operationId, + ($e?->getMessage() ?: 'Azzeramento interrotto (timeout o errore del worker).') + . ' Controlla lo stato dei dati prima di ritentare.' + ); + } + /** * Cancella ogni utente tranne chi ha chiesto l'azzeramento. * diff --git a/app/Jobs/RestoreBackupArchive.php b/app/Jobs/RestoreBackupArchive.php index 792e715..d12bb53 100644 --- a/app/Jobs/RestoreBackupArchive.php +++ b/app/Jobs/RestoreBackupArchive.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Spatie\Permission\PermissionRegistrar; /** * Ripristina un pacchetto di backup in coda, riportando l'avanzamento fase per @@ -82,6 +83,13 @@ class RestoreBackupArchive implements ShouldQueue $stato->fase($this->operationId, 'Blocco degli account', 1); $utenti = $this->richiediCambioPasswordATutti(); + + // L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la + // mappa permessi-ruoli: senza questo, i processi che l'hanno gia' + // caricata continuano a decidere gli accessi con quella di prima + // del ripristino, finche' la cache non scade da sola. + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $stato->avanza($this->operationId, 1, 1); } catch (\Throwable $e) { Log::error('Ripristino del backup non riuscito.', ['exception' => $e]); @@ -125,6 +133,27 @@ class RestoreBackupArchive implements ShouldQueue ]); } + /** + * Vedi CreateBackupArchive::failed(): chiude l'operazione quando il job + * muore per cause che handle() non intercetta, cosi' il posto unico si + * libera subito invece di restare occupato fino alla scadenza. + * + * Qui il messaggio dice anche in che stato si e' rimasti: un ripristino + * interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi + * con database e file disallineati, e chi legge deve sapere che la strada + * e' rilanciare il ripristino, non ignorare l'errore. + */ + public function failed(?\Throwable $e): void + { + app(BackupOperationState::class)->markFailed( + $this->operationId, + ($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).') + . ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.' + ); + + @unlink($this->zipPath); + } + /** * Blocca ogni account finche' non sceglie una password nuova, * l'amministratore che sta ripristinando compreso. diff --git a/app/Livewire/Profile.php b/app/Livewire/Profile.php index ef487b6..a9a8507 100644 --- a/app/Livewire/Profile.php +++ b/app/Livewire/Profile.php @@ -52,8 +52,17 @@ class Profile extends Component ]); // Il cast `hashed` sul modello User cifra il valore in fase di salvataggio. + // + // Si spegne anche `password_change_required`: oggi chi ha quel flag + // viene dirottato dal middleware e questa pagina non la raggiunge + // nemmeno, ma la condizione che il flag esprime — "questa password non + // e' piu' un segreto" — la soddisfa qualunque cambio, non solo quello + // fatto dalla schermata obbligatoria. Lasciarlo acceso qui + // significherebbe che un domani, allentando il middleware, l'utente + // resterebbe bloccato dopo aver gia' fatto quello che gli si chiedeva. auth()->user()->update([ 'password' => $this->password, + 'password_change_required' => false, ]); $this->reset(['current_password', 'password', 'password_confirmation']); diff --git a/app/Livewire/Settings/BackupManager.php b/app/Livewire/Settings/BackupManager.php index 6cebd00..5a4d44c 100644 --- a/app/Livewire/Settings/BackupManager.php +++ b/app/Livewire/Settings/BackupManager.php @@ -48,15 +48,29 @@ class BackupManager extends Component */ public string $passwordConferma = ''; + /** Messaggio mostrato in cima quando un avvio viene rifiutato. */ + public string $erroreOperazione = ''; + public function mount(): void { $this->authorize('settings.manage'); + + // Riaggancia un'operazione gia' in corso. Senza, ricaricare la pagina + // durante un backup lungo farebbe sparire la barra, e un azzeramento + // fermo in attesa di conferma diventerebbe irraggiungibile: nessuno + // potrebbe piu' ne' confermarlo ne' annullarlo. + $this->operationId = app(BackupOperationState::class)->corrente(); + $this->aggiorna(); } public function avviaBackup(): void { $this->authorize('settings.manage'); + if (! $this->postoLibero()) { + return; + } + $this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP); CreateBackupArchive::dispatch($this->operationId, false); @@ -64,6 +78,32 @@ class BackupManager extends Component $this->aggiorna(); } + /** + * Vero se non c'e' gia' un'operazione in corso. + * + * Backup, ripristino e azzeramento lavorano tutti sullo stesso database e + * sullo stesso disco: farne partire due insieme — due schede, due + * amministratori — non li rallenta soltanto, li fa sovrascrivere a vicenda. + */ + private function postoLibero(): bool + { + $inCorso = app(BackupOperationState::class)->corrente(); + + if ($inCorso === null) { + $this->erroreOperazione = ''; + + return true; + } + + // Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha + // premuto il pulsante vuole comunque vedere a che punto e'. + $this->operationId = $inCorso; + $this->aggiorna(); + $this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.'; + + return false; + } + /** * Primo tempo dell'azzeramento: si costruisce il backup e lo si scarica. * La cancellazione non parte da qui — serve il secondo tempo, @@ -79,6 +119,10 @@ class BackupManager extends Component 'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.'] ); + if (! $this->postoLibero()) { + return; + } + $this->confermaReset = ''; $this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET); @@ -316,6 +360,21 @@ class BackupManager extends Component && in_array($this->operazione['status'] ?? '', ['queued', 'running'], true); } + /** + * Ogni quanto reinterrogare lo stato. + * + * Mentre un job lavora servono aggiornamenti fitti, la barra deve muoversi. + * In attesa-download invece si aspetta una persona, che puo' metterci + * minuti: continuare a bussare ogni 750ms vorrebbe dire migliaia di + * richieste inutili se qualcuno lascia la scheda aperta e se ne va. + */ + public function getIntervalloPollProperty(): string + { + return ($this->operazione['fase'] ?? null) === BackupOperationState::FASE_ATTESA_DOWNLOAD + ? '5s' + : '750ms'; + } + public function render() { return view('livewire.settings.backup-manager'); diff --git a/app/Livewire/Territori/TerritorioCestino.php b/app/Livewire/Territori/TerritorioCestino.php index c7ece4f..29959cb 100644 --- a/app/Livewire/Territori/TerritorioCestino.php +++ b/app/Livewire/Territori/TerritorioCestino.php @@ -5,6 +5,8 @@ namespace App\Livewire\Territori; use Livewire\Component; use Livewire\WithPagination; use App\Models\Territorio; +use App\Services\TerritorioThumbnailService; +use Illuminate\Support\Facades\Storage; class TerritorioCestino extends Component { @@ -36,10 +38,36 @@ class TerritorioCestino extends Component activity()->causedBy(auth()->user()) ->withProperties(['numero' => $territorio->numero]) ->log('force_deleted_territorio'); + + // I file vanno via con la riga. Cancellare solo il record lascerebbe la + // mappa e la miniatura sul volume senza piu' nulla che le riferisca: + // invisibili in ogni schermata, non raggiungibili da nessuna + // cancellazione futura, e comunque dentro ogni backup successivo. + // Prima della riga, non dopo: se la cancellazione dei file fallisce, + // il territorio resta nel cestino e l'operazione si puo' ritentare. + $this->rimuoviFile($territorio); + $territorio->forceDelete(); session()->flash('success', 'Territorio eliminato definitivamente.'); } + /** + * Rimuove mappa e miniatura di un territorio dal disco `public`. + * + * Stessa coppia di cancellazioni che fa TerritorioEdit quando si sostituisce + * il PDF; qui e' definitiva perche' non resta una riga a cui riattaccarle. + */ + private function rimuoviFile(Territorio $territorio): void + { + if ($territorio->pdf_path) { + Storage::disk('public')->delete($territorio->pdf_path); + } + + if ($territorio->thumbnail_path) { + app(TerritorioThumbnailService::class)->delete($territorio->thumbnail_path); + } + } + public function render() { return view('livewire.territori.territorio-cestino', [ diff --git a/app/Services/BackupArchive.php b/app/Services/BackupArchive.php index a70405c..2ff166c 100644 --- a/app/Services/BackupArchive.php +++ b/app/Services/BackupArchive.php @@ -92,10 +92,12 @@ class BackupArchive 'files_count' => $totale, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - if (! $zip->close()) { + try { + $this->chiudi($zip); + } catch (\Throwable $e) { @unlink($destinazione); - throw new RuntimeException('Scrittura del file di backup non riuscita.'); + throw $e; } if ($totale === 0) { @@ -116,7 +118,19 @@ class BackupArchive 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); + // + // 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 + // senza sollevare, e l'archivio finirebbe con un buco di + // cui nessuno si accorge fino al giorno del ripristino. + if (! $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) { + throw new RuntimeException( + 'Impossibile aggiungere "' . $relativePath . '" al backup: ' + . 'il file non e\' leggibile o e\' stato rimosso durante la creazione.' + ); + } + $nelLotto++; $fatti++; @@ -133,11 +147,62 @@ class BackupArchive throw $e; } + try { + $this->verificaArchivio($destinazione, $totale); + } 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): void + { + $zip = new ZipArchive(); + + if ($zip->open($path, ZipArchive::RDONLY) !== true) { + throw new RuntimeException('Il backup appena creato non e\' rileggibile.'); + } + + $trovati = 0; + for ($i = 0; $i < $zip->numFiles; $i++) { + $nome = $zip->getNameIndex($i); + if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) { + $trovati++; + } + } + + $haDati = $zip->locateName(self::ENTRY_DATA) !== false; + $haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false; + + $zip->close(); + + if (! $haDati || ! $haManifest) { + throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.'); + } + + if ($trovati !== $fileAttesi) { + throw new RuntimeException(sprintf( + 'Il backup appena creato contiene %d file invece di %d: archivio scartato.', + $trovati, + $fileAttesi + )); + } + } + /** * Apre l'archivio in append, esegue il callback e lo chiude, garantendo la * chiusura anche se il callback solleva. @@ -152,14 +217,21 @@ class BackupArchive try { $callback($zip); } catch (\Throwable $e) { - $zip->close(); + // 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; } - if (! $zip->close()) { - throw new RuntimeException('Scrittura del file di backup non riuscita.'); - } + $this->chiudi($zip); } /** @@ -169,15 +241,42 @@ class BackupArchive */ private function chiudiEriapri(ZipArchive $zip, string $path): void { - if (! $zip->close()) { - throw new RuntimeException('Scrittura del file di backup non riuscita.'); - } + $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. @@ -233,9 +332,17 @@ 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); + // 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_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) { + $daEstrarre++; + } + } $onProgress && $onProgress(0, $daEstrarre); for ($index = 0; $index < $zip->numFiles; $index++) { diff --git a/app/Services/BackupOperationState.php b/app/Services/BackupOperationState.php index 3c3305f..7b81947 100644 --- a/app/Services/BackupOperationState.php +++ b/app/Services/BackupOperationState.php @@ -31,6 +31,23 @@ class BackupOperationState /** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */ private const MAX_LOG = 60; + /** + * Puntatore all'unica operazione ammessa alla volta. + * + * Backup, ripristino e azzeramento lavorano tutti sullo stesso database e + * sullo stesso disco: due in parallelo — due schede aperte, due + * amministratori — non si limitano a rallentarsi, si sovrascrivono. Un + * ripristino che riscrive le tabelle mentre un azzeramento le cancella + * lascia uno stato che nessuno dei due voleva. + * + * Il TTL e' la valvola di sicurezza: se il worker muore senza chiudere + * l'operazione, il blocco si scioglie da solo entro un'ora invece di + * richiedere un intervento sul server. Prima ancora, corrente() considera + * libero il posto se l'operazione puntata non esiste piu' o e' terminata. + */ + private const CHIAVE_CORRENTE = 'backup-op:corrente'; + private const TTL_CORRENTE = 3600; + public function initialize(string $id, string $tipo, string $fase, int $total = 0): array { $state = [ @@ -48,10 +65,40 @@ class BackupOperationState ]; $this->put($id, $state); + Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE); return $state; } + /** + * Id dell'operazione in corso, o null se non ce n'e' nessuna. + * + * Serve a due cose: rifiutare l'avvio di una seconda operazione, e + * riagganciare la barra di avanzamento quando la pagina viene ricaricata — + * senza, un azzeramento fermo in attesa di conferma resterebbe irraggiungibile + * e nessuno potrebbe piu' confermarlo o annullarlo. + */ + public function corrente(): ?string + { + $id = Cache::get(self::CHIAVE_CORRENTE); + + if (! is_string($id) || $id === '') { + return null; + } + + $stato = $this->get($id); + + // Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto + // e' libero, e il puntatore va tolto di mezzo. + if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) { + Cache::forget(self::CHIAVE_CORRENTE); + + return null; + } + + return $id; + } + public function get(string $id): ?array { return Cache::get($this->key($id)); @@ -141,6 +188,8 @@ class BackupOperationState return $state; }); + + $this->liberaCorrente($id); } public function markFailed(string $id, string $error): void @@ -154,6 +203,19 @@ class BackupOperationState }); $this->log($id, 'ERRORE: ' . $error); + $this->liberaCorrente($id); + } + + /** + * Libera il posto, ma solo se e' ancora questa operazione a occuparlo: + * senza il confronto, la chiusura di un'operazione vecchia sbloccherebbe + * quella appena avviata da qualcun altro. + */ + private function liberaCorrente(string $id): void + { + if (Cache::get(self::CHIAVE_CORRENTE) === $id) { + Cache::forget(self::CHIAVE_CORRENTE); + } } /** diff --git a/resources/views/livewire/settings/backup-manager.blade.php b/resources/views/livewire/settings/backup-manager.blade.php index 802b16e..7580bc4 100644 --- a/resources/views/livewire/settings/backup-manager.blade.php +++ b/resources/views/livewire/settings/backup-manager.blade.php @@ -77,6 +77,10 @@ return; } + // 409: c'è già un'operazione in corso. Il pacchetto è stato + // caricato per niente, quindi la barra torna a zero: lasciarla + // al 100% farebbe credere che il ripristino sia partito. + this.uploadPercento = 0; this.erroreUpload = payload.message || (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.'); }); @@ -91,11 +95,17 @@ }" x-on:scarica-backup.window="window.location.href = $event.detail.url"> + @if($erroreOperazione !== '') +
+ {{ $erroreOperazione }} +
+ @endif + {{-- ─── Pannello di avanzamento ────────────────────────────────── --}} @if($operationId && $op)
inCorso) wire:poll.750ms="aggiorna" @endif> + @if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
@@ -269,12 +279,16 @@
+ @php($bloccato = $this->inCorso) + @if($bloccato) +

Un’altra operazione è in corso: attendi che finisca.

+ @endif
diff --git a/routes/console.php b/routes/console.php index e5b4ae7..f6dd04e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -10,3 +10,8 @@ Artisan::command('inspire', function () { // Pulizia audit log secondo retention configurata Schedule::command('audit:cleanup')->daily(); + +// Archivi di backup e pacchetti di ripristino lasciati indietro da un download +// mai completato o da un job interrotto. Vivono nello stesso volume dei PDF dei +// territori, quindi accumularli finisce per riempirlo. +Schedule::command('backup:cleanup')->daily(); diff --git a/routes/web.php b/routes/web.php index a6c65be..975b76d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -166,7 +166,14 @@ Route::middleware('auth')->group(function () { // Backup completo (.zip): dati, utenti e file caricati. L'avvio delle // operazioni e la barra di avanzamento stanno in Livewire // (Settings\BackupManager); qui passa solo il trasferimento dei file. - Route::get('backup/file/{operationId}', [BackupController::class, 'file'])->name('backup.file'); + // whereUuid non e' decorazione: l'id finisce in + // CreateBackupArchive::percorso(), che lo concatena a un percorso su + // disco. Oggi il controller si ferma prima, perche' un id inventato non + // ha uno stato in cache, ma vincolare il formato toglie di mezzo la + // classe di problema invece di dipendere da quell'ordine di controlli. + Route::get('backup/file/{operationId}', [BackupController::class, 'file']) + ->whereUuid('operationId') + ->name('backup.file'); Route::post('backup/restore', [BackupController::class, 'upload'])->name('backup.restore'); });