++ add reset program
This commit is contained in:
117
app/Jobs/CreateBackupArchive.php
Normal file
117
app/Jobs/CreateBackupArchive.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Costruisce il pacchetto di backup in coda.
|
||||
*
|
||||
* In coda e non nella richiesta perche' un'installazione con qualche centinaio
|
||||
* di PDF supera comodamente i 300 secondi di `fastcgi_read_timeout`: il browser
|
||||
* si vedrebbe chiudere la connessione a meta' compressione, senza modo di
|
||||
* distinguerlo da un errore.
|
||||
*/
|
||||
class CreateBackupArchive implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
/** Sotto il --timeout=600 del worker: se sfora, e' il worker a ucciderlo. */
|
||||
public int $timeout = 570;
|
||||
|
||||
/**
|
||||
* @param bool $perAzzeramento Quando true il job non chiude l'operazione
|
||||
* ma la lascia in attesa del download: e'
|
||||
* l'azzeramento che aspetta di avere
|
||||
* l'archivio al sicuro prima di partire.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $operationId,
|
||||
public bool $perAzzeramento = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||
{
|
||||
$stato->markRunning($this->operationId);
|
||||
$stato->fase($this->operationId, 'Lettura dei dati gestionali');
|
||||
|
||||
$destinazione = self::percorso($this->operationId);
|
||||
|
||||
try {
|
||||
$this->rimuoviArchiviVecchi($archivio);
|
||||
|
||||
$xml = $exchange->currentXmlBackup();
|
||||
|
||||
$stato->fase($this->operationId, 'Compressione dei file');
|
||||
|
||||
$archivio->create(
|
||||
$xml,
|
||||
$destinazione,
|
||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Creazione del backup non riuscita.', ['exception' => $e]);
|
||||
$stato->markFailed($this->operationId, $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$risultato = [
|
||||
'file' => basename($destinazione),
|
||||
'nome_download' => 'termanager2-backup-' . now()->format('Ymd-His') . '.zip',
|
||||
'dimensione' => filesize($destinazione) ?: 0,
|
||||
];
|
||||
|
||||
if ($this->perAzzeramento) {
|
||||
$stato->log($this->operationId, 'Archivio pronto. In attesa che il download sia completato.');
|
||||
$stato->markAttesaDownload($this->operationId, $risultato);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$stato->log($this->operationId, 'Backup pronto per il download.');
|
||||
$stato->markCompleted($this->operationId, $risultato);
|
||||
}
|
||||
|
||||
public static function percorso(string $operationId): string
|
||||
{
|
||||
return storage_path('app/backups/' . $operationId . '.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gli archivi restano su disco finche' il browser non li scarica, quindi un
|
||||
* download mai completato lascerebbe un file per sempre. Si ripulisce
|
||||
* all'inizio di ogni nuovo backup invece di aggiungere un task pianificato:
|
||||
* il momento in cui serve spazio e' esattamente questo.
|
||||
*/
|
||||
private function rimuoviArchiviVecchi(BackupArchive $archivio): void
|
||||
{
|
||||
$cartella = storage_path('app/backups');
|
||||
|
||||
if (! is_dir($cartella)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$limite = now()->subDay()->getTimestamp();
|
||||
|
||||
foreach (glob($cartella . '/*.zip') ?: [] as $file) {
|
||||
if (filemtime($file) < $limite) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
app/Jobs/ResetApplication.php
Normal file
175
app/Jobs/ResetApplication.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
|
||||
* nessun file caricato, impostazioni ai valori di default, ruoli e permessi
|
||||
* riseminati.
|
||||
*
|
||||
* Non e' reversibile e non fa backup da solo: parte solo dopo che l'archivio
|
||||
* generato da CreateBackupArchive risulta scaricato per intero (vedi
|
||||
* BackupController::file), cosi' non esiste una via che azzeri senza che una
|
||||
* copia sia gia' al sicuro sul computer di chi ha premuto il pulsante.
|
||||
*/
|
||||
class ResetApplication implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 570;
|
||||
|
||||
/**
|
||||
* @param int $actorId L'amministratore che ha chiesto l'azzeramento.
|
||||
* E' l'unico account che sopravvive: un'installazione
|
||||
* appena creata ha un amministratore, e cancellarli
|
||||
* tutti lascerebbe l'applicazione senza nessuno che
|
||||
* possa accedere fino al riavvio del container.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $operationId,
|
||||
public int $actorId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio): void
|
||||
{
|
||||
$stato->markRunning($this->operationId);
|
||||
|
||||
try {
|
||||
// I file per primi, prima di toccare il database: svuotare il disco
|
||||
// e' l'unico passo che puo' fallire per cause esterne (permessi sul
|
||||
// volume), e fallire qui non ha ancora cancellato niente. Nell'ordine
|
||||
// inverso un errore sui file lascerebbe un'applicazione con il
|
||||
// database azzerato e il disco pieno di PDF che non appartengono
|
||||
// piu' a nessun territorio.
|
||||
$stato->fase($this->operationId, 'Cancellazione dei file caricati', 1);
|
||||
$fileRimossi = $archivio->svuotaDiscoPubblico();
|
||||
$stato->avanza($this->operationId, 1);
|
||||
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||
|
||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8);
|
||||
|
||||
DB::transaction(function () use ($stato) {
|
||||
$passi = [
|
||||
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
||||
'Campagne' => fn () => Campagna::query()->delete(),
|
||||
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||
'Zone' => fn () => Zona::query()->delete(),
|
||||
'Anni teocratici' => fn () => AnnoTeocratico::query()->delete(),
|
||||
'Impostazioni' => fn () => Setting::query()->delete(),
|
||||
];
|
||||
|
||||
$fatti = 0;
|
||||
foreach ($passi as $etichetta => $azione) {
|
||||
$azione();
|
||||
$stato->avanza($this->operationId, ++$fatti);
|
||||
$stato->log($this->operationId, $etichetta . ': azzerate.');
|
||||
}
|
||||
});
|
||||
|
||||
// Il mass delete non emette eventi, quindi l'istanza memoizzata di
|
||||
// Setting punta ancora alla riga cancellata: senza questo, il
|
||||
// firstOrCreate piu' sotto tornerebbe il fantasma invece di
|
||||
// ricreare la riga.
|
||||
Setting::flushCache();
|
||||
|
||||
$stato->fase($this->operationId, 'Rimozione degli altri utenti');
|
||||
$utentiRimossi = $this->rimuoviAltriUtenti($stato);
|
||||
|
||||
// Il registro si svuota *dopo* le cancellazioni, non prima: gli
|
||||
// altri utenti sono modelli con LogsActivity, quindi eliminarli
|
||||
// scrive righe nuove che una pulizia anticipata non intercetta e
|
||||
// che resterebbero li' come unica traccia — un residuo, non una
|
||||
// scelta. Svuotato per ultimo, l'unica riga che sopravvive e'
|
||||
// quella scritta apposta qui sotto.
|
||||
$stato->fase($this->operationId, 'Pulizia del registro attivita\'', 1);
|
||||
DB::table('activity_log')->delete();
|
||||
activity()
|
||||
->causedBy(User::find($this->actorId))
|
||||
->log('Applicazione azzerata e riportata allo stato iniziale');
|
||||
$stato->avanza($this->operationId, 1);
|
||||
|
||||
$stato->fase($this->operationId, 'Ripristino di ruoli e impostazioni', 3);
|
||||
|
||||
// Ruoli e permessi non vengono cancellati, ma il seeder e' additivo
|
||||
// e idempotente: rieseguirlo garantisce il set di partenza anche se
|
||||
// qualcuno ne aveva rimosso qualcuno a mano.
|
||||
(new RolesAndPermissionsSeeder())->run();
|
||||
$stato->avanza($this->operationId, 1);
|
||||
|
||||
Setting::instance();
|
||||
$stato->avanza($this->operationId, 2);
|
||||
|
||||
$attore = User::find($this->actorId);
|
||||
if ($attore && ! $attore->hasRole('amministratore')) {
|
||||
$attore->assignRole('amministratore');
|
||||
}
|
||||
$stato->avanza($this->operationId, 3);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Azzeramento dell\'applicazione non riuscito.', ['exception' => $e]);
|
||||
$stato->markFailed($this->operationId, $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$messaggio = sprintf(
|
||||
'Applicazione azzerata: dati gestionali rimossi, %d file cancellati, %d utenti eliminati. '
|
||||
. 'Resta il tuo account di amministratore.',
|
||||
$fileRimossi,
|
||||
$utentiRimossi
|
||||
);
|
||||
|
||||
$stato->log($this->operationId, $messaggio);
|
||||
$stato->markCompleted($this->operationId, ['messaggio' => $messaggio]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancella ogni utente tranne chi ha chiesto l'azzeramento.
|
||||
*
|
||||
* Uno alla volta e via Eloquent, non con un delete di massa: il trait
|
||||
* HasRoles di Spatie stacca ruoli e permessi nell'evento `deleting`, che un
|
||||
* delete di massa non fa scattare — resterebbero righe orfane in
|
||||
* model_has_roles pronte a essere riassegnate al primo utente che riceve un
|
||||
* id gia' usato.
|
||||
*/
|
||||
private function rimuoviAltriUtenti(BackupOperationState $stato): int
|
||||
{
|
||||
$altri = User::query()->where('id', '!=', $this->actorId)->get();
|
||||
$stato->avanza($this->operationId, 0, $altri->count());
|
||||
|
||||
$rimossi = 0;
|
||||
foreach ($altri as $utente) {
|
||||
$utente->delete();
|
||||
$stato->avanza($this->operationId, ++$rimossi);
|
||||
}
|
||||
|
||||
return $rimossi;
|
||||
}
|
||||
}
|
||||
156
app/Jobs/RestoreBackupArchive.php
Normal file
156
app/Jobs/RestoreBackupArchive.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Ripristina un pacchetto di backup in coda, riportando l'avanzamento fase per
|
||||
* fase (estrazione, database, file).
|
||||
*/
|
||||
class RestoreBackupArchive implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 570;
|
||||
|
||||
/** Chiave con cui la schermata di cambio password ritrova l'esito. */
|
||||
public const CHIAVE_ESITO = 'restore-esito:';
|
||||
|
||||
public function __construct(
|
||||
public string $operationId,
|
||||
public string $zipPath,
|
||||
public int $actorId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||
{
|
||||
$stato->markRunning($this->operationId);
|
||||
|
||||
$staging = null;
|
||||
|
||||
try {
|
||||
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
|
||||
|
||||
$estratto = $archivio->extract(
|
||||
$this->zipPath,
|
||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||
);
|
||||
$staging = $estratto['staging'];
|
||||
|
||||
$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.
|
||||
Auth::onceUsingId($this->actorId);
|
||||
|
||||
$result = $exchange->importXmlFromContent($estratto['xml']);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$stato->markFailed($this->operationId, $result['error']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
|
||||
$stato->fase($this->operationId, 'Ripristino dei file');
|
||||
|
||||
$fileRipristinati = $archivio->restoreFiles(
|
||||
$staging,
|
||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||
);
|
||||
|
||||
$stato->fase($this->operationId, 'Blocco degli account', 1);
|
||||
$utenti = $this->richiediCambioPasswordATutti();
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
|
||||
$stato->markFailed($this->operationId, $e->getMessage());
|
||||
|
||||
return;
|
||||
} finally {
|
||||
Auth::forgetUser();
|
||||
|
||||
if ($staging !== null) {
|
||||
$archivio->deleteDirectory($staging);
|
||||
}
|
||||
|
||||
@unlink($this->zipPath);
|
||||
}
|
||||
|
||||
$stats = $result['stats'];
|
||||
|
||||
$messaggio = sprintf(
|
||||
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
|
||||
. 'Per sicurezza %s impostare una nuova password.',
|
||||
$stats['territori_importati'] ?? 0,
|
||||
$stats['proclamatori_importati'] ?? 0,
|
||||
$stats['assegnazioni_importate'] ?? 0,
|
||||
$fileRipristinati,
|
||||
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
|
||||
);
|
||||
|
||||
// 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($result['issues'], 0, 50),
|
||||
'file_ripristinati' => $fileRipristinati,
|
||||
'messaggio' => $messaggio,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
'remember_token' => null,
|
||||
]);
|
||||
|
||||
return (int) DB::table('users')->count();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user