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

@@ -9,7 +9,6 @@ use App\Models\Proclamatore;
use App\Models\Assegnazione;
use App\Models\AnnoTeocratico;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class Assegna extends Component
{
@@ -99,9 +98,12 @@ class Assegna extends Component
activity()->causedBy(auth()->user())
->performedOn($assegnazione)
// L'id, non il nome: nome e cognome sono cifrati nel database, e
// copiati qui in chiaro restavano leggibili anche dopo
// l'anonimizzazione. Il registro mostra il nome di oggi.
->withProperties([
'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo,
'proclamatore_id' => $proclamatore->id,
])
->log('assigned');
@@ -123,7 +125,7 @@ class Assegna extends Component
return null;
}
$t = Territorio::find($this->territorio_id);
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
return $t?->miniaturaUrl();
}
public function render()

View File

@@ -92,7 +92,7 @@ class Rientra extends Component
->performedOn($this->assegnazione)
->withProperties([
'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo,
'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
'giorni' => $this->assegnazione->giorni,
'campagna' => $this->counted_in_campaign,
])

View File

@@ -48,8 +48,22 @@ class AuditLog extends Component
$events = Activity::select('description')->distinct()->pluck('description');
$users = \App\Models\User::orderBy('name')->get();
$activities = $query->paginate(30);
// Il log conserva solo l'id del proclamatore: il nome si legge adesso,
// così chi è stato anonimizzato compare come tale anche nello storico.
$idProclamatori = $activities->getCollection()
->map(fn ($a) => data_get($a->properties, 'proclamatore_id'))
->filter()
->unique();
$nomiProclamatori = \App\Models\Proclamatore::withTrashed()
->whereIn('id', $idProclamatori)
->get()
->mapWithKeys(fn ($p) => [$p->id => $p->nome_completo]);
return view('livewire.audit-log', [
'activities' => $query->paginate(30),
'activities' => $activities,
'nomiProclamatori' => $nomiProclamatori,
'events' => $events,
'users' => $users,
]);

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Campagne;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Campagna;
class CampagnaIndex extends Component
{
use Avvisa;
use WithPagination;
public function deleteCampagna(int $id)
@@ -19,7 +21,7 @@ class CampagnaIndex extends Component
$campagna = Campagna::findOrFail($id);
$campagna->delete();
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
$this->avvisa("Campagna '{$campagna->descrizione}' eliminata.");
}
public function render()

View File

@@ -18,12 +18,12 @@ class CampagnaShow extends Component
public function render()
{
// Assignments counted for this campaign:
// - assigned on or after campaign start
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
->where('counted_in_campaign', true)
->where('assigned_at', '>=', $this->campagna->start_date)
// Le assegnazioni contate per la campagna, cioè quelle per cui al
// rientro si è risposto sì (campagnaApplicabile propone anche quelle
// uscite poco prima dell'inizio e rientrate durante). Le stesse della
// percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
// e l'elenco era più corto di quanto la percentuale contasse.
$conteggiate = $this->campagna->assegnazioniConteggiate()
->with(['territorio', 'proclamatore'])
->orderBy('returned_at')
->get();

View File

@@ -174,22 +174,46 @@ trait SegueOperazioneBackup
return true;
}
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
// premuto il pulsante vuole comunque vedere a che punto e'.
$this->operationId = $inCorso;
$this->logSpediti = 0;
$this->aggiorna();
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
$this->rifiutaPerOccupato($inCorso);
return false;
}
protected function nuovaOperazione(string $tipo): string
/**
* Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
* premuto il pulsante vuole comunque vedere a che punto e'. Il messaggio
* dice quale operazione occupa il posto, perche' puo' essere un import
* avviato da un'altra zona della pagina, o da un altro amministratore.
*/
private function rifiutaPerOccupato(string $inCorso): void
{
$this->operationId = $inCorso;
$this->logSpediti = 0;
$this->aggiorna();
$this->erroreOperazione = app(BackupOperationState::class)->messaggioOccupato($inCorso);
}
/**
* Prende il posto e crea l'operazione; null se il posto e' occupato.
*
* postoLibero() resta il controllo rapido prima di validare il resto, ma fra
* quel controllo e questo passo un'altra richiesta puo' arrivare prima — una
* seconda scheda, un import partito da un'altra zona. E' qui, sotto il mutex
* di BackupOperationState::occupa(), che si decide davvero.
*/
protected function nuovaOperazione(string $tipo): ?string
{
$operationId = (string) Str::uuid();
$stato = app(BackupOperationState::class);
$stato->initialize($operationId, $tipo, 'In coda');
$inCorso = $stato->occupa($operationId, $tipo, 'In coda');
if ($inCorso !== null) {
$this->rifiutaPerOccupato($inCorso);
return null;
}
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
$this->logSpediti = 0;

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Proclamatore;
class ProclamatoreCestino extends Component
{
use Avvisa;
use WithPagination;
public function restore(int $id)
@@ -19,7 +21,7 @@ class ProclamatoreCestino extends Component
activity()->causedBy(auth()->user())
->performedOn($proclamatore)
->log('restored');
session()->flash('success', "Proclamatore ripristinato.");
$this->avvisa("Proclamatore ripristinato.");
}
public function forceDelete(int $id)
@@ -29,14 +31,14 @@ class ProclamatoreCestino extends Component
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
if ($proclamatore->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
$this->avvisaErrore('Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
return;
}
activity()->causedBy(auth()->user())
->log('force_deleted_proclamatore');
$proclamatore->forceDelete();
session()->flash('success', 'Proclamatore eliminato definitivamente.');
$this->avvisa('Proclamatore eliminato definitivamente.');
}
public function render()

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Proclamatore;
class ProclamatoreIndex extends Component
{
use Avvisa;
use WithPagination;
public string $search = '';
@@ -59,12 +61,12 @@ class ProclamatoreIndex extends Component
$proclamatore = Proclamatore::findOrFail($id);
if ($proclamatore->assegnazioni()->aperte()->exists()) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return;
}
$proclamatore->delete();
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
}
public function anonimizza(int $id)
@@ -73,7 +75,7 @@ class ProclamatoreIndex extends Component
$proclamatore = Proclamatore::findOrFail($id);
$proclamatore->anonimizza();
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
}
public function render()

View File

@@ -2,11 +2,13 @@
namespace App\Livewire;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Validation\Rule;
use Livewire\Component;
class Profile extends Component
{
use Avvisa;
public string $name = '';
public string $email = '';
@@ -67,7 +69,7 @@ class Profile extends Component
$this->reset(['current_password', 'password', 'password_confirmation']);
session()->flash('password_success', 'Password aggiornata con successo.');
$this->avvisa('Password aggiornata con successo.');
}
public function render()

View File

@@ -48,7 +48,11 @@ class BackupManager extends Component
return;
}
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
if ($operationId === null) {
return;
}
$this->operationId = $operationId;
CreateBackupArchive::dispatch($this->operationId, false);

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use App\Models\Setting;
class SettingsEdit extends Component
{
use Avvisa;
public string $congregazione_nome = '';
public int $giorni_giacenza_da_assegnare = 120;
public int $giorni_giacenza_prioritari = 180;
@@ -55,7 +57,7 @@ class SettingsEdit extends Component
'audit_retention_days' => $this->audit_retention_days,
]);
session()->flash('success', 'Impostazioni aggiornate.');
$this->avvisa('Impostazioni aggiornate.');
}
public function render()

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use App\Models\Tipologia;
class TipologieIndex extends Component
{
use Avvisa;
public string $nuovaTipologia = '';
public ?int $editingId = null;
public string $editingNome = '';
@@ -16,7 +18,7 @@ class TipologieIndex extends Component
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
$this->nuovaTipologia = '';
session()->flash('success', 'Tipologia aggiunta.');
$this->avvisa('Tipologia aggiunta.');
}
public function startEdit(int $id)
@@ -33,7 +35,7 @@ class TipologieIndex extends Component
$tipo->update(['nome' => $this->editingNome]);
$this->editingId = null;
$this->editingNome = '';
session()->flash('success', 'Tipologia aggiornata.');
$this->avvisa('Tipologia aggiornata.');
}
public function cancelEdit()
@@ -52,11 +54,11 @@ class TipologieIndex extends Component
{
$tipo = Tipologia::findOrFail($id);
if ($tipo->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
$this->avvisaErrore("Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
return;
}
$tipo->delete();
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata.");
$this->avvisa("Tipologia '{$tipo->nome}' eliminata.");
}
public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Assegnazione;
use App\Models\User;
use Illuminate\Support\Facades\DB;
@@ -13,6 +14,7 @@ use Spatie\Permission\Models\Role;
class UsersIndex extends Component
{
use Avvisa;
public string $name = '';
public string $email = '';
public string $password = '';
@@ -83,7 +85,7 @@ class UsersIndex extends Component
if (! empty($this->availableRoles)) {
$this->selectedRole = $this->availableRoles[0];
}
session()->flash('success', 'Utente creato con successo.');
$this->avvisa('Utente creato con successo.');
}
public function startEdit(int $userId): void
@@ -147,7 +149,7 @@ class UsersIndex extends Component
$user->syncRoles([$validated['editSelectedRole']]);
$this->cancelEdit();
session()->flash('success', 'Utente aggiornato con successo.');
$this->avvisa('Utente aggiornato con successo.');
}
public function deleteUser(int $userId): void
@@ -156,12 +158,12 @@ class UsersIndex extends Component
$user = User::query()->with('roles')->findOrFail($userId);
if (! $currentUser || $currentUser->id === $user->id) {
session()->flash('error', 'Non puoi cancellare il tuo utente.');
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
return;
}
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.');
$this->avvisaErrore('Non puoi cancellare l\'ultimo amministratore.');
return;
}
@@ -201,7 +203,7 @@ class UsersIndex extends Component
$this->cancelEdit();
}
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
$this->avvisa('Utente cancellato. I log sono stati preservati.');
}
public function render()

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');

View File

@@ -98,8 +98,13 @@ class ZonaPericolosa extends Component
return;
}
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
if ($operationId === null) {
return;
}
$this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
$this->operationId = $operationId;
CreateBackupArchive::dispatch($this->operationId, true);

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use App\Models\Zona;
class ZoneIndex extends Component
{
use Avvisa;
public string $nuovaZona = '';
public ?int $editingId = null;
public string $editingNome = '';
@@ -16,7 +18,7 @@ class ZoneIndex extends Component
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
$this->nuovaZona = '';
session()->flash('success', 'Zona aggiunta.');
$this->avvisa('Zona aggiunta.');
}
public function startEdit(int $id)
@@ -33,7 +35,7 @@ class ZoneIndex extends Component
$zona->update(['nome' => $this->editingNome]);
$this->editingId = null;
$this->editingNome = '';
session()->flash('success', 'Zona aggiornata.');
$this->avvisa('Zona aggiornata.');
}
public function cancelEdit()
@@ -52,11 +54,11 @@ class ZoneIndex extends Component
{
$zona = Zona::findOrFail($id);
if ($zona->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
$this->avvisaErrore("Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
return;
}
$zona->delete();
session()->flash('success', "Zona '{$zona->nome}' eliminata.");
$this->avvisa("Zona '{$zona->nome}' eliminata.");
}
public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Territorio;
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
class TerritorioCestino extends Component
{
use Avvisa;
use WithPagination;
public function restore(int $id)
@@ -21,7 +23,7 @@ class TerritorioCestino extends Component
activity()->causedBy(auth()->user())
->performedOn($territorio)
->log('restored');
session()->flash('success', "Territorio {$territorio->numero} ripristinato.");
$this->avvisa("Territorio {$territorio->numero} ripristinato.");
}
public function forceDelete(int $id)
@@ -31,7 +33,7 @@ class TerritorioCestino extends Component
$territorio = Territorio::onlyTrashed()->findOrFail($id);
if ($territorio->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
$this->avvisaErrore('Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
return;
}
@@ -48,7 +50,7 @@ class TerritorioCestino extends Component
$this->rimuoviFile($territorio);
$territorio->forceDelete();
session()->flash('success', 'Territorio eliminato definitivamente.');
$this->avvisa('Territorio eliminato definitivamente.');
}
/**

View File

@@ -64,14 +64,17 @@ class TerritorioEdit extends Component
if ($this->pdf) {
$thumbService = app(TerritorioThumbnailService::class);
// Remove old PDF and thumbnail
if ($this->territorio->pdf_path) {
Storage::disk('public')->delete($this->territorio->pdf_path);
}
if ($this->territorio->thumbnail_path) {
$thumbService->delete($this->territorio->thumbnail_path);
}
// Prima il nuovo, poi il vecchio nello storico (ripristinabile
// dalla pagina Piantine). Prima il vecchio si cancellava subito:
// un caricamento sbagliato non si poteva disfare, e con il
// salvataggio fallito il territorio restava senza PDF.
$pdfPath = $this->pdf->store('territori-pdf', 'public');
if (! $pdfPath) {
$this->addError('pdf', 'Non è stato possibile salvare il PDF: quello in uso non è stato toccato.');
return;
}
app(\App\Services\Piantine\PiantinaPdfService::class)->archiviaCorrente($this->territorio, 'manuale');
$data['pdf_path'] = $pdfPath;
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Component;
use Livewire\WithPagination;
@@ -11,6 +12,7 @@ use App\Models\Tipologia;
class TerritorioIndex extends Component
{
use Avvisa;
use WithPagination;
public string $search = '';
@@ -127,7 +129,7 @@ class TerritorioIndex extends Component
->performedOn($territorio)
->log('soft_deleted');
$territorio->delete();
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
$this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
}
public function render()