Files
termanager2/app/Services/TerritorioPdfImportDispatcher.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

245 lines
9.3 KiB
PHP

<?php
namespace App\Services;
use App\Jobs\ImportTerritoryPdfFolder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;
class TerritorioPdfImportDispatcher
{
/** Per-entry uncompressed ceiling, matching the 10 MB Livewire upload rule. */
private const MAX_ENTRY_BYTES = 10 * 1024 * 1024;
/** Caps the job payload — every entry is serialized into one queue message. */
private const MAX_ENTRIES = 2000;
public function __construct(
protected TerritorioPdfImportState $stateService,
protected BackupOperationState $postoUnico,
) {
}
/**
* 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);
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;
}
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',
'local'
);
$zipAbsolutePath = storage_path('app/' . $zipStoredPath);
$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 {
for ($index = 0; $index < $zip->numFiles; $index++) {
$entryName = $zip->getNameIndex($index);
if (! $entryName || str_ends_with($entryName, '/')) {
continue;
}
if (self::daIgnorare($entryName)) {
$ignorati[] = basename($entryName);
continue;
}
if ($entryIndex >= self::MAX_ENTRIES) {
throw new RuntimeException(
'Lo ZIP contiene più di ' . self::MAX_ENTRIES . ' PDF. Suddividilo in archivi più piccoli.'
);
}
// Check the *uncompressed* size before reading. getFromIndex()
// inflates the entry entirely into memory, so a small zip with
// one hugely compressible entry would otherwise exhaust PHP's
// memory limit and abort the request mid-extraction.
$uncompressedSize = $zip->statIndex($index)['size'] ?? 0;
if ($uncompressedSize > self::MAX_ENTRY_BYTES) {
throw new RuntimeException(sprintf(
'Il file "%s" nello ZIP supera %d MB.',
basename($entryName),
self::MAX_ENTRY_BYTES / 1048576
));
}
$content = $zip->getFromIndex($index);
if ($content === false) {
continue;
}
$originalName = basename($entryName);
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$storedPath = 'bulk-territori-imports/' . $importId . '/zip-' . str_pad((string) $entryIndex, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf';
file_put_contents(storage_path('app/' . $storedPath), $content);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
$entryIndex++;
}
} catch (\Throwable $exception) {
// Nothing was queued, so the job's own cleanup will never run —
// drop the partially extracted files rather than orphaning them.
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $exception;
} finally {
$zip->close();
}
if ($storedFiles === []) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
}
$this->dispatchStoredFiles(
$importId,
$storedFiles,
$actorId,
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.',
$ignorati
);
return $importId;
}
}