markRunning($this->operationId); $staging = null; $scambio = null; $confermato = false; try { $stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio'); $estratto = $archivio->extract( $this->zipPath, fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) ); $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); 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'])) { throw new RuntimeException($result['error']); } $esitoPiantine = $piantine->ripristina($estratto['piantine']); $utenti = $this->richiediCambioPasswordATutti(); $stato->avanza($this->operationId, 1, 1); $stato->fase($this->operationId, 'Sostituzione dei file', 1); $scambio = $archivio->scambiaFile($estratto); }); $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' // 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) { $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, $messaggio); return; } finally { Auth::forgetUser(); if ($staging !== null) { $archivio->deleteDirectory($staging); } @unlink($this->zipPath); } $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, %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 // lascia in cache a suo nome, e quella schermata lo consuma. Cache::put(self::CHIAVE_ESITO . $this->actorId, $messaggio, 3600); $stato->log($this->operationId, $messaggio); $stato->markCompleted($this->operationId, [ 'stats' => $stats, 'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50), 'file_ripristinati' => $fileRipristinati, 'messaggio' => $messaggio, ]); } /** * 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 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).') . ' 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); } /** * Blocca ogni account finche' non sceglie una password nuova, * l'amministratore che sta ripristinando compreso. * * Il pacchetto contiene gli hash bcrypt di tutti gli utenti: chi ha avuto * il file fra le mani ha potuto tentarne il crack con calma, quindi dopo un * ripristino nessuna delle vecchie password si puo' considerare segreta. * Anche i remember_token vengono azzerati: sono credenziali persistenti a * tutti gli effetti e stanno pure loro nel backup. * * Scritto con il query builder di proposito — un update di massa su * Eloquent qui non aggiungerebbe nulla e toccherebbe updated_at su tutte le * righe appena importate con i loro timestamp originali. * * @return int Account bloccati. Non e' il valore di ritorno dell'update: * MariaDB conta le righe *cambiate*, quindi un utente gia' * marcato da un ripristino precedente non comparirebbe e il * riepilogo direbbe meno account di quanti ne sono bloccati. */ private function richiediCambioPasswordATutti(): int { DB::table('users')->update([ 'password_change_required' => true, // Il motivo ora è il ripristino, anche per chi aveva una // password temporanea (pagina del cambio password). 'password_temporanea' => false, 'remember_token' => null, ]); return (int) DB::table('users')->count(); } }