++ 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: sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account:
nessuno resta chiuso fuori dall'applicazione. 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 - **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
disco è l'unico passo che può fallire per cause esterne (permessi sul 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 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 worker da www-data) perché l'operazione si dichiari riuscita lasciando il
disco popolato. 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 - **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione
appena creata ha un amministratore; cancellarli tutti lascerebbe appena creata ha un amministratore; cancellarli tutti lascerebbe
l'applicazione senza nessuno che possa accedere fino al riavvio del container l'applicazione senza nessuno che possa accedere fino al riavvio del container
@@ -459,13 +514,37 @@ In ordine di priorità.
### 1. Nessun test automatico ### 1. Nessun test automatico
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste**. Ogni `composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste** e non
verifica è manuale. È il debito più rilevante: le regole di business (anno c'è `phpunit.xml`. Ogni verifica è manuale. È il debito più rilevante: le regole
teocratico, stato territorio, priorità automatica, percentuale campagne) sono di business (anno teocratico, stato territorio, priorità automatica, percentuale
esattamente il tipo di logica che va protetta da test. 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`. Tre blocchi concreti da rimuovere prima di poter scrivere il primo test:
Per usare SQLite in memoria nei test va riaggiunta la connessione `sqlite`.
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 ### 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 # Pulizia manuale dell'audit log
docker exec -it <app> php artisan audit:cleanup 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 # Svuotare le cache
docker exec -it <app> php artisan optimize:clear 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 ## Troubleshooting
@@ -697,7 +704,7 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione | | Tabella | Descrizione |
|-------------------|--------------------------------------------------------------| |-------------------|--------------------------------------------------------------|
| `users` | Utenti dell'applicazione | | `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
| `settings` | Configurazione singleton | | `settings` | Configurazione singleton |
| `zone` | Zone territoriali | | `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio | | `tipologie` | Tipologie di territorio |
@@ -771,13 +778,14 @@ quando la soglia è scattata.
``` ```
TerManager2/ TerManager2/
├── app/ ├── app/
│ ├── Console/Commands/ # audit:cleanup, app:create-initial-admin │ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin
│ ├── Http/Controllers/ # PDF pubblici, import, login │ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password
│ ├── Jobs/ # Import PDF massivo (in coda) │ ├── Http/Middleware/ # RequirePasswordChange
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
│ ├── Livewire/ # Tutti i componenti di interfaccia │ ├── Livewire/ # Tutti i componenti di interfaccia
│ ├── Models/ # 9 modelli Eloquent │ ├── Models/ # 9 modelli Eloquent
│ ├── Providers/ # AppServiceProvider │ ├── Providers/ # AppServiceProvider
│ └── Services/ # Miniature PDF, stato import │ └── Services/ # Miniature PDF, stato import, archivio backup
├── bootstrap/app.php # Middleware, proxy e host fidati ├── bootstrap/app.php # Middleware, proxy e host fidati
├── config/ # Configurazione Laravel ├── config/ # Configurazione Laravel
├── database/ ├── 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, 'forced' => (bool) $user->password_change_required,
// Il ripristino gira in coda e non ha una sessione in cui lasciare // 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 // 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. // lo si legge qui, che e' la prima pagina che quella persona vede.
'esitoRipristino' => Cache::pull(RestoreBackupArchive::CHIAVE_ESITO . $user->id), //
// 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->setRememberToken(null);
$user->save(); $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'); activity()->causedBy($user)->log('password changed');
return redirect()->route('dashboard') 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.', '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(); $operationId = (string) Str::uuid();
// Il file dell'upload vive in una cartella temporanea che sparisce a // 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); $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 public static function percorso(string $operationId): string
{ {
return storage_path('app/backups/' . $operationId . '.zip'); return storage_path('app/backups/' . $operationId . '.zip');

View File

@@ -150,6 +150,19 @@ class ResetApplication implements ShouldQueue
$stato->markCompleted($this->operationId, ['messaggio' => $messaggio]); $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. * 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\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Spatie\Permission\PermissionRegistrar;
/** /**
* Ripristina un pacchetto di backup in coda, riportando l'avanzamento fase per * 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); $stato->fase($this->operationId, 'Blocco degli account', 1);
$utenti = $this->richiediCambioPasswordATutti(); $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); $stato->avanza($this->operationId, 1, 1);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Ripristino del backup non riuscito.', ['exception' => $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, * Blocca ogni account finche' non sceglie una password nuova,
* l'amministratore che sta ripristinando compreso. * 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. // 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([ auth()->user()->update([
'password' => $this->password, 'password' => $this->password,
'password_change_required' => false,
]); ]);
$this->reset(['current_password', 'password', 'password_confirmation']); $this->reset(['current_password', 'password', 'password_confirmation']);

View File

@@ -48,15 +48,29 @@ class BackupManager extends Component
*/ */
public string $passwordConferma = ''; public string $passwordConferma = '';
/** Messaggio mostrato in cima quando un avvio viene rifiutato. */
public string $erroreOperazione = '';
public function mount(): void public function mount(): void
{ {
$this->authorize('settings.manage'); $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 public function avviaBackup(): void
{ {
$this->authorize('settings.manage'); $this->authorize('settings.manage');
if (! $this->postoLibero()) {
return;
}
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP); $this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
CreateBackupArchive::dispatch($this->operationId, false); CreateBackupArchive::dispatch($this->operationId, false);
@@ -64,6 +78,32 @@ class BackupManager extends Component
$this->aggiorna(); $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. * Primo tempo dell'azzeramento: si costruisce il backup e lo si scarica.
* La cancellazione non parte da qui — serve il secondo tempo, * 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.'] 'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.']
); );
if (! $this->postoLibero()) {
return;
}
$this->confermaReset = ''; $this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET); $this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
@@ -316,6 +360,21 @@ class BackupManager extends Component
&& in_array($this->operazione['status'] ?? '', ['queued', 'running'], true); && 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() public function render()
{ {
return view('livewire.settings.backup-manager'); return view('livewire.settings.backup-manager');

View File

@@ -5,6 +5,8 @@ namespace App\Livewire\Territori;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Territorio; use App\Models\Territorio;
use App\Services\TerritorioThumbnailService;
use Illuminate\Support\Facades\Storage;
class TerritorioCestino extends Component class TerritorioCestino extends Component
{ {
@@ -36,10 +38,36 @@ class TerritorioCestino extends Component
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
->withProperties(['numero' => $territorio->numero]) ->withProperties(['numero' => $territorio->numero])
->log('force_deleted_territorio'); ->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(); $territorio->forceDelete();
session()->flash('success', 'Territorio eliminato definitivamente.'); 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() public function render()
{ {
return view('livewire.territori.territorio-cestino', [ return view('livewire.territori.territorio-cestino', [

View File

@@ -92,10 +92,12 @@ class BackupArchive
'files_count' => $totale, 'files_count' => $totale,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
if (! $zip->close()) { try {
$this->chiudi($zip);
} catch (\Throwable $e) {
@unlink($destinazione); @unlink($destinazione);
throw new RuntimeException('Scrittura del file di backup non riuscita.'); throw $e;
} }
if ($totale === 0) { if ($totale === 0) {
@@ -116,7 +118,19 @@ class BackupArchive
foreach ($files as $relativePath) { foreach ($files as $relativePath) {
// addFile() legge dal disco durante close(): i PDF non // addFile() legge dal disco durante close(): i PDF non
// passano mai interi per la memoria del processo. // 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++; $nelLotto++;
$fatti++; $fatti++;
@@ -133,11 +147,62 @@ class BackupArchive
throw $e; throw $e;
} }
try {
$this->verificaArchivio($destinazione, $totale);
} catch (\Throwable $e) {
@unlink($destinazione);
throw $e;
}
$onProgress && $onProgress($totale, $totale); $onProgress && $onProgress($totale, $totale);
return $destinazione; 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 * Apre l'archivio in append, esegue il callback e lo chiude, garantendo la
* chiusura anche se il callback solleva. * chiusura anche se il callback solleva.
@@ -152,14 +217,21 @@ class BackupArchive
try { try {
$callback($zip); $callback($zip);
} catch (\Throwable $e) { } 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; throw $e;
} }
if (! $zip->close()) { $this->chiudi($zip);
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
} }
/** /**
@@ -169,15 +241,42 @@ class BackupArchive
*/ */
private function chiudiEriapri(ZipArchive $zip, string $path): void private function chiudiEriapri(ZipArchive $zip, string $path): void
{ {
if (! $zip->close()) { $this->chiudi($zip);
throw new RuntimeException('Scrittura del file di backup non riuscita.');
}
if ($zip->open($path) !== true) { if ($zip->open($path) !== true) {
throw new RuntimeException('Impossibile riaprire il file di backup.'); 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 * Apre il pacchetto, ne verifica la struttura ed estrae i file in una
* cartella di staging, restituendo l'XML e il percorso dello staging. * cartella di staging, restituendo l'XML e il percorso dello staging.
@@ -233,9 +332,17 @@ class BackupArchive
$totalBytes = 0; $totalBytes = 0;
$extracted = 0; $extracted = 0;
// Le entry di storage/ sono tutte tranne manifest e data.xml: e' il // Il totale si conta scorrendo i nomi, non come "numFiles meno le
// totale su cui si misura l'estrazione. // due entry di servizio": un archivio con una entry in piu' (una
$daEstrarre = max(0, $zip->numFiles - 2); // 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); $onProgress && $onProgress(0, $daEstrarre);
for ($index = 0; $index < $zip->numFiles; $index++) { 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. */ /** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */
private const MAX_LOG = 60; 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 public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
{ {
$state = [ $state = [
@@ -48,10 +65,40 @@ class BackupOperationState
]; ];
$this->put($id, $state); $this->put($id, $state);
Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE);
return $state; 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 public function get(string $id): ?array
{ {
return Cache::get($this->key($id)); return Cache::get($this->key($id));
@@ -141,6 +188,8 @@ class BackupOperationState
return $state; return $state;
}); });
$this->liberaCorrente($id);
} }
public function markFailed(string $id, string $error): void public function markFailed(string $id, string $error): void
@@ -154,6 +203,19 @@ class BackupOperationState
}); });
$this->log($id, 'ERRORE: ' . $error); $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; 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 this.erroreUpload = payload.message
|| (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.'); || (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"> 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 ────────────────────────────────── --}} {{-- ─── Pannello di avanzamento ────────────────────────────────── --}}
@if($operationId && $op) @if($operationId && $op)
<div class="rounded-xl border p-5 <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') }}" {{ $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="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0"> <div class="min-w-0">
@@ -269,12 +279,16 @@
</div> </div>
</div> </div>
@php($bloccato = $this->inCorso)
<button type="submit" <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;" 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 Ripristina backup
</button> </button>
@if($bloccato)
<p class="text-xs text-gray-500">Un&rsquo;altra operazione è in corso: attendi che finisca.</p>
@endif
</form> </form>
</div> </div>

View File

@@ -10,3 +10,8 @@ Artisan::command('inspire', function () {
// Pulizia audit log secondo retention configurata // Pulizia audit log secondo retention configurata
Schedule::command('audit:cleanup')->daily(); 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 // Backup completo (.zip): dati, utenti e file caricati. L'avvio delle
// operazioni e la barra di avanzamento stanno in Livewire // operazioni e la barra di avanzamento stanno in Livewire
// (Settings\BackupManager); qui passa solo il trasferimento dei file. // (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'); Route::post('backup/restore', [BackupController::class, 'upload'])->name('backup.restore');
}); });