Files
termanager2/app/Livewire/Territori/TerritorioIndex.php
francesco 0502fad996 Correggi bug di sicurezza e integrita' dei dati
I due piu' gravi potevano causare perdita di dati silenziosa.

AppServiceProvider chiamava key:generate --force a ogni richiesta quando
APP_KEY risultava vuota. Poiche' nome e cognome dei proclamatori sono
cifrati con quella chiave, ogni riga scritta sotto una chiave poi
sostituita diventava irrecuperabile. La chiave e' ora risolta una sola
volta all'avvio del container e il provider solleva un'eccezione se manca,
invece di rigenerarla.

importTerritoryPdfFolder() caricava i PDF, costruiva l'elenco dei file e
poi lo scartava senza mai accodare il job: nessun errore, nessun territorio
aggiornato, e i file restavano su disco a crescita illimitata perche' la
pulizia viveva solo nel finally del job mai eseguito.

Sicurezza:
- i permessi non venivano riapplicati alle richieste /livewire/update, per
  cui un permesso revocato restava attivo fino al reload della pagina
- host header injection: nginx passava l'Host del client come
  X-Forwarded-Host e Laravel si fidava di tutti i proxy, abbastanza per far
  generare a route() link verso domini arbitrari, inclusi i PDF pubblici
  che contengono un codice di accesso valido
- lo schema forwarded e' ora deciso dal server e non dall'header del client
- campagna_id in Rientra e' #[Locked]: era derivato lato server ma
  manipolabile dal client, permettendo di conteggiare un rientro su una
  campagna arbitraria
- sortField del registro passava non validato a orderBy(), con 500 su
  colonna inesistente
- SESSION_SECURE_COOKIE ora vale true fuori da local
- limiti su dimensione e numero di voci negli import ZIP

Integrita' e prestazioni:
- il default di assignment_link_ttl_hours era rimasto 24 dopo la
  conversione da ore a mesi: i link PDF pubblici duravano due anni invece
  di uno mese
- il seeder usava syncPermissions(), che a un secondo giro azzerava i
  permessi personalizzati dei ruoli; ora e' additivo
- pdftoppm senza timeout poteva bloccare il worker indefinitamente
- nomi file deterministici nell'import: "1 A" e "1-A" scrivevano sullo
  stesso PDF, sovrascrivendosi
- Setting::flushCache() dopo le cancellazioni massive, che non emettono
  eventi e lasciavano la cache puntata a una riga inesistente
- riga settings creata dal seeder, per evitare due singleton concorrenti
- rimosse due migrazioni ridondanti che rompevano migrate:rollback
- indici su assigned_at, (attivo, deleted_at) e deleted_at
- risolti gli N+1 negli elenchi territori e proclamatori

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:49:42 +02:00

263 lines
8.6 KiB
PHP

<?php
namespace App\Livewire\Territori;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Territorio;
use App\Models\Zona;
use App\Models\Tipologia;
class TerritorioIndex extends Component
{
use WithPagination;
public string $search = '';
public string $filterZona = '';
public string $filterTipologia = '';
public string $filterStato = '';
public string $filterPriorita = '';
public string $filterPdf = '';
public string $filterContenuti = '';
public string $sortField = 'numero';
public string $sortDirection = 'asc';
protected $queryString = [
'search' => ['except' => ''],
'filterZona' => ['except' => ''],
'filterTipologia' => ['except' => ''],
'filterStato' => ['except' => ''],
'filterPriorita' => ['except' => ''],
'filterPdf' => ['except' => ''],
'filterContenuti' => ['except' => ''],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingFilterZona()
{
$this->resetPage();
}
public function updatingFilterTipologia()
{
$this->resetPage();
}
public function updatingFilterStato()
{
$this->resetPage();
}
public function updatingFilterPriorita()
{
$this->resetPage();
}
public function updatingFilterPdf()
{
$this->resetPage();
}
public function updatingFilterContenuti()
{
$this->resetPage();
}
public function sortBy(string $field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters(): void
{
$this->reset([
'search',
'filterZona',
'filterTipologia',
'filterStato',
'filterPriorita',
'filterPdf',
'filterContenuti',
]);
$this->sortField = 'numero';
$this->sortDirection = 'asc';
$this->resetPage();
}
public function toggleActive(Territorio $territorio)
{
$territorio->update(['attivo' => !$territorio->attivo]);
activity()->causedBy(auth()->user())
->performedOn($territorio)
->withProperties(['attivo' => $territorio->attivo])
->log($territorio->attivo ? 'activated' : 'deactivated');
}
public function togglePriority(Territorio $territorio)
{
$territorio->update(['prioritario' => !$territorio->prioritario]);
activity()->causedBy(auth()->user())
->performedOn($territorio)
->withProperties(['prioritario' => $territorio->prioritario])
->log($territorio->prioritario ? 'set_priority' : 'unset_priority');
}
public function deleteTerritorio(Territorio $territorio)
{
activity()->causedBy(auth()->user())
->performedOn($territorio)
->log('soft_deleted');
$territorio->delete();
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
}
public function render()
{
// ultimaAssegnazione is eager-loaded because getGiorniGiacenzaAttribute()
// and is_prioritario read it for every row — and this query fetches the
// whole table (pagination happens in memory below), so lazy-loading it
// meant one extra query per territory on every render.
$query = Territorio::with([
'zona',
'tipologia',
'assegnazioneCorrente.proclamatore',
'ultimaAssegnazione',
]);
if ($this->search) {
$search = $this->search;
$query->where(function ($subQuery) use ($search) {
$subQuery->where('numero', 'like', "%{$search}%")
->orWhere('note', 'like', "%{$search}%")
->orWhere('confini', 'like', "%{$search}%")
->orWhereHas('zona', fn($zonaQuery) => $zonaQuery->where('nome', 'like', "%{$search}%"))
->orWhereHas('tipologia', fn($tipologiaQuery) => $tipologiaQuery->where('nome', 'like', "%{$search}%"));
});
}
if ($this->filterZona) {
$query->where('zona_id', $this->filterZona);
}
if ($this->filterTipologia) {
$query->where('tipologia_id', $this->filterTipologia);
}
if ($this->filterStato) {
match ($this->filterStato) {
'in_reparto' => $query->inReparto(),
'assegnato' => $query->assegnato(),
'da_rientrare' => $query->daRientrare(),
'inattivo' => $query->where('attivo', false),
'prioritari' => $query->inReparto(),
default => null,
};
}
if ($this->filterPdf) {
match ($this->filterPdf) {
'con_pdf' => $query->whereNotNull('pdf_path'),
'senza_pdf' => $query->whereNull('pdf_path'),
'con_thumbnail' => $query->whereNotNull('thumbnail_path'),
'senza_thumbnail' => $query->whereNull('thumbnail_path'),
default => null,
};
}
if ($this->filterContenuti) {
match ($this->filterContenuti) {
'con_note' => $query->whereNotNull('note')->where('note', '!=', ''),
'senza_note' => $query->where(function ($subQuery) {
$subQuery->whereNull('note')->orWhere('note', '');
}),
'con_confini' => $query->whereNotNull('confini')->where('confini', '!=', ''),
'senza_confini' => $query->where(function ($subQuery) {
$subQuery->whereNull('confini')->orWhere('confini', '');
}),
default => null,
};
}
$territori = $query->get();
if ($this->filterStato === 'prioritari') {
$territori = $territori->filter(fn(Territorio $territorio) => $territorio->is_prioritario)->values();
}
if ($this->filterPriorita) {
$territori = $territori->filter(function (Territorio $territorio) {
return match ($this->filterPriorita) {
'prioritari' => $territorio->is_prioritario,
'manuali' => $territorio->prioritario,
'automatici' => $territorio->is_prioritario && !$territorio->prioritario,
'non_prioritari' => !$territorio->is_prioritario,
default => true,
};
})->values();
}
if ($this->usesPriorityOrdering()) {
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->is_prioritario <=> (int) $left->is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->giorni_giacenza <=> $left->giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})->values();
} else {
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
$result = strnatcasecmp((string) data_get($left, $this->sortField), (string) data_get($right, $this->sortField));
return $this->sortDirection === 'asc' ? $result : -$result;
})->values();
}
$perPage = 20;
$page = $this->getPage();
$items = $territori->slice(($page - 1) * $perPage, $perPage)->values();
$paginatedTerritori = new LengthAwarePaginator(
$items,
$territori->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
return view('livewire.territori.territorio-index', [
'territori' => $paginatedTerritori,
'zone' => Zona::attive()->get(),
'tipologie' => Tipologia::attive()->get(),
'usesPriorityOrdering' => $this->usesPriorityOrdering(),
]);
}
protected function usesPriorityOrdering(): bool
{
return $this->sortField === 'numero'
&& $this->sortDirection === 'asc'
&& (
in_array($this->filterStato, ['in_reparto', 'prioritari'], true)
|| $this->filterPriorita !== ''
);
}
}