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>
This commit is contained in:
2026-10-12 15:01:41 +02:00
parent 056d95cfcb
commit 426a95211d
78 changed files with 3971 additions and 429 deletions

View File

@@ -20,8 +20,15 @@ use ZipArchive;
*
* manifest.json formato, data, conteggi (letto per validare il file)
* data.xml lo stesso XML dell'export "Esporta XML"
* piantine.json disegni e storico dei PDF (Piantine\PiantineBackup)
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
* relativo che sta in pdf_path/thumbnail_path/logo_path
* piantine/<percorso> l'area KML delle piantine (storage/app/piantine),
* che sta accanto al disco `public`, non dentro
*
* piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1
* si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area
* KML in uso non si tocca (vedi scambiaFile()).
*/
class BackupArchive
{
@@ -29,11 +36,23 @@ class BackupArchive
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
* quello che sa leggere invece di ripristinarli a meta'.
*/
public const FORMAT_VERSION = 1;
public const FORMAT_VERSION = 2;
private const ENTRY_MANIFEST = 'manifest.json';
private const ENTRY_DATA = 'data.xml';
private const ENTRY_PIANTINE = 'piantine.json';
private const PREFIX_STORAGE = 'storage/';
private const PREFIX_PIANTINE = 'piantine/';
/**
* Sottocartelle dello staging, una per ogni cartella che il ripristino
* sostituisce (vedi radiciDaSostituire()).
*/
private const STAGING_PUBBLICO = 'pubblico';
private const STAGING_PIANTINE = 'piantine';
/** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */
private const CARTELLA_PIANTINE = 'piantine';
/**
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
@@ -65,12 +84,32 @@ class BackupArchive
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
*
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
* @param array $piantine Contenuto di piantine.json (PiantineBackup::esporta()).
* Obbligatorio e non facoltativo di proposito: un
* chiamante che se ne dimentica produrrebbe archivi
* che al ripristino svuotano le piantine.
* @param callable|null $onProgress fn(int $fatti, int $totale): void
*/
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string
{
$disk = Storage::disk('public');
$files = $disk->allFiles();
$local = Storage::disk('local');
// Due cartelle sorgente, un solo elenco di terne: percorso relativo
// (per i messaggi), percorso sul disco, nome dell'entry.
$files = [];
foreach ($disk->allFiles() as $relativePath) {
$files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath];
}
$filePubblici = count($files);
// allFiles('piantine') restituisce gia' "piantine/area.kml": il
// percorso relativo al disco coincide con il nome dell'entry.
foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) {
$files[] = [$relativePath, $local->path($relativePath), $relativePath];
}
$filePiantine = count($files) - $filePubblici;
$totale = count($files);
$cartella = dirname($destinazione);
@@ -84,12 +123,14 @@ class BackupArchive
}
$zip->addFromString(self::ENTRY_DATA, $xml);
$zip->addFromString(self::ENTRY_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
'format_version' => self::FORMAT_VERSION,
'application' => 'TerManager2',
'generated_at' => now()->toIso8601String(),
'app_url' => (string) config('app.url'),
'files_count' => $totale,
'files_count' => $filePubblici,
'piantine_files_count' => $filePiantine,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
try {
@@ -110,12 +151,12 @@ class BackupArchive
try {
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
$files, $totale, $perLotto, $onProgress, $destinazione
) {
$nelLotto = 0;
$fatti = 0;
foreach ($files as $relativePath) {
foreach ($files as [$relativePath, $sorgente, $entry]) {
// addFile() legge dal disco durante close(): i PDF non
// passano mai interi per la memoria del processo.
//
@@ -133,7 +174,7 @@ class BackupArchive
// raggiunto mai, e l'amministratore leggerebbe la stringa
// grezza di PHP. Stesso problema, e stessa scelta, del
// close() avvolto in chiudi().
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
if (! @$zip->addFile($sorgente, $entry)) {
throw new RuntimeException(
'Impossibile aggiungere "' . $relativePath . '" al backup: '
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
@@ -157,7 +198,7 @@ class BackupArchive
}
try {
$this->verificaArchivio($destinazione, $totale);
$this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
} catch (\Throwable $e) {
@unlink($destinazione);
@@ -178,7 +219,7 @@ class BackupArchive
* non dei contenuti — quindi e' un controllo a buon mercato che copre
* troncamenti, chiusure a meta' e lotti persi.
*/
private function verificaArchivio(string $path, int $fileAttesi): void
private function verificaArchivio(string $path, int $fileAttesi, int $piantineAttesi): void
{
$zip = new ZipArchive();
@@ -187,27 +228,31 @@ class BackupArchive
}
$trovati = 0;
$piantineTrovati = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
$trovati++;
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
$piantineTrovati++;
}
}
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
$zip->close();
if (! $haDati || ! $haManifest) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
if (! $haDati || ! $haManifest || ! $haPiantine) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.');
}
if ($trovati !== $fileAttesi) {
if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) {
throw new RuntimeException(sprintf(
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
$trovati,
$fileAttesi
$trovati + $piantineTrovati,
$fileAttesi + $piantineAttesi
));
}
}
@@ -294,8 +339,15 @@ class BackupArchive
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
* dati correnti sono ancora intatti.
*
* E' anche la verifica completa dell'archivio: ogni entry si estrae per
* intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un
* file troncato, un'entry corrotta o il disco che si riempie durante
* l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso
* volume dei file veri, quindi se c'e' posto per estrarre c'e' posto
* anche per lo scambio, che e' solo una serie di rename().
*
* @param callable|null $onProgress fn(int $fatti, int $totale): void
* @return array{xml: string, staging: string, files: int}
* @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: int}
*/
public function extract(string $zipPath, ?callable $onProgress = null): array
{
@@ -334,12 +386,35 @@ class BackupArchive
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
}
// Dal formato 2 piantine.json e' obbligatorio: la sua assenza non
// e' "nessuna piantina" ma un archivio monco, e ripristinarlo
// svuoterebbe disegni e storico senza che nessuno l'abbia chiesto.
// Null resta riservato al formato 1, che le piantine non le aveva.
$piantine = null;
if ($formatVersion >= 2) {
$piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE);
$piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null;
if (! is_array($piantine)) {
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.');
}
}
// Le entry che si estraggono, prefisso => sottocartella dello
// staging. L'area KML solo dal formato 2: un archivio vecchio non
// la porta, e non deve poterla portare per caso.
$prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO];
if ($formatVersion >= 2) {
$prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE;
}
if ($zip->numFiles > self::MAX_ENTRIES) {
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
}
$totalBytes = 0;
$extracted = 0;
$estrattiPiantine = 0;
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
// due entry di servizio": un archivio con una entry in piu' (una
@@ -348,7 +423,7 @@ class BackupArchive
$daEstrarre = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
if ($nome !== false && ! str_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) {
$daEstrarre++;
}
}
@@ -361,11 +436,12 @@ class BackupArchive
continue;
}
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
$prefisso = $this->prefissoDi($entryName, $prefissi);
if ($prefisso === null) {
continue;
}
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
$relativePath = substr($entryName, strlen($prefisso));
$safePath = $this->safeRelativePath($relativePath);
if ($safePath === null) {
@@ -375,7 +451,8 @@ class BackupArchive
// La dimensione dichiarata si legge *prima* di estrarre: e'
// l'unico momento in cui si puo' rifiutare un'entry che, una
// volta decompressa, riempirebbe il disco.
$size = (int) ($zip->statIndex($index)['size'] ?? 0);
$stat = $zip->statIndex($index);
$size = (int) ($stat['size'] ?? 0);
if ($size > self::MAX_ENTRY_BYTES) {
throw new RuntimeException(sprintf(
@@ -390,7 +467,7 @@ class BackupArchive
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
}
$destination = $staging . '/' . $safePath;
$destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
$directory = dirname($destination);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
@@ -409,12 +486,40 @@ class BackupArchive
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
}
stream_copy_to_stream($stream, $target);
// Il risultato della copia si controlla, ed e' qui che il
// ripristino diventa sicuro: prima un disco pieno o un'entry
// corrotta lasciavano nello staging un file troncato senza
// nessun errore, e quel file finiva al posto del PDF buono.
// La @ per lo stesso motivo di addFile(): il warning di PHP
// ("CRC error") arriverebbe all'amministratore al posto del
// messaggio che dice quale file.
$copiati = @stream_copy_to_stream($stream, $target);
fclose($stream);
fclose($target);
$chiuso = fclose($target);
$extracted++;
$onProgress && $onProgress($extracted, $daEstrarre);
if ($copiati === false || $copiati !== $size || ! $chiuso) {
throw new RuntimeException(sprintf(
'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.',
$safePath,
$copiati === false ? '0' : (string) $copiati,
$size
));
}
// Il CRC lo controlla gia' libzip a fine lettura, ma solo se la
// lettura arriva in fondo e come warning: ricalcolarlo sul file
// scritto costa una rilettura e non dipende da come libzip
// segnala l'errore.
if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) {
throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.');
}
if ($prefissi[$prefisso] === self::STAGING_PIANTINE) {
$estrattiPiantine++;
} else {
$extracted++;
}
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
}
} catch (\Throwable $e) {
$zip->close();
@@ -425,63 +530,180 @@ class BackupArchive
$zip->close();
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted];
return [
'xml' => $xml,
'piantine' => $piantine,
'formato' => $formatVersion,
'staging' => $staging,
'pubblico' => $staging . '/' . self::STAGING_PUBBLICO,
'files' => $extracted,
'file_piantine' => $estrattiPiantine,
];
}
/** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */
private function prefissoDi(string $entryName, array $prefissi): ?string
{
foreach (array_keys($prefissi) as $prefisso) {
if (str_starts_with($entryName, $prefisso)) {
return $prefisso;
}
}
return null;
}
/**
* Sostituisce il contenuto del disco `public` con quello estratto.
* Mette i file estratti al posto di quelli in uso, tenendo da parte i
* vecchi finche' il ripristino non e' confermato.
*
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
* righe del database, che il ripristino ha appena riscritto per intero.
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
*
* @param callable|null $onProgress fn(int $fatti, int $totale): void
* Prima si svuotava il disco e poi si ricopiava dallo staging file per
* file: un errore a meta' (disco pieno, permessi) lasciava il database
* nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile.
* Adesso sono solo rename() sullo stesso volume, per voce di primo livello
* (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno
* di spazio, e ognuno annotato in un registro che annullaScambio()
* ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui
* e si solleva: il chiamante annulla la transazione e l'installazione
* resta com'era.
*
* Si spostano le voci e non la cartella radice: a quella del disco
* `public` punta il symlink public/storage.
*
* @param array $estratto Il risultato di extract().
* @return array{file: int, accantonati: string, spostamenti: list<array{0: string, 1: string}>}
* Da passare a confermaScambio() a database confermato, o ad
* annullaScambio() se qualcosa fallisce dopo.
*/
public function restoreFiles(string $staging, ?callable $onProgress = null): int
public function scambiaFile(array $estratto): array
{
$disk = Storage::disk('public');
// Fuori da backup-restore/ di proposito: quella cartella la ripulisce
// backup:cleanup dopo un giorno, e qui dentro, se l'annullamento
// stesso fallisse, resterebbero gli unici file buoni.
$scambio = [
'file' => $estratto['files'] + $estratto['file_piantine'],
'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()),
'spostamenti' => [],
];
$this->svuotaDiscoPubblico();
try {
foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) {
$this->preparaCartella($radice);
$this->preparaCartella($scambio['accantonati'] . '/' . $nome);
if (! is_dir($staging)) {
return 0;
foreach ($this->vociDi($radice) as $voce) {
$this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
}
// Una sottocartella assente nello staging vuol dire "nessun
// file": la radice resta vuota, come nell'installazione
// salvata.
$nuovi = $estratto['staging'] . '/' . $nome;
foreach ($this->vociDi($nuovi) as $voce) {
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
}
}
} catch (\Throwable $e) {
$this->annullaScambio($scambio, $e);
throw $e;
}
$restored = 0;
$totale = iterator_count(new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
));
$onProgress && $onProgress(0, $totale);
return $scambio;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
);
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
public function confermaScambio(array $scambio): void
{
$this->deleteDirectory($scambio['accantonati']);
}
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
/**
* Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli
* spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro
* posto.
*
* Se anche questo fallisce non si cancella niente: i file precedenti
* restano nella cartella degli accantonati, e il messaggio dice dove, cosi'
* si possono rimettere a mano invece di darli per persi.
*/
public function annullaScambio(array $scambio, ?\Throwable $causa = null): void
{
try {
foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) {
if (! @rename($a, $da)) {
throw new RuntimeException('impossibile rimettere "' . $da . '"');
}
}
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
$handle = fopen($file->getPathname(), 'rb');
if ($handle === false) {
continue;
}
$disk->writeStream($relativePath, $handle);
if (is_resource($handle)) {
fclose($handle);
}
$restored++;
$onProgress && $onProgress($restored, $totale);
} catch (\Throwable $e) {
throw new RuntimeException(
($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '')
. 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): '
. 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.',
0,
$causa ?? $e
);
}
return $restored;
$this->deleteDirectory($scambio['accantonati']);
}
/**
* Le cartelle che il ripristino sostituisce: sottocartella dello staging
* => cartella vera.
*
* L'area KML solo con un archivio di formato 2, che la porta sempre (anche
* vuota, se l'installazione salvata non ne aveva). Un archivio di formato
* 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire
* buttare un file che con i dati ripristinati non ha nessun legame: nessuna
* riga lo cita, e' la mappa della citta'.
*
* @return array<string, string>
*/
private function radiciDaSostituire(int $formato): array
{
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
if ($formato >= 2) {
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
}
return $radici;
}
/** @return list<string> Le voci di primo livello di una cartella (vuoto se non esiste). */
private function vociDi(string $cartella): array
{
if (! is_dir($cartella)) {
return [];
}
return array_values(array_diff(scandir($cartella) ?: [], ['.', '..']));
}
private function preparaCartella(string $cartella): void
{
if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.');
}
}
private function sposta(string $da, string $a, array &$scambio): void
{
if (! @rename($da, $a)) {
throw new RuntimeException(sprintf(
'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: '
. 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.',
basename($da)
));
}
$scambio['spostamenti'][] = [$da, $a];
}
/**

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
/**
@@ -23,6 +24,18 @@ class BackupOperationState
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';
@@ -48,6 +61,16 @@ class BackupOperationState
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 = [
@@ -99,6 +122,66 @@ class BackupOperationState
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));

View File

@@ -257,8 +257,11 @@ class PiantinaPdfService
/**
* Sposta il PDF attuale del territorio (se esiste) nello storico.
*
* Pubblica perché la usa anche l'import dei PDF da cartella: chi
* sostituisce un PDF lo archivia sempre allo stesso modo.
*/
private function archiviaCorrente(Territorio $territorio, string $origine): void
public function archiviaCorrente(Territorio $territorio, string $origine): void
{
$disk = Storage::disk('public');

View File

@@ -12,10 +12,10 @@ use RuntimeException;
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
* restano al loro posto.
*
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
* KML originale — che quindi conviene conservare fuori di qui.
* Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
* impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
* ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
* contiene, e ripristinandolo l'area in uso resta com'e'.
*/
class PiantineArea
{

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Services\Piantine;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
/**
* Le tabelle della sotto-app delle piantine dentro il backup completo:
* i disegni (`territorio_mappe`) e lo storico dei PDF sostituiti
* (`territorio_pdf_versioni`).
*
* Non passano dall'XML del gestionale di proposito: l'export XML e' il formato
* di scambio dei dati gestionali, e la sotto-app deve restare rimovibile senza
* toccarlo. Viaggiano in `piantine.json` accanto a `data.xml`; i file che
* citano (immagini in territori-mappe/, PDF in territori-pdf-versioni/) stanno
* sul disco `public` e sono gia' nel pacchetto con tutti gli altri.
*
* Il collegamento al territorio si salva come *numero*, non solo come id:
* l'import XML ricrea i territori con id nuovi (autoincremento), quindi l'id
* del backup dopo il ripristino punterebbe al territorio sbagliato o a nessuno.
* Il numero e' quello che l'import usa per riconoscere i territori, ed e'
* unico.
*/
class PiantineBackup
{
public const TABELLA_MAPPE = 'territorio_mappe';
public const TABELLA_VERSIONI = 'territorio_pdf_versioni';
/**
* Le righe delle due tabelle cosi' come sono nel database, piu' il numero
* del territorio collegato.
*
* Query builder e non Eloquent: si vuole la riga grezza, timestamp
* compresi, da reinserire identica — non i valori passati per i cast.
*
* @return array<string, list<array<string, mixed>>>
*/
public function esporta(): array
{
return [
self::TABELLA_MAPPE => $this->righe(self::TABELLA_MAPPE),
self::TABELLA_VERSIONI => $this->righe(self::TABELLA_VERSIONI),
];
}
/**
* Controlla la struttura di piantine.json prima che il ripristino tocchi
* qualunque cosa. Null e' ammesso: e' l'archivio di formato 1, che le
* piantine non le conteneva.
*/
public function valida(?array $dati): void
{
if ($dati === null) {
return;
}
foreach ([self::TABELLA_MAPPE, self::TABELLA_VERSIONI] as $tabella) {
if (! isset($dati[$tabella]) || ! is_array($dati[$tabella]) || ! array_is_list($dati[$tabella])) {
throw new RuntimeException('Archivio non valido: in piantine.json manca l\'elenco "' . $tabella . '".');
}
foreach ($dati[$tabella] as $posizione => $riga) {
if (! is_array($riga) || ! isset($riga['id']) || ! is_numeric($riga['id'])) {
throw new RuntimeException(sprintf(
'Archivio non valido: la riga %d di "%s" in piantine.json non e\' leggibile.',
$posizione + 1,
$tabella
));
}
}
}
}
/**
* Sostituisce il contenuto delle due tabelle con quello del backup.
*
* Va chiamato dentro la transazione del ripristino e *dopo* l'import XML:
* servono i territori gia' ricreati per ritrovare i collegamenti. DELETE e
* non TRUNCATE: su MariaDB TRUNCATE e' DDL e fa commit implicito, cioe'
* chiuderebbe a meta' la transazione che deve poter annullare tutto.
*
* Con $dati null (archivio di formato 1) le tabelle si svuotano soltanto.
* Tenere le righe dell'installazione corrente vorrebbe dire piantine che
* citano immagini del disco appena sostituito — file che non ci sono piu'
* — accanto ai dati di un'altra epoca: proprio lo stato misto che il
* ripristino deve evitare. Lo storico, del resto, lo svuotava gia' la
* cascata sulla cancellazione dei territori.
*
* @return array{mappe: int, versioni: int, issues: list<array{entity: string, legacy_id: string, reason: string}>}
*/
public function ripristina(?array $dati): array
{
DB::table(self::TABELLA_VERSIONI)->delete();
DB::table(self::TABELLA_MAPPE)->delete();
$esito = ['mappe' => 0, 'versioni' => 0, 'issues' => []];
if ($dati === null) {
return $esito;
}
// withTrashed implicito: il query builder non conosce i soft delete, e
// una piantina collegata a un territorio nel cestino deve restarlo.
$territori = DB::table('territori')->pluck('id', 'numero')
->mapWithKeys(fn ($id, $numero) => [trim((string) $numero) => (int) $id])
->all();
$colonneMappe = array_flip(Schema::getColumnListing(self::TABELLA_MAPPE));
$collegati = [];
foreach ($dati[self::TABELLA_MAPPE] as $riga) {
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
$territorioId = null;
if (($riga['territorio_id'] ?? null) !== null) {
$territorioId = $territori[$numero] ?? null;
// territorio_id e' unico: due piantine sullo stesso numero
// (possibile solo se il backup aveva territori con numero
// ripetuto, che l'import scarta) non possono restare collegate
// entrambe.
if ($territorioId !== null && isset($collegati[$territorioId])) {
$territorioId = null;
}
if ($territorioId === null) {
// Il disegno non si butta: resta scollegato, con il numero
// come etichetta per riconoscerlo.
if (trim((string) ($riga['nome'] ?? '')) === '' && $numero !== '') {
$riga['nome'] = 'Terr. ' . $numero;
}
$esito['issues'][] = $this->problema('piantina', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: piantina ripristinata scollegata');
} else {
$collegati[$territorioId] = true;
}
}
$riga['territorio_id'] = $territorioId;
DB::table(self::TABELLA_MAPPE)->insert(array_intersect_key($riga, $colonneMappe));
$esito['mappe']++;
}
$colonneVersioni = array_flip(Schema::getColumnListing(self::TABELLA_VERSIONI));
foreach ($dati[self::TABELLA_VERSIONI] as $riga) {
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
$territorioId = $territori[$numero] ?? null;
// Una versione senza territorio non e' ripristinabile da nessuna
// schermata (e la colonna non ammette NULL): si scarta, e lo si dice.
if ($territorioId === null) {
$esito['issues'][] = $this->problema('versione PDF', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: versione non ripristinata');
continue;
}
$riga['territorio_id'] = $territorioId;
DB::table(self::TABELLA_VERSIONI)->insert(array_intersect_key($riga, $colonneVersioni));
$esito['versioni']++;
}
return $esito;
}
private function righe(string $tabella): array
{
return DB::table($tabella)
->leftJoin('territori', 'territori.id', '=', $tabella . '.territorio_id')
->orderBy($tabella . '.id')
->get([$tabella . '.*', 'territori.numero as territorio_numero'])
->map(fn (object $riga) => (array) $riga)
->all();
}
/** Stessa forma delle segnalazioni dell'import XML, per mostrarle insieme. */
private function problema(string $entita, array $riga, string $motivo): array
{
return ['entity' => $entita, 'legacy_id' => (string) ($riga['id'] ?? ''), 'reason' => $motivo];
}
}

View File

@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
public function __construct(
protected TerritorioPdfImportState $stateService,
protected BackupOperationState $postoUnico,
) {
}
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
/**
* Prende il posto unico per questo import, o solleva spiegando chi lo occupa.
*
* L'import PDF sostituisce file e righe dei territori: in parallelo a un
* ripristino scriverebbe su tabelle che l'altro sta riscrivendo, e durante
* un backup l'archivio fotograferebbe meta' PDF vecchi e meta' nuovi (o
* fallirebbe su un file cancellato fra l'elenco e la compressione). Il posto
* si libera nel job, a fine lavoro o in failed().
*/
public function occupaPosto(string $importId): void
{
$inCorso = $this->postoUnico->occupa($importId, BackupOperationState::TIPO_IMPORT_PDF, 'In coda');
if ($inCorso !== null) {
throw new RuntimeException($this->postoUnico->messaggioOccupato($inCorso));
}
}
/** Libera il posto preso da un import che non e' arrivato alla coda. */
public function liberaPosto(string $importId, string $motivo): void
{
$this->postoUnico->markFailed($importId, $motivo);
}
/**
* Vero per i file che non vanno nemmeno considerati: quelli che non sono
* PDF e quelli di sistema o nascosti.
*
* Una cartella vera non contiene solo PDF: Windows ci lascia Thumbs.db e
* desktop.ini, macOS .DS_Store e — dentro gli ZIP — una copia "._nome.pdf"
* di ogni file sotto __MACOSX/. Prima uno solo di questi faceva fallire la
* validazione dell'intero caricamento; peggio, "._1.pdf" ha l'estensione
* giusta e sarebbe finito come mappa del territorio 1.
*/
public static function daIgnorare(string $percorso): bool
{
$segmenti = array_values(array_filter(preg_split('#[/\\\\]+#', $percorso) ?: [], fn ($s) => $s !== ''));
if ($segmenti === []) {
return true;
}
foreach ($segmenti as $segmento) {
if (str_starts_with($segmento, '.') || strcasecmp($segmento, '__MACOSX') === 0) {
return true;
}
}
// "~$" e' il prefisso dei file temporanei di Office.
$nome = end($segmenti);
return str_starts_with($nome, '~$')
|| strtolower(pathinfo($nome, PATHINFO_EXTENSION)) !== 'pdf';
}
/**
* Mette in coda l'import. Il posto unico deve essere gia' stato preso con
* occupaPosto(): se l'invio alla coda fallisce lo si libera qui.
*
* @param array<int, string> $ignorati nomi dei file scartati da daIgnorare(), solo per il log
* @param array<int, array{file: string, message: string}> $scartati PDF rifiutati dalla validazione
*/
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog, array $ignorati = [], array $scartati = []): array
{
$state = $this->stateService->initialize($importId, count($storedFiles));
$this->stateService->appendLog($importId, $initialLog);
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
if ($ignorati !== []) {
$elenco = array_slice($ignorati, 0, 10);
$altri = count($ignorati) - count($elenco);
$this->stateService->appendLog(
$importId,
'Ignorati ' . count($ignorati) . ' file che non sono PDF da importare (file di sistema, nascosti o di altro tipo): '
. implode(', ', $elenco) . ($altri > 0 ? ' e altri ' . $altri : '') . '.'
);
}
foreach ($scartati as $scartato) {
$this->stateService->appendLog($importId, '[SKIP] ' . $scartato['file'] . ' - ' . $scartato['message']);
$this->stateService->addIssue($importId, [
'file' => $scartato['file'],
'type' => 'invalid-file',
'message' => $scartato['message'],
]);
}
try {
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
} catch (\Throwable $exception) {
// Il job non partira' mai, quindi non sara' lui a liberare il posto.
$this->stateService->markFailed($importId);
$this->liberaPosto($importId, 'Import PDF non messo in coda: ' . $exception->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $exception;
}
return $this->stateService->get($importId) ?? $state;
}
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
{
$importId = (string) Str::uuid();
// Il posto si prende prima di salvare ed estrarre: e' la parte lenta, e
// farla per poi sentirsi rispondere "c'e' un ripristino in corso" sarebbe
// lavoro buttato.
$this->occupaPosto($importId);
try {
return $this->estraiEAccoda($zipFile, $importId, $actorId);
} catch (\Throwable $exception) {
// Se il job e' partito il posto e' suo e lo libera lui; altrimenti
// resterebbe occupato da un import che non esiste.
if ($this->stateService->get($importId) === null) {
$this->liberaPosto($importId, 'Import PDF non avviato: ' . $exception->getMessage());
}
throw $exception;
}
}
private function estraiEAccoda(UploadedFile $zipFile, string $importId, ?int $actorId): string
{
$zipStoredPath = $zipFile->storeAs(
'bulk-territori-imports/' . $importId,
'archivio-' . $importId . '.zip',
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
$zip = new ZipArchive();
if ($zip->open($zipAbsolutePath) !== true) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Impossibile aprire il file ZIP.');
}
$storedFiles = [];
$ignorati = [];
$entryIndex = 0;
try {
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
continue;
}
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
if (self::daIgnorare($entryName)) {
$ignorati[] = basename($entryName);
continue;
}
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
}
if ($storedFiles === []) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
}
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
$importId,
$storedFiles,
$actorId,
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.',
$ignorati
);
return $importId;