Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri

Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:01:41 +02:00
parent 056d95cfcb
commit 426a95211d
78 changed files with 3971 additions and 429 deletions

View File

@@ -3,8 +3,10 @@
namespace App\Jobs;
use App\Livewire\Settings\XmlExchange;
use App\Models\Setting;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Spatie\Permission\PermissionRegistrar;
/**
@@ -41,14 +44,35 @@ class RestoreBackupArchive implements ShouldQueue
) {
}
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
/**
* Il ripristino e' tutto o niente. Tre tempi:
*
* 1. estrazione e verifica dell'archivio nello staging, piantine.json
* compreso: un archivio rotto si ferma qui, a installazione intatta;
* 2. un'unica transazione con l'import XML, le tabelle delle piantine, il
* blocco degli account e — per ultimo, ancora dentro — lo scambio dei
* file. Se lo scambio fallisce si disfa da solo e la transazione si
* annulla; se fallisce il commit, i file si rimettono a posto qui;
* 3. a commit avvenuto, si buttano i file precedenti.
*
* Prima import, blocco account e file erano tre passi separati: un errore
* sui file lasciava il database gia' riscritto con il disco a meta', e un
* errore sul blocco lasciava attive le password del backup.
*
* Niente TRUNCATE in nessuno dei passi: su MariaDB e' DDL e fa commit
* implicito, chiudendo la transazione a meta'. L'import XML e
* PiantineBackup cancellano con DELETE.
*/
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
{
$stato->markRunning($this->operationId);
$staging = null;
$scambio = null;
$confermato = false;
try {
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
$stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
$estratto = $archivio->extract(
$this->zipPath,
@@ -56,33 +80,46 @@ class RestoreBackupArchive implements ShouldQueue
);
$staging = $estratto['staging'];
$piantine->valida($estratto['piantine']);
$stato->fase($this->operationId, 'Riscrittura del database', 1);
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
// primo id disponibile invece che su chi ha avviato il ripristino.
//
// E' anche cio' che tiene dentro chi ripristina: l'import riconosce
// l'attore e ne conserva hash della password, remember_token e
// ruolo di amministratore. Con AuthenticateSession attivo, un hash
// cambiato chiuderebbe la sua sessione e lo costringerebbe a
// rientrare con la password dell'epoca del backup, che puo' non
// ricordare. Se il suo account nel backup non c'e', l'import (che
// fa upsert per email, senza cancellare nessuno) non lo tocca
// affatto: resta con password e ruoli attuali.
Auth::onceUsingId($this->actorId);
$result = $exchange->importXmlFromContent($estratto['xml']);
DB::transaction(function () use ($stato, $archivio, $exchange, $piantine, $estratto, &$result, &$esitoPiantine, &$utenti, &$scambio) {
// La transazione dell'import diventa un savepoint di questa:
// il suo commit non e' piu' definitivo.
$result = $exchange->importXmlFromContent($estratto['xml']);
if (isset($result['error'])) {
$stato->markFailed($this->operationId, $result['error']);
if (isset($result['error'])) {
throw new RuntimeException($result['error']);
}
return;
}
$esitoPiantine = $piantine->ripristina($estratto['piantine']);
$stato->avanza($this->operationId, 1, 1);
$utenti = $this->richiediCambioPasswordATutti();
$stato->fase($this->operationId, 'Ripristino dei file');
$stato->avanza($this->operationId, 1, 1);
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
$fileRipristinati = $archivio->restoreFiles(
$staging,
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
);
$scambio = $archivio->scambiaFile($estratto);
});
$stato->fase($this->operationId, 'Blocco degli account', 1);
$utenti = $this->richiediCambioPasswordATutti();
$confermato = true;
$archivio->confermaScambio($scambio);
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
$stato->avanza($this->operationId, 1, 1);
} catch (\Throwable $e) {
$messaggio = $e->getMessage();
// Lo scambio era riuscito ma il commit no: il database e' tornato
// indietro, i file devono seguirlo.
if ($scambio !== null && ! $confermato) {
try {
$archivio->annullaScambio($scambio, $e);
} catch (\Throwable $annullamento) {
$messaggio = $annullamento->getMessage();
}
}
// La transazione annullata non svuota le cache in memoria del
// processo: Setting terrebbe l'istanza creata dall'import (una
// riga che non esiste piu') e Spatie i ruoli letti durante l'import.
Setting::flushCache();
app(PermissionRegistrar::class)->forgetCachedPermissions();
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
$stato->markFailed($this->operationId, $e->getMessage());
$stato->markFailed($this->operationId, $messaggio);
return;
} finally {
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
}
$stats = $result['stats'];
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
$fileRipristinati = $scambio['file'];
$messaggio = sprintf(
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni, %d piantine e %d file ripristinati. '
. 'Per sicurezza %s impostare una nuova password.',
$stats['territori_importati'] ?? 0,
$stats['proclamatori_importati'] ?? 0,
$stats['assegnazioni_importate'] ?? 0,
$esitoPiantine['mappe'],
$fileRipristinati,
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
);
// Un archivio di formato 1 non conteneva le piantine: va detto, o
// l'amministratore le cercherebbe senza sapere perche' sono sparite.
if ($estratto['piantine'] === null) {
$stato->log(
$this->operationId,
'Archivio di un formato precedente: non contiene piantine e storico dei PDF, che restano vuoti. '
. 'L\'area KML in uso non e\' stata toccata.'
);
}
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
// il middleware dirotta l'amministratore sul cambio password. Lo si
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
$stato->log($this->operationId, $messaggio);
$stato->markCompleted($this->operationId, [
'stats' => $stats,
'issues' => array_slice($result['issues'], 0, 50),
'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
'file_ripristinati' => $fileRipristinati,
'messaggio' => $messaggio,
]);
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
* 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.
* Qui il messaggio dice anche in che stato si e' rimasti. Un processo
* ucciso a meta' non arriva al commit, e il database annulla da se' la
* transazione rimasta aperta: i dati restano quelli di prima. L'unica
* finestra scoperta e' quella, di pochi millisecondi, fra lo scambio dei
* file e il commit — in quel caso i file precedenti sono ancora in
* storage/app/backup-precedenti/. La strada resta 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.'
. ' Un ripristino interrotto prima della conferma non modifica il database: rilancialo con lo stesso pacchetto. '
. 'Se i file risultano diversi, quelli precedenti sono in storage/app/backup-precedenti/.'
);
@unlink($this->zipPath);