324 lines
10 KiB
PHP
324 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Settings;
|
|
|
|
use App\Jobs\CreateBackupArchive;
|
|
use App\Jobs\ResetApplication;
|
|
use App\Services\BackupOperationState;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Avvio e monitoraggio delle operazioni lunghe: backup, ripristino,
|
|
* azzeramento.
|
|
*
|
|
* Il lavoro vero sta nei job; qui si avvia e si interroga lo stato. La barra
|
|
* mostra `processed/total` dell'operazione in corso, cioe' unita' di lavoro
|
|
* davvero completate — file finiti nell'archivio, file estratti, tabelle
|
|
* svuotate — non un'animazione a tempo.
|
|
*/
|
|
class BackupManager extends Component
|
|
{
|
|
/** Operazione seguita in questo momento; null quando non c'e' nulla in corso. */
|
|
public ?string $operationId = null;
|
|
|
|
public array $operazione = [];
|
|
|
|
/** Frase da digitare per sbloccare l'azzeramento. */
|
|
public const CONFERMA_AZZERAMENTO = 'AZZERA TUTTO';
|
|
|
|
/** Password sbagliate ammesse prima di bloccare l'azzeramento, e per quanto. */
|
|
private const TENTATIVI_PASSWORD = 5;
|
|
private const FINESTRA_TENTATIVI = 300;
|
|
|
|
public string $confermaReset = '';
|
|
|
|
/**
|
|
* Esiste solo per dare un nome agli errori del campo password: Livewire
|
|
* scarta dal messaggio di risposta ogni errore la cui chiave non
|
|
* corrisponda a una proprieta' del componente (SupportValidation::dehydrate),
|
|
* quindi senza questa riga "Password non corretta" non arriverebbe mai a
|
|
* schermo e il pulsante sembrerebbe non fare nulla.
|
|
*
|
|
* NON collegarla al campo con wire:model: la password viaggia come
|
|
* parametro di confermaAzzeramento() proprio per non finire nello
|
|
* snapshot, che Livewire rimanda al browser a ogni risposta. Qui resta
|
|
* sempre stringa vuota.
|
|
*/
|
|
public string $passwordConferma = '';
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->authorize('settings.manage');
|
|
}
|
|
|
|
public function avviaBackup(): void
|
|
{
|
|
$this->authorize('settings.manage');
|
|
|
|
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
|
|
|
CreateBackupArchive::dispatch($this->operationId, false);
|
|
|
|
$this->aggiorna();
|
|
}
|
|
|
|
/**
|
|
* Primo tempo dell'azzeramento: si costruisce il backup e lo si scarica.
|
|
* La cancellazione non parte da qui — serve il secondo tempo,
|
|
* confermaAzzeramento(), a valle del download.
|
|
*/
|
|
public function avviaAzzeramento(): void
|
|
{
|
|
$this->authorize('settings.manage');
|
|
|
|
$this->validate(
|
|
['confermaReset' => 'required|in:' . self::CONFERMA_AZZERAMENTO],
|
|
['confermaReset.in' => 'Scrivi esattamente "' . self::CONFERMA_AZZERAMENTO . '" per confermare.',
|
|
'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.']
|
|
);
|
|
|
|
$this->confermaReset = '';
|
|
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
|
|
|
CreateBackupArchive::dispatch($this->operationId, true);
|
|
|
|
$this->aggiorna();
|
|
}
|
|
|
|
/**
|
|
* Secondo tempo dell'azzeramento: parte solo su conferma esplicita, dopo
|
|
* che l'archivio e' stato servito.
|
|
*
|
|
* La conferma umana non e' un doppione del `wire:confirm` iniziale: e' il
|
|
* solo punto in cui si sa che il backup e' davvero sul computer di chi sta
|
|
* cancellando. Il server puo' verificare di averlo *spedito*
|
|
* (`servito_at`), non che sia arrivato: nginx bufferizza la risposta, il
|
|
* processo PHP la considera scritta e un download annullato dal browser
|
|
* resterebbe indistinguibile da uno riuscito. Chiedere all'operatore e'
|
|
* l'unico controllo che copre anche quel caso.
|
|
*/
|
|
public function confermaAzzeramento(string $password = ''): void
|
|
{
|
|
$this->authorize('settings.manage');
|
|
|
|
$this->aggiorna();
|
|
|
|
if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) {
|
|
$this->addError('confermaReset', 'Nessun azzeramento in attesa di conferma.');
|
|
|
|
return;
|
|
}
|
|
|
|
if (($this->operazione['result']['servito_at'] ?? null) === null) {
|
|
$this->addError('confermaReset', 'Scarica prima il backup: senza, l\'azzeramento non parte.');
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $this->passwordConfermata($password)) {
|
|
return;
|
|
}
|
|
|
|
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
|
|
$state['fase'] = 'Azzeramento in avvio';
|
|
$state['processed'] = 0;
|
|
$state['total'] = 0;
|
|
|
|
return $state;
|
|
});
|
|
|
|
ResetApplication::dispatch($this->operationId, (int) auth()->id());
|
|
|
|
$this->aggiorna();
|
|
}
|
|
|
|
/**
|
|
* Verifica la password dell'amministratore prima della cancellazione.
|
|
*
|
|
* A questo punto la sessione e' gia' autenticata, quindi la password non
|
|
* serve a stabilire *chi* sia: serve a stabilire che davanti allo schermo
|
|
* ci sia ancora quella persona. E' l'ultimo passo prima di
|
|
* un'operazione irreversibile, e una scheda lasciata aperta su una
|
|
* postazione condivisa e' lo scenario esatto contro cui protegge.
|
|
*
|
|
* La password arriva come parametro del metodo e non come proprieta' del
|
|
* componente: le proprieta' pubbliche di Livewire vengono serializzate
|
|
* nello snapshot e rispedite al browser a ogni risposta, e una password
|
|
* non ha motivo di fare quel viaggio di ritorno.
|
|
*/
|
|
private function passwordConfermata(string $password): bool
|
|
{
|
|
$utente = auth()->user();
|
|
$chiave = 'azzeramento-password:' . $utente->id;
|
|
|
|
// Senza un limite, la richiesta di password su una sessione dirottata
|
|
// sarebbe solo un rallentamento: si proverebbero le password comuni a
|
|
// raffica. Il blocco e' sull'azione, non sull'account, quindi nessuno
|
|
// resta chiuso fuori dall'applicazione.
|
|
if (RateLimiter::tooManyAttempts($chiave, self::TENTATIVI_PASSWORD)) {
|
|
$this->addError('passwordConferma', sprintf(
|
|
'Troppi tentativi. Riprova fra %d secondi.',
|
|
RateLimiter::availableIn($chiave)
|
|
));
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($password === '') {
|
|
$this->addError('passwordConferma', 'Inserisci la tua password per confermare.');
|
|
|
|
return false;
|
|
}
|
|
|
|
if (! Hash::check($password, $utente->getAuthPassword())) {
|
|
RateLimiter::hit($chiave, self::FINESTRA_TENTATIVI);
|
|
$this->addError('passwordConferma', 'Password non corretta.');
|
|
|
|
return false;
|
|
}
|
|
|
|
RateLimiter::clear($chiave);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Annulla un azzeramento fermo in attesa di conferma. Il backup resta
|
|
* scaricabile: e' comunque un backup valido.
|
|
*/
|
|
public function annullaAzzeramento(): void
|
|
{
|
|
$this->authorize('settings.manage');
|
|
|
|
if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) {
|
|
return;
|
|
}
|
|
|
|
app(BackupOperationState::class)->markCompleted($this->operationId, [
|
|
'messaggio' => 'Azzeramento annullato. Il backup appena creato resta scaricabile.',
|
|
]);
|
|
|
|
// Da qui in poi e' un backup come un altro: la scheda mostra il
|
|
// pulsante di download, non piu' la conferma di cancellazione.
|
|
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
|
|
$state['tipo'] = BackupOperationState::TIPO_BACKUP;
|
|
|
|
return $state;
|
|
});
|
|
|
|
$this->aggiorna();
|
|
}
|
|
|
|
/**
|
|
* Aggancia un'operazione avviata fuori da Livewire (il ripristino, che
|
|
* parte da un upload XHR e restituisce il suo id in JSON).
|
|
*/
|
|
public function seguiOperazione(string $operationId): void
|
|
{
|
|
$this->authorize('settings.manage');
|
|
|
|
$this->operationId = $operationId;
|
|
$this->aggiorna();
|
|
}
|
|
|
|
public function aggiorna(): void
|
|
{
|
|
if (! $this->operationId) {
|
|
return;
|
|
}
|
|
|
|
$stato = app(BackupOperationState::class)->get($this->operationId);
|
|
|
|
if (! $stato) {
|
|
$this->operationId = null;
|
|
$this->operazione = [];
|
|
|
|
return;
|
|
}
|
|
|
|
$this->operazione = $stato;
|
|
|
|
// Il download parte da solo appena l'archivio e' pronto: per il backup
|
|
// e' una comodita', per l'azzeramento e' il passaggio che sblocca la
|
|
// cancellazione, quindi non puo' dipendere da un clic.
|
|
if ($this->scaricaOra()) {
|
|
$this->dispatch('scarica-backup', url: route('backup.file', $this->operationId));
|
|
}
|
|
}
|
|
|
|
public function chiudi(): void
|
|
{
|
|
$this->operationId = null;
|
|
$this->operazione = [];
|
|
}
|
|
|
|
/**
|
|
* True una volta sola per operazione: l'evento di download va emesso quando
|
|
* l'archivio compare, non a ogni poll successivo.
|
|
*/
|
|
private function scaricaOra(): bool
|
|
{
|
|
$pronto = ($this->operazione['result']['file'] ?? null) !== null;
|
|
$giaInviato = $this->operazione['result']['download_inviato'] ?? false;
|
|
|
|
if (! $pronto || $giaInviato) {
|
|
return false;
|
|
}
|
|
|
|
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
|
|
$state['result']['download_inviato'] = true;
|
|
|
|
return $state;
|
|
});
|
|
|
|
$this->operazione['result']['download_inviato'] = true;
|
|
|
|
return true;
|
|
}
|
|
|
|
private function nuovaOperazione(string $tipo): string
|
|
{
|
|
$operationId = (string) \Illuminate\Support\Str::uuid();
|
|
|
|
$stato = app(BackupOperationState::class);
|
|
$stato->initialize($operationId, $tipo, 'In coda');
|
|
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
|
|
|
|
return $operationId;
|
|
}
|
|
|
|
/** Percentuale della fase corrente, 0-100. */
|
|
public function getPercentualeProperty(): int
|
|
{
|
|
$stato = $this->operazione['status'] ?? null;
|
|
|
|
if ($stato === 'completed') {
|
|
return 100;
|
|
}
|
|
|
|
$total = (int) ($this->operazione['total'] ?? 0);
|
|
|
|
if ($total <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$processed = (int) ($this->operazione['processed'] ?? 0);
|
|
|
|
return (int) min(100, round($processed / $total * 100));
|
|
}
|
|
|
|
/** Se true la pagina continua a interrogare lo stato. */
|
|
public function getInCorsoProperty(): bool
|
|
{
|
|
return $this->operationId !== null
|
|
&& in_array($this->operazione['status'] ?? '', ['queued', 'running'], true);
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.settings.backup-manager');
|
|
}
|
|
}
|