++ consolidamento funzionalità Backup
This commit is contained in:
91
HANDOFF.md
91
HANDOFF.md
@@ -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
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -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/
|
||||
|
||||
96
app/Console/Commands/BackupCleanup.php
Normal file
96
app/Console/Commands/BackupCleanup.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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’altra operazione è in corso: attendi che finisca.</p>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user