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

@@ -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),
]);
}

View File

@@ -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]);
}
}

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;
/**
* 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)) {

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;
}
}