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 $ignorati nomi dei file scartati da daIgnorare(), solo per il log * @param array $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; } }