Files
termanager2/app/Services/BackupOperationState.php
Francesco Picone 426a95211d Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:01:41 +02:00

325 lines
11 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
/**
* Stato di avanzamento delle operazioni lunghe di backup, ripristino e
* azzeramento.
*
* Le tre operazioni girano in coda e la pagina le segue interrogando questo
* stato: `total` e `processed` contano unita' di lavoro davvero completate
* (file scritti nell'archivio, file estratti, tabelle svuotate), non un
* temporizzatore. La barra si muove quando qualcosa e' finito sul disco.
*
* Stessa impostazione di TerritorioPdfImportState, con cui condivide anche il
* TTL: lo stato sopravvive un giorno, quindi ricaricare la pagina dopo un
* ripristino lungo mostra ancora l'esito.
*/
class BackupOperationState
{
public const TIPO_BACKUP = 'backup';
public const TIPO_RESTORE = 'restore';
public const TIPO_RESET = 'reset';
/**
* Gli import non sono operazioni di backup, ma occupano lo stesso posto:
* l'import XML svuota e riscrive le tabelle, l'import PDF sostituisce file
* e righe dei territori. Uno dei due in parallelo a un ripristino (o a un
* backup che sta copiando quegli stessi file) produce uno stato che nessuno
* ha chiesto. Il loro avanzamento non sta qui: l'import PDF ha
* TerritorioPdfImportState, l'import XML e' sincrono. Qui c'e' solo
* l'occupazione del posto.
*/
public const TIPO_IMPORT_PDF = 'import-pdf';
public const TIPO_IMPORT_XML = 'import-xml';
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
protected int $ttlSeconds = 86400;
/** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */
private const MAX_LOG = 60;
/**
* Puntatore all'unica operazione ammessa alla volta.
*
* Backup, ripristino e azzeramento lavorano tutti sullo stesso database e
* sullo stesso disco: due in parallelo — due schede aperte, due
* amministratori — non si limitano a rallentarsi, si sovrascrivono. Un
* ripristino che riscrive le tabelle mentre un azzeramento le cancella
* lascia uno stato che nessuno dei due voleva.
*
* Il TTL e' la valvola di sicurezza: se il worker muore senza chiudere
* l'operazione, il blocco si scioglie da solo entro un'ora invece di
* richiedere un intervento sul server. Prima ancora, corrente() considera
* libero il posto se l'operazione puntata non esiste piu' o e' terminata.
*/
private const CHIAVE_CORRENTE = 'backup-op:corrente';
private const TTL_CORRENTE = 3600;
/**
* Mutex attorno a "il posto e' libero? allora lo prendo".
*
* corrente() seguito da initialize() sono due passi: due richieste che
* arrivano insieme vedono entrambe il posto libero, ed entrambe lo prendono
* — la seconda sovrascrive il puntatore della prima, e i due lavori girano
* in parallelo proprio come il posto unico doveva impedire.
*/
private const CHIAVE_AVVIO = 'backup-op:avvio';
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
{
$state = [
'id' => $id,
'tipo' => $tipo,
'status' => 'queued',
'fase' => $fase,
'total' => $total,
'processed' => 0,
'logs' => [],
'result' => [],
'error' => null,
'started_at' => null,
'finished_at' => null,
];
$this->put($id, $state);
Cache::put(self::CHIAVE_CORRENTE, $id, self::TTL_CORRENTE);
return $state;
}
/**
* Id dell'operazione in corso, o null se non ce n'e' nessuna.
*
* Serve a due cose: rifiutare l'avvio di una seconda operazione, e
* riagganciare la barra di avanzamento quando la pagina viene ricaricata —
* senza, un azzeramento fermo in attesa di conferma resterebbe irraggiungibile
* e nessuno potrebbe piu' confermarlo o annullarlo.
*/
public function corrente(): ?string
{
$id = Cache::get(self::CHIAVE_CORRENTE);
if (! is_string($id) || $id === '') {
return null;
}
$stato = $this->get($id);
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
// e' libero, e il puntatore va tolto di mezzo.
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
Cache::forget(self::CHIAVE_CORRENTE);
return null;
}
return $id;
}
/**
* Prende il posto unico se e' libero, in un passo solo.
*
* Restituisce null se il posto e' stato preso (e l'operazione inizializzata
* come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa.
* Tutte le porte d'ingresso passano da qui proprio per il mutex: un
* controllo con corrente() e un initialize() separati lasciano aperta la
* finestra fra i due.
*
* $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a
* meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il
* posto resterebbe occupato per l'ora intera del TTL ordinario.
*/
public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string
{
try {
return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) {
$inCorso = $this->corrente();
if ($inCorso !== null) {
return $inCorso;
}
$this->initialize($id, $tipo, $fase);
if ($ttlPosto !== null) {
Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto);
}
return null;
});
} catch (LockTimeoutException) {
// Qualcun altro sta prendendo il posto proprio adesso: il mutex
// copre pochi millisecondi, quindi cinque secondi d'attesa vogliono
// dire che il posto non e' disponibile, non che va forzato.
return $this->corrente() ?? 'avvio-in-corso';
}
}
/**
* Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo
* blocca, perche' l'operazione in corso puo' essere stata avviata da un
* altro amministratore in un'altra zona della pagina.
*/
public function messaggioOccupato(string $idInCorso): string
{
$tipo = $this->get($idInCorso)['tipo'] ?? null;
$cosa = match ($tipo) {
self::TIPO_BACKUP => ' (un backup)',
self::TIPO_RESTORE => ' (un ripristino)',
self::TIPO_RESET => ' (un azzeramento)',
self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)',
self::TIPO_IMPORT_XML => ' (un import XML)',
default => '',
};
return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.';
}
public function get(string $id): ?array
{
return Cache::get($this->key($id));
}
public function put(string $id, array $state): void
{
Cache::put($this->key($id), $state, $this->ttlSeconds);
}
public function forget(string $id): void
{
Cache::forget($this->key($id));
}
public function update(string $id, callable $callback): ?array
{
$state = $this->get($id);
if (! $state) {
return null;
}
$state = $callback($state) ?? $state;
$this->put($id, $state);
return $state;
}
public function log(string $id, string $message): void
{
$this->update($id, function (array $state) use ($message) {
$state['logs'][] = '[' . now()->format('H:i:s') . '] ' . $message;
$state['logs'] = array_slice($state['logs'], -self::MAX_LOG);
return $state;
});
}
/**
* Cambia fase e, insieme, il totale di riferimento: ogni fase ha una sua
* unita' di misura (file da comprimere, file da estrarre, passi di
* cancellazione) e la barra riparte da zero su quel totale.
*/
public function fase(string $id, string $fase, int $total = 0): void
{
$this->update($id, function (array $state) use ($fase, $total) {
$state['fase'] = $fase;
$state['total'] = $total;
$state['processed'] = 0;
return $state;
});
$this->log($id, $fase . ($total > 0 ? ' (' . $total . ')' : ''));
}
public function avanza(string $id, int $processed, ?int $total = null): void
{
$this->update($id, function (array $state) use ($processed, $total) {
$state['processed'] = $processed;
if ($total !== null) {
$state['total'] = $total;
}
return $state;
});
}
public function markRunning(string $id): void
{
$this->update($id, function (array $state) {
$state['status'] = 'running';
$state['started_at'] ??= now()->toDateTimeString();
return $state;
});
}
public function markCompleted(string $id, array $result = []): void
{
$this->update($id, function (array $state) use ($result) {
$state['status'] = 'completed';
$state['processed'] = max($state['processed'], $state['total']);
$state['result'] = $result + $state['result'];
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
$this->liberaCorrente($id);
}
public function markFailed(string $id, string $error): void
{
$this->update($id, function (array $state) use ($error) {
$state['status'] = 'failed';
$state['error'] = $error;
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
$this->log($id, 'ERRORE: ' . $error);
$this->liberaCorrente($id);
}
/**
* Libera il posto, ma solo se e' ancora questa operazione a occuparlo:
* senza il confronto, la chiusura di un'operazione vecchia sbloccherebbe
* quella appena avviata da qualcun altro.
*/
private function liberaCorrente(string $id): void
{
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
Cache::forget(self::CHIAVE_CORRENTE);
}
}
/**
* L'archivio e' pronto ma non si prosegue: l'azzeramento parte solo quando
* il download risulta consegnato per intero.
*/
public function markAttesaDownload(string $id, array $result): void
{
$this->update($id, function (array $state) use ($result) {
$state['status'] = 'running';
$state['fase'] = self::FASE_ATTESA_DOWNLOAD;
$state['processed'] = $state['total'];
$state['result'] = $result + $state['result'];
return $state;
});
}
protected function key(string $id): string
{
return 'backup-op:' . $id;
}
}