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

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Link scaduto — TerManager2</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
<title>Territorio {{ $assignment->territorio?->numero }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -48,9 +48,19 @@
sanno a quale account associare la credenziale che salvano. --}}
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
@unless($forced)
{{-- Dopo un ripristino non si chiede: la password attuale e'
quella del backup, che si puo' non ricordare. --}}
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>
<input type="password" name="current_password" id="current_password" required autofocus autocomplete="current-password"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
</div>
@endunless
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
<input type="password" name="password" id="password" required autofocus autocomplete="new-password"
<input type="password" name="password" id="password" required @if($forced) autofocus @endif autocomplete="new-password"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p>
</div>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
@@ -17,13 +18,16 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-3">
<x-btn tono="velato" icona class="lg:hidden"
{{-- Nascosto dal contenitore: vedi il profilo qui sotto. --}}
<div class="lg:hidden">
<x-btn tono="velato" icona
x-data x-on:click="$dispatch('toggle-sidebar')"
aria-label="Apri il menu di navigazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</x-btn>
</div>
@php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path)
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
@@ -35,14 +39,21 @@
</span>
</div>
<div class="flex items-center gap-4">
{{-- Il contenitore si nasconde, non il pulsante: .tm-btn sta
fuori dai layer di Tailwind e il suo display vince
su "hidden", così su telefono nome ed email
spingevano "Esci" fuori dallo schermo. Il profilo
resta raggiungibile dal menu. --}}
<div class="hidden sm:block">
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo"
class="hidden sm:flex {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
class="{{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="flex flex-col leading-tight text-left">
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span>
</x-btn>
</div>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-btn tono="velato" dimensione="sm" type="submit">

View File

@@ -14,6 +14,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>

View File

@@ -83,7 +83,11 @@
</div>
@else
@foreach($activity->properties->toArray() as $k => $v)
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
@if($k === 'proclamatore_id')
<div>proclamatore: {{ $nomiProclamatori[$v] ?? '#' . $v }}</div>
@else
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
@endif
@endforeach
@endif
</div>

View File

@@ -131,7 +131,7 @@
<div class="tm-azioni-riga">
@if($mappa->collegata() && $mappa->territorio->pdf_path)
<a href="{{ asset('storage/' . $mappa->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
<a href="{{ $mappa->territorio->pdfUrl() }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
@endif
@if($mappa->bozzaScartabile())
<x-btn variante="ghost" dimensione="sm" wire:click="scartaModifiche"

View File

@@ -97,7 +97,7 @@
@endif
@if($piantina->collegata() && $piantina->territorio->pdf_path)
<a href="{{ asset('storage/' . $piantina->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
<a href="{{ $piantina->territorio->pdfUrl() }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm"
@@ -167,7 +167,8 @@
<div class="tm-card-testata">
<h2 class="tm-card-titolo">Storico versioni PDF <span class="tm-conteggio">{{ $versioni->total() }}</span></h2>
<p class="tm-card-nota">
I PDF dei territori sostituiti da una pubblicazione o da un ripristino. Ripristinare una versione
I PDF dei territori sostituiti: da una pubblicazione, da un caricamento (dalla scheda o
dall'import da cartella) o da un ripristino. Ripristinare una versione
rimette in uso quel PDF: la piantina del territorio torna "da pubblicare".
</p>
</div>
@@ -195,7 +196,7 @@
<td data-label="Sostituito il">{{ $versione->sostituito_il?->format('d/m/Y H:i') }}</td>
<td data-label="Origine">{{ $versione->origine }}</td>
<td class="cell-actions">
<a href="{{ asset('storage/' . $versione->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Apri</a>
<a href="{{ route('territori.file.versione', $versione) }}" target="_blank" rel="noopener" class="tm-collegamento">Apri</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm"
wire:click="ripristinaVersione({{ $versione->id }})"
wire:confirm="Ripristinare questa versione per il territorio {{ $versione->territorio?->numero }}? Il PDF attuale finisce a sua volta nello storico.">

View File

@@ -58,12 +58,6 @@
<h3 class="text-lg font-semibold text-gray-900 mb-1">Cambia password</h3>
<p class="text-sm text-gray-500 mb-4">Per motivi di sicurezza conferma la password attuale.</p>
@if(session('password_success'))
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
{{ session('password_success') }}
</div>
@endif
<form wire:submit="updatePassword" class="space-y-4">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>

View File

@@ -77,7 +77,7 @@
<td data-label="Territorio" class="px-3 py-2">
<div class="flex items-center gap-2">
@if($a->territorio?->thumbnail_path)
<img src="{{ asset('storage/' . $a->territorio->thumbnail_path) }}"
<img src="{{ $a->territorio->miniaturaUrl() }}"
alt="Thumbnail territorio {{ $a->territorio?->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
@endif

View File

@@ -14,12 +14,6 @@
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
@if (session()->has('success'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 3000)" x-transition.duration.500ms
class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-700 border border-green-200">
{{ session('success') }}
</div>
@endif
<form wire:submit="save" class="space-y-5">
<div>

View File

@@ -4,12 +4,6 @@
<p class="text-sm text-gray-500 mt-1">Crea utenti e assegna un ruolo applicativo.</p>
</div>
@if (session()->has('success'))
<div class="rounded-lg bg-green-50 p-3 text-sm text-green-700 border border-green-200">{{ session('success') }}</div>
@endif
@if (session()->has('error'))
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">{{ session('error') }}</div>
@endif
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Nuovo utente</h2>

View File

@@ -61,15 +61,124 @@
zipInCorso: false,
zipPercento: 0,
{{-- La cartella viaggia a lotti: vedi XmlExchange::getFilePerLottoProperty().
Il tetto in byte tiene ogni richiesta lontana dai 100 MB
che Cloudflare accetta per un singolo caricamento. --}}
fileCartella: [],
cartellaInCorso: false,
caricatiCartella: 0,
filePerLotto: {{ $this->filePerLotto }},
byteMassimiLotto: 40 * 1000 * 1000,
log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'import-pdf' });
},
/*
* Stessa regola di TerritorioPdfImportDispatcher::daIgnorare():
* Thumbs.db, desktop.ini, .DS_Store e gli altri file di sistema
* o nascosti non partono nemmeno. Il server li scarta comunque,
* ma caricarli sarebbe solo traffico.
*/
daIgnorare(file) {
const segmenti = (file.webkitRelativePath || file.name).split('/').filter((s) => s !== '');
if (segmenti.some((s) => s.startsWith('.') || s.toUpperCase() === '__MACOSX')) {
return true;
}
const nome = segmenti[segmenti.length - 1] || '';
return nome.startsWith('~$') || !nome.toLowerCase().endsWith('.pdf');
},
cartellaScelta(event) {
this.fileScelti = event.target.files.length;
if (this.fileScelti > 0) {
this.log(`Cartella scelta: ${this.fileScelti} file.`);
this.log(`In attesa del caricamento temporaneo di Livewire...`, 'attesa');
const tutti = Array.from(event.target.files);
this.fileCartella = tutti.filter((file) => !this.daIgnorare(file));
this.fileScelti = this.fileCartella.length;
this.caricatiCartella = 0;
const ignorati = tutti.length - this.fileCartella.length;
if (tutti.length > 0) {
this.log(`Cartella scelta: ${tutti.length} file, ${this.fileScelti} PDF da importare`
+ (ignorati > 0 ? ` (${ignorati} ignorati: file di sistema, nascosti o non PDF).` : `.`));
}
},
lotti() {
const lotti = [];
let lotto = [];
let byte = 0;
for (const file of this.fileCartella) {
if (lotto.length > 0 && (lotto.length >= this.filePerLotto || byte + file.size > this.byteMassimiLotto)) {
lotti.push(lotto);
lotto = [];
byte = 0;
}
lotto.push(file);
byte += file.size;
}
if (lotto.length > 0) {
lotti.push(lotto);
}
return lotti;
},
/*
* Un lotto alla volta, aggiunto (append) a quelli gia' arrivati.
* La guardia esiste perche' Livewire non ascolta gli errori di
* rete del caricamento: una connessione caduta lascerebbe la
* promessa appesa per sempre, con la pagina muta.
*/
caricaLotto(lotto) {
return new Promise((ok, ko) => {
let ultimoSegno = Date.now();
const guardia = setInterval(() => {
if (Date.now() - ultimoSegno > 120000) {
clearInterval(guardia);
this.$wire.cancelUpload('pdfFolder');
ko(new Error(`nessun avanzamento da due minuti`));
}
}, 5000);
this.$wire.uploadMultiple('pdfFolder', lotto,
() => { clearInterval(guardia); ok(); },
() => { clearInterval(guardia); ko(new Error(`il server ha rifiutato il lotto`)); },
() => { ultimoSegno = Date.now(); },
() => { clearInterval(guardia); ko(new Error(`caricamento annullato`)); },
true);
});
},
async inviaCartella() {
if (this.fileCartella.length === 0 || this.cartellaInCorso) {
return;
}
this.$dispatch('console-apri');
if (!(await this.$wire.cartellaPuoPartire())) {
this.log(`Import non avviato: c'è già un'operazione in corso.`, 'errore');
return;
}
this.cartellaInCorso = true;
this.caricatiCartella = 0;
try {
await this.$wire.svuotaCartella();
const lotti = this.lotti();
this.log(`Caricamento di ${this.fileCartella.length} PDF in ${lotti.length} lotti...`);
for (let i = 0; i < lotti.length; i++) {
await this.caricaLotto(lotti[i]);
this.caricatiCartella += lotti[i].length;
this.log(`Lotto ${i + 1}/${lotti.length} caricato: ${this.caricatiCartella}/${this.fileCartella.length} file.`);
}
this.log(`Cartella caricata. Preparazione import sul server...`, 'ok');
if (await this.$wire.importTerritoryPdfFolder()) {
this.fileCartella = [];
this.fileScelti = 0;
this.$refs.cartella.value = '';
} else {
this.log(`Import non avviato: il motivo è nell'avviso a comparsa. La cartella resta scelta, puoi ripremere.`, 'errore');
}
} catch (errore) {
this.log(`ERRORE durante il caricamento della cartella: ${errore.message}. Nessun import è partito: riprova.`, 'errore');
} finally {
this.cartellaInCorso = false;
}
},
@@ -139,17 +248,15 @@
xhr.send(new FormData(form));
},
}"
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { $dispatch('console-apri'); log('Caricamento della cartella avviato...'); }"
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { log('Caricamento cartella: ' + $event.detail.progress + '%'); }"
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { log('Cartella caricata. Preparazione import sul server...', 'ok'); }"
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { log('ERRORE durante il caricamento temporaneo dei file.', 'errore'); }">
}">
<div>
<h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 &middot; Import PDF territori</h2>
<p class="text-xs text-gray-500 mt-1">
Associa i PDF ai territori gi&agrave; presenti e ne genera le miniature. Il nome del file pu&ograve;
variare: basta che contenga il numero di un territorio esistente.
Associa i PDF ai territori gi&agrave; presenti e ne genera le miniature. Il nome del file deve
contenere il numero del territorio: una lettera subito dopo il numero ne fa parte (&laquo;12 B&raquo;,
&laquo;12B&raquo; e &laquo;12-b&raquo; sono tutti il 12B, mai il 12). I file che non si abbinano con
certezza a un solo territorio vengono saltati e segnalati nell&rsquo;esito.
</p>
</div>
@@ -186,30 +293,32 @@
</form>
{{-- Strada alternativa --}}
<form wire:submit.prevent="importTerritoryPdfFolder" class="rounded-lg border border-gray-200 p-4 space-y-3">
{{-- Niente wire:model sull'input: con wire:model Livewire spedisce
tutta la cartella in una sola richiesta, e PHP ne tiene solo i
primi max_file_uploads (20). La spedisce inviaCartella(), a lotti. --}}
<form @submit.prevent="inviaCartella()" class="rounded-lg border border-gray-200 p-4 space-y-3">
<div>
<h3 class="text-sm font-semibold text-gray-900">Da cartella</h3>
<p class="mt-1 text-xs text-gray-500">Comoda per pochi file. Ogni PDF viaggia come caricamento temporaneo a s&eacute;.</p>
<p class="mt-1 text-xs text-gray-500">I PDF partono a gruppi di {{ $this->filePerLotto }}; i file che non sono PDF (Thumbs.db, desktop.ini&hellip;) vengono ignorati.</p>
</div>
<div>
<label for="pdfFolder" class="sr-only">Cartella di PDF</label>
<input wire:model="pdfFolder" id="pdfFolder" type="file" multiple webkitdirectory directory
accept=".pdf,application/pdf"
<input id="pdfFolder" type="file" multiple webkitdirectory directory
x-ref="cartella"
data-file-per-lotto="{{ $this->filePerLotto }}"
x-on:change="cartellaScelta($event)"
x-bind:disabled="cartellaInCorso"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
@error('pdfFolder') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
@error('pdfFolder.*') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak x-text="fileScelti + ' file selezionati'"></p>
<p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak
x-text="cartellaInCorso ? caricatiCartella + ' / ' + fileScelti + ' PDF caricati' : fileScelti + ' PDF da importare'"></p>
<x-btn variante="secondary" type="submit"
x-on:click="$dispatch('console-apri')"
wire:loading.attr="disabled"
{{-- Vedi la nota in components/btn.blade.php: dentro un
tag di componente serve :disabled, non @disabled. --}}
:disabled="empty($pdfFolder)">
x-bind:disabled="fileScelti === 0 || cartellaInCorso">
Importa cartella
</x-btn>
<span wire:loading wire:target="importTerritoryPdfFolder" class="text-xs text-indigo-700">Preparazione import...</span>
@@ -329,7 +438,8 @@
<h3 class="text-sm font-semibold text-gray-900">Importa</h3>
<p class="text-xs text-gray-500">
<strong>Sostituisce</strong> zone, tipologie, proclamatori, territori, anni, campagne,
assegnazioni e impostazioni.
assegnazioni e impostazioni. I PDF e le miniature dei territori sostituiti che il file non
richiama pi&ugrave; vengono eliminati.
</p>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" class="space-y-3"

View File

@@ -108,7 +108,7 @@
<td data-label="N°" class="px-4 py-3 text-sm font-semibold text-gray-900">
<div class="flex items-center gap-2">
@if($territorio->thumbnail_path)
<img src="{{ asset('storage/' . $territorio->thumbnail_path) }}"
<img src="{{ $territorio->miniaturaUrl() }}"
alt="Thumbnail territorio {{ $territorio->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none shadow-sm">
@endif

View File

@@ -132,10 +132,10 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<h3 class="text-sm font-semibold text-gray-700 mb-3">PDF Territorio</h3>
<div class="w-full" style="min-height: 500px;">
<embed src="{{ asset('storage/' . $territorio->pdf_path) }}" type="application/pdf" width="100%" height="500px">
<embed src="{{ $territorio->pdfUrl() }}" type="application/pdf" width="100%" height="500px">
<p class="mt-2 text-sm text-gray-500">
Se il PDF non è visibile:
<a href="{{ asset('storage/' . $territorio->pdf_path) }}" target="_blank" class="text-indigo-600 hover:underline">Scarica PDF</a>
<a href="{{ $territorio->pdfUrl() }}" target="_blank" class="text-indigo-600 hover:underline">Scarica PDF</a>
</p>
</div>
</div>