++ consolidamento funzionalità Backup

This commit is contained in:
2026-07-29 16:34:11 +02:00
parent fd15903c9c
commit 7ac454cd25
16 changed files with 585 additions and 30 deletions

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* Rimuove gli archivi di backup e i pacchetti caricati piu' vecchi di un
* giorno.
*
* Sono file grossi che vivono nel volume `storage_data`, lo stesso dei PDF dei
* territori: un download mai completato o un ripristino interrotto ne lascia
* uno indietro, e senza pulizia si accumulano finche' il volume non si riempie
* — a quel punto smettono di funzionare anche l'import dei PDF e i log.
*
* La creazione di un nuovo backup fa gia' la sua pulizia, ma solo se qualcuno
* ne crea uno: un'installazione che non tocca piu' la pagina resterebbe con i
* residui addosso per sempre. Da qui il task pianificato.
*/
class BackupCleanup extends Command
{
protected $signature = 'backup:cleanup {--ore=24 : Eta minima, in ore, dei file da rimuovere}';
protected $description = 'Rimuove archivi di backup e pacchetti di ripristino piu\' vecchi di N ore';
/** Cartelle sotto storage/app in cui vivono i file temporanei del backup. */
private const CARTELLE = [
'backups' => '*.zip',
'backup-restore-in' => '*.zip',
];
public function handle(): int
{
$ore = max(1, (int) $this->option('ore'));
$limite = now()->subHours($ore)->getTimestamp();
$rimossi = 0;
$liberati = 0;
foreach (self::CARTELLE as $cartella => $pattern) {
$percorso = storage_path('app/' . $cartella);
if (! is_dir($percorso)) {
continue;
}
foreach (glob($percorso . '/' . $pattern) ?: [] as $file) {
if (filemtime($file) >= $limite) {
continue;
}
$dimensione = filesize($file) ?: 0;
if (@unlink($file)) {
$rimossi++;
$liberati += $dimensione;
}
}
}
// Le cartelle di staging del ripristino: il job le cancella nel
// finally, ma un processo ucciso a meta' non arriva a eseguirlo.
$staging = storage_path('app/backup-restore');
if (is_dir($staging)) {
foreach (glob($staging . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (filemtime($dir) < $limite) {
$this->rimuoviCartella($dir);
$rimossi++;
}
}
}
$this->info(sprintf(
'Pulizia backup: %d elementi rimossi, %.1f MB liberati.',
$rimossi,
$liberati / 1048576
));
return self::SUCCESS;
}
private function rimuoviCartella(string $path): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
/** @var \SplFileInfo $item */
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
}
@rmdir($path);
}
}

View File

@@ -29,8 +29,15 @@ class PasswordChangeController extends Controller
'forced' => (bool) $user->password_change_required,
// Il ripristino gira in coda e non ha una sessione in cui lasciare
// un flash; deposita l'esito in cache a nome di chi l'ha avviato e
// lo si consuma qui, che e' la prima pagina che quella persona vede.
'esitoRipristino' => Cache::pull(RestoreBackupArchive::CHIAVE_ESITO . $user->id),
// lo si legge qui, che e' la prima pagina che quella persona vede.
//
// Si legge senza consumare, e si cancella solo a password cambiata.
// Con un pull, il messaggio non arriverebbe mai a schermo: quando e'
// il middleware a dirottare una richiesta di Livewire, il client
// segue il 302 via fetch — ed e' quella fetch a bruciare il
// messaggio — per poi rifare la stessa GET con window.location, che
// troverebbe la cache gia' vuota.
'esitoRipristino' => Cache::get(RestoreBackupArchive::CHIAVE_ESITO . $user->id),
]);
}
@@ -66,6 +73,10 @@ class PasswordChangeController extends Controller
$user->setRememberToken(null);
$user->save();
// L'esito del ripristino ha esaurito il suo scopo: e' stato letto, e la
// password richiesta e' stata impostata.
Cache::forget(RestoreBackupArchive::CHIAVE_ESITO . $user->id);
activity()->causedBy($user)->log('password changed');
return redirect()->route('dashboard')

View File

@@ -88,6 +88,15 @@ class BackupController extends Controller
'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.',
]);
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
// una porta d'ingresso separata: un ripristino avviato mentre gira un
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando.
if ($this->stato->corrente() !== null) {
return response()->json([
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.',
], 409);
}
$operationId = (string) Str::uuid();
// Il file dell'upload vive in una cartella temporanea che sparisce a

View File

@@ -87,6 +87,25 @@ class CreateBackupArchive implements ShouldQueue
$stato->markCompleted($this->operationId, $risultato);
}
/**
* Chiamato da Laravel quando il job muore per cause che handle() non puo'
* intercettare: il timeout del worker (che uccide il processo), un errore
* fatale, una dipendenza non risolvibile.
*
* Senza, l'operazione resterebbe "in corso" e terrebbe occupato il posto
* unico fino alla scadenza del puntatore: un'ora in cui nessuno puo'
* avviare un backup e la pagina mostra una barra che non avanzera' mai.
*/
public function failed(?\Throwable $e): void
{
app(BackupOperationState::class)->markFailed(
$this->operationId,
$e?->getMessage() ?: 'Creazione del backup interrotta (timeout o errore del worker).'
);
@unlink(self::percorso($this->operationId));
}
public static function percorso(string $operationId): string
{
return storage_path('app/backups/' . $operationId . '.zip');

View File

@@ -150,6 +150,19 @@ class ResetApplication implements ShouldQueue
$stato->markCompleted($this->operationId, ['messaggio' => $messaggio]);
}
/**
* Vedi CreateBackupArchive::failed(): chiude l'operazione quando il job
* muore per cause che handle() non intercetta, liberando il posto unico.
*/
public function failed(?\Throwable $e): void
{
app(BackupOperationState::class)->markFailed(
$this->operationId,
($e?->getMessage() ?: 'Azzeramento interrotto (timeout o errore del worker).')
. ' Controlla lo stato dei dati prima di ritentare.'
);
}
/**
* Cancella ogni utente tranne chi ha chiesto l'azzeramento.
*

View File

@@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Spatie\Permission\PermissionRegistrar;
/**
* Ripristina un pacchetto di backup in coda, riportando l'avanzamento fase per
@@ -82,6 +83,13 @@ class RestoreBackupArchive implements ShouldQueue
$stato->fase($this->operationId, 'Blocco degli account', 1);
$utenti = $this->richiediCambioPasswordATutti();
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
// caricata continuano a decidere gli accessi con quella di prima
// del ripristino, finche' la cache non scade da sola.
app(PermissionRegistrar::class)->forgetCachedPermissions();
$stato->avanza($this->operationId, 1, 1);
} catch (\Throwable $e) {
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
@@ -125,6 +133,27 @@ class RestoreBackupArchive implements ShouldQueue
]);
}
/**
* Vedi CreateBackupArchive::failed(): chiude l'operazione quando il job
* muore per cause che handle() non intercetta, cosi' il posto unico si
* libera subito invece di restare occupato fino alla scadenza.
*
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi
* con database e file disallineati, e chi legge deve sapere che la strada
* e' rilanciare il ripristino, non ignorare l'errore.
*/
public function failed(?\Throwable $e): void
{
app(BackupOperationState::class)->markFailed(
$this->operationId,
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).')
. ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.'
);
@unlink($this->zipPath);
}
/**
* Blocca ogni account finche' non sceglie una password nuova,
* l'amministratore che sta ripristinando compreso.

View File

@@ -52,8 +52,17 @@ class Profile extends Component
]);
// Il cast `hashed` sul modello User cifra il valore in fase di salvataggio.
//
// Si spegne anche `password_change_required`: oggi chi ha quel flag
// viene dirottato dal middleware e questa pagina non la raggiunge
// nemmeno, ma la condizione che il flag esprime — "questa password non
// e' piu' un segreto" — la soddisfa qualunque cambio, non solo quello
// fatto dalla schermata obbligatoria. Lasciarlo acceso qui
// significherebbe che un domani, allentando il middleware, l'utente
// resterebbe bloccato dopo aver gia' fatto quello che gli si chiedeva.
auth()->user()->update([
'password' => $this->password,
'password_change_required' => false,
]);
$this->reset(['current_password', 'password', 'password_confirmation']);

View File

@@ -48,15 +48,29 @@ class BackupManager extends Component
*/
public string $passwordConferma = '';
/** Messaggio mostrato in cima quando un avvio viene rifiutato. */
public string $erroreOperazione = '';
public function mount(): void
{
$this->authorize('settings.manage');
// Riaggancia un'operazione gia' in corso. Senza, ricaricare la pagina
// durante un backup lungo farebbe sparire la barra, e un azzeramento
// fermo in attesa di conferma diventerebbe irraggiungibile: nessuno
// potrebbe piu' ne' confermarlo ne' annullarlo.
$this->operationId = app(BackupOperationState::class)->corrente();
$this->aggiorna();
}
public function avviaBackup(): void
{
$this->authorize('settings.manage');
if (! $this->postoLibero()) {
return;
}
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
CreateBackupArchive::dispatch($this->operationId, false);
@@ -64,6 +78,32 @@ class BackupManager extends Component
$this->aggiorna();
}
/**
* Vero se non c'e' gia' un'operazione in corso.
*
* Backup, ripristino e azzeramento lavorano tutti sullo stesso database e
* sullo stesso disco: farne partire due insieme — due schede, due
* amministratori — non li rallenta soltanto, li fa sovrascrivere a vicenda.
*/
private function postoLibero(): bool
{
$inCorso = app(BackupOperationState::class)->corrente();
if ($inCorso === null) {
$this->erroreOperazione = '';
return true;
}
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
// premuto il pulsante vuole comunque vedere a che punto e'.
$this->operationId = $inCorso;
$this->aggiorna();
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
return false;
}
/**
* Primo tempo dell'azzeramento: si costruisce il backup e lo si scarica.
* La cancellazione non parte da qui — serve il secondo tempo,
@@ -79,6 +119,10 @@ class BackupManager extends Component
'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.']
);
if (! $this->postoLibero()) {
return;
}
$this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
@@ -316,6 +360,21 @@ class BackupManager extends Component
&& in_array($this->operazione['status'] ?? '', ['queued', 'running'], true);
}
/**
* Ogni quanto reinterrogare lo stato.
*
* Mentre un job lavora servono aggiornamenti fitti, la barra deve muoversi.
* In attesa-download invece si aspetta una persona, che puo' metterci
* minuti: continuare a bussare ogni 750ms vorrebbe dire migliaia di
* richieste inutili se qualcuno lascia la scheda aperta e se ne va.
*/
public function getIntervalloPollProperty(): string
{
return ($this->operazione['fase'] ?? null) === BackupOperationState::FASE_ATTESA_DOWNLOAD
? '5s'
: '750ms';
}
public function render()
{
return view('livewire.settings.backup-manager');

View File

@@ -5,6 +5,8 @@ namespace App\Livewire\Territori;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Territorio;
use App\Services\TerritorioThumbnailService;
use Illuminate\Support\Facades\Storage;
class TerritorioCestino extends Component
{
@@ -36,10 +38,36 @@ class TerritorioCestino extends Component
activity()->causedBy(auth()->user())
->withProperties(['numero' => $territorio->numero])
->log('force_deleted_territorio');
// I file vanno via con la riga. Cancellare solo il record lascerebbe la
// mappa e la miniatura sul volume senza piu' nulla che le riferisca:
// invisibili in ogni schermata, non raggiungibili da nessuna
// cancellazione futura, e comunque dentro ogni backup successivo.
// Prima della riga, non dopo: se la cancellazione dei file fallisce,
// il territorio resta nel cestino e l'operazione si puo' ritentare.
$this->rimuoviFile($territorio);
$territorio->forceDelete();
session()->flash('success', 'Territorio eliminato definitivamente.');
}
/**
* Rimuove mappa e miniatura di un territorio dal disco `public`.
*
* Stessa coppia di cancellazioni che fa TerritorioEdit quando si sostituisce
* il PDF; qui e' definitiva perche' non resta una riga a cui riattaccarle.
*/
private function rimuoviFile(Territorio $territorio): void
{
if ($territorio->pdf_path) {
Storage::disk('public')->delete($territorio->pdf_path);
}
if ($territorio->thumbnail_path) {
app(TerritorioThumbnailService::class)->delete($territorio->thumbnail_path);
}
}
public function render()
{
return view('livewire.territori.territorio-cestino', [

View File

@@ -92,10 +92,12 @@ class BackupArchive
'files_count' => $totale,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
if (! $zip->close()) {
try {
$this->chiudi($zip);
} catch (\Throwable $e) {
@unlink($destinazione);
throw new RuntimeException('Scrittura del file di backup non riuscita.');
throw $e;
}
if ($totale === 0) {
@@ -116,7 +118,19 @@ class BackupArchive
foreach ($files as $relativePath) {
// addFile() legge dal disco durante close(): i PDF non
// passano mai interi per la memoria del processo.
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
//
// Il valore di ritorno si controlla: se il file e' sparito
// fra allFiles() e qui — cancellato da un'altra richiesta,
// o illeggibile per i permessi — addFile() torna false
// senza sollevare, e l'archivio finirebbe con un buco di
// cui nessuno si accorge fino al giorno del ripristino.
if (! $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
throw new RuntimeException(
'Impossibile aggiungere "' . $relativePath . '" al backup: '
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
);
}
$nelLotto++;
$fatti++;
@@ -133,11 +147,62 @@ class BackupArchive
throw $e;
}
try {
$this->verificaArchivio($destinazione, $totale);
} catch (\Throwable $e) {
@unlink($destinazione);
throw $e;
}
$onProgress && $onProgress($totale, $totale);
return $destinazione;
}
/**
* Rilegge l'archivio appena scritto e controlla che contenga quello che
* dichiara.
*
* Un backup e' l'unico file di cui non ci si accorge che era rotto finche'
* non serve. Costa una riapertura e la lettura della directory centrale —
* non dei contenuti — quindi e' un controllo a buon mercato che copre
* troncamenti, chiusure a meta' e lotti persi.
*/
private function verificaArchivio(string $path, int $fileAttesi): void
{
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::RDONLY) !== true) {
throw new RuntimeException('Il backup appena creato non e\' rileggibile.');
}
$trovati = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
$trovati++;
}
}
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
$zip->close();
if (! $haDati || ! $haManifest) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
}
if ($trovati !== $fileAttesi) {
throw new RuntimeException(sprintf(
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
$trovati,
$fileAttesi
));
}
}
/**
* Apre l'archivio in append, esegue il callback e lo chiude, garantendo la
* chiusura anche se il callback solleva.
@@ -152,14 +217,21 @@ class BackupArchive
try {
$callback($zip);
} catch (\Throwable $e) {
$zip->close();
// La chiusura di cortesia non deve poter sostituire l'errore vero:
// dopo un close() fallito l'oggetto ZipArchive resta inutilizzabile
// e ogni metodo successivo solleva "Invalid or uninitialized Zip
// object", che finirebbe sotto gli occhi dell'amministratore al
// posto del messaggio che spiega davvero cos'e' andato storto.
try {
$zip->close();
} catch (\Throwable) {
// gia' chiuso o invalido: non c'e' niente da salvare
}
throw $e;
}
if (! $zip->close()) {
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
$this->chiudi($zip);
}
/**
@@ -169,15 +241,42 @@ class BackupArchive
*/
private function chiudiEriapri(ZipArchive $zip, string $path): void
{
if (! $zip->close()) {
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
$this->chiudi($zip);
if ($zip->open($path) !== true) {
throw new RuntimeException('Impossibile riaprire il file di backup.');
}
}
/**
* Chiude l'archivio riportando il motivo del fallimento.
*
* E' in close() che libzip legge davvero i file e scrive l'archivio, quindi
* e' qui che si manifestano permessi negati, disco pieno e file spariti. Un
* "scrittura non riuscita" secco lascerebbe l'amministratore senza il pezzo
* che serve — quale file, e perche' — che sta in getStatusString().
*/
private function chiudi(ZipArchive $zip): void
{
$motivo = '';
try {
if ($zip->close()) {
return;
}
$motivo = (string) $zip->getStatusString();
} catch (\Throwable $e) {
$motivo = $e->getMessage();
}
throw new RuntimeException(
'Scrittura del file di backup non riuscita'
. ($motivo !== '' ? ': ' . rtrim($motivo, '. ') . '.' : '.')
. ' Cause tipiche: un file non leggibile nel volume storage, o spazio esaurito.'
);
}
/**
* Apre il pacchetto, ne verifica la struttura ed estrae i file in una
* cartella di staging, restituendo l'XML e il percorso dello staging.
@@ -233,9 +332,17 @@ class BackupArchive
$totalBytes = 0;
$extracted = 0;
// Le entry di storage/ sono tutte tranne manifest e data.xml: e' il
// totale su cui si misura l'estrazione.
$daEstrarre = max(0, $zip->numFiles - 2);
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
// due entry di servizio": un archivio con una entry in piu' (una
// cartella dichiarata, un file aggiunto a mano) sfalserebbe la
// barra fino a farla superare il 100%.
$daEstrarre = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
$daEstrarre++;
}
}
$onProgress && $onProgress(0, $daEstrarre);
for ($index = 0; $index < $zip->numFiles; $index++) {

View File

@@ -31,6 +31,23 @@ class BackupOperationState
/** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */
private const MAX_LOG = 60;
/**
* Puntatore all'unica operazione ammessa alla volta.
*
* Backup, ripristino e azzeramento lavorano tutti sullo stesso database e
* sullo stesso disco: due in parallelo — due schede aperte, due
* amministratori — non si limitano a rallentarsi, si sovrascrivono. Un
* ripristino che riscrive le tabelle mentre un azzeramento le cancella
* lascia uno stato che nessuno dei due voleva.
*
* Il TTL e' la valvola di sicurezza: se il worker muore senza chiudere
* l'operazione, il blocco si scioglie da solo entro un'ora invece di
* richiedere un intervento sul server. Prima ancora, corrente() considera
* libero il posto se l'operazione puntata non esiste piu' o e' terminata.
*/
private const CHIAVE_CORRENTE = 'backup-op:corrente';
private const TTL_CORRENTE = 3600;
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
{
$state = [
@@ -48,10 +65,40 @@ class BackupOperationState
];
$this->put($id, $state);
Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE);
return $state;
}
/**
* Id dell'operazione in corso, o null se non ce n'e' nessuna.
*
* Serve a due cose: rifiutare l'avvio di una seconda operazione, e
* riagganciare la barra di avanzamento quando la pagina viene ricaricata —
* senza, un azzeramento fermo in attesa di conferma resterebbe irraggiungibile
* e nessuno potrebbe piu' confermarlo o annullarlo.
*/
public function corrente(): ?string
{
$id = Cache::get(self::CHIAVE_CORRENTE);
if (! is_string($id) || $id === '') {
return null;
}
$stato = $this->get($id);
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
// e' libero, e il puntatore va tolto di mezzo.
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
Cache::forget(self::CHIAVE_CORRENTE);
return null;
}
return $id;
}
public function get(string $id): ?array
{
return Cache::get($this->key($id));
@@ -141,6 +188,8 @@ class BackupOperationState
return $state;
});
$this->liberaCorrente($id);
}
public function markFailed(string $id, string $error): void
@@ -154,6 +203,19 @@ class BackupOperationState
});
$this->log($id, 'ERRORE: ' . $error);
$this->liberaCorrente($id);
}
/**
* Libera il posto, ma solo se e' ancora questa operazione a occuparlo:
* senza il confronto, la chiusura di un'operazione vecchia sbloccherebbe
* quella appena avviata da qualcun altro.
*/
private function liberaCorrente(string $id): void
{
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
Cache::forget(self::CHIAVE_CORRENTE);
}
}
/**