++ 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

@@ -248,7 +248,7 @@ Tre ritocchi minori ma con qualche implicazione:
`permission:settings.manage` e il link in sidebar è sotto `permission:settings.manage` e il link in sidebar è sotto
`@can('settings.manage')`. `@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`, L'export XML da solo non ricostruisce un'installazione: `pdf_path`,
`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un `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 - **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 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 `settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge
dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero
per la memoria del processo. 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 - **Ordine del restore: staging → database → file.** I file si estraggono
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi fuori limite fallisce mentre i dati correnti sono ancora intatti; poi

View File

@@ -399,11 +399,16 @@ Un aggiornamento ordinario non richiede alcun intervento manuale.
### Dall'applicazione: pacchetto `.zip` (consigliato) ### 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, 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 miniature, logo). È la via da usare per spostare o rimettere in piedi
un'installazione senza toccare i container. 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 Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i
dati gestionali correnti *e* i file caricati. 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/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. `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 ### Dai volumi: cosa salvare
| Volume | Contenuto | Nel backup | | Volume | Contenuto | Nel backup |
@@ -595,7 +622,10 @@ docker compose --profile dev up -d --build
### Backup e import ### Backup e import
- **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF - **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF
territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile 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 - 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) - Import ed export XML dell'intero set di dati gestionali (senza i file)
- Conversione di dump SQL legacy in XML compatibile - Conversione di dump SQL legacy in XML compatibile
- Import massivo dei PDF dei territori da cartella o archivio ZIP - Import massivo dei PDF dei territori da cartella o archivio ZIP

View File

@@ -3,7 +3,9 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\RestoreBackupArchive;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
/** /**
@@ -18,11 +20,17 @@ class PasswordChangeController extends Controller
{ {
public function show() public function show()
{ {
$user = auth()->user();
return view('auth.password-change', [ return view('auth.password-change', [
'user' => auth()->user(), 'user' => $user,
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro; // Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
// chi e' stato dirottato dal middleware no. // 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),
]); ]);
} }

View File

@@ -3,61 +3,79 @@
namespace App\Http\Controllers\Settings; namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Livewire\Settings\XmlExchange; use App\Jobs\CreateBackupArchive;
use App\Services\BackupArchive; use App\Jobs\RestoreBackupArchive;
use App\Services\BackupOperationState;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
/** /**
* Backup e ripristino dell'intera applicazione in un unico pacchetto .zip: * Trasferimento dei file di backup: consegna dell'archivio prodotto e
* dati gestionali, utenti e file caricati. * ricezione di quello da ripristinare.
* *
* Non e' un componente Livewire come il resto della pagina Import: lo scarico * Non e' un componente Livewire come il resto della pagina: lo `streamDownload`
* di un Livewire streamDownload passa il file base64 dentro la risposta JSON, * di Livewire passa il file base64 dentro la risposta JSON, e un archivio con
* e un archivio con tutti i PDF dei territori non ci sta. Sono due rotte HTTP * tutti i PDF dei territori non ci sta. L'avvio delle operazioni e la barra di
* normali, gia' protette da `settings.manage` nel gruppo di rotte. * avanzamento vivono invece in Livewire\Settings\BackupManager, che interroga
* BackupOperationState. Entrambe le rotte sono nel gruppo `settings.manage`.
*/ */
class BackupController extends Controller class BackupController extends Controller
{ {
public function __construct( public function __construct(
private readonly BackupArchive $archive, private readonly BackupOperationState $stato,
) { ) {
} }
/** /**
* Tetto di tempo per comprimere/estrarre un archivio con molti PDF. * Consegna l'archivio prodotto da un'operazione di backup e annota che e'
* Sopra c'e' comunque `fastcgi_read_timeout 300` in docker/nginx/ * stato servito.
* default.conf: alzare questo valore da solo non serve a nulla. *
* 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 file(string $operationId)
public function download(XmlExchange $exchange)
{ {
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() return response()
->download($path, $name, ['Content-Type' => 'application/zip']) ->download($percorso, $nome, [
->deleteFileAfterSend(true); '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 * Risponde JSON perche' il form viene inviato via XMLHttpRequest: e' l'unico
* archivio corrotto fallisce mentre i dati correnti sono ancora intatti), * modo di avere una barra di caricamento reale sull'upload, che per un
* poi si riscrive il database in transazione, poi si sostituiscono i file. * archivio di centinaia di MB e' la parte piu' lunga dell'operazione.
* L'inverso lascerebbe il disco pieno di file di un'installazione e il
* database di un'altra.
*/ */
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 // 256 MB e' il tetto reale della pila, non una scelta di questa
// validazione: piu' in su intervengono prima `client_max_body_size` di // validazione: piu' in su intervengono prima `client_max_body_size` di
// nginx e `upload_max_filesize`/`post_max_size` di PHP, che rifiutano // 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.', 'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.',
]); ]);
$staging = null; $operationId = (string) Str::uuid();
try { // Il file dell'upload vive in una cartella temporanea che sparisce a
$extracted = $this->archive->extract($request->file('backupZip')->getRealPath()); // fine richiesta: il job lo troverebbe gia' cancellato.
$staging = $extracted['staging']; $percorso = $request->file('backupZip')->storeAs(
'backup-restore-in',
$result = $exchange->importXmlFromContent($extracted['xml']); $operationId . '.zip',
'local'
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
); );
return redirect()->route('password.change') $this->stato->initialize(
->with('success', $message) $operationId,
->with('importStats', $stats) BackupOperationState::TIPO_RESTORE,
->with('importIssues', $result['issues']); 'In coda'
} );
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
/** RestoreBackupArchive::dispatch(
* Blocca ogni account finche' non sceglie una password nuova, $operationId,
* l'amministratore che sta ripristinando compreso. storage_path('app/' . $percorso),
* (int) auth()->id()
* 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. return response()->json(['operation_id' => $operationId]);
* 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,
]);
} }
} }

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Jobs;
use App\Livewire\Settings\XmlExchange;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Costruisce il pacchetto di backup in coda.
*
* In coda e non nella richiesta perche' un'installazione con qualche centinaio
* di PDF supera comodamente i 300 secondi di `fastcgi_read_timeout`: il browser
* si vedrebbe chiudere la connessione a meta' compressione, senza modo di
* distinguerlo da un errore.
*/
class CreateBackupArchive implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
/** Sotto il --timeout=600 del worker: se sfora, e' il worker a ucciderlo. */
public int $timeout = 570;
/**
* @param bool $perAzzeramento Quando true il job non chiude l'operazione
* ma la lascia in attesa del download: e'
* l'azzeramento che aspetta di avere
* l'archivio al sicuro prima di partire.
*/
public function __construct(
public string $operationId,
public bool $perAzzeramento = false,
) {
}
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
{
$stato->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);
}
}
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Jobs;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\Tipologia;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
* nessun file caricato, impostazioni ai valori di default, ruoli e permessi
* riseminati.
*
* Non e' reversibile e non fa backup da solo: parte solo dopo che l'archivio
* generato da CreateBackupArchive risulta scaricato per intero (vedi
* BackupController::file), cosi' non esiste una via che azzeri senza che una
* copia sia gia' al sicuro sul computer di chi ha premuto il pulsante.
*/
class ResetApplication implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 570;
/**
* @param int $actorId L'amministratore che ha chiesto l'azzeramento.
* E' l'unico account che sopravvive: un'installazione
* appena creata ha un amministratore, e cancellarli
* tutti lascerebbe l'applicazione senza nessuno che
* possa accedere fino al riavvio del container.
*/
public function __construct(
public string $operationId,
public int $actorId,
) {
}
public function handle(BackupOperationState $stato, BackupArchive $archivio): void
{
$stato->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;
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Jobs;
use App\Livewire\Settings\XmlExchange;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Ripristina un pacchetto di backup in coda, riportando l'avanzamento fase per
* fase (estrazione, database, file).
*/
class RestoreBackupArchive implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 570;
/** Chiave con cui la schermata di cambio password ritrova l'esito. */
public const CHIAVE_ESITO = 'restore-esito:';
public function __construct(
public string $operationId,
public string $zipPath,
public int $actorId,
) {
}
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
{
$stato->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();
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Livewire\Settings;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ResetApplication;
use App\Services\BackupOperationState;
use Livewire\Component;
/**
* Avvio e monitoraggio delle operazioni lunghe: backup, ripristino,
* azzeramento.
*
* Il lavoro vero sta nei job; qui si avvia e si interroga lo stato. La barra
* mostra `processed/total` dell'operazione in corso, cioe' unita' di lavoro
* davvero completate — file finiti nell'archivio, file estratti, tabelle
* svuotate — non un'animazione a tempo.
*/
class BackupManager extends Component
{
/** Operazione seguita in questo momento; null quando non c'e' nulla in corso. */
public ?string $operationId = null;
public array $operazione = [];
/** Frase da digitare per sbloccare l'azzeramento. */
public const CONFERMA_AZZERAMENTO = 'AZZERA TUTTO';
public string $confermaReset = '';
public function mount(): void
{
$this->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');
}
}

View File

@@ -45,53 +45,137 @@ class BackupArchive
private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024; private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024;
/** /**
* Costruisce il pacchetto e ne restituisce il percorso assoluto. Il file e' * Quante volte l'archivio viene chiuso e riaperto mentre lo si costruisce.
* temporaneo: chi chiama lo consegna al browser e lo cancella. *
* 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 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'); $disk = Storage::disk('public');
$files = $disk->allFiles(); $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(); $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.'); throw new RuntimeException('Impossibile creare il file di backup.');
} }
try {
$zip->addFromString(self::ENTRY_DATA, $xml); $zip->addFromString(self::ENTRY_DATA, $xml);
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([ $zip->addFromString(self::ENTRY_MANIFEST, json_encode([
'format_version' => self::FORMAT_VERSION, 'format_version' => self::FORMAT_VERSION,
'application' => 'TerManager2', 'application' => 'TerManager2',
'generated_at' => now()->toIso8601String(), 'generated_at' => now()->toIso8601String(),
'app_url' => (string) config('app.url'), 'app_url' => (string) config('app.url'),
'files_count' => count($files), 'files_count' => $totale,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// addFile() legge dal disco in fase di close(): i PDF non passano if (! $zip->close()) {
// mai interi per la memoria del processo, per quanto sia grande @unlink($destinazione);
// l'archivio.
foreach ($files as $relativePath) { throw new RuntimeException('Scrittura del file di backup non riuscita.');
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
} }
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) { } catch (\Throwable $e) {
$zip->close(); $zip->close();
@unlink($path);
throw $e; throw $e;
} }
if (! $zip->close()) { 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.'); 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 * l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
* dati correnti sono ancora intatti. * dati correnti sono ancora intatti.
* *
* @param callable|null $onProgress fn(int $fatti, int $totale): void
* @return array{xml: string, staging: string, files: int} * @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(); $zip = new ZipArchive();
if ($zip->open($zipPath) !== true) { if ($zip->open($zipPath) !== true) {
@@ -148,6 +233,11 @@ class BackupArchive
$totalBytes = 0; $totalBytes = 0;
$extracted = 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++) { for ($index = 0; $index < $zip->numFiles; $index++) {
$entryName = $zip->getNameIndex($index); $entryName = $zip->getNameIndex($index);
@@ -208,6 +298,7 @@ class BackupArchive
fclose($target); fclose($target);
$extracted++; $extracted++;
$onProgress && $onProgress($extracted, $daEstrarre);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$zip->close(); $zip->close();
@@ -228,21 +319,25 @@ class BackupArchive
* righe del database, che il ripristino ha appena riscritto per intero. * righe del database, che il ripristino ha appena riscritto per intero.
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati * Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge. * 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'); $disk = Storage::disk('public');
foreach ($disk->directories() as $directory) { $this->svuotaDiscoPubblico();
$disk->deleteDirectory($directory);
}
$disk->delete($disk->files());
if (! is_dir($staging)) { if (! is_dir($staging)) {
return 0; return 0;
} }
$restored = 0; $restored = 0;
$totale = iterator_count(new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
));
$onProgress && $onProgress(0, $totale);
$iterator = new \RecursiveIteratorIterator( $iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS) new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
); );
@@ -267,11 +362,56 @@ class BackupArchive
} }
$restored++; $restored++;
$onProgress && $onProgress($restored, $totale);
} }
return $restored; 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 public function deleteDirectory(string $path): void
{ {
if (! is_dir($path)) { if (! is_dir($path)) {

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
/**
* Stato di avanzamento delle operazioni lunghe di backup, ripristino e
* azzeramento.
*
* Le tre operazioni girano in coda e la pagina le segue interrogando questo
* stato: `total` e `processed` contano unita' di lavoro davvero completate
* (file scritti nell'archivio, file estratti, tabelle svuotate), non un
* temporizzatore. La barra si muove quando qualcosa e' finito sul disco.
*
* Stessa impostazione di TerritorioPdfImportState, con cui condivide anche il
* TTL: lo stato sopravvive un giorno, quindi ricaricare la pagina dopo un
* ripristino lungo mostra ancora l'esito.
*/
class BackupOperationState
{
public const TIPO_BACKUP = 'backup';
public const TIPO_RESTORE = 'restore';
public const TIPO_RESET = 'reset';
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
protected int $ttlSeconds = 86400;
/** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */
private const MAX_LOG = 60;
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
{
$state = [
'id' => $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;
}
}

View File

@@ -15,6 +15,12 @@
</div> </div>
@endif @endif
@if($esitoRipristino)
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
{{ $esitoRipristino }}
</div>
@endif
@if($forced) @if($forced)
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800"> <div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<strong>Ripristino da backup eseguito.</strong> <strong>Ripristino da backup eseguito.</strong>

View File

@@ -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
<div class="space-y-6"
x-data="{
zipSelezionato: '',
uploadInCorso: false,
uploadPercento: 0,
erroreUpload: '',
inviaRipristino(event) {
const form = event.target;
const formData = new FormData(form);
this.erroreUpload = '';
this.uploadInCorso = true;
this.uploadPercento = 0;
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
this.uploadPercento = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
this.uploadInCorso = false;
let payload = {};
try { payload = JSON.parse(xhr.responseText || '{}'); } catch (e) { payload = {}; }
if (xhr.status >= 200 && xhr.status < 300 && payload.operation_id) {
this.uploadPercento = 100;
this.zipSelezionato = '';
form.reset();
// Da qui in poi l'avanzamento arriva dal job, non dall'upload.
$wire.seguiOperazione(payload.operation_id);
return;
}
this.erroreUpload = payload.message
|| (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.');
});
xhr.addEventListener('error', () => {
this.uploadInCorso = false;
this.erroreUpload = 'Errore di rete durante il caricamento del pacchetto.';
});
xhr.send(formData);
},
}"
x-on:scarica-backup.window="window.location.href = $event.detail.url">
{{-- ─── Pannello di avanzamento ────────────────────────────────── --}}
@if($operationId && $op)
<div class="rounded-xl border p-5
{{ $stato === 'failed' ? 'border-red-300 bg-red-50' : ($stato === 'completed' ? 'border-green-300 bg-green-50' : 'border-indigo-200 bg-indigo-50') }}"
@if($this->inCorso) wire:poll.750ms="aggiorna" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900">
{{ $stato === 'completed' ? 'Operazione completata' : ($stato === 'failed' ? 'Operazione fallita' : $etichettaTipo) }}
</div>
<div class="mt-0.5 text-xs text-gray-600 truncate">
{{ $op['fase'] ?? '' }}
@if(($op['total'] ?? 0) > 0 && $stato !== 'completed')
&mdash; {{ $op['processed'] ?? 0 }} / {{ $op['total'] }}
@endif
</div>
</div>
<div class="text-2xl font-bold tabular-nums {{ $stato === 'failed' ? 'text-red-700' : 'text-indigo-700' }}">
{{ $this->percentuale }}%
</div>
</div>
<div class="mt-3 h-2.5 overflow-hidden rounded-full bg-white/70">
<div class="h-full rounded-full transition-all duration-300
{{ $stato === 'failed' ? 'bg-red-600' : ($stato === 'completed' ? 'bg-green-600' : 'bg-indigo-600') }}"
style="width: {{ $this->percentuale }}%"></div>
</div>
@if($attesaDownload)
<div class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-4 py-3 space-y-3">
<p class="text-sm text-amber-900">
<strong>L&rsquo;archivio &egrave; pronto e il download &egrave; partito.</strong>
Non &egrave; stato cancellato ancora nulla: l&rsquo;azzeramento aspetta la tua conferma.
</p>
<p class="text-xs text-amber-800">
Apri il file scaricato e verifica che sia integro. Il server sa di aver <em>spedito</em>
l&rsquo;archivio, non pu&ograve; sapere se &egrave; arrivato intero: quella verifica tocca a te,
ed &egrave; l&rsquo;ultima possibilit&agrave; di tornare indietro.
</p>
<div class="flex flex-wrap items-center gap-3">
<a href="{{ route('backup.file', $operationId) }}"
style="display:inline-flex;align-items:center;gap:6px;background:#fff;color:#92400e;border:1px solid #d97706;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
Scarica di nuovo
</a>
<button type="button"
wire:click="confermaAzzeramento"
wire:confirm="Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente."
style="display:inline-flex;align-items:center;gap:6px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;">
Ho salvato il backup, azzera tutto
</button>
<button type="button" wire:click="annullaAzzeramento" class="text-xs text-gray-700 underline">
Annulla l&rsquo;azzeramento
</button>
</div>
@error('confermaReset') <p class="text-xs font-medium text-red-700">{{ $message }}</p> @enderror
</div>
@endif
@if($stato === 'failed')
<p class="mt-3 text-sm text-red-800">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && ($op['result']['messaggio'] ?? null))
<p class="mt-3 text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['file'] ?? null))
<div class="mt-3 flex flex-wrap items-center gap-3">
<a href="{{ route('backup.file', $operationId) }}"
style="display:inline-flex;align-items:center;gap:6px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
Scarica di nuovo
</a>
<span class="text-xs text-gray-600">
{{ $op['result']['nome_download'] ?? '' }} &mdash;
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
</span>
</div>
@endif
@if(!empty($op['logs']))
<details class="mt-3">
<summary class="cursor-pointer text-xs font-medium text-gray-700">Dettaglio</summary>
<textarea readonly rows="8" class="mt-2 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ implode(PHP_EOL, $op['logs']) }}</textarea>
</details>
@endif
@if(in_array($stato, ['completed', 'failed'], true))
<button type="button" wire:click="chiudi" class="mt-3 text-xs text-gray-600 underline">Chiudi</button>
@endif
</div>
@endif
{{-- ─── Backup ─────────────────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 class="text-lg font-semibold text-gray-900">Backup completo (.zip)</h2>
<p class="text-xs text-gray-500 mt-1">
Un unico pacchetto con <strong>tutti</strong> i dati dell&rsquo;applicazione &mdash; utenti compresi &mdash;
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
E&rsquo; l&rsquo;archivio da conservare per rimettere in piedi l&rsquo;installazione da zero.
</p>
</div>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> 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.
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button type="button" wire:click="avviaBackup" @disabled($this->inCorso)
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Crea e scarica backup
</button>
<p class="text-xs text-gray-500">L&rsquo;archivio si genera in background: puoi seguire l&rsquo;avanzamento qui sopra.</p>
</div>
</div>
{{-- ─── Ripristino ─────────────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Ripristina da backup</h2>
<p class="text-xs text-gray-500 mt-1">
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
gestionali correnti e tutti i file caricati.
</p>
</div>
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-xs text-red-800">
<strong>Al termine tutti dovranno cambiare password</strong>, amministratore compreso: il pacchetto
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono piu&rsquo; da
considerarsi segrete. Verrai portato subito sulla schermata di cambio password.
</div>
<form action="{{ route('backup.restore') }}" method="POST" enctype="multipart/form-data" class="space-y-4"
@submit.prevent="if (confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.')) inviaRipristino($event)">
@csrf
<div>
<input name="backupZip" type="file" accept=".zip,application/zip"
x-on:change="zipSelezionato = $event.target.files[0] ? $event.target.files[0].name : ''; erroreUpload = ''"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
<p x-show="erroreUpload" x-cloak class="text-red-500 text-xs mt-1" x-text="erroreUpload"></p>
</div>
{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}
<div x-show="uploadInCorso || uploadPercento > 0" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploadInCorso ? 'Caricamento del pacchetto...' : 'Pacchetto caricato'"></span>
<span class="tabular-nums" x-text="uploadPercento + '%'"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-200" :style="'width:' + uploadPercento + '%'"></div>
</div>
</div>
<button type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#dc2626;color:#fff;border:1px solid #991b1b;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
x-bind:style="(!zipSelezionato || uploadInCorso) ? 'opacity:.5;cursor:not-allowed;' : ''">
Ripristina backup
</button>
</form>
</div>
{{-- ─── Zona pericolosa ────────────────────────────────────────── --}}
<div class="rounded-xl border-2 border-red-300 bg-red-50/40 p-6 space-y-4">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 flex-none text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
<div>
<h2 class="text-lg font-semibold text-red-900">Zona pericolosa</h2>
<p class="text-xs text-red-800 mt-1">Operazioni irreversibili. Leggi prima di premere.</p>
</div>
</div>
<div class="rounded-lg border border-red-300 bg-white p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Azzera tutto e riparti da zero</h3>
<p class="text-xs text-gray-600">
Riporta l&rsquo;applicazione allo stato di installazione appena eseguita. In ordine:
</p>
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ciò che c&rsquo;è adesso.</li>
<li>L&rsquo;operazione si ferma e aspetta: <strong>controlli il file scaricato e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e <strong>tutti i file caricati</strong>.</li>
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale &mdash; come un&rsquo;installazione nuova, che ha un amministratore solo.</li>
</ol>
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
Il backup scaricato è l&rsquo;<strong>unico</strong> modo per tornare indietro. Verifica di averlo
salvato prima di chiudere il browser.
</div>
<div>
<label for="confermaReset" class="block text-xs font-medium text-gray-700">
Per confermare, scrivi <code class="rounded bg-gray-100 px-1 py-0.5 font-mono">{{ \App\Livewire\Settings\BackupManager::CONFERMA_AZZERAMENTO }}</code>
</label>
<input wire:model="confermaReset" type="text" id="confermaReset" autocomplete="off"
class="mt-1 block w-full max-w-xs rounded-lg border-gray-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm font-mono">
@error('confermaReset') <p class="text-red-600 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="button"
wire:click="avviaAzzeramento"
wire:confirm="Ultima conferma. Verrà scaricato un backup e poi l'applicazione sarà azzerata. L'operazione non è reversibile."
@disabled($this->inCorso)
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:600;"
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.87 12.14A2 2 0 0116.14 21H7.86a2 2 0 01-1.99-1.86L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3M4 7h16"/></svg>
Scarica backup e azzera tutto
</button>
</div>
</div>
</div>

View File

@@ -4,62 +4,12 @@
<p class="text-sm text-gray-500 mt-1">Backup completo dell&rsquo;applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p> <p class="text-sm text-gray-500 mt-1">Backup completo dell&rsquo;applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p>
</div> </div>
{{-- Backup completo: e' la sezione che serve nel 99% dei casi, quindi sta {{-- Backup, ripristino e zona pericolosa: componente a sé perché ha uno
in cima. Le sezioni XML piu' sotto restano per gli usi parziali e per stato proprio (l'operazione in corso) e un poll che non deve far
la conversione dei dump legacy. --}} ri-renderizzare tutto il resto della pagina. Sta in cima perché è la
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5"> sezione che serve nel 99% dei casi; le sezioni XML più sotto restano
<div> per gli usi parziali e per la conversione dei dump legacy. --}}
<h2 class="text-lg font-semibold text-gray-900">Backup completo (.zip)</h2> <livewire:settings.backup-manager />
<p class="text-xs text-gray-500 mt-1">
Un unico pacchetto con <strong>tutti</strong> i dati dell&rsquo;applicazione &mdash; utenti compresi &mdash;
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
E&rsquo; l&rsquo;archivio da conservare per rimettere in piedi l&rsquo;installazione da zero.
</p>
</div>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> 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.
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<a href="{{ route('backup.download') }}"
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;text-decoration:none;">
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Scarica backup completo
</a>
<p class="text-xs text-gray-500">La preparazione dell&rsquo;archivio puo&rsquo; richiedere qualche minuto se ci sono molti PDF.</p>
</div>
<div class="border-t border-gray-200 pt-5">
<h3 class="text-sm font-semibold text-gray-900">Ripristina da backup</h3>
<p class="mt-1 text-xs text-gray-500">
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
gestionali correnti e tutti i file caricati.
</p>
<div class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-xs text-red-800">
<strong>Al termine tutti dovranno cambiare password</strong>, amministratore compreso: il pacchetto
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono piu&rsquo; da
considerarsi segrete. Verrai portato subito sulla schermata di cambio password.
</div>
<form action="{{ route('backup.restore') }}" method="POST" enctype="multipart/form-data" class="mt-4 space-y-4"
onsubmit="return confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.');">
@csrf
<div>
<input name="backupZip" type="file" accept=".zip,application/zip"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
@error('backupZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="submit"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#dc2626;color:#fff;border:1px solid #991b1b;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;">
Ripristina backup
</button>
</form>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4" <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
x-data="{ x-data="{

View File

@@ -163,9 +163,11 @@ Route::middleware('auth')->group(function () {
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml'); Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip'); Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
// Backup completo (.zip): dati, utenti e file caricati. // Backup completo (.zip): dati, utenti e file caricati. L'avvio delle
Route::get('backup/download', [BackupController::class, 'download'])->name('backup.download'); // operazioni e la barra di avanzamento stanno in Livewire
Route::post('backup/restore', [BackupController::class, 'restore'])->name('backup.restore'); // (Settings\BackupManager); qui passa solo il trasferimento dei file.
Route::get('backup/file/{operationId}', [BackupController::class, 'file'])->name('backup.file');
Route::post('backup/restore', [BackupController::class, 'upload'])->name('backup.restore');
}); });
// Privacy / Informativa GDPR — visibile solo all'amministratore. // Privacy / Informativa GDPR — visibile solo all'amministratore.