authorize('settings.manage'); // Riaggancia un'operazione gia' in corso. Senza, ricaricare la pagina // durante un backup lungo farebbe sparire la barra, e un azzeramento // fermo in attesa di conferma diventerebbe irraggiungibile: nessuno // potrebbe piu' ne' confermarlo ne' annullarlo. $this->operationId = app(BackupOperationState::class)->corrente(); $this->aggiorna(); } public function avviaBackup(): void { $this->authorize('settings.manage'); if (! $this->postoLibero()) { return; } $this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP); CreateBackupArchive::dispatch($this->operationId, false); $this->aggiorna(); } /** * Vero se non c'e' gia' un'operazione in corso. * * Backup, ripristino e azzeramento lavorano tutti sullo stesso database e * sullo stesso disco: farne partire due insieme — due schede, due * amministratori — non li rallenta soltanto, li fa sovrascrivere a vicenda. */ private function postoLibero(): bool { $inCorso = app(BackupOperationState::class)->corrente(); if ($inCorso === null) { $this->erroreOperazione = ''; return true; } // Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha // premuto il pulsante vuole comunque vedere a che punto e'. $this->operationId = $inCorso; $this->aggiorna(); $this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.'; return false; } /** * 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.'] ); if (! $this->postoLibero()) { return; } $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); } /** * Ogni quanto reinterrogare lo stato. * * Mentre un job lavora servono aggiornamenti fitti, la barra deve muoversi. * In attesa-download invece si aspetta una persona, che puo' metterci * minuti: continuare a bussare ogni 750ms vorrebbe dire migliaia di * richieste inutili se qualcuno lascia la scheda aperta e se ne va. */ public function getIntervalloPollProperty(): string { return ($this->operazione['fase'] ?? null) === BackupOperationState::FASE_ATTESA_DOWNLOAD ? '5s' : '750ms'; } public function render() { return view('livewire.settings.backup-manager'); } }