Files
termanager2/app/Livewire/Settings/ZonaPericolosa.php
2026-07-30 17:05:39 +02:00

252 lines
8.7 KiB
PHP

<?php
namespace App\Livewire\Settings;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ResetApplication;
use App\Livewire\Concerns\SegueOperazioneBackup;
use App\Services\BackupOperationState;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
/**
* Azzeramento dell'applicazione: l'unica operazione irreversibile della pagina,
* e per questo l'ultima zona, dopo tutte le altre.
*
* E' un componente separato da BackupManager per una ragione di sola
* disposizione: la zona pericolosa deve stare in fondo alla pagina, mentre le
* sezioni di backup e ripristino stanno in cima. Rendendola dallo stesso
* componente finiva a meta' pagina, in mezzo alle sezioni di import, che e'
* l'ultimo posto in cui si vuole trovare un pulsante che cancella tutto.
*
* Lo stato dell'operazione resta condiviso con BackupManager attraverso
* BackupOperationState (il trait), quindi i due non hanno bisogno di parlarsi per
* restare coerenti: il posto unico e' uno solo per entrambi.
*
* L'azzeramento ha due tempi, separati dal download dell'archivio:
*
* 1. avviaAzzeramento() crea il backup e si ferma in attesa-download
* 2. confermaAzzeramento() cancella, solo su conferma umana e con la password
*
* Il primo tentativo faceva partire la cancellazione dalla coda dello
* streamDownload, "dopo l'ultimo byte": non funziona. Con fastcgi_buffering
* attivo (il default) nginx accetta l'intera risposta dal processo PHP, che
* prosegue anche se il browser ha chiuso la connessione al primo byte, quindi un
* download annullato azzerava lo stesso. Il server puo' verificare di aver
* *spedito* l'archivio, non che sia arrivato: quella verifica e' dell'operatore,
* e per questo la conferma e' umana.
*/
class ZonaPericolosa extends Component
{
use SegueOperazioneBackup;
/** 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');
$this->agganciaOperazioneCorrente();
}
/**
* Solo l'azzeramento: backup e ripristino li racconta BackupManager. Vedi
* SegueOperazioneBackup::tipiSeguiti().
*/
protected function tipiSeguiti(): array
{
return [BackupOperationState::TIPO_RESET];
}
/**
* Primo tempo: si costruisce il backup e lo si scarica. La cancellazione non
* parte da qui.
*/
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.']
);
if (! $this->postoLibero()) {
return;
}
$this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
CreateBackupArchive::dispatch($this->operationId, true);
$this->aggiorna();
// La sezione di backup deve sapere che il posto e' occupato, altrimenti
// continuerebbe a offrire il proprio pulsante durante l'azzeramento.
// Indirizzato e non gridato: senza ->to() l'evento tornerebbe anche qui.
$this->dispatch('operazione-avviata')->to(BackupManager::class);
}
/**
* Secondo tempo: parte solo su conferma esplicita, dopo che l'archivio e'
* stato servito.
*/
public function confermaAzzeramento(string $password = ''): void
{
$this->authorize('settings.manage');
$this->aggiorna();
if (! $this->attesaDownload) {
$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.
*/
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->attesaDownload) {
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 sezione di backup 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();
$this->dispatch('operazione-avviata')->to(BackupManager::class);
}
/**
* Un azzeramento avviato altrove non esiste, ma il posto puo' essere occupato
* da un backup o da un ripristino: la zona deve saperlo per non offrire un
* pulsante che verrebbe rifiutato.
*/
#[\Livewire\Attributes\On('operazione-avviata')]
public function agganciaAltrove(): void
{
$this->agganciaOperazioneCorrente();
}
/**
* Vero quando questa zona sta seguendo un azzeramento (non un backup o un
* ripristino avviati altrove).
*/
public function getAzzeramentoInCorsoProperty(): bool
{
return $this->operationId !== null
&& ($this->operazione['tipo'] ?? null) === BackupOperationState::TIPO_RESET;
}
public function render()
{
return view('livewire.settings.zona-pericolosa');
}
}