From 588910b8934c632b602e17f3ac5aa51cad09b650 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Wed, 29 Jul 2026 14:26:36 +0200 Subject: [PATCH] ++ add reset program --- HANDOFF.md | 58 +++- README.md | 32 +- .../Auth/PasswordChangeController.php | 12 +- .../Controllers/Settings/BackupController.php | 159 ++++------ app/Jobs/CreateBackupArchive.php | 117 +++++++ app/Jobs/ResetApplication.php | 175 +++++++++++ app/Jobs/RestoreBackupArchive.php | 156 +++++++++ app/Livewire/Settings/BackupManager.php | 249 +++++++++++++++ app/Services/BackupArchive.php | 198 ++++++++++-- app/Services/BackupOperationState.php | 179 +++++++++++ .../views/auth/password-change.blade.php | 6 + .../settings/backup-manager.blade.php | 296 ++++++++++++++++++ .../livewire/settings/xml-exchange.blade.php | 62 +--- routes/web.php | 8 +- 14 files changed, 1524 insertions(+), 183 deletions(-) create mode 100644 app/Jobs/CreateBackupArchive.php create mode 100644 app/Jobs/ResetApplication.php create mode 100644 app/Jobs/RestoreBackupArchive.php create mode 100644 app/Livewire/Settings/BackupManager.php create mode 100644 app/Services/BackupOperationState.php create mode 100644 resources/views/livewire/settings/backup-manager.blade.php diff --git a/HANDOFF.md b/HANDOFF.md index cb87498..fc72718 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -248,7 +248,7 @@ Tre ritocchi minori ma con qualche implicazione: `permission:settings.manage` e il link in sidebar è sotto `@can('settings.manage')`. -### Backup completo `.zip`: dati + file, e cambio password forzato al restore +### Backup completo `.zip`, ripristino, azzeramento L'export XML da solo non ricostruisce un'installazione: `pdf_path`, `thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un @@ -263,11 +263,65 @@ Scelte non ovvie: - **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci - sta. `backup/download` e `backup/restore` sono due rotte normali nel gruppo + sta. `backup/file/{op}` e `backup/restore` sono due rotte normali nel gruppo `settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero per la memoria del processo. +- **Tutto in coda, con avanzamento reale.** Backup, ripristino e azzeramento + sono job (`Jobs/CreateBackupArchive`, `RestoreBackupArchive`, + `ResetApplication`) che scrivono su `Services/BackupOperationState`, e + `Livewire/Settings/BackupManager` lo interroga con `wire:poll`. In coda + perché un'installazione con qualche centinaio di PDF supera i 300 secondi di + `fastcgi_read_timeout` e il browser vedrebbe cadere la connessione a metà + compressione, senza modo di distinguerlo da un errore. + + La barra conta unità di lavoro completate, non tempo. Per la compressione + questo costa qualcosa: `ZipArchive` non scrive niente finché non si chiama + `close()`, quindi contare le `addFile()` porterebbe al 100% in un istante per + poi restare fermo per tutta la scrittura. `BackupArchive::create()` chiude e + riapre l'archivio a lotti (`FLUSH_MASSIMI = 20`): si paga una ricopiatura + delle entry già presenti — byte a byte, senza ricomprimerle — e in cambio + ogni avanzamento corrisponde a byte davvero finiti sul disco. Il lotto è + `totale/20`, così la ricopiatura resta limitata a ~10 volte la dimensione + dell'archivio quanti che siano i file. Estrazione e riscrittura dei file sono + cicli nostri, lì l'avanzamento è per-file senza artifici. + +- **L'azzeramento ha due tempi, separati dal download.** Il pulsante nella zona + pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in + `attesa-download`; la cancellazione parte da un secondo clic esplicito + (`confermaAzzeramento`), che rifiuta se lo stato non registra un + `servito_at`. Il primo tentativo faceva partire il reset dalla coda dello + `streamDownload`, "dopo l'ultimo byte" — **non funziona**: con + `fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo + disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi + prosegue anche se il browser ha chiuso la connessione al primo byte. Un + download annullato azzerava lo stesso. Il server può verificare di aver + *spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore, + e per questo la conferma è umana. + +- **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 + errore sui file lascerebbe database azzerato e disco pieno di PDF orfani. + Collegato: `svuotaDiscoPubblico()` ricontrolla il disco dopo la cancellazione + e solleva se qualcosa sopravvive, invece di restituire il conteggio iniziale. + Flysystem non solleva se una `unlink` fallisce, e basta un file scritto da un + processo con un altro utente (l'entrypoint lavora da root, php-fpm e il + worker da www-data) perché l'operazione si dichiari riuscita lasciando il + disco popolato. + +- **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 + (l'admin iniziale lo ricrea `entrypoint.sh` da `INITIAL_ADMIN_*`, che in + esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta + via Eloquent, non con un delete di massa: è il trait `HasRoles` di Spatie a + staccare ruoli e permessi nell'evento `deleting`, che un delete di massa non + fa scattare. Il registro attività si svuota **per ultimo** — quelle + cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che + sopravvive, quella dell'azzeramento stesso. + - **Ordine del restore: staging → database → file.** I file si estraggono prima in `storage/app/backup-restore/`, così un archivio corrotto o fuori limite fallisce mentre i dati correnti sono ancora intatti; poi diff --git a/README.md b/README.md index 1abc1e8..beec95c 100644 --- a/README.md +++ b/README.md @@ -399,11 +399,16 @@ Un aggiornamento ordinario non richiede alcun intervento manuale. ### Dall'applicazione: pacchetto `.zip` (consigliato) -*Backup e import* → **Scarica backup completo**. Produce un unico `.zip` con +*Backup e import* → **Crea e scarica backup**. Produce un unico `.zip` con tutti i dati gestionali, gli utenti e tutti i file caricati (PDF dei territori, miniature, logo). È la via da usare per spostare o rimettere in piedi un'installazione senza toccare i container. +L'archivio si prepara in background e la pagina mostra l'avanzamento reale +(file compressi / file totali); il download parte da solo appena è pronto. +Stessa cosa per il ripristino: prima la barra di caricamento del pacchetto, +poi quella delle fasi di lavoro (estrazione, database, file). + Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i dati gestionali correnti *e* i file caricati. @@ -421,6 +426,28 @@ Il tetto per l'upload è **256 MB** (`client_max_body_size` in `docker/nginx/default.conf` e `upload_max_filesize`/`post_max_size` in `docker/php/php.ini`). Oltre quella soglia usa il backup dei volumi qui sotto. +### Zona pericolosa: azzera tutto + +In fondo alla stessa pagina, **Scarica backup e azzera tutto** riporta +l'applicazione allo stato di installazione appena eseguita. Va scritto +`AZZERA TUTTO` per sbloccare il pulsante, e l'operazione procede in due tempi: + +1. Viene creato e scaricato un backup completo di tutto ciò che c'è. +2. L'operazione **si ferma e aspetta**: controlli il file scaricato e confermi + con un secondo clic. Fino a lì non viene toccato nulla, e puoi annullare. +3. Solo dopo la conferma vengono eliminati territori, proclamatori, + assegnazioni, campagne, zone, tipologie, anni di servizio, il registro + attività e tutti i file caricati; le impostazioni tornano ai default e i + ruoli vengono riseminati. + +> **Tutti gli altri utenti vengono eliminati.** Resta solo l'account +> dell'amministratore che ha eseguito l'operazione, con la sua password +> attuale — come un'installazione nuova, che ha un amministratore solo. +> Cancellarli tutti lascerebbe l'applicazione senza nessuno che possa +> accedere fino al riavvio del container. + +Il backup scaricato al punto 1 è l'unico modo per tornare indietro. + ### Dai volumi: cosa salvare | Volume | Contenuto | Nel backup | @@ -595,7 +622,10 @@ docker compose --profile dev up -d --build ### Backup e import - **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile +- Barra di avanzamento reale su backup e ripristino (lavoro svolto, non tempo) - Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti +- **Zona pericolosa**: azzeramento completo con backup obbligatorio scaricato + e confermato prima di procedere - Import ed export XML dell'intero set di dati gestionali (senza i file) - Conversione di dump SQL legacy in XML compatibile - Import massivo dei PDF dei territori da cartella o archivio ZIP diff --git a/app/Http/Controllers/Auth/PasswordChangeController.php b/app/Http/Controllers/Auth/PasswordChangeController.php index b18618a..8ac0ee2 100644 --- a/app/Http/Controllers/Auth/PasswordChangeController.php +++ b/app/Http/Controllers/Auth/PasswordChangeController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Jobs\RestoreBackupArchive; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Validation\Rules\Password; /** @@ -18,11 +20,17 @@ class PasswordChangeController extends Controller { public function show() { + $user = auth()->user(); + return view('auth.password-change', [ - 'user' => auth()->user(), + 'user' => $user, // Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro; // chi e' stato dirottato dal middleware no. - 'forced' => (bool) auth()->user()->password_change_required, + '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), ]); } diff --git a/app/Http/Controllers/Settings/BackupController.php b/app/Http/Controllers/Settings/BackupController.php index a574ebc..08d5a21 100644 --- a/app/Http/Controllers/Settings/BackupController.php +++ b/app/Http/Controllers/Settings/BackupController.php @@ -3,61 +3,79 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; -use App\Livewire\Settings\XmlExchange; -use App\Services\BackupArchive; +use App\Jobs\CreateBackupArchive; +use App\Jobs\RestoreBackupArchive; +use App\Services\BackupOperationState; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; /** - * Backup e ripristino dell'intera applicazione in un unico pacchetto .zip: - * dati gestionali, utenti e file caricati. + * Trasferimento dei file di backup: consegna dell'archivio prodotto e + * ricezione di quello da ripristinare. * - * Non e' un componente Livewire come il resto della pagina Import: lo scarico - * di un Livewire streamDownload passa il file base64 dentro la risposta JSON, - * e un archivio con tutti i PDF dei territori non ci sta. Sono due rotte HTTP - * normali, gia' protette da `settings.manage` nel gruppo di rotte. + * Non e' un componente Livewire come il resto della pagina: lo `streamDownload` + * di Livewire passa il file base64 dentro la risposta JSON, e un archivio con + * tutti i PDF dei territori non ci sta. L'avvio delle operazioni e la barra di + * avanzamento vivono invece in Livewire\Settings\BackupManager, che interroga + * BackupOperationState. Entrambe le rotte sono nel gruppo `settings.manage`. */ class BackupController extends Controller { public function __construct( - private readonly BackupArchive $archive, + private readonly BackupOperationState $stato, ) { } /** - * Tetto di tempo per comprimere/estrarre un archivio con molti PDF. - * Sopra c'e' comunque `fastcgi_read_timeout 300` in docker/nginx/ - * default.conf: alzare questo valore da solo non serve a nulla. + * Consegna l'archivio prodotto da un'operazione di backup e annota che e' + * stato servito. + * + * L'annotazione e' meta' del blocco che protegge l'azzeramento: senza un + * `servito_at` il pulsante di conferma rifiuta di procedere, quindi non + * esiste una strada che cancelli i dati senza che l'archivio sia almeno + * uscito dal server. L'altra meta' e' la conferma umana in + * BackupManager::confermaAzzeramento(). + * + * Il completamento del trasferimento *non* e' verificabile da qui: con + * `fastcgi_buffering on` (il default, e docker/nginx/default.conf non lo + * disattiva) nginx accetta l'intera risposta dal processo PHP e la inoltra + * per conto suo. PHP finisce di scrivere e prosegue anche se il browser ha + * chiuso la connessione al primo byte, quindi far scattare l'azzeramento + * "dopo l'ultimo byte" azzererebbe anche su un download annullato. */ - private const SECONDI_MAX = 300; - - public function download(XmlExchange $exchange) + public function file(string $operationId) { - set_time_limit(self::SECONDI_MAX); + $operazione = $this->stato->get($operationId); - $path = $this->archive->create($exchange->currentXmlBackup()); + abort_if($operazione === null, 404, 'Backup non piu\' disponibile: rigeneralo.'); - $name = 'termanager2-backup-' . now()->format('Ymd-His') . '.zip'; + $percorso = CreateBackupArchive::percorso($operationId); + abort_unless(is_file($percorso), 404, 'Archivio di backup non trovato: rigeneralo.'); + + $nome = $operazione['result']['nome_download'] ?? 'termanager2-backup.zip'; + + $this->stato->update($operationId, function (array $state) { + $state['result']['servito_at'] = now()->toDateTimeString(); + + return $state; + }); return response() - ->download($path, $name, ['Content-Type' => 'application/zip']) - ->deleteFileAfterSend(true); + ->download($percorso, $nome, [ + 'Content-Type' => 'application/zip', + 'Content-Length' => (string) filesize($percorso), + ]); } /** - * Ripristina un pacchetto di backup. + * Riceve il pacchetto da ripristinare e mette in coda il lavoro. * - * Ordine deliberato: i file vengono prima estratti in staging (cosi' un - * archivio corrotto fallisce mentre i dati correnti sono ancora intatti), - * poi si riscrive il database in transazione, poi si sostituiscono i file. - * L'inverso lascerebbe il disco pieno di file di un'installazione e il - * database di un'altra. + * Risponde JSON perche' il form viene inviato via XMLHttpRequest: e' l'unico + * modo di avere una barra di caricamento reale sull'upload, che per un + * archivio di centinaia di MB e' la parte piu' lunga dell'operazione. */ - public function restore(Request $request, XmlExchange $exchange) + public function upload(Request $request) { - set_time_limit(self::SECONDI_MAX); - // 256 MB e' il tetto reale della pila, non una scelta di questa // validazione: piu' in su intervengono prima `client_max_body_size` di // nginx e `upload_max_filesize`/`post_max_size` di PHP, che rifiutano @@ -70,68 +88,29 @@ class BackupController extends Controller 'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.', ]); - $staging = null; + $operationId = (string) Str::uuid(); - try { - $extracted = $this->archive->extract($request->file('backupZip')->getRealPath()); - $staging = $extracted['staging']; - - $result = $exchange->importXmlFromContent($extracted['xml']); - - if (isset($result['error'])) { - return back()->withErrors(['backupZip' => $result['error']]); - } - - $fileCount = $this->archive->restoreFiles($staging); - - $utentiBloccati = $this->requirePasswordChangeForEveryone(); - } catch (\Throwable $e) { - Log::error('Ripristino del backup non riuscito.', ['exception' => $e]); - - return back()->withErrors(['backupZip' => $e->getMessage()]); - } finally { - if ($staging !== null) { - $this->archive->deleteDirectory($staging); - } - } - - $stats = $result['stats']; - - $message = sprintf( - 'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. ' - . 'Per sicurezza tutti i %d account devono impostare una nuova password.', - $stats['territori_importati'] ?? 0, - $stats['proclamatori_importati'] ?? 0, - $stats['assegnazioni_importate'] ?? 0, - $fileCount, - $utentiBloccati + // Il file dell'upload vive in una cartella temporanea che sparisce a + // fine richiesta: il job lo troverebbe gia' cancellato. + $percorso = $request->file('backupZip')->storeAs( + 'backup-restore-in', + $operationId . '.zip', + 'local' ); - return redirect()->route('password.change') - ->with('success', $message) - ->with('importStats', $stats) - ->with('importIssues', $result['issues']); - } + $this->stato->initialize( + $operationId, + BackupOperationState::TIPO_RESTORE, + 'In coda' + ); + $this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.'); - /** - * Blocca ogni account finche' non sceglie una password nuova, - * l'amministratore che sta ripristinando compreso. - * - * Il pacchetto contiene gli hash bcrypt di tutti gli utenti: chi ha avuto - * il file fra le mani ha potuto tentarne il crack con calma, quindi dopo un - * ripristino nessuna delle vecchie password si puo' considerare segreta. - * Anche i remember_token vengono azzerati: sono credenziali persistenti a - * tutti gli effetti e stanno pure loro nel backup. - * - * Scritto con il query builder di proposito — un update di massa su - * Eloquent qui non aggiungerebbe nulla e toccherebbe updated_at su tutte le - * righe appena importate con i loro timestamp originali. - */ - private function requirePasswordChangeForEveryone(): int - { - return DB::table('users')->update([ - 'password_change_required' => true, - 'remember_token' => null, - ]); + RestoreBackupArchive::dispatch( + $operationId, + storage_path('app/' . $percorso), + (int) auth()->id() + ); + + return response()->json(['operation_id' => $operationId]); } } diff --git a/app/Jobs/CreateBackupArchive.php b/app/Jobs/CreateBackupArchive.php new file mode 100644 index 0000000..5f0db33 --- /dev/null +++ b/app/Jobs/CreateBackupArchive.php @@ -0,0 +1,117 @@ +markRunning($this->operationId); + $stato->fase($this->operationId, 'Lettura dei dati gestionali'); + + $destinazione = self::percorso($this->operationId); + + try { + $this->rimuoviArchiviVecchi($archivio); + + $xml = $exchange->currentXmlBackup(); + + $stato->fase($this->operationId, 'Compressione dei file'); + + $archivio->create( + $xml, + $destinazione, + fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) + ); + } catch (\Throwable $e) { + Log::error('Creazione del backup non riuscita.', ['exception' => $e]); + $stato->markFailed($this->operationId, $e->getMessage()); + + return; + } + + $risultato = [ + 'file' => basename($destinazione), + 'nome_download' => 'termanager2-backup-' . now()->format('Ymd-His') . '.zip', + 'dimensione' => filesize($destinazione) ?: 0, + ]; + + if ($this->perAzzeramento) { + $stato->log($this->operationId, 'Archivio pronto. In attesa che il download sia completato.'); + $stato->markAttesaDownload($this->operationId, $risultato); + + return; + } + + $stato->log($this->operationId, 'Backup pronto per il download.'); + $stato->markCompleted($this->operationId, $risultato); + } + + public static function percorso(string $operationId): string + { + return storage_path('app/backups/' . $operationId . '.zip'); + } + + /** + * Gli archivi restano su disco finche' il browser non li scarica, quindi un + * download mai completato lascerebbe un file per sempre. Si ripulisce + * all'inizio di ogni nuovo backup invece di aggiungere un task pianificato: + * il momento in cui serve spazio e' esattamente questo. + */ + private function rimuoviArchiviVecchi(BackupArchive $archivio): void + { + $cartella = storage_path('app/backups'); + + if (! is_dir($cartella)) { + return; + } + + $limite = now()->subDay()->getTimestamp(); + + foreach (glob($cartella . '/*.zip') ?: [] as $file) { + if (filemtime($file) < $limite) { + @unlink($file); + } + } + } +} diff --git a/app/Jobs/ResetApplication.php b/app/Jobs/ResetApplication.php new file mode 100644 index 0000000..a29ceec --- /dev/null +++ b/app/Jobs/ResetApplication.php @@ -0,0 +1,175 @@ +markRunning($this->operationId); + + try { + // I file per primi, prima di toccare il database: svuotare il disco + // e' l'unico passo che puo' fallire per cause esterne (permessi sul + // volume), e fallire qui non ha ancora cancellato niente. Nell'ordine + // inverso un errore sui file lascerebbe un'applicazione con il + // database azzerato e il disco pieno di PDF che non appartengono + // piu' a nessun territorio. + $stato->fase($this->operationId, 'Cancellazione dei file caricati', 1); + $fileRimossi = $archivio->svuotaDiscoPubblico(); + $stato->avanza($this->operationId, 1); + $stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.'); + + $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8); + + DB::transaction(function () use ($stato) { + $passi = [ + 'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(), + 'Campagne' => fn () => Campagna::query()->delete(), + 'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(), + 'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(), + 'Tipologie' => fn () => Tipologia::query()->delete(), + 'Zone' => fn () => Zona::query()->delete(), + 'Anni teocratici' => fn () => AnnoTeocratico::query()->delete(), + 'Impostazioni' => fn () => Setting::query()->delete(), + ]; + + $fatti = 0; + foreach ($passi as $etichetta => $azione) { + $azione(); + $stato->avanza($this->operationId, ++$fatti); + $stato->log($this->operationId, $etichetta . ': azzerate.'); + } + }); + + // Il mass delete non emette eventi, quindi l'istanza memoizzata di + // Setting punta ancora alla riga cancellata: senza questo, il + // firstOrCreate piu' sotto tornerebbe il fantasma invece di + // ricreare la riga. + Setting::flushCache(); + + $stato->fase($this->operationId, 'Rimozione degli altri utenti'); + $utentiRimossi = $this->rimuoviAltriUtenti($stato); + + // Il registro si svuota *dopo* le cancellazioni, non prima: gli + // altri utenti sono modelli con LogsActivity, quindi eliminarli + // scrive righe nuove che una pulizia anticipata non intercetta e + // che resterebbero li' come unica traccia — un residuo, non una + // scelta. Svuotato per ultimo, l'unica riga che sopravvive e' + // quella scritta apposta qui sotto. + $stato->fase($this->operationId, 'Pulizia del registro attivita\'', 1); + DB::table('activity_log')->delete(); + activity() + ->causedBy(User::find($this->actorId)) + ->log('Applicazione azzerata e riportata allo stato iniziale'); + $stato->avanza($this->operationId, 1); + + $stato->fase($this->operationId, 'Ripristino di ruoli e impostazioni', 3); + + // Ruoli e permessi non vengono cancellati, ma il seeder e' additivo + // e idempotente: rieseguirlo garantisce il set di partenza anche se + // qualcuno ne aveva rimosso qualcuno a mano. + (new RolesAndPermissionsSeeder())->run(); + $stato->avanza($this->operationId, 1); + + Setting::instance(); + $stato->avanza($this->operationId, 2); + + $attore = User::find($this->actorId); + if ($attore && ! $attore->hasRole('amministratore')) { + $attore->assignRole('amministratore'); + } + $stato->avanza($this->operationId, 3); + } catch (\Throwable $e) { + Log::error('Azzeramento dell\'applicazione non riuscito.', ['exception' => $e]); + $stato->markFailed($this->operationId, $e->getMessage()); + + return; + } + + $messaggio = sprintf( + 'Applicazione azzerata: dati gestionali rimossi, %d file cancellati, %d utenti eliminati. ' + . 'Resta il tuo account di amministratore.', + $fileRimossi, + $utentiRimossi + ); + + $stato->log($this->operationId, $messaggio); + $stato->markCompleted($this->operationId, ['messaggio' => $messaggio]); + } + + /** + * Cancella ogni utente tranne chi ha chiesto l'azzeramento. + * + * Uno alla volta e via Eloquent, non con un delete di massa: il trait + * HasRoles di Spatie stacca ruoli e permessi nell'evento `deleting`, che un + * delete di massa non fa scattare — resterebbero righe orfane in + * model_has_roles pronte a essere riassegnate al primo utente che riceve un + * id gia' usato. + */ + private function rimuoviAltriUtenti(BackupOperationState $stato): int + { + $altri = User::query()->where('id', '!=', $this->actorId)->get(); + $stato->avanza($this->operationId, 0, $altri->count()); + + $rimossi = 0; + foreach ($altri as $utente) { + $utente->delete(); + $stato->avanza($this->operationId, ++$rimossi); + } + + return $rimossi; + } +} diff --git a/app/Jobs/RestoreBackupArchive.php b/app/Jobs/RestoreBackupArchive.php new file mode 100644 index 0000000..792e715 --- /dev/null +++ b/app/Jobs/RestoreBackupArchive.php @@ -0,0 +1,156 @@ +markRunning($this->operationId); + + $staging = null; + + try { + $stato->fase($this->operationId, 'Estrazione dell\'archivio'); + + $estratto = $archivio->extract( + $this->zipPath, + fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) + ); + $staging = $estratto['staging']; + + $stato->fase($this->operationId, 'Riscrittura del database', 1); + + // importXmlFromContent() attribuisce a auth()->id() le assegnazioni + // il cui autore non e' nel file. Qui non c'e' sessione, quindi + // l'utente va reimpostato a mano: senza, l'audit finirebbe sul + // primo id disponibile invece che su chi ha avviato il ripristino. + Auth::onceUsingId($this->actorId); + + $result = $exchange->importXmlFromContent($estratto['xml']); + + if (isset($result['error'])) { + $stato->markFailed($this->operationId, $result['error']); + + return; + } + + $stato->avanza($this->operationId, 1, 1); + + $stato->fase($this->operationId, 'Ripristino dei file'); + + $fileRipristinati = $archivio->restoreFiles( + $staging, + fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) + ); + + $stato->fase($this->operationId, 'Blocco degli account', 1); + $utenti = $this->richiediCambioPasswordATutti(); + $stato->avanza($this->operationId, 1, 1); + } catch (\Throwable $e) { + Log::error('Ripristino del backup non riuscito.', ['exception' => $e]); + $stato->markFailed($this->operationId, $e->getMessage()); + + return; + } finally { + Auth::forgetUser(); + + if ($staging !== null) { + $archivio->deleteDirectory($staging); + } + + @unlink($this->zipPath); + } + + $stats = $result['stats']; + + $messaggio = sprintf( + 'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. ' + . 'Per sicurezza %s impostare una nuova password.', + $stats['territori_importati'] ?? 0, + $stats['proclamatori_importati'] ?? 0, + $stats['assegnazioni_importate'] ?? 0, + $fileRipristinati, + $utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono' + ); + + // Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la + // pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso, + // il middleware dirotta l'amministratore sul cambio password. Lo si + // lascia in cache a suo nome, e quella schermata lo consuma. + Cache::put(self::CHIAVE_ESITO . $this->actorId, $messaggio, 3600); + + $stato->log($this->operationId, $messaggio); + $stato->markCompleted($this->operationId, [ + 'stats' => $stats, + 'issues' => array_slice($result['issues'], 0, 50), + 'file_ripristinati' => $fileRipristinati, + 'messaggio' => $messaggio, + ]); + } + + /** + * Blocca ogni account finche' non sceglie una password nuova, + * l'amministratore che sta ripristinando compreso. + * + * Il pacchetto contiene gli hash bcrypt di tutti gli utenti: chi ha avuto + * il file fra le mani ha potuto tentarne il crack con calma, quindi dopo un + * ripristino nessuna delle vecchie password si puo' considerare segreta. + * Anche i remember_token vengono azzerati: sono credenziali persistenti a + * tutti gli effetti e stanno pure loro nel backup. + * + * Scritto con il query builder di proposito — un update di massa su + * Eloquent qui non aggiungerebbe nulla e toccherebbe updated_at su tutte le + * righe appena importate con i loro timestamp originali. + * + * @return int Account bloccati. Non e' il valore di ritorno dell'update: + * MariaDB conta le righe *cambiate*, quindi un utente gia' + * marcato da un ripristino precedente non comparirebbe e il + * riepilogo direbbe meno account di quanti ne sono bloccati. + */ + private function richiediCambioPasswordATutti(): int + { + DB::table('users')->update([ + 'password_change_required' => true, + 'remember_token' => null, + ]); + + return (int) DB::table('users')->count(); + } +} diff --git a/app/Livewire/Settings/BackupManager.php b/app/Livewire/Settings/BackupManager.php new file mode 100644 index 0000000..6d13bf6 --- /dev/null +++ b/app/Livewire/Settings/BackupManager.php @@ -0,0 +1,249 @@ +authorize('settings.manage'); + } + + public function avviaBackup(): void + { + $this->authorize('settings.manage'); + + $this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP); + + CreateBackupArchive::dispatch($this->operationId, false); + + $this->aggiorna(); + } + + /** + * Primo tempo dell'azzeramento: si costruisce il backup e lo si scarica. + * La cancellazione non parte da qui — serve il secondo tempo, + * confermaAzzeramento(), a valle del download. + */ + public function avviaAzzeramento(): void + { + $this->authorize('settings.manage'); + + $this->validate( + ['confermaReset' => 'required|in:' . self::CONFERMA_AZZERAMENTO], + ['confermaReset.in' => 'Scrivi esattamente "' . self::CONFERMA_AZZERAMENTO . '" per confermare.', + 'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.'] + ); + + $this->confermaReset = ''; + $this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET); + + CreateBackupArchive::dispatch($this->operationId, true); + + $this->aggiorna(); + } + + /** + * Secondo tempo dell'azzeramento: parte solo su conferma esplicita, dopo + * che l'archivio e' stato servito. + * + * La conferma umana non e' un doppione del `wire:confirm` iniziale: e' il + * solo punto in cui si sa che il backup e' davvero sul computer di chi sta + * cancellando. Il server puo' verificare di averlo *spedito* + * (`servito_at`), non che sia arrivato: nginx bufferizza la risposta, il + * processo PHP la considera scritta e un download annullato dal browser + * resterebbe indistinguibile da uno riuscito. Chiedere all'operatore e' + * l'unico controllo che copre anche quel caso. + */ + public function confermaAzzeramento(): void + { + $this->authorize('settings.manage'); + + $this->aggiorna(); + + if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) { + $this->addError('confermaReset', 'Nessun azzeramento in attesa di conferma.'); + + return; + } + + if (($this->operazione['result']['servito_at'] ?? null) === null) { + $this->addError('confermaReset', 'Scarica prima il backup: senza, l\'azzeramento non parte.'); + + return; + } + + app(BackupOperationState::class)->update($this->operationId, function (array $state) { + $state['fase'] = 'Azzeramento in avvio'; + $state['processed'] = 0; + $state['total'] = 0; + + return $state; + }); + + ResetApplication::dispatch($this->operationId, (int) auth()->id()); + + $this->aggiorna(); + } + + /** + * Annulla un azzeramento fermo in attesa di conferma. Il backup resta + * scaricabile: e' comunque un backup valido. + */ + public function annullaAzzeramento(): void + { + $this->authorize('settings.manage'); + + if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) { + return; + } + + app(BackupOperationState::class)->markCompleted($this->operationId, [ + 'messaggio' => 'Azzeramento annullato. Il backup appena creato resta scaricabile.', + ]); + + // Da qui in poi e' un backup come un altro: la scheda mostra il + // pulsante di download, non piu' la conferma di cancellazione. + app(BackupOperationState::class)->update($this->operationId, function (array $state) { + $state['tipo'] = BackupOperationState::TIPO_BACKUP; + + return $state; + }); + + $this->aggiorna(); + } + + /** + * Aggancia un'operazione avviata fuori da Livewire (il ripristino, che + * parte da un upload XHR e restituisce il suo id in JSON). + */ + public function seguiOperazione(string $operationId): void + { + $this->authorize('settings.manage'); + + $this->operationId = $operationId; + $this->aggiorna(); + } + + public function aggiorna(): void + { + if (! $this->operationId) { + return; + } + + $stato = app(BackupOperationState::class)->get($this->operationId); + + if (! $stato) { + $this->operationId = null; + $this->operazione = []; + + return; + } + + $this->operazione = $stato; + + // Il download parte da solo appena l'archivio e' pronto: per il backup + // e' una comodita', per l'azzeramento e' il passaggio che sblocca la + // cancellazione, quindi non puo' dipendere da un clic. + if ($this->scaricaOra()) { + $this->dispatch('scarica-backup', url: route('backup.file', $this->operationId)); + } + } + + public function chiudi(): void + { + $this->operationId = null; + $this->operazione = []; + } + + /** + * True una volta sola per operazione: l'evento di download va emesso quando + * l'archivio compare, non a ogni poll successivo. + */ + private function scaricaOra(): bool + { + $pronto = ($this->operazione['result']['file'] ?? null) !== null; + $giaInviato = $this->operazione['result']['download_inviato'] ?? false; + + if (! $pronto || $giaInviato) { + return false; + } + + app(BackupOperationState::class)->update($this->operationId, function (array $state) { + $state['result']['download_inviato'] = true; + + return $state; + }); + + $this->operazione['result']['download_inviato'] = true; + + return true; + } + + private function nuovaOperazione(string $tipo): string + { + $operationId = (string) \Illuminate\Support\Str::uuid(); + + $stato = app(BackupOperationState::class); + $stato->initialize($operationId, $tipo, 'In coda'); + $stato->log($operationId, 'Operazione creata. In attesa del worker di coda.'); + + return $operationId; + } + + /** Percentuale della fase corrente, 0-100. */ + public function getPercentualeProperty(): int + { + $stato = $this->operazione['status'] ?? null; + + if ($stato === 'completed') { + return 100; + } + + $total = (int) ($this->operazione['total'] ?? 0); + + if ($total <= 0) { + return 0; + } + + $processed = (int) ($this->operazione['processed'] ?? 0); + + return (int) min(100, round($processed / $total * 100)); + } + + /** Se true la pagina continua a interrogare lo stato. */ + public function getInCorsoProperty(): bool + { + return $this->operationId !== null + && in_array($this->operazione['status'] ?? '', ['queued', 'running'], true); + } + + public function render() + { + return view('livewire.settings.backup-manager'); + } +} diff --git a/app/Services/BackupArchive.php b/app/Services/BackupArchive.php index d5db98d..a70405c 100644 --- a/app/Services/BackupArchive.php +++ b/app/Services/BackupArchive.php @@ -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)) { diff --git a/app/Services/BackupOperationState.php b/app/Services/BackupOperationState.php new file mode 100644 index 0000000..3c3305f --- /dev/null +++ b/app/Services/BackupOperationState.php @@ -0,0 +1,179 @@ + $id, + 'tipo' => $tipo, + 'status' => 'queued', + 'fase' => $fase, + 'total' => $total, + 'processed' => 0, + 'logs' => [], + 'result' => [], + 'error' => null, + 'started_at' => null, + 'finished_at' => null, + ]; + + $this->put($id, $state); + + return $state; + } + + public function get(string $id): ?array + { + return Cache::get($this->key($id)); + } + + public function put(string $id, array $state): void + { + Cache::put($this->key($id), $state, $this->ttlSeconds); + } + + public function forget(string $id): void + { + Cache::forget($this->key($id)); + } + + public function update(string $id, callable $callback): ?array + { + $state = $this->get($id); + + if (! $state) { + return null; + } + + $state = $callback($state) ?? $state; + $this->put($id, $state); + + return $state; + } + + public function log(string $id, string $message): void + { + $this->update($id, function (array $state) use ($message) { + $state['logs'][] = '[' . now()->format('H:i:s') . '] ' . $message; + $state['logs'] = array_slice($state['logs'], -self::MAX_LOG); + + return $state; + }); + } + + /** + * Cambia fase e, insieme, il totale di riferimento: ogni fase ha una sua + * unita' di misura (file da comprimere, file da estrarre, passi di + * cancellazione) e la barra riparte da zero su quel totale. + */ + public function fase(string $id, string $fase, int $total = 0): void + { + $this->update($id, function (array $state) use ($fase, $total) { + $state['fase'] = $fase; + $state['total'] = $total; + $state['processed'] = 0; + + return $state; + }); + + $this->log($id, $fase . ($total > 0 ? ' (' . $total . ')' : '')); + } + + public function avanza(string $id, int $processed, ?int $total = null): void + { + $this->update($id, function (array $state) use ($processed, $total) { + $state['processed'] = $processed; + if ($total !== null) { + $state['total'] = $total; + } + + return $state; + }); + } + + public function markRunning(string $id): void + { + $this->update($id, function (array $state) { + $state['status'] = 'running'; + $state['started_at'] ??= now()->toDateTimeString(); + + return $state; + }); + } + + public function markCompleted(string $id, array $result = []): void + { + $this->update($id, function (array $state) use ($result) { + $state['status'] = 'completed'; + $state['processed'] = max($state['processed'], $state['total']); + $state['result'] = $result + $state['result']; + $state['finished_at'] = now()->toDateTimeString(); + + return $state; + }); + } + + public function markFailed(string $id, string $error): void + { + $this->update($id, function (array $state) use ($error) { + $state['status'] = 'failed'; + $state['error'] = $error; + $state['finished_at'] = now()->toDateTimeString(); + + return $state; + }); + + $this->log($id, 'ERRORE: ' . $error); + } + + /** + * L'archivio e' pronto ma non si prosegue: l'azzeramento parte solo quando + * il download risulta consegnato per intero. + */ + public function markAttesaDownload(string $id, array $result): void + { + $this->update($id, function (array $state) use ($result) { + $state['status'] = 'running'; + $state['fase'] = self::FASE_ATTESA_DOWNLOAD; + $state['processed'] = $state['total']; + $state['result'] = $result + $state['result']; + + return $state; + }); + } + + protected function key(string $id): string + { + return 'backup-op:' . $id; + } +} diff --git a/resources/views/auth/password-change.blade.php b/resources/views/auth/password-change.blade.php index d34c857..9d5b5b6 100644 --- a/resources/views/auth/password-change.blade.php +++ b/resources/views/auth/password-change.blade.php @@ -15,6 +15,12 @@ @endif + @if($esitoRipristino) +
+ {{ $esitoRipristino }} +
+ @endif + @if($forced)
Ripristino da backup eseguito. diff --git a/resources/views/livewire/settings/backup-manager.blade.php b/resources/views/livewire/settings/backup-manager.blade.php new file mode 100644 index 0000000..836fa17 --- /dev/null +++ b/resources/views/livewire/settings/backup-manager.blade.php @@ -0,0 +1,296 @@ +{{-- + Backup, ripristino e azzeramento, con avanzamento reale. + + La barra legge processed/total dell'operazione in corso, che i job + aggiornano quando qualcosa e' davvero finito sul disco. Il poll resta + attivo solo mentre l'operazione e' in coda o in esecuzione: appena chiude, + si ferma da solo. +--}} +@php + $op = $operazione; + $tipo = $op['tipo'] ?? null; + $stato = $op['status'] ?? null; + $attesaDownload = ($op['fase'] ?? null) === \App\Services\BackupOperationState::FASE_ATTESA_DOWNLOAD; + + $etichettaTipo = match ($tipo) { + 'backup' => 'Backup in corso', + 'restore' => 'Ripristino in corso', + 'reset' => 'Azzeramento in corso', + default => 'Operazione in corso', + }; +@endphp + +
+ + {{-- ─── Pannello di avanzamento ────────────────────────────────── --}} + @if($operationId && $op) +
inCorso) wire:poll.750ms="aggiorna" @endif> + +
+
+
+ {{ $stato === 'completed' ? 'Operazione completata' : ($stato === 'failed' ? 'Operazione fallita' : $etichettaTipo) }} +
+
+ {{ $op['fase'] ?? '' }} + @if(($op['total'] ?? 0) > 0 && $stato !== 'completed') + — {{ $op['processed'] ?? 0 }} / {{ $op['total'] }} + @endif +
+
+
+ {{ $this->percentuale }}% +
+
+ +
+
+
+ + @if($attesaDownload) +
+

+ L’archivio è pronto e il download è partito. + Non è stato cancellato ancora nulla: l’azzeramento aspetta la tua conferma. +

+

+ Apri il file scaricato e verifica che sia integro. Il server sa di aver spedito + l’archivio, non può sapere se è arrivato intero: quella verifica tocca a te, + ed è l’ultima possibilità di tornare indietro. +

+ +
+ + Scarica di nuovo + + + + + +
+ + @error('confermaReset')

{{ $message }}

@enderror +
+ @endif + + @if($stato === 'failed') +

{{ $op['error'] ?? 'Errore non specificato.' }}

+ @endif + + @if($stato === 'completed' && ($op['result']['messaggio'] ?? null)) +

{{ $op['result']['messaggio'] }}

+ @endif + + @if($stato === 'completed' && $tipo === 'backup' && ($op['result']['file'] ?? null)) +
+ + Scarica di nuovo + + + {{ $op['result']['nome_download'] ?? '' }} — + {{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB + +
+ @endif + + @if(!empty($op['logs'])) +
+ Dettaglio + +
+ @endif + + @if(in_array($stato, ['completed', 'failed'], true)) + + @endif +
+ @endif + + {{-- ─── Backup ─────────────────────────────────────────────────── --}} +
+
+

Backup completo (.zip)

+

+ Un unico pacchetto con tutti i dati dell’applicazione — utenti compresi — + e tutti i file caricati: PDF dei territori, miniature e logo. + E’ l’archivio da conservare per rimettere in piedi l’installazione da zero. +

+
+ +
+ Dati sensibili. Il pacchetto include gli utenti con le password (hash), i nomi dei + proclamatori in chiaro e le mappe dei territori. Conservalo in un luogo sicuro e non condividerlo. +
+ +
+ +

L’archivio si genera in background: puoi seguire l’avanzamento qui sopra.

+
+
+ + {{-- ─── Ripristino ─────────────────────────────────────────────── --}} +
+
+

Ripristina da backup

+

+ Carica un pacchetto .zip generato qui sopra. Il ripristino sostituisce i dati + gestionali correnti e tutti i file caricati. +

+
+ +
+ Al termine tutti dovranno cambiare password, amministratore compreso: il pacchetto + contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono piu’ da + considerarsi segrete. Verrai portato subito sulla schermata di cambio password. +
+ +
+ @csrf +
+ +

+
+ + {{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}} +
+
+ + +
+
+
+
+
+ + +
+
+ + {{-- ─── Zona pericolosa ────────────────────────────────────────── --}} +
+
+ +
+

Zona pericolosa

+

Operazioni irreversibili. Leggi prima di premere.

+
+
+ +
+

Azzera tutto e riparti da zero

+ +

+ Riporta l’applicazione allo stato di installazione appena eseguita. In ordine: +

+
    +
  1. Viene creato e scaricato un backup completo di tutto ciò che c’è adesso.
  2. +
  3. L’operazione si ferma e aspetta: controlli il file scaricato e confermi. Fino a quel clic non viene toccato nulla, e puoi annullare.
  4. +
  5. Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e tutti i file caricati.
  6. +
  7. Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.
  8. +
  9. Tutti gli altri utenti vengono eliminati. Resta solo il tuo account di amministratore, con la password attuale — come un’installazione nuova, che ha un amministratore solo.
  10. +
+ +
+ Il backup scaricato è l’unico modo per tornare indietro. Verifica di averlo + salvato prima di chiudere il browser. +
+ +
+ + + @error('confermaReset')

{{ $message }}

@enderror +
+ + +
+
+
diff --git a/resources/views/livewire/settings/xml-exchange.blade.php b/resources/views/livewire/settings/xml-exchange.blade.php index 64cbb06..b23e1ed 100644 --- a/resources/views/livewire/settings/xml-exchange.blade.php +++ b/resources/views/livewire/settings/xml-exchange.blade.php @@ -4,62 +4,12 @@

Backup completo dell’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.

- {{-- Backup completo: e' la sezione che serve nel 99% dei casi, quindi sta - in cima. Le sezioni XML piu' sotto restano per gli usi parziali e per - la conversione dei dump legacy. --}} -
-
-

Backup completo (.zip)

-

- Un unico pacchetto con tutti i dati dell’applicazione — utenti compresi — - e tutti i file caricati: PDF dei territori, miniature e logo. - E’ l’archivio da conservare per rimettere in piedi l’installazione da zero. -

-
- -
- Dati sensibili. Il pacchetto include gli utenti con le password (hash), i nomi dei - proclamatori in chiaro e le mappe dei territori. Conservalo in un luogo sicuro e non condividerlo. -
- -
- - - Scarica backup completo - -

La preparazione dell’archivio puo’ richiedere qualche minuto se ci sono molti PDF.

-
- -
-

Ripristina da backup

-

- Carica un pacchetto .zip generato qui sopra. Il ripristino sostituisce i dati - gestionali correnti e tutti i file caricati. -

- -
- Al termine tutti dovranno cambiare password, amministratore compreso: il pacchetto - contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono piu’ da - considerarsi segrete. Verrai portato subito sulla schermata di cambio password. -
- -
- @csrf -
- - @error('backupZip')

{{ $message }}

@enderror -
- - -
-
-
+ {{-- Backup, ripristino e zona pericolosa: componente a sé perché ha uno + stato proprio (l'operazione in corso) e un poll che non deve far + ri-renderizzare tutto il resto della pagina. Sta in cima perché è la + sezione che serve nel 99% dei casi; le sezioni XML più sotto restano + per gli usi parziali e per la conversione dei dump legacy. --}} +