++ 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:
|
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
|
||||||
|
|
||||||
|
|||||||
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
|
# 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/
|
||||||
|
|||||||
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,
|
'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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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', [
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
// 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();
|
$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++) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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’altra operazione è in corso: attendi che finisca.</p>
|
||||||
|
@endif
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user