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

@@ -324,6 +324,35 @@ Scelte non ovvie:
sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account:
nessuno resta chiuso fuori dall'applicazione.
- **Una sola operazione alla volta, con blocco che si autoripara.** Backup,
ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso
disco: due in parallelo — due schede, due amministratori — non si rallentano
soltanto, si sovrascrivono. `BackupOperationState::corrente()` fa da posto
unico; lo controllano sia il componente Livewire sia
`BackupController::upload()`, che sono due porte d'ingresso separate (la
seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e
`corrente()` lo considera libero se l'operazione puntata non esiste più o è
terminata. In più i tre job hanno un `failed()` — Laravel lo chiama quando il
worker uccide il job per timeout o errore fatale, cioè proprio i casi che
`handle()` non può intercettare — che chiude l'operazione e libera il posto
invece di lasciare una barra ferma per un'ora.
- **La pagina si riaggancia all'operazione in corso.** `BackupManager::mount()`
legge `corrente()`. Senza, ricaricare la pagina durante un backup lungo
faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di
conferma diventava irraggiungibile: nessuno poteva più né confermarlo né
annullarlo, e il posto restava occupato fino alla scadenza.
- **L'esito del ripristino si legge senza consumarlo.** Il job non ha una
sessione in cui lasciare un flash, quindi deposita il messaggio in cache a
nome di chi ha avviato il ripristino e la schermata di cambio password lo
mostra. Deve essere un `Cache::get` + `forget` a password cambiata, **non** un
`pull`: quando è il middleware a dirottare una richiesta di Livewire, il
client segue il 302 via `fetch` — ed è quella `fetch` a bruciare il messaggio
— per poi rifare la stessa GET con `window.location`, che troverebbe la cache
già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del
proprio ripristino.
- **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
disco è l'unico passo che può fallire per cause esterne (permessi sul
volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un
@@ -335,6 +364,32 @@ Scelte non ovvie:
worker da www-data) perché l'operazione si dichiari riuscita lasciando il
disco popolato.
- **L'archivio si controlla prima di consegnarlo.** Un backup è l'unico file di
cui non ci si accorge che era rotto finché non serve, quindi
`BackupArchive::create()` non si fida di aver scritto bene: controlla il
valore di ritorno di ogni `addFile()` (che torna `false`, senza sollevare, se
il file è sparito o è illeggibile — l'archivio finirebbe con un buco
silenzioso), e alla fine riapre l'archivio e verifica che contenga
`data.xml`, `manifest.json` e *esattamente* i file attesi. Se qualcosa non
torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun
backup che un backup che sembra buono.
Attenzione a una trappola di `ZipArchive`: dopo un `close()` fallito
l'oggetto resta inutilizzabile e ogni metodo successivo solleva `Invalid or
uninitialized Zip object`. Una chiusura di cortesia dentro un `catch`
sostituiva il messaggio vero con quello, e l'amministratore leggeva una
stringa incomprensibile al posto di «permesso negato su questo file». Da qui
`chiudi()`, che cattura `getStatusString()` prima che vada perso, e i
`close()` di pulizia avvolti in un try/catch che non può mascherare l'errore
originale.
- **Eliminare definitivamente un territorio porta via anche i suoi file.**
`TerritorioCestino::forceDelete()` cancellava la riga e lasciava mappa e
miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da
nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono
*prima* della riga, così se la cancellazione fallisce il territorio resta nel
cestino e l'operazione si può ritentare.
- **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione
appena creata ha un amministratore; cancellarli tutti lascerebbe
l'applicazione senza nessuno che possa accedere fino al riavvio del container
@@ -459,13 +514,37 @@ In ordine di priorità.
### 1. Nessun test automatico
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste**. Ogni
verifica è manuale. È il debito più rilevante: le regole di business (anno
teocratico, stato territorio, priorità automatica, percentuale campagne) sono
esattamente il tipo di logica che va protetta da test.
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste** e non
c'è `phpunit.xml`. Ogni verifica è manuale. È il debito più rilevante: le regole
di business (anno teocratico, stato territorio, priorità automatica, percentuale
campagne) sono esattamente il tipo di logica che va protetta da test — e a
maggior ragione backup, ripristino e azzeramento, dove un errore non si vede
finché non è troppo tardi.
Nota: `config/database.php` ridefinisce `connections` e lascia solo `mysql`.
Per usare SQLite in memoria nei test va riaggiunta la connessione `sqlite`.
Tre blocchi concreti da rimuovere prima di poter scrivere il primo test:
1. **PHPUnit non è nell'immagine.** `docker/php/Dockerfile` installa con
`--no-dev` (giusto, per la produzione), quindi `vendor/phpunit` nel container
non c'è e `docker compose exec app ./vendor/bin/phpunit` non funziona.
Servono o un `composer install` locale sull'host, o uno stage `dev` nel
Dockerfile che tenga le dipendenze di sviluppo.
2. **Manca la connessione SQLite.** `config/database.php` ridefinisce
`connections` e lascia solo `mysql`. Per usare SQLite in memoria va
riaggiunta la connessione `sqlite`.
3. **Manca `phpunit.xml`.** L'autoload `Tests\` in `composer.json` c'è già, il
resto no.
Dove conviene cominciare, in ordine di rapporto valore/attrito — i primi due
non hanno bisogno del database e quindi si sbloccano risolvendo solo il punto 1:
- `Services/BackupArchive` con `Storage::fake('public')`: round-trip
crea → verifica → estrai, rifiuto dello zip slip, rifiuto del formato futuro,
fallimento su archivio incompleto.
- `Services/BackupOperationState` con il driver di cache `array`: il posto
unico, il rilascio alla chiusura, il puntatore orfano, il non-rilascio da
parte di un'operazione vecchia.
- `Livewire/Registro::righeS13()`: il ciclo da quattro e la colonna del
riporto, con i casi 2/3/4/5/8/9 assegnazioni (questi sì, con database).
### 2. Race su doppia assegnazione

View File

@@ -521,10 +521,17 @@ docker exec -it <app> php artisan app:create-initial-admin \
# Pulizia manuale dell'audit log
docker exec -it <app> php artisan audit:cleanup
# Pulizia manuale degli archivi di backup residui (default: più vecchi di 24h)
docker exec -it <app> php artisan backup:cleanup
docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti
# Svuotare le cache
docker exec -it <app> php artisan optimize:clear
```
Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i
comandi qui sopra servono solo per anticiparle.
---
## Troubleshooting
@@ -697,7 +704,7 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione |
|-------------------|--------------------------------------------------------------|
| `users` | Utenti dell'applicazione |
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
| `settings` | Configurazione singleton |
| `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio |
@@ -771,13 +778,14 @@ quando la soglia è scattata.
```
TerManager2/
├── app/
│ ├── Console/Commands/ # audit:cleanup, app:create-initial-admin
│ ├── Http/Controllers/ # PDF pubblici, import, login
│ ├── Jobs/ # Import PDF massivo (in coda)
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin
│ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password
│ ├── Http/Middleware/ # RequirePasswordChange
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
│ ├── Livewire/ # Tutti i componenti di interfaccia
│ ├── Models/ # 9 modelli Eloquent
│ ├── Providers/ # AppServiceProvider
│ └── Services/ # Miniature PDF, stato import
│ └── Services/ # Miniature PDF, stato import, archivio backup
├── bootstrap/app.php # Middleware, proxy e host fidati
├── config/ # Configurazione Laravel
├── database/

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

View File

@@ -77,6 +77,10 @@
return;
}
// 409: c'è già un'operazione in corso. Il pacchetto è stato
// caricato per niente, quindi la barra torna a zero: lasciarla
// al 100% farebbe credere che il ripristino sia partito.
this.uploadPercento = 0;
this.erroreUpload = payload.message
|| (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.');
});
@@ -91,11 +95,17 @@
}"
x-on:scarica-backup.window="window.location.href = $event.detail.url">
@if($erroreOperazione !== '')
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{{ $erroreOperazione }}
</div>
@endif
{{-- ─── 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>
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
@@ -269,12 +279,16 @@
</div>
</div>
@php($bloccato = $this->inCorso)
<button type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso"
x-bind:disabled="!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}"
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;' : ''">
x-bind:style="(!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}) ? 'opacity:.5;cursor:not-allowed;' : ''">
Ripristina backup
</button>
@if($bloccato)
<p class="text-xs text-gray-500">Un&rsquo;altra operazione è in corso: attendi che finisca.</p>
@endif
</form>
</div>

View File

@@ -10,3 +10,8 @@ Artisan::command('inspire', function () {
// Pulizia audit log secondo retention configurata
Schedule::command('audit:cleanup')->daily();
// Archivi di backup e pacchetti di ripristino lasciati indietro da un download
// mai completato o da un job interrotto. Vivono nello stesso volume dei PDF dei
// territori, quindi accumularli finisce per riempirlo.
Schedule::command('backup:cleanup')->daily();

View File

@@ -166,7 +166,14 @@ Route::middleware('auth')->group(function () {
// Backup completo (.zip): dati, utenti e file caricati. L'avvio delle
// operazioni e la barra di avanzamento stanno in Livewire
// (Settings\BackupManager); qui passa solo il trasferimento dei file.
Route::get('backup/file/{operationId}', [BackupController::class, 'file'])->name('backup.file');
// whereUuid non e' decorazione: l'id finisce in
// CreateBackupArchive::percorso(), che lo concatena a un percorso su
// disco. Oggi il controller si ferma prima, perche' un id inventato non
// ha uno stato in cache, ma vincolare il formato toglie di mezzo la
// classe di problema invece di dipendere da quell'ordine di controlli.
Route::get('backup/file/{operationId}', [BackupController::class, 'file'])
->whereUuid('operationId')
->name('backup.file');
Route::post('backup/restore', [BackupController::class, 'upload'])->name('backup.restore');
});