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

@@ -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;