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>
245 lines
9.3 KiB
PHP
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;
|
|
}
|
|
} |