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

@@ -2,6 +2,7 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
@@ -9,20 +10,27 @@ use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class XmlExchange extends Component
{
use Avvisa;
use WithFileUploads;
public array $importStats = [];
@@ -52,39 +60,149 @@ class XmlExchange extends Component
}
}
public function importTerritoryPdfFolder(): void
/**
* Quanti file viaggiano in una singola richiesta di caricamento della
* cartella.
*
* Il browser non manda la cartella tutta insieme: la spezza in lotti e li
* aggiunge uno dopo l'altro a $pdfFolder. Un'unica richiesta con tutti i file
* si scontrava con `max_file_uploads` di PHP (20 per default), che non
* rifiuta: scarta in silenzio i file in piu', e l'import partiva con i primi
* venti di una cartella da trecento. Alzare il limite non basterebbe: la
* richiesta unica resterebbe soggetta al tetto di dimensione e al timeout di
* 100 secondi di Cloudflare, e un errore a meta' farebbe ricominciare da capo.
* A lotti, ogni richiesta e' piccola e un errore ne ferma una sola.
*
* Si legge il valore vero di PHP, perche' un'installazione con un limite piu'
* basso non perda file in silenzio come prima.
*/
public function getFilePerLottoProperty(): int
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
}
$importId = (string) Str::uuid();
$storedFiles = [];
/**
* Controllo rapido prima di caricare la cartella: senza, si caricherebbero
* centinaia di file per poi sentirsi dire che c'e' un ripristino in corso.
* Non prende il posto — lo prende importTerritoryPdfFolder(), a caricamento
* finito — perche' una scheda chiusa a meta' caricamento lo terrebbe
* occupato per un'ora.
*/
public function cartellaPuoPartire(): bool
{
$inCorso = app(BackupOperationState::class)->corrente();
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
if ($inCorso === null) {
return true;
}
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
$this->avvisaErrore(app(BackupOperationState::class)->messaggioOccupato($inCorso));
return false;
}
/** Il browser azzera i lotti di un caricamento precedente prima di iniziarne uno nuovo. */
public function svuotaCartella(): void
{
foreach ($this->pdfFolder as $file) {
if ($file instanceof TemporaryUploadedFile) {
$file->delete();
}
}
$this->pdfFolder = [];
}
/**
* Mette in coda l'import dei lotti gia' caricati. Restituisce true se
* l'import e' partito: il browser tiene la cartella scelta finche' non lo e',
* cosi' dopo un rifiuto basta ripremere.
*/
public function importTerritoryPdfFolder(): bool
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
]);
$importId = (string) Str::uuid();
$dispatcher = app(TerritorioPdfImportDispatcher::class);
try {
$dispatcher->occupaPosto($importId);
} catch (\RuntimeException $e) {
// Il posto l'ha preso qualcun altro fra il controllo iniziale e la
// fine del caricamento: si avvisa, e non si tocca niente.
$this->avvisaErrore($e->getMessage());
return false;
}
$storedFiles = [];
$ignorati = [];
$scartati = [];
try {
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
// Thumbs.db, .DS_Store, desktop.ini e simili: prima uno solo di
// questi faceva fallire la validazione e con lei l'intero import.
if (TerritorioPdfImportDispatcher::daIgnorare($originalName)) {
$ignorati[] = $originalName;
continue;
}
// Un PDF rotto o troppo grande si scarta da solo, con il suo
// motivo nell'esito: non e' una ragione per buttare gli altri.
$verifica = Validator::make(['file' => $file], ['file' => ['file', 'mimes:pdf', 'max:10240']]);
if ($verifica->fails()) {
$scartati[] = [
'file' => $originalName,
'message' => 'File non valido: ' . $verifica->errors()->first('file'),
];
continue;
}
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
} catch (\Throwable $e) {
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $e;
}
$this->svuotaCartella();
if ($storedFiles === []) {
$dispatcher->liberaPosto($importId, 'Nessun PDF da importare.');
$this->avvisaErrore('Nella cartella non c\'è nessun PDF da importare'
. ($ignorati !== [] ? ' (ignorati ' . count($ignorati) . ' file di sistema o di altro tipo)' : '')
. ($scartati !== [] ? ' (' . count($scartati) . ' PDF non validi: ' . implode(', ', array_column($scartati, 'file')) . ')' : '')
. '.');
return false;
}
$this->dispatchPdfImport(
$importId,
$storedFiles,
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
$ignorati,
$scartati
);
return true;
}
public function refreshPdfImportStatus(): void
@@ -114,8 +232,9 @@ class XmlExchange extends Component
* contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e
* non partecipa al posto unico: puo' girare mentre un backup lavora.
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
* Del posto unico usa solo l'occupazione (BackupOperationState::occupa()):
* non gira mai insieme a un backup, un ripristino o un azzeramento.
*/
protected function spedisciImportAllaConsole(): void
{
@@ -156,10 +275,10 @@ class XmlExchange extends Component
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
@@ -172,13 +291,13 @@ class XmlExchange extends Component
$this->logPdfSpediti = 0;
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
$this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
$this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
return;
}
@@ -236,6 +355,67 @@ class XmlExchange extends Component
return $this->datasetToXml($dataset, $source);
}
/**
* I file sul disco `public` a cui puntano i territori e il loro storico PDF.
*
* Serve all'import XML per sapere, dopo, quali file ha reso orfani. L'import
* cancella tutti i territori e li ricrea con id nuovi; i PDF portano l'id nel
* nome (territorio-142-141.pdf), quindi un file che arriva da un'altra
* installazione, o da un dump della vecchia applicazione senza PDF, lascia i
* file dei territori cancellati sul disco: nessuna schermata li mostra,
* nessuna cancellazione li raggiunge, e finiscono dentro ogni backup
* successivo. E' cosi' che la cartella territori-pdf si era trovata con due
* PDF per territorio.
*
* @return array<int, string>
*/
public static function fileDeiTerritori(): array
{
return Territorio::withTrashed()->get(['pdf_path', 'thumbnail_path'])
->concat(TerritorioPdfVersione::query()->get(['pdf_path', 'thumbnail_path']))
->flatMap(fn ($riga) => [$riga->pdf_path, $riga->thumbnail_path])
->filter()
->unique()
->values()
->all();
}
/**
* Elimina i file che prima dell'import appartenevano a un territorio e che
* adesso nessuna riga richiama piu'.
*
* Si toccano solo i file elencati in $fileDiPrima: un file sul disco che
* nessuno conosceva non e' affare dell'import. E prima di cancellare si
* ricontrolla ogni riferimento possibile — anche logo e piantine — cosi' un
* XML che riporta gli stessi percorsi (un export di questa stessa
* installazione) non perde niente.
*
* @param array<int, string> $fileDiPrima
*/
public static function eliminaFileNonPiuUsati(array $fileDiPrima): int
{
$usati = array_flip(array_merge(
self::fileDeiTerritori(),
array_filter([Setting::instance()->logo_path]),
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
));
$disco = Storage::disk('public');
$eliminati = 0;
foreach ($fileDiPrima as $percorso) {
if (isset($usati[$percorso]) || ! $disco->exists($percorso)) {
continue;
}
if ($disco->delete($percorso)) {
$eliminati++;
}
}
return $eliminati;
}
public function importXmlFromContent(string $content): array
{
$this->importStats = [];
@@ -370,14 +550,20 @@ class XmlExchange extends Component
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
// Per chi sta importando password e token restano i suoi:
// quelli del file possono essere di mesi fa.
//
// Chi riceve l'hash dal file deve scegliere una password nuova,
// come dopo un ripristino: chi ha avuto il file fra le mani ha
// potuto tentarne il crack con calma. Il "ricordami" si azzera
// e basta: non si esporta più, e quello di un file vecchio
// sarebbe una credenziale persistente regalata a chi lo tiene.
$meta = [];
$passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash;
$meta['password_change_required'] = true;
}
if (! $eAttore) {
$rememberToken = (string) ($userNode->remember_token ?? '');
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
$meta['remember_token'] = null;
}
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
@@ -620,7 +806,8 @@ class XmlExchange extends Component
'email' => (string) $u->email,
'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
'remember_token' => (string) ($u->getRememberToken() ?? ''),
// remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(),
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
@@ -1030,7 +1217,6 @@ class XmlExchange extends Component
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles');