From 426a95211dbc2d20813e879df99ad6b8ea4df0d6 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Mon, 12 Oct 2026 15:01:41 +0200 Subject: [PATCH] Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- HANDOFF.md | 141 ++++--- app/Console/Commands/TerritoriFileOrfani.php | 79 ++++ .../Auth/PasswordChangeController.php | 36 +- .../Controllers/Settings/BackupController.php | 45 ++- .../Settings/XmlExchangeUploadController.php | 49 ++- .../Controllers/TerritorioFileController.php | 62 ++++ app/Jobs/CreateBackupArchive.php | 5 +- app/Jobs/ImportTerritoryPdfFolder.php | 202 +++++++--- app/Jobs/ResetApplication.php | 17 +- app/Jobs/RestoreBackupArchive.php | 119 ++++-- app/Livewire/Assegnazioni/Assegna.php | 8 +- app/Livewire/Assegnazioni/Rientra.php | 2 +- app/Livewire/AuditLog.php | 16 +- app/Livewire/Campagne/CampagnaIndex.php | 4 +- app/Livewire/Campagne/CampagnaShow.php | 12 +- .../Concerns/SegueOperazioneBackup.php | 40 +- .../Proclamatori/ProclamatoreCestino.php | 8 +- .../Proclamatori/ProclamatoreIndex.php | 8 +- app/Livewire/Profile.php | 4 +- app/Livewire/Settings/BackupManager.php | 6 +- app/Livewire/Settings/SettingsEdit.php | 4 +- app/Livewire/Settings/TipologieIndex.php | 10 +- app/Livewire/Settings/UsersIndex.php | 12 +- app/Livewire/Settings/XmlExchange.php | 248 +++++++++++-- app/Livewire/Settings/ZonaPericolosa.php | 7 +- app/Livewire/Settings/ZoneIndex.php | 10 +- app/Livewire/Territori/TerritorioCestino.php | 8 +- app/Livewire/Territori/TerritorioEdit.php | 17 +- app/Livewire/Territori/TerritorioIndex.php | 4 +- app/Models/Campagna.php | 27 +- app/Models/Territorio.php | 20 + app/Providers/AppServiceProvider.php | 9 + app/Services/BackupArchive.php | 344 ++++++++++++++--- app/Services/BackupOperationState.php | 83 +++++ app/Services/Piantine/PiantinaPdfService.php | 5 +- app/Services/Piantine/PiantineArea.php | 8 +- app/Services/Piantine/PiantineBackup.php | 183 +++++++++ .../TerritorioPdfImportDispatcher.php | 127 ++++++- bootstrap/app.php | 16 + config/queue.php | 26 ++ ...000010_nomi_proclamatori_fuori_dal_log.php | 51 +++ docker-compose.yml | 11 +- docker/nginx/default.conf | 12 +- public/favicon.svg | 1 + .../views/assignments/link-scaduto.blade.php | 1 + .../views/assignments/pdf-viewer.blade.php | 1 + .../views/auth/password-change.blade.php | 12 +- .../views/components/layouts/app.blade.php | 15 +- .../views/components/layouts/editor.blade.php | 1 + .../views/components/layouts/guest.blade.php | 1 + resources/views/livewire/audit-log.blade.php | 6 +- .../piantine/piantine-editor.blade.php | 2 +- .../piantine/piantine-manager.blade.php | 7 +- resources/views/livewire/profile.blade.php | 6 - resources/views/livewire/registro.blade.php | 2 +- .../livewire/settings/settings-edit.blade.php | 6 - .../livewire/settings/users-index.blade.php | 6 - .../livewire/settings/xml-exchange.blade.php | 154 ++++++-- .../territori/territorio-index.blade.php | 2 +- .../territori/territorio-show.blade.php | 4 +- routes/web.php | 17 + tests/Feature/AvvisiTest.php | 73 ++++ tests/Feature/AzzeramentoPiantineTest.php | 58 +++ tests/Feature/CambioPasswordTest.php | 123 ++++++ tests/Feature/CampagnaCalcoliTest.php | 87 +++++ tests/Feature/CodaTest.php | 46 +++ tests/Feature/ImportPdfTerritoriTest.php | 333 +++++++++++++++++ tests/Feature/ImportXmlFileOrfaniTest.php | 129 +++++++ tests/Feature/LogSenzaNomiTest.php | 88 +++++ tests/Feature/PiantineEditorTest.php | 2 + tests/Feature/PiantineManagerTest.php | 2 + tests/Feature/PostoUnicoImportTest.php | 268 +++++++++++++ tests/Feature/RipristinoBackupTest.php | 351 ++++++++++++++++++ tests/Feature/TerritoriFileOrfaniTest.php | 61 +++ tests/Feature/TerritorioFileTest.php | 134 +++++++ tests/Feature/XmlExchangeImportTest.php | 35 ++ tests/TestCase.php | 5 + tests/Unit/BackupArchiveTest.php | 256 ++++++++++++- 78 files changed, 3971 insertions(+), 429 deletions(-) create mode 100644 app/Console/Commands/TerritoriFileOrfani.php create mode 100644 app/Http/Controllers/TerritorioFileController.php create mode 100644 app/Services/Piantine/PiantineBackup.php create mode 100644 config/queue.php create mode 100644 database/migrations/2026_10_12_000010_nomi_proclamatori_fuori_dal_log.php create mode 100644 public/favicon.svg create mode 100644 tests/Feature/AvvisiTest.php create mode 100644 tests/Feature/AzzeramentoPiantineTest.php create mode 100644 tests/Feature/CambioPasswordTest.php create mode 100644 tests/Feature/CampagnaCalcoliTest.php create mode 100644 tests/Feature/CodaTest.php create mode 100644 tests/Feature/ImportPdfTerritoriTest.php create mode 100644 tests/Feature/ImportXmlFileOrfaniTest.php create mode 100644 tests/Feature/LogSenzaNomiTest.php create mode 100644 tests/Feature/PostoUnicoImportTest.php create mode 100644 tests/Feature/RipristinoBackupTest.php create mode 100644 tests/Feature/TerritoriFileOrfaniTest.php create mode 100644 tests/Feature/TerritorioFileTest.php diff --git a/HANDOFF.md b/HANDOFF.md index 2cc7e77..ce610b2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -77,69 +77,60 @@ e l'unica che richiede una decisione, non del lavoro. ## Lavori in corso — 12 ottobre 2026 (riprendere da qui) -### Già fatto e committato su `dev` (commit `d433c93`, NON ancora pushato) +Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo): +- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in + `app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione + (integrità dati). +- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con + anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona: + [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). +- Il commit successivo chiude i blocchi 2 e 3 della revisione (sotto). -- **Modulo S-13** riscritto su modulo ufficiale (FPDI), regole decise con l'utente: - 20 righe/foglio, tutti i territori attivi, ciclo da 4 con riporto, riporto - iniziale = ultimo rientro prima dell'anno, assegnazioni a cavallo d'anno su - entrambi i fogli (sul vecchio senza rientro), nomi "Cognome I.", scelta per - data. Codice in `app/Services/Registro/ModuloS13.php`. -- **Blocco 1 della revisione** (integrità dati): date export/import che - slittavano di un giorno, import XML che accettava file estranei, caratteri di - controllo, territori senza zona scartati, admin estromesso dall'import, - doppia assegnazione (`Assegnazione::sovrappostaA()` + lock), ultimo admin, - `APP_KEY` sovrascritta dall'entrypoint (`APP_KEY_ROTATE=1`). Vedi la tabella - "Bug corretti". Suite: 328 test verdi. -- Il push su `dev` fa partire il deploy su demo: va fatto quando si decide. +Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di +sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare. -### Finito, NON ancora committato: riprogettazione dell'Editor Piantine +### Revisione profonda: blocchi 2 e 3 chiusi -Decisioni dell'utente: due pagine separate (gestione / editor); **un solo -pulsante "Pubblica"** (immagine + PDF) con **anteprima obbligatoria** da -confermare, disponibile da editor e gestione; dall'editor **si modificano i -dati del territorio** (salvati subito nel gestionale); uso soprattutto da PC ma -anche **tablet**; **stato di bozza**: in bozza va solo il disegno, salvata -**automaticamente**; stati Bozza / Pubblicata / Modifiche da pubblicare. -Come funziona: sezione [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). +| Problema | Correzione | +|---|---| +| Esiti delle azioni Livewire persi o mostrati alla pagina dopo (11 componenti) | trait `Avvisa` + ``; un test vieta `session()->flash()` senza redirect | +| PDF, miniature e versioni scaricabili da `/storage/territori-*` senza login (nomi indovinabili, cache Cloudflare 7 giorni) | nginx risponde 404 su `/storage/territori-`; `TerritorioFileController` con permessi della pagina e cache privata; `Territorio::pdfUrl()` / `miniaturaUrl()` | +| Ripristino non atomico | estrazione verificata (CRC, byte) → una transazione con import, piantine, blocco account e scambio file (`rename`, annullabile) → file vecchi cancellati solo dopo il commit | +| Piantine, storico PDF, area KML fuori dal backup | formato archivio 2 (`piantine.json`, `piantine/…`, `PiantineBackup`); gli archivi di formato 1 si ripristinano lo stesso | +| Blocco "una sola operazione" ignorato da import XML/PDF, e controllo+presa in due passi | `BackupOperationState::occupa()` sotto `Cache::lock`, usato da tutte le porte; rilascio anche su errore e in `failed()` | +| Import PDF da cartella: `1-A` → territorio 1, `12 B` ambiguo, solo 20 file, `Thumbs.db` blocca tutto, `__MACOSX` | abbinamento "cifre + al massimo una lettera"; ambigui scartati; caricamento a lotti dal browser; file non PDF ignorati | +| Import PDF e caricamento dalla scheda cancellavano il vecchio PDF prima di scrivere il nuovo | prima il nuovo, poi il vecchio nello storico (`PiantinaPdfService::archiviaCorrente`, origini `import` / `manuale`) | +| L'import XML lasciava sul disco i file dei territori ricreati (nel PC: 549 file, 80 MB) | l'import elimina i file non più usati; per i vecchi c'è `php artisan territori:file-orfani` (elenca; `--elimina` cancella) — **da lanciare quando si decide** | +| Azzeramento lasciava piantine orfane, area KML, anteprime | cancellate anche quelle | +| `/cambia-password` senza password attuale | richiesta, tranne dopo un ripristino (cambio obbligatorio) | +| Sessioni aperte altrove non chiuse al cambio password | `AuthenticateSession` nel gruppo web; il ripristino conserva l'hash di chi lo esegue, così non viene buttato fuori | +| Nomi dei proclamatori in chiaro nel registro attività | si salva `proclamatore_id`, il registro mostra il nome attuale; migrazione `2026_10_12_000010` ripulisce le voci vecchie | +| Export XML con `remember_token`; l'import XML non obbligava a cambiare password | token non esportato e azzerato all'import; chi riceve l'hash dal file deve cambiare password | +| Campagne: sparivano l'ultimo giorno; percentuale oltre il 100% | confronto per data; si contano i territori attivi distinti | +| `retry_after` 90 s con job fino a 900 s | `config/queue.php`: 960 s | +| Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` | +| Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` | +| Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) | +| Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore | -Stato al 12/10/2026: -- Codice completo: modello, servizio di pubblicazione + job, finestra condivisa, - editor e gestione riscritti, JS e CSS. Migrazione - `2026_10_09_000010_bozza_e_pubblicazione_piantine`. -- Test: `PiantineEditorTest` (22), `PiantineManagerTest`, - `PubblicazionePiantinaTest` (nuovo). Suite intera: **360 test verdi**. -- Provato in browser (Playwright + Edge, in locale) su PC 1440, iPad - orizzontale e verticale, iPhone: disegno, modifica vertici (8 maniglie su - poligono), collegamento, anteprima generata dal renderer vero, conferma, - stato "Pubblicata", filtri della gestione. Corretti lì: tendina territori che - segnava tutti "(disattivato)" (colonna `attivo` non caricata), zoom sopra la - ricerca dell'elenco, barra strumenti sopra il pannello su tablet e fuori - schermo su telefono, crediti mappa stampati sul foglio del pannello. -- Aggiunto `.gitattributes` (`*.sh text eol=lf`): con `core.autocrlf=true` - l'entrypoint usciva con CRLF e il container locale non partiva ("no such - file or directory"). Nel repository era già LF: il deploy non era a rischio. -- **Non provato:** modifica vertici al tocco su un tablet vero, MultiPolygon - in modifica vertici (si modifica il primo poligono). -- Visto e **non** corretto (fuori tema): su telefono la barra superiore - dell'app trabocca con nome/email lunghi ("Esci" fuori schermo); - `/favicon.ico` risponde 404. +Non cambiato, di proposito: `trustProxies('*')` (commento in +`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS). -### Revisione profonda: blocchi ancora aperti +Suite: **427 test verdi**. Provati in browser in locale: file protetti (404 +senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2 +lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato). -- **Blocco 2:** avvisi in tutte le ~10 pagine che oggi li perdono (usare il - trait `Avvisa`); PDF dei territori serviti da nginx su `/storage` senza - autenticazione (spostare su disco privato); ripristino non atomico e blocco - "una sola operazione" ignorato da import XML/PDF; piantine e storico PDF - assenti dal backup; import PDF da cartella (`1-A` sovrascrive `1`, solo 20 - file per `max_file_uploads`, `Thumbs.db` blocca tutto, `12 B` ambiguo). -- **Blocco 3:** `/cambia-password` senza password attuale, sessioni non - invalidate, `trustProxies('*')`, nomi in chiaro nell'activity log, export XML - con hash/token, campagne (ultimo giorno, percentuali), `retry_after` 90 s, - `stop_grace_period` del worker, Redis `allkeys-lru`, cache statica di - `Setting` nel worker, azzeramento che lascia piantine e KML. -- Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC - di sviluppo, più la demo da `dev`). Nessuna correzione una tantum delle date - slittate da fare. +**Non provato / aperto:** +- chiusura delle sessioni con due browser veri; coda e worker con un riavvio + reale; modifica vertici al tocco su tablet vero; +- dopo un ripristino le piantine pubblicate risultano "Modifiche da pubblicare" + (l'import ricrea i territori con id nuovi): verso sicuro, basta ripubblicare; +- `corrente()` / `liberaCorrente()` di `BackupOperationState` fanno get+forget + senza mutex (finestra di millisecondi); +- l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche + lotto riceve 429 (il browser lo segnala); +- numeri di territorio con lettere davanti ("A12") non si abbinano da nome + file: il PDF si carica dalla scheda. --- @@ -748,13 +739,16 @@ Scelte non ovvie: cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che sopravvive, quella dell'azzeramento stesso. -- **Ordine del restore: staging → database → file.** I file si estraggono - prima in `storage/app/backup-restore/`, così un archivio corrotto o - fuori limite fallisce mentre i dati correnti sono ancora intatti; poi - `importXmlFromContent()` in transazione; poi la sostituzione dei file. Il - disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe - appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna - schermata mostra e nessuna cancellazione raggiunge. +- **Ordine del restore: verifica → una transazione → pulizia.** Dal 12/10/2026 + (`RestoreBackupArchive`): si estrae e si verifica tutto (CRC, byte, il + `piantine.json` del formato 2) prima di toccare qualcosa; poi una sola + `DB::transaction` contiene import XML, tabelle delle piantine, blocco degli + account e, per ultimo, lo scambio dei file (`BackupArchive::scambiaFile()`: + solo `rename()` sullo stesso volume, annotati e annullabili). Se qualcosa + fallisce si torna allo stato di prima; i file vecchi aspettano in + `storage/app/backup-precedenti/` e si cancellano solo dopo il commit. + Il disco `public` viene sostituito, non fuso: i percorsi vivono nelle righe + appena riscritte. - **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su numero di entry, dimensione della singola entry e totale decompresso, pochi @@ -1245,21 +1239,12 @@ pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da `pagination::`. L'esclusione resta a guardia di future viste di terze parti. -### 7. Il backup non contiene l'area KML delle piantine +### 7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026 -`BackupArchive` impacchetta il solo disco `public` (`storage/app/public`), e -`PiantineArea` scrive in `storage/app/piantine/` — una cartella **sorella**, non -dentro. Verificato: dopo un ripristino su volumi vuoti l'area va ricaricata a -mano dal KML originale, che quindi conviene conservare fuori di qui. - -I disegni invece si salvano: stanno in `territorio_mappe`, e da quando la chiave -esterna è `nullOnDelete` sopravvivono anche allo svuotamento di `territori` che -fa ogni import XML — scollegati, ma recuperabili. - -Sistemarlo vuol dire estendere `BackupArchive` a una seconda cartella, cosa che -tocca anche il formato dell'archivio (`FORMAT_VERSION`) e il ripristino. Non è -stato fatto perché il KML è un file che l'utente ha già sul suo computer: la -perdita è un fastidio, non un dato irrecuperabile. +Dal formato 2 l'archivio contiene `piantine.json` (righe di `territorio_mappe` +e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e +la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina +lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca. --- diff --git a/app/Console/Commands/TerritoriFileOrfani.php b/app/Console/Commands/TerritoriFileOrfani.php new file mode 100644 index 0000000..2329d54 --- /dev/null +++ b/app/Console/Commands/TerritoriFileOrfani.php @@ -0,0 +1,79 @@ +logo_path]), + TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(), + )); + + $disco = Storage::disk('public'); + $orfani = []; + $byte = 0; + + foreach (self::CARTELLE as $cartella) { + foreach ($disco->files($cartella) as $file) { + if (! isset($usati[$file])) { + $orfani[] = $file; + $byte += $disco->size($file); + } + } + } + + $peso = number_format($byte / 1048576, 1, ',', '.') . ' MB'; + + if ($orfani === []) { + $this->info('Nessun file orfano.'); + + return self::SUCCESS; + } + + if (! $this->option('elimina')) { + foreach ($orfani as $file) { + $this->line($file); + } + $this->info(count($orfani) . " file orfani ({$peso}). Rilancia con --elimina per cancellarli."); + + return self::SUCCESS; + } + + $cancellati = 0; + foreach ($orfani as $file) { + if ($disco->delete($file)) { + $cancellati++; + } + } + + $this->info("{$cancellati} file orfani cancellati ({$peso})."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Auth/PasswordChangeController.php b/app/Http/Controllers/Auth/PasswordChangeController.php index a13126e..ee8eaed 100644 --- a/app/Http/Controllers/Auth/PasswordChangeController.php +++ b/app/Http/Controllers/Auth/PasswordChangeController.php @@ -43,23 +43,30 @@ class PasswordChangeController extends Controller public function store(Request $request) { - // La password attuale NON viene richiesta, ed e' una scelta. + $user = $request->user(); + + // La password attuale si chiede solo a chi arriva qui di sua + // iniziativa, come fa il profilo. // - // Il ripristino sovrascrive gli hash con quelli del backup: subito dopo, - // la "password attuale" di un account e' quella che aveva all'epoca del - // backup, che l'utente puo' benissimo non ricordare — l'amministratore - // che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori - // proprio le persone che questa schermata deve sbloccare. La garanzia - // qui e' la sessione: si arriva a questa pagina gia' autenticati. - $validated = $request->validate([ - 'password' => ['required', 'confirmed', Password::min(8)], - ], [ + // Dopo un ripristino no: gli hash sono quelli del backup, e la + // "password attuale" di un account e' quella che aveva all'epoca, che + // l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori + // proprio le persone che questa schermata deve sbloccare; la garanzia li' + // e' la sessione. Fuori da quel caso, invece, bastava una sessione + // lasciata aperta per cambiare la password a qualcun altro e chiuderlo + // fuori dal suo account. + $regole = ['password' => ['required', 'confirmed', Password::min(8)]]; + if (! $user->password_change_required) { + $regole['current_password'] = ['required', 'current_password']; + } + + $validated = $request->validate($regole, [ 'password.required' => 'Inserisci la nuova password.', 'password.confirmed' => 'Le due password non coincidono.', + 'current_password.required' => 'Inserisci la password attuale.', + 'current_password.current_password' => 'La password attuale non è corretta.', ]); - $user = $request->user(); - // Il cast `hashed` sul modello cifra il valore al salvataggio. $user->update([ 'password' => $validated['password'], @@ -68,7 +75,10 @@ class PasswordChangeController extends Controller // La sessione corrente sopravvive al cambio, le altre no: dopo un // ripristino le vecchie credenziali sono da considerarsi compromesse e - // una sessione aperta altrove lo e' altrettanto. + // una sessione aperta altrove lo e' altrettanto. Le altre le chiude + // AuthenticateSession (bootstrap/app.php) alla loro prossima richiesta, + // perche' l'hash della password e' cambiato; qui si cambia l'id di + // questa e si invalida il "ricordami". $request->session()->regenerate(); $user->setRememberToken(null); $user->save(); diff --git a/app/Http/Controllers/Settings/BackupController.php b/app/Http/Controllers/Settings/BackupController.php index c1495b6..86c856a 100644 --- a/app/Http/Controllers/Settings/BackupController.php +++ b/app/Http/Controllers/Settings/BackupController.php @@ -110,30 +110,45 @@ class BackupController extends Controller 'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.', ]); + $operationId = (string) Str::uuid(); + // Stesso blocco del componente Livewire, ripetuto qui perche' questa e' // una porta d'ingresso separata: un ripristino avviato mentre gira un // azzeramento riscriverebbe le tabelle che l'altro sta cancellando. - if ($this->stato->corrente() !== null) { + // Il posto si prende subito e in un passo solo (occupa()): controllarlo + // e prenderlo solo dopo aver salvato il file lasciava una finestra in cui + // un import o un secondo ripristino passavano entrambi. + $inCorso = $this->stato->occupa($operationId, BackupOperationState::TIPO_RESTORE, 'In coda'); + if ($inCorso !== null) { return response()->json([ - 'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.', + 'message' => $this->stato->messaggioOccupato($inCorso), ], 409); } - $operationId = (string) Str::uuid(); + try { + // Il file dell'upload vive in una cartella temporanea che sparisce a + // fine richiesta: il job lo troverebbe gia' cancellato. + $percorso = $request->file('backupZip')->storeAs( + 'backup-restore-in', + $operationId . '.zip', + 'local' + ); + } catch (\Throwable $e) { + // Il posto e' gia' preso: senza chiuderlo resterebbe occupato da un + // ripristino che non partira' mai, per l'ora intera del TTL. + $this->stato->markFailed($operationId, 'Archivio non salvato sul server: ' . $e->getMessage()); - // Il file dell'upload vive in una cartella temporanea che sparisce a - // fine richiesta: il job lo troverebbe gia' cancellato. - $percorso = $request->file('backupZip')->storeAs( - 'backup-restore-in', - $operationId . '.zip', - 'local' - ); + throw $e; + } + + if ($percorso === false) { + $this->stato->markFailed($operationId, 'Archivio non salvato sul server.'); + + return response()->json([ + 'message' => 'Il server non è riuscito a salvare il pacchetto ricevuto (spazio o permessi sul disco).', + ], 500); + } - $this->stato->initialize( - $operationId, - BackupOperationState::TIPO_RESTORE, - 'In coda' - ); $this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.'); RestoreBackupArchive::dispatch( diff --git a/app/Http/Controllers/Settings/XmlExchangeUploadController.php b/app/Http/Controllers/Settings/XmlExchangeUploadController.php index 9f546c4..fb4aaca 100644 --- a/app/Http/Controllers/Settings/XmlExchangeUploadController.php +++ b/app/Http/Controllers/Settings/XmlExchangeUploadController.php @@ -4,10 +4,21 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; use App\Livewire\Settings\XmlExchange; +use App\Services\BackupOperationState; use Illuminate\Http\Request; +use Illuminate\Support\Str; class XmlExchangeUploadController extends Controller { + /** + * Quanto resta occupato il posto unico se la richiesta di import muore senza + * liberarlo (timeout, errore fatale: casi in cui il `finally` non gira). + * L'import e' sincrono e PHP lo ferma comunque dopo max_execution_time + * (120 s): dieci minuti bastano a coprirlo, invece dell'ora intera pensata + * per i job in coda. + */ + private const TTL_POSTO_IMPORT_XML = 600; + public function convertSqlToXml(Request $request) { $request->validate([ @@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']); } - $exchange = new XmlExchange(); - $result = $exchange->importXmlFromContent($content); + // L'import svuota e riscrive le tabelle: non puo' girare insieme a un + // ripristino (che fa lo stesso), a un azzeramento, a un backup che le sta + // fotografando o a un import PDF che sta aggiornando i territori. Il + // ripristino chiama importXmlFromContent() per conto suo, gia' dentro il + // proprio posto: per questo il blocco sta qui, sulla porta d'ingresso, e + // non dentro l'import. + $stato = app(BackupOperationState::class); + $operationId = (string) Str::uuid(); + $inCorso = $stato->occupa($operationId, BackupOperationState::TIPO_IMPORT_XML, 'Import XML', self::TTL_POSTO_IMPORT_XML); + + if ($inCorso !== null) { + return back()->withErrors(['xmlImport' => $stato->messaggioOccupato($inCorso) . ' Nessun dato è stato modificato.']); + } + + $stato->markRunning($operationId); + + try { + $exchange = new XmlExchange(); + $fileDiPrima = XmlExchange::fileDeiTerritori(); + $result = $exchange->importXmlFromContent($content); + + if (! isset($result['error'])) { + $result['stats']['file_rimossi'] = XmlExchange::eliminaFileNonPiuUsati($fileDiPrima); + } + } catch (\Throwable $e) { + $stato->markFailed($operationId, $e->getMessage()); + + throw $e; + } if (isset($result['error'])) { + $stato->markFailed($operationId, $result['error']); + return back()->withErrors(['xmlImport' => $result['error']]); } + $stato->markCompleted($operationId); + $stats = $result['stats']; $issues = $result['issues']; @@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) { $message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.'; } + if (($stats['file_rimossi'] ?? 0) > 0) { + $message .= ' Eliminati ' . $stats['file_rimossi'] . ' file (PDF e miniature) dei territori sostituiti, che i dati importati non usano più.'; + } return redirect()->route('xml.exchange') ->with('success', $message) diff --git a/app/Http/Controllers/TerritorioFileController.php b/app/Http/Controllers/TerritorioFileController.php new file mode 100644 index 0000000..f1ea9a4 --- /dev/null +++ b/app/Http/Controllers/TerritorioFileController.php @@ -0,0 +1,62 @@ +servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf'); + } + + public function miniatura(Territorio $territorio): Response + { + return $this->servi($territorio->thumbnail_path, null, null); + } + + public function versione(TerritorioPdfVersione $versione): Response + { + $numero = $versione->territorio?->numero ?? 'senza-territorio'; + + return $this->servi($versione->pdf_path, 'application/pdf', "territorio-{$numero}-versione-{$versione->id}.pdf"); + } + + private function servi(?string $percorso, ?string $tipo, ?string $nome): Response + { + $disco = Storage::disk('public'); + + abort_unless($percorso && $disco->exists($percorso), 404); + + $intestazioni = []; + if ($tipo) { + $intestazioni['Content-Type'] = $tipo; + } + if ($nome) { + $intestazioni['Content-Disposition'] = 'inline; filename="' . $nome . '"'; + } + + // Privata: niente cache intermedie (Cloudflare). Va impostata dopo, + // perché BinaryFileResponse si dichiara pubblica da sola e + // sovrascriverebbe un Cache-Control passato fra le intestazioni. + // L'indirizzo porta l'impronta del percorso (?v=), che cambia col file. + return response()->file($disco->path($percorso), $intestazioni) + ->setPrivate() + ->setMaxAge(86400); + } +} diff --git a/app/Jobs/CreateBackupArchive.php b/app/Jobs/CreateBackupArchive.php index bd62b4f..ac9b00c 100644 --- a/app/Jobs/CreateBackupArchive.php +++ b/app/Jobs/CreateBackupArchive.php @@ -5,6 +5,7 @@ namespace App\Jobs; use App\Livewire\Settings\XmlExchange; use App\Services\BackupArchive; use App\Services\BackupOperationState; +use App\Services\Piantine\PiantineBackup; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -44,7 +45,7 @@ class CreateBackupArchive implements ShouldQueue ) { } - public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void + public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void { $stato->markRunning($this->operationId); $stato->fase($this->operationId, 'Lettura dei dati gestionali'); @@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue $this->rimuoviArchiviVecchi($archivio); $xml = $exchange->currentXmlBackup(); + $datiPiantine = $piantine->esporta(); $stato->fase($this->operationId, 'Compressione dei file'); $archivio->create( $xml, + $datiPiantine, $destinazione, fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) ); diff --git a/app/Jobs/ImportTerritoryPdfFolder.php b/app/Jobs/ImportTerritoryPdfFolder.php index 605ee59..b61abab 100644 --- a/app/Jobs/ImportTerritoryPdfFolder.php +++ b/app/Jobs/ImportTerritoryPdfFolder.php @@ -4,6 +4,8 @@ namespace App\Jobs; use App\Models\Territorio; use App\Models\User; +use App\Services\BackupOperationState; +use App\Services\Piantine\PiantinaPdfService; use App\Services\TerritorioPdfImportState; use App\Services\TerritorioThumbnailService; use Illuminate\Bus\Queueable; @@ -35,14 +37,21 @@ class ImportTerritoryPdfFolder implements ShouldQueue ) { } - public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void + public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService, BackupOperationState $postoUnico, PiantinaPdfService $pdfService): void { $stateService->markRunning($this->importId); + $postoUnico->markRunning($this->importId); $stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.'); + // Una chiave puo' raccogliere piu' territori ("12B" e "12 B"): non si + // tiene l'ultimo arrivato, si tengono tutti, cosi' l'abbinamento li vede + // e scarta il file come ambiguo invece di sceglierne uno a caso. $territoriMap = []; foreach (Territorio::withTrashed()->get() as $territorio) { - $territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio; + $chiave = $this->chiaveTerritorio((string) $territorio->numero); + if ($chiave !== '') { + $territoriMap[$chiave][] = $territorio; + } } $actor = User::find($this->actorId); @@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue continue; } - $normalizedNumber = $territoryMatch['normalized_number']; + if (($territoryMatch['type'] ?? 'single') === 'unknown-suffix') { + $codici = implode(', ', $territoryMatch['codes']); + $esistenti = implode(', ', $territoryMatch['matched_numbers']); + $stateService->increment($this->importId, 'skipped'); + $stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - il nome indica il territorio ' . $codici . ', che non esiste (esiste ' . $esistenti . '): file non associato, per non sostituire il PDF di un altro territorio.'); + $stateService->addIssue($this->importId, [ + 'file' => $originalName, + 'type' => 'unknown-suffix', + 'message' => 'Il nome indica il territorio ' . $codici . ', che non esiste: non associato a ' . $esistenti . '.', + 'matched_numbers' => $territoryMatch['matched_numbers'], + ]); + continue; + } + $matchedNumber = $territoryMatch['matched_number']; $territorio = $territoryMatch['territorio']; + // Per id, non per numero: due file che nominano lo stesso + // territorio scritto in due modi ("12B.pdf", "12 B.pdf") sono + // comunque un doppione. + $normalizedNumber = $territorio->id; if (isset($seenNumbers[$normalizedNumber])) { $stateService->increment($this->importId, 'skipped'); @@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue } try { - if ($territorio->pdf_path) { - Storage::disk('public')->delete($territorio->pdf_path); - } - - if ($territorio->thumbnail_path) { - $thumbnailService->delete($territorio->thumbnail_path); - } - // The territory id is part of the filename because the slug // alone is not unique: "1 A" and "1-A" are distinct // territory numbers that both slug to "territorio-1-a". // Without it the second import overwrites the first's PDF // and both rows point at one file — then removing the PDF // from either territory breaks the other. + // + // Il nuovo file si scrive per primo, con un nome che non + // coincide con quello in vigore; solo dopo il vecchio va + // nello storico, come per le piantine. Prima si cancellava + // il vecchio e poi si scriveva: con una scrittura fallita il + // territorio restava senza PDF, e un import sbagliato non si + // poteva annullare. $storedFilename = Str::slug('territorio-' . $territorio->numero) - . '-' . $territorio->id . '.pdf'; + . '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf'; $publicPath = 'territori-pdf/' . $storedFilename; - Storage::disk('public')->put($publicPath, file_get_contents($sourcePath)); + if (! Storage::disk('public')->put($publicPath, file_get_contents($sourcePath))) { + throw new \RuntimeException('impossibile scrivere il file sul disco: il PDF in vigore non è stato toccato.'); + } + + $pdfService->archiviaCorrente($territorio, 'import'); $thumbnailPath = $thumbnailService->generate($publicPath); $territorio->update([ @@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue $stateService->appendLog($this->importId, 'Import completato.'); $stateService->markCompleted($this->importId); + $postoUnico->markCompleted($this->importId); } catch (\Throwable $exception) { $stateService->increment($this->importId, 'errors'); $stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage()); $stateService->markFailed($this->importId); + $postoUnico->markFailed($this->importId, $exception->getMessage()); throw $exception; } finally { @@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue * Without this the import state is stranded in "running" — the UI polls it * until the 24h cache entry expires — and the uploaded temp directory is * never reclaimed, since handle()'s finally never executes. + * + * Libera anche il posto unico: senza, un import ucciso dal timeout + * lascerebbe backup, ripristino e azzeramento bloccati per l'ora intera + * del TTL, con un'operazione che non avanza piu'. */ public function failed(?\Throwable $exception): void { @@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue '[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout') ); $stateService->markFailed($this->importId); + app(BackupOperationState::class)->markFailed( + $this->importId, + 'Import PDF terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout') + ); Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId); } + /** + * Abbina il nome di un file al territorio, oppure spiega perche' non lo fa. + * + * La regola: nel nome si cercano i "numeri di territorio", cioe' cifre + * seguite al massimo da UNA lettera, attaccata o separata da uno spazio, un + * trattino, un trattino basso o un punto ("12", "012", "12B", "12 B", + * "12-b"). Le forme scritte in modo diverso dello stesso numero sono lo + * stesso numero: "12 B", "12B" e "12-B" sono tutte 12B, mai 12. Il numero + * deve stare da solo — "T12" o "12AB" non contano — e quelli che non + * corrispondono a nessun territorio (un anno, un CAP) vengono ignorati. + * + * Prima bastava che il numero di un territorio *comparisse* nel nome: "1-A" + * conteneva "1", e in assenza di un territorio 1A il PDF finiva sul + * territorio 1, sostituendone la mappa senza avvisare. Adesso una lettera + * dopo il numero fa parte del numero, e se quel territorio non esiste il + * file si scarta: si nomina il territorio 1 nel messaggio, ma non lo si + * tocca. + * + * Il file si scarta anche quando l'abbinamento non e' univoco: due numeri + * di territori esistenti nello stesso nome, o due territori che si + * scrivono diversi ma sono lo stesso numero ("12B" e "12 B"). Sostituire + * la mappa sbagliata e' peggio che non sostituirne nessuna: un file + * scartato si ricarica, un PDF sovrascritto non torna. + * + * I territori con numeri di altra forma (lettere davanti, piu' lettere in + * fondo) non si abbinano da nome file: il PDF va caricato dalla loro + * scheda. + */ protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array { - if ($territoriMap === []) { - return null; - } + $trovati = []; + $suffissiSconosciuti = []; - $basename = pathinfo($filename, PATHINFO_FILENAME); - $normalizedBasename = mb_strtoupper($basename); - $normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename); - $normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename)); + foreach ($this->numeriNelNomeFile($filename) as $codice) { + if (isset($territoriMap[$codice])) { + foreach ($territoriMap[$codice] as $territorio) { + $trovati[$territorio->id] = $territorio; + } - $territoryKeys = array_keys($territoriMap); - usort($territoryKeys, function (string $left, string $right) { - $lengthComparison = mb_strlen($right) <=> mb_strlen($left); - - if ($lengthComparison !== 0) { - return $lengthComparison; - } - - return strnatcasecmp($left, $right); - }); - - $matches = []; - - foreach ($territoryKeys as $normalizedNumber) { - if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) { continue; } - $matches[] = [ - 'normalized_number' => $normalizedNumber, - 'matched_number' => $territoriMap[$normalizedNumber]->numero, - 'territorio' => $territoriMap[$normalizedNumber], + // "1A" che non esiste, mentre 1 si': e' il caso che prima + // sovrascriveva il territorio 1. Lo si annota per spiegarlo. + $base = preg_replace('/\p{L}$/u', '', $codice); + if ($base !== $codice && isset($territoriMap[$base])) { + $suffissiSconosciuti[$codice] = $territoriMap[$base]; + } + } + + $numeri = fn (array $territori) => array_values(array_unique(array_map(fn (Territorio $t) => $t->numero, $territori))); + + if (count($trovati) > 1 || ($trovati !== [] && $suffissiSconosciuti !== [])) { + return [ + 'type' => 'ambiguous', + 'matched_numbers' => $numeri(array_merge(array_values($trovati), ...array_values($suffissiSconosciuti))), ]; } - if ($matches === []) { + if ($trovati === [] && $suffissiSconosciuti !== []) { + return [ + 'type' => 'unknown-suffix', + 'codes' => array_keys($suffissiSconosciuti), + 'matched_numbers' => $numeri(array_merge(...array_values($suffissiSconosciuti))), + ]; + } + + if ($trovati === []) { return null; } - if (count($matches) > 1) { - return [ - 'type' => 'ambiguous', - 'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)), - ]; - } + $territorio = reset($trovati); - return $matches[0]; + return [ + 'type' => 'single', + 'normalized_number' => $this->chiaveTerritorio($territorio->numero), + 'matched_number' => $territorio->numero, + 'territorio' => $territorio, + ]; } - protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool + /** + * I numeri di territorio presenti nel nome del file, gia' nella forma di + * chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename(). + * + * @return array + */ + protected function numeriNelNomeFile(string $filename): array { - $escapedNumber = preg_quote($normalizedNumber, '/'); + $nome = mb_strtoupper(pathinfo($filename, PATHINFO_FILENAME)); - if (preg_match('/^\d+$/', $normalizedNumber)) { - $escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/'); + // Il lookahead finale e' quello che separa "12 B" (numero 12B) da + // "12 Via Roma" (numero 12): la lettera vale come parte del numero solo + // se dopo di lei la parola finisce. + preg_match_all('/(?avanza($this->operationId, 1); $stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.'); - $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8); + $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10); DB::transaction(function () use ($stato) { $passi = [ 'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(), 'Campagne' => fn () => Campagna::query()->delete(), + // Prima dei territori, e per esteso: le piantine hanno la + // chiave a null on delete, quindi sopravvivevano scollegate + // e con l'immagine che puntava a un file appena cancellato. + 'Piantine' => fn () => TerritorioMappa::query()->delete(), + 'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(), 'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(), 'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(), 'Tipologie' => fn () => Tipologia::query()->delete(), @@ -100,6 +108,13 @@ class ResetApplication implements ShouldQueue // ricreare la riga. Setting::flushCache(); + // Area della città e anteprime delle pubblicazioni stanno sul disco + // privato, che svuotaDiscoPubblico() non tocca: restavano lì e + // l'editor ripartiva con la città di prima. + Storage::disk('local')->deleteDirectory('piantine'); + Storage::disk('local')->deleteDirectory('piantine-anteprime'); + $stato->log($this->operationId, 'Area della città e anteprime delle piantine rimosse.'); + $stato->fase($this->operationId, 'Rimozione degli altri utenti'); $utentiRimossi = $this->rimuoviAltriUtenti($stato); diff --git a/app/Jobs/RestoreBackupArchive.php b/app/Jobs/RestoreBackupArchive.php index d12bb53..f4bf864 100644 --- a/app/Jobs/RestoreBackupArchive.php +++ b/app/Jobs/RestoreBackupArchive.php @@ -3,8 +3,10 @@ namespace App\Jobs; use App\Livewire\Settings\XmlExchange; +use App\Models\Setting; use App\Services\BackupArchive; use App\Services\BackupOperationState; +use App\Services\Piantine\PiantineBackup; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use RuntimeException; use Spatie\Permission\PermissionRegistrar; /** @@ -41,14 +44,35 @@ class RestoreBackupArchive implements ShouldQueue ) { } - public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void + /** + * Il ripristino e' tutto o niente. Tre tempi: + * + * 1. estrazione e verifica dell'archivio nello staging, piantine.json + * compreso: un archivio rotto si ferma qui, a installazione intatta; + * 2. un'unica transazione con l'import XML, le tabelle delle piantine, il + * blocco degli account e — per ultimo, ancora dentro — lo scambio dei + * file. Se lo scambio fallisce si disfa da solo e la transazione si + * annulla; se fallisce il commit, i file si rimettono a posto qui; + * 3. a commit avvenuto, si buttano i file precedenti. + * + * Prima import, blocco account e file erano tre passi separati: un errore + * sui file lasciava il database gia' riscritto con il disco a meta', e un + * errore sul blocco lasciava attive le password del backup. + * + * Niente TRUNCATE in nessuno dei passi: su MariaDB e' DDL e fa commit + * implicito, chiudendo la transazione a meta'. L'import XML e + * PiantineBackup cancellano con DELETE. + */ + public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void { $stato->markRunning($this->operationId); $staging = null; + $scambio = null; + $confermato = false; try { - $stato->fase($this->operationId, 'Estrazione dell\'archivio'); + $stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio'); $estratto = $archivio->extract( $this->zipPath, @@ -56,33 +80,46 @@ class RestoreBackupArchive implements ShouldQueue ); $staging = $estratto['staging']; + $piantine->valida($estratto['piantine']); + $stato->fase($this->operationId, 'Riscrittura del database', 1); // importXmlFromContent() attribuisce a auth()->id() le assegnazioni // il cui autore non e' nel file. Qui non c'e' sessione, quindi // l'utente va reimpostato a mano: senza, l'audit finirebbe sul // primo id disponibile invece che su chi ha avviato il ripristino. + // + // E' anche cio' che tiene dentro chi ripristina: l'import riconosce + // l'attore e ne conserva hash della password, remember_token e + // ruolo di amministratore. Con AuthenticateSession attivo, un hash + // cambiato chiuderebbe la sua sessione e lo costringerebbe a + // rientrare con la password dell'epoca del backup, che puo' non + // ricordare. Se il suo account nel backup non c'e', l'import (che + // fa upsert per email, senza cancellare nessuno) non lo tocca + // affatto: resta con password e ruoli attuali. Auth::onceUsingId($this->actorId); - $result = $exchange->importXmlFromContent($estratto['xml']); + DB::transaction(function () use ($stato, $archivio, $exchange, $piantine, $estratto, &$result, &$esitoPiantine, &$utenti, &$scambio) { + // La transazione dell'import diventa un savepoint di questa: + // il suo commit non e' piu' definitivo. + $result = $exchange->importXmlFromContent($estratto['xml']); - if (isset($result['error'])) { - $stato->markFailed($this->operationId, $result['error']); + if (isset($result['error'])) { + throw new RuntimeException($result['error']); + } - return; - } + $esitoPiantine = $piantine->ripristina($estratto['piantine']); - $stato->avanza($this->operationId, 1, 1); + $utenti = $this->richiediCambioPasswordATutti(); - $stato->fase($this->operationId, 'Ripristino dei file'); + $stato->avanza($this->operationId, 1, 1); + $stato->fase($this->operationId, 'Sostituzione dei file', 1); - $fileRipristinati = $archivio->restoreFiles( - $staging, - fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) - ); + $scambio = $archivio->scambiaFile($estratto); + }); - $stato->fase($this->operationId, 'Blocco degli account', 1); - $utenti = $this->richiediCambioPasswordATutti(); + $confermato = true; + $archivio->confermaScambio($scambio); // L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la // mappa permessi-ruoli: senza questo, i processi che l'hanno gia' @@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue $stato->avanza($this->operationId, 1, 1); } catch (\Throwable $e) { + $messaggio = $e->getMessage(); + + // Lo scambio era riuscito ma il commit no: il database e' tornato + // indietro, i file devono seguirlo. + if ($scambio !== null && ! $confermato) { + try { + $archivio->annullaScambio($scambio, $e); + } catch (\Throwable $annullamento) { + $messaggio = $annullamento->getMessage(); + } + } + + // La transazione annullata non svuota le cache in memoria del + // processo: Setting terrebbe l'istanza creata dall'import (una + // riga che non esiste piu') e Spatie i ruoli letti durante l'import. + Setting::flushCache(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Log::error('Ripristino del backup non riuscito.', ['exception' => $e]); - $stato->markFailed($this->operationId, $e->getMessage()); + $stato->markFailed($this->operationId, $messaggio); return; } finally { @@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue } $stats = $result['stats']; + $stats['piantine_ripristinate'] = $esitoPiantine['mappe']; + $stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni']; + $fileRipristinati = $scambio['file']; $messaggio = sprintf( - 'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. ' + 'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni, %d piantine e %d file ripristinati. ' . 'Per sicurezza %s impostare una nuova password.', $stats['territori_importati'] ?? 0, $stats['proclamatori_importati'] ?? 0, $stats['assegnazioni_importate'] ?? 0, + $esitoPiantine['mappe'], $fileRipristinati, $utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono' ); + // Un archivio di formato 1 non conteneva le piantine: va detto, o + // l'amministratore le cercherebbe senza sapere perche' sono sparite. + if ($estratto['piantine'] === null) { + $stato->log( + $this->operationId, + 'Archivio di un formato precedente: non contiene piantine e storico dei PDF, che restano vuoti. ' + . 'L\'area KML in uso non e\' stata toccata.' + ); + } + // Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la // pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso, // il middleware dirotta l'amministratore sul cambio password. Lo si @@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue $stato->log($this->operationId, $messaggio); $stato->markCompleted($this->operationId, [ 'stats' => $stats, - 'issues' => array_slice($result['issues'], 0, 50), + 'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50), 'file_ripristinati' => $fileRipristinati, 'messaggio' => $messaggio, ]); @@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue * muore per cause che handle() non intercetta, cosi' il posto unico si * libera subito invece di restare occupato fino alla scadenza. * - * Qui il messaggio dice anche in che stato si e' rimasti: un ripristino - * interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi - * con database e file disallineati, e chi legge deve sapere che la strada - * e' rilanciare il ripristino, non ignorare l'errore. + * Qui il messaggio dice anche in che stato si e' rimasti. Un processo + * ucciso a meta' non arriva al commit, e il database annulla da se' la + * transazione rimasta aperta: i dati restano quelli di prima. L'unica + * finestra scoperta e' quella, di pochi millisecondi, fra lo scambio dei + * file e il commit — in quel caso i file precedenti sono ancora in + * storage/app/backup-precedenti/. La strada resta rilanciare il + * ripristino, non ignorare l'errore. */ public function failed(?\Throwable $e): void { app(BackupOperationState::class)->markFailed( $this->operationId, ($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).') - . ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.' + . ' Un ripristino interrotto prima della conferma non modifica il database: rilancialo con lo stesso pacchetto. ' + . 'Se i file risultano diversi, quelli precedenti sono in storage/app/backup-precedenti/.' ); @unlink($this->zipPath); diff --git a/app/Livewire/Assegnazioni/Assegna.php b/app/Livewire/Assegnazioni/Assegna.php index dee5cf9..10cd991 100644 --- a/app/Livewire/Assegnazioni/Assegna.php +++ b/app/Livewire/Assegnazioni/Assegna.php @@ -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() diff --git a/app/Livewire/Assegnazioni/Rientra.php b/app/Livewire/Assegnazioni/Rientra.php index 430d6d3..0de247a 100644 --- a/app/Livewire/Assegnazioni/Rientra.php +++ b/app/Livewire/Assegnazioni/Rientra.php @@ -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, ]) diff --git a/app/Livewire/AuditLog.php b/app/Livewire/AuditLog.php index c99fc09..b80a345 100644 --- a/app/Livewire/AuditLog.php +++ b/app/Livewire/AuditLog.php @@ -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, ]); diff --git a/app/Livewire/Campagne/CampagnaIndex.php b/app/Livewire/Campagne/CampagnaIndex.php index 84cf51d..e1407e1 100644 --- a/app/Livewire/Campagne/CampagnaIndex.php +++ b/app/Livewire/Campagne/CampagnaIndex.php @@ -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() diff --git a/app/Livewire/Campagne/CampagnaShow.php b/app/Livewire/Campagne/CampagnaShow.php index a18febf..cd03c56 100644 --- a/app/Livewire/Campagne/CampagnaShow.php +++ b/app/Livewire/Campagne/CampagnaShow.php @@ -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(); diff --git a/app/Livewire/Concerns/SegueOperazioneBackup.php b/app/Livewire/Concerns/SegueOperazioneBackup.php index 46cb65a..bec0343 100644 --- a/app/Livewire/Concerns/SegueOperazioneBackup.php +++ b/app/Livewire/Concerns/SegueOperazioneBackup.php @@ -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; diff --git a/app/Livewire/Proclamatori/ProclamatoreCestino.php b/app/Livewire/Proclamatori/ProclamatoreCestino.php index a08f4ac..191870e 100644 --- a/app/Livewire/Proclamatori/ProclamatoreCestino.php +++ b/app/Livewire/Proclamatori/ProclamatoreCestino.php @@ -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() diff --git a/app/Livewire/Proclamatori/ProclamatoreIndex.php b/app/Livewire/Proclamatori/ProclamatoreIndex.php index c1946ce..3ee368d 100644 --- a/app/Livewire/Proclamatori/ProclamatoreIndex.php +++ b/app/Livewire/Proclamatori/ProclamatoreIndex.php @@ -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() diff --git a/app/Livewire/Profile.php b/app/Livewire/Profile.php index a9a8507..9d07f8f 100644 --- a/app/Livewire/Profile.php +++ b/app/Livewire/Profile.php @@ -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() diff --git a/app/Livewire/Settings/BackupManager.php b/app/Livewire/Settings/BackupManager.php index 3be8efb..665f3f8 100644 --- a/app/Livewire/Settings/BackupManager.php +++ b/app/Livewire/Settings/BackupManager.php @@ -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); diff --git a/app/Livewire/Settings/SettingsEdit.php b/app/Livewire/Settings/SettingsEdit.php index 24d371d..6619ae1 100644 --- a/app/Livewire/Settings/SettingsEdit.php +++ b/app/Livewire/Settings/SettingsEdit.php @@ -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() diff --git a/app/Livewire/Settings/TipologieIndex.php b/app/Livewire/Settings/TipologieIndex.php index 4d7bab6..0659b9d 100644 --- a/app/Livewire/Settings/TipologieIndex.php +++ b/app/Livewire/Settings/TipologieIndex.php @@ -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() diff --git a/app/Livewire/Settings/UsersIndex.php b/app/Livewire/Settings/UsersIndex.php index dd44eac..5c1f87e 100644 --- a/app/Livewire/Settings/UsersIndex.php +++ b/app/Livewire/Settings/UsersIndex.php @@ -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() diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index ed565bb..be19099 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -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 + */ + 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 $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'); diff --git a/app/Livewire/Settings/ZonaPericolosa.php b/app/Livewire/Settings/ZonaPericolosa.php index be2fa9d..a973832 100644 --- a/app/Livewire/Settings/ZonaPericolosa.php +++ b/app/Livewire/Settings/ZonaPericolosa.php @@ -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); diff --git a/app/Livewire/Settings/ZoneIndex.php b/app/Livewire/Settings/ZoneIndex.php index 11f84db..b6941f0 100644 --- a/app/Livewire/Settings/ZoneIndex.php +++ b/app/Livewire/Settings/ZoneIndex.php @@ -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() diff --git a/app/Livewire/Territori/TerritorioCestino.php b/app/Livewire/Territori/TerritorioCestino.php index 29959cb..600ccb2 100644 --- a/app/Livewire/Territori/TerritorioCestino.php +++ b/app/Livewire/Territori/TerritorioCestino.php @@ -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.'); } /** diff --git a/app/Livewire/Territori/TerritorioEdit.php b/app/Livewire/Territori/TerritorioEdit.php index 4b2f097..6482b70 100644 --- a/app/Livewire/Territori/TerritorioEdit.php +++ b/app/Livewire/Territori/TerritorioEdit.php @@ -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); } diff --git a/app/Livewire/Territori/TerritorioIndex.php b/app/Livewire/Territori/TerritorioIndex.php index 9e1e5fc..bb5307f 100644 --- a/app/Livewire/Territori/TerritorioIndex.php +++ b/app/Livewire/Territori/TerritorioIndex.php @@ -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() diff --git a/app/Models/Campagna.php b/app/Models/Campagna.php index b5f0678..7222712 100644 --- a/app/Models/Campagna.php +++ b/app/Models/Campagna.php @@ -46,8 +46,13 @@ class Campagna extends Model */ public static function attiva(): ?static { - return static::where('start_date', '<=', now()) - ->where('end_date', '>=', now()) + // Confronto sulla data, non sull'ora: end_date vale la mezzanotte del + // suo giorno, e contro now() la campagna spariva dalla home proprio + // nel suo ultimo giorno. + $oggi = now()->toDateString(); + + return static::whereDate('start_date', '<=', $oggi) + ->whereDate('end_date', '>=', $oggi) ->first(); } @@ -70,9 +75,11 @@ class Campagna extends Model } /** - * Campaign coverage percentage. - * Numerator: assignments counted for campaign - * Denominator: total active territories + * Quota dei territori attivi percorsi durante la campagna. + * + * Si contano i territori, non le assegnazioni: un territorio percorso due + * volte valeva doppio, e insieme ai territori poi disattivati (fuori dal + * denominatore ma ancora nel numeratore) la percentuale superava il 100%. */ public function getPercentualePercorrenzaAttribute(): float { @@ -82,13 +89,17 @@ class Campagna extends Model return 0.0; } - $conteggiate = $this->assegnazioniConteggiate()->count(); + $percorsi = $this->assegnazioniConteggiate() + ->whereHas('territorio', fn ($q) => $q->where('attivo', true)) + ->distinct() + ->count('territorio_id'); - return round(($conteggiate / $totaleAttivi) * 100, 1); + return round(($percorsi / $totaleAttivi) * 100, 1); } public function scopeCompletate($query) { - return $query->where('end_date', '<', now())->orderByDesc('end_date'); + // Una campagna che finisce oggi è ancora in corso. + return $query->whereDate('end_date', '<', now()->toDateString())->orderByDesc('end_date'); } } diff --git a/app/Models/Territorio.php b/app/Models/Territorio.php index c627123..f7a4804 100644 --- a/app/Models/Territorio.php +++ b/app/Models/Territorio.php @@ -220,4 +220,24 @@ class Territorio extends Model }); }); } + + // ─── Indirizzi dei file (serviti da TerritorioFileController) ────────── + + /** + * ?v= cambia con il file: l'indirizzo resta per territorio, e senza la + * cache del browser mostrerebbe il PDF di prima dopo una sostituzione. + */ + public function pdfUrl(): ?string + { + return $this->pdf_path + ? route('territori.file.pdf', ['territorio' => $this->id, 'v' => substr(md5($this->pdf_path), 0, 8)]) + : null; + } + + public function miniaturaUrl(): ?string + { + return $this->thumbnail_path + ? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)]) + : null; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dacebbf..00ea3d5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,10 @@ namespace App\Providers; +use App\Models\Setting; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\URL; use Livewire\Livewire; use RuntimeException; @@ -17,6 +19,13 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { + // Setting tiene le impostazioni in una variabile statica, svuotata + // solo quando le salva lo stesso processo. Il worker resta vivo fino a + // un'ora: senza questo, ogni job usava le impostazioni lette dal primo, + // anche dopo che l'amministratore le aveva cambiate dal browser o un + // ripristino le aveva sostituite. + Queue::before(fn () => Setting::flushCache()); + // Pin generated URLs to APP_URL. // // Behind a trusted proxy, Symfony derives the port from diff --git a/app/Services/BackupArchive.php b/app/Services/BackupArchive.php index 7d4583a..99bc533 100644 --- a/app/Services/BackupArchive.php +++ b/app/Services/BackupArchive.php @@ -20,8 +20,15 @@ use ZipArchive; * * manifest.json formato, data, conteggi (letto per validare il file) * data.xml lo stesso XML dell'export "Esporta XML" + * piantine.json disegni e storico dei PDF (Piantine\PiantineBackup) * storage/ ogni file del disco `public`, con lo stesso percorso * relativo che sta in pdf_path/thumbnail_path/logo_path + * piantine/ l'area KML delle piantine (storage/app/piantine), + * che sta accanto al disco `public`, non dentro + * + * piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1 + * si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area + * KML in uso non si tocca (vedi scambiaFile()). */ class BackupArchive { @@ -29,11 +36,23 @@ class BackupArchive * Versione del formato. L'import rifiuta i pacchetti piu' recenti di * quello che sa leggere invece di ripristinarli a meta'. */ - public const FORMAT_VERSION = 1; + public const FORMAT_VERSION = 2; private const ENTRY_MANIFEST = 'manifest.json'; private const ENTRY_DATA = 'data.xml'; + private const ENTRY_PIANTINE = 'piantine.json'; private const PREFIX_STORAGE = 'storage/'; + private const PREFIX_PIANTINE = 'piantine/'; + + /** + * Sottocartelle dello staging, una per ogni cartella che il ripristino + * sostituisce (vedi radiciDaSostituire()). + */ + private const STAGING_PUBBLICO = 'pubblico'; + private const STAGING_PIANTINE = 'piantine'; + + /** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */ + private const CARTELLA_PIANTINE = 'piantine'; /** * Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza @@ -65,12 +84,32 @@ class BackupArchive * Costruisce il pacchetto in $destinazione e ne restituisce il percorso. * * @param string $xml Contenuto di data.xml (l'export XML gia' pronto). + * @param array $piantine Contenuto di piantine.json (PiantineBackup::esporta()). + * Obbligatorio e non facoltativo di proposito: un + * chiamante che se ne dimentica produrrebbe archivi + * che al ripristino svuotano le piantine. * @param callable|null $onProgress fn(int $fatti, int $totale): void */ - public function create(string $xml, string $destinazione, ?callable $onProgress = null): string + public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string { $disk = Storage::disk('public'); - $files = $disk->allFiles(); + $local = Storage::disk('local'); + + // Due cartelle sorgente, un solo elenco di terne: percorso relativo + // (per i messaggi), percorso sul disco, nome dell'entry. + $files = []; + foreach ($disk->allFiles() as $relativePath) { + $files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath]; + } + $filePubblici = count($files); + + // allFiles('piantine') restituisce gia' "piantine/area.kml": il + // percorso relativo al disco coincide con il nome dell'entry. + foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) { + $files[] = [$relativePath, $local->path($relativePath), $relativePath]; + } + $filePiantine = count($files) - $filePubblici; + $totale = count($files); $cartella = dirname($destinazione); @@ -84,12 +123,14 @@ class BackupArchive } $zip->addFromString(self::ENTRY_DATA, $xml); + $zip->addFromString(self::ENTRY_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); $zip->addFromString(self::ENTRY_MANIFEST, json_encode([ 'format_version' => self::FORMAT_VERSION, 'application' => 'TerManager2', 'generated_at' => now()->toIso8601String(), 'app_url' => (string) config('app.url'), - 'files_count' => $totale, + 'files_count' => $filePubblici, + 'piantine_files_count' => $filePiantine, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); try { @@ -110,12 +151,12 @@ class BackupArchive try { $this->conArchivioAperto($destinazione, function (ZipArchive $zip) use ( - $disk, $files, $totale, $perLotto, $onProgress, $destinazione + $files, $totale, $perLotto, $onProgress, $destinazione ) { $nelLotto = 0; $fatti = 0; - foreach ($files as $relativePath) { + foreach ($files as [$relativePath, $sorgente, $entry]) { // addFile() legge dal disco durante close(): i PDF non // passano mai interi per la memoria del processo. // @@ -133,7 +174,7 @@ class BackupArchive // raggiunto mai, e l'amministratore leggerebbe la stringa // grezza di PHP. Stesso problema, e stessa scelta, del // close() avvolto in chiudi(). - if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) { + if (! @$zip->addFile($sorgente, $entry)) { throw new RuntimeException( 'Impossibile aggiungere "' . $relativePath . '" al backup: ' . 'il file non e\' leggibile o e\' stato rimosso durante la creazione.' @@ -157,7 +198,7 @@ class BackupArchive } try { - $this->verificaArchivio($destinazione, $totale); + $this->verificaArchivio($destinazione, $filePubblici, $filePiantine); } catch (\Throwable $e) { @unlink($destinazione); @@ -178,7 +219,7 @@ class BackupArchive * non dei contenuti — quindi e' un controllo a buon mercato che copre * troncamenti, chiusure a meta' e lotti persi. */ - private function verificaArchivio(string $path, int $fileAttesi): void + private function verificaArchivio(string $path, int $fileAttesi, int $piantineAttesi): void { $zip = new ZipArchive(); @@ -187,27 +228,31 @@ class BackupArchive } $trovati = 0; + $piantineTrovati = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $nome = $zip->getNameIndex($i); if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) { $trovati++; + } elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) { + $piantineTrovati++; } } $haDati = $zip->locateName(self::ENTRY_DATA) !== false; $haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false; + $haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false; $zip->close(); - if (! $haDati || ! $haManifest) { - throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.'); + if (! $haDati || ! $haManifest || ! $haPiantine) { + throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.'); } - if ($trovati !== $fileAttesi) { + if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) { throw new RuntimeException(sprintf( 'Il backup appena creato contiene %d file invece di %d: archivio scartato.', - $trovati, - $fileAttesi + $trovati + $piantineTrovati, + $fileAttesi + $piantineAttesi )); } } @@ -294,8 +339,15 @@ class BackupArchive * l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i * dati correnti sono ancora intatti. * + * E' anche la verifica completa dell'archivio: ogni entry si estrae per + * intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un + * file troncato, un'entry corrotta o il disco che si riempie durante + * l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso + * volume dei file veri, quindi se c'e' posto per estrarre c'e' posto + * anche per lo scambio, che e' solo una serie di rename(). + * * @param callable|null $onProgress fn(int $fatti, int $totale): void - * @return array{xml: string, staging: string, files: int} + * @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: int} */ public function extract(string $zipPath, ?callable $onProgress = null): array { @@ -334,12 +386,35 @@ class BackupArchive throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.'); } + // Dal formato 2 piantine.json e' obbligatorio: la sua assenza non + // e' "nessuna piantina" ma un archivio monco, e ripristinarlo + // svuoterebbe disegni e storico senza che nessuno l'abbia chiesto. + // Null resta riservato al formato 1, che le piantine non le aveva. + $piantine = null; + if ($formatVersion >= 2) { + $piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE); + $piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null; + + if (! is_array($piantine)) { + throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.'); + } + } + + // Le entry che si estraggono, prefisso => sottocartella dello + // staging. L'area KML solo dal formato 2: un archivio vecchio non + // la porta, e non deve poterla portare per caso. + $prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO]; + if ($formatVersion >= 2) { + $prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE; + } + if ($zip->numFiles > self::MAX_ENTRIES) { throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.'); } $totalBytes = 0; $extracted = 0; + $estrattiPiantine = 0; // Il totale si conta scorrendo i nomi, non come "numFiles meno le // due entry di servizio": un archivio con una entry in piu' (una @@ -348,7 +423,7 @@ class BackupArchive $daEstrarre = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $nome = $zip->getNameIndex($i); - if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) { + if ($nome !== false && ! str_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) { $daEstrarre++; } } @@ -361,11 +436,12 @@ class BackupArchive continue; } - if (! str_starts_with($entryName, self::PREFIX_STORAGE)) { + $prefisso = $this->prefissoDi($entryName, $prefissi); + if ($prefisso === null) { continue; } - $relativePath = substr($entryName, strlen(self::PREFIX_STORAGE)); + $relativePath = substr($entryName, strlen($prefisso)); $safePath = $this->safeRelativePath($relativePath); if ($safePath === null) { @@ -375,7 +451,8 @@ class BackupArchive // La dimensione dichiarata si legge *prima* di estrarre: e' // l'unico momento in cui si puo' rifiutare un'entry che, una // volta decompressa, riempirebbe il disco. - $size = (int) ($zip->statIndex($index)['size'] ?? 0); + $stat = $zip->statIndex($index); + $size = (int) ($stat['size'] ?? 0); if ($size > self::MAX_ENTRY_BYTES) { throw new RuntimeException(sprintf( @@ -390,7 +467,7 @@ class BackupArchive throw new RuntimeException('L\'archivio decompresso supera il limite consentito.'); } - $destination = $staging . '/' . $safePath; + $destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath; $directory = dirname($destination); if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { @@ -409,12 +486,40 @@ class BackupArchive throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.'); } - stream_copy_to_stream($stream, $target); + // Il risultato della copia si controlla, ed e' qui che il + // ripristino diventa sicuro: prima un disco pieno o un'entry + // corrotta lasciavano nello staging un file troncato senza + // nessun errore, e quel file finiva al posto del PDF buono. + // La @ per lo stesso motivo di addFile(): il warning di PHP + // ("CRC error") arriverebbe all'amministratore al posto del + // messaggio che dice quale file. + $copiati = @stream_copy_to_stream($stream, $target); fclose($stream); - fclose($target); + $chiuso = fclose($target); - $extracted++; - $onProgress && $onProgress($extracted, $daEstrarre); + if ($copiati === false || $copiati !== $size || ! $chiuso) { + throw new RuntimeException(sprintf( + 'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.', + $safePath, + $copiati === false ? '0' : (string) $copiati, + $size + )); + } + + // Il CRC lo controlla gia' libzip a fine lettura, ma solo se la + // lettura arriva in fondo e come warning: ricalcolarlo sul file + // scritto costa una rilettura e non dipende da come libzip + // segnala l'errore. + if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) { + throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.'); + } + + if ($prefissi[$prefisso] === self::STAGING_PIANTINE) { + $estrattiPiantine++; + } else { + $extracted++; + } + $onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre); } } catch (\Throwable $e) { $zip->close(); @@ -425,63 +530,180 @@ class BackupArchive $zip->close(); - return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted]; + return [ + 'xml' => $xml, + 'piantine' => $piantine, + 'formato' => $formatVersion, + 'staging' => $staging, + 'pubblico' => $staging . '/' . self::STAGING_PUBBLICO, + 'files' => $extracted, + 'file_piantine' => $estrattiPiantine, + ]; + } + + /** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */ + private function prefissoDi(string $entryName, array $prefissi): ?string + { + foreach (array_keys($prefissi) as $prefisso) { + if (str_starts_with($entryName, $prefisso)) { + return $prefisso; + } + } + + return null; } /** - * Sostituisce il contenuto del disco `public` con quello estratto. + * Mette i file estratti al posto di quelli in uso, tenendo da parte i + * vecchi finche' il ripristino non e' confermato. * * La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle * righe del database, che il ripristino ha appena riscritto per intero. * Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati * da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge. * - * @param callable|null $onProgress fn(int $fatti, int $totale): void + * Prima si svuotava il disco e poi si ricopiava dallo staging file per + * file: un errore a meta' (disco pieno, permessi) lasciava il database + * nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile. + * Adesso sono solo rename() sullo stesso volume, per voce di primo livello + * (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno + * di spazio, e ognuno annotato in un registro che annullaScambio() + * ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui + * e si solleva: il chiamante annulla la transazione e l'installazione + * resta com'era. + * + * Si spostano le voci e non la cartella radice: a quella del disco + * `public` punta il symlink public/storage. + * + * @param array $estratto Il risultato di extract(). + * @return array{file: int, accantonati: string, spostamenti: list} + * Da passare a confermaScambio() a database confermato, o ad + * annullaScambio() se qualcosa fallisce dopo. */ - public function restoreFiles(string $staging, ?callable $onProgress = null): int + public function scambiaFile(array $estratto): array { - $disk = Storage::disk('public'); + // Fuori da backup-restore/ di proposito: quella cartella la ripulisce + // backup:cleanup dopo un giorno, e qui dentro, se l'annullamento + // stesso fallisse, resterebbero gli unici file buoni. + $scambio = [ + 'file' => $estratto['files'] + $estratto['file_piantine'], + 'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()), + 'spostamenti' => [], + ]; - $this->svuotaDiscoPubblico(); + try { + foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) { + $this->preparaCartella($radice); + $this->preparaCartella($scambio['accantonati'] . '/' . $nome); - if (! is_dir($staging)) { - return 0; + foreach ($this->vociDi($radice) as $voce) { + $this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio); + } + + // Una sottocartella assente nello staging vuol dire "nessun + // file": la radice resta vuota, come nell'installazione + // salvata. + $nuovi = $estratto['staging'] . '/' . $nome; + foreach ($this->vociDi($nuovi) as $voce) { + $this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio); + } + } + } catch (\Throwable $e) { + $this->annullaScambio($scambio, $e); + + throw $e; } - $restored = 0; - $totale = iterator_count(new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS) - )); - $onProgress && $onProgress(0, $totale); + return $scambio; + } - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS) - ); + /** Il ripristino e' confermato: i file precedenti non servono piu'. */ + public function confermaScambio(array $scambio): void + { + $this->deleteDirectory($scambio['accantonati']); + } - foreach ($iterator as $file) { - /** @var \SplFileInfo $file */ - if (! $file->isFile()) { - continue; + /** + * Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli + * spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro + * posto. + * + * Se anche questo fallisce non si cancella niente: i file precedenti + * restano nella cartella degli accantonati, e il messaggio dice dove, cosi' + * si possono rimettere a mano invece di darli per persi. + */ + public function annullaScambio(array $scambio, ?\Throwable $causa = null): void + { + try { + foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) { + if (! @rename($a, $da)) { + throw new RuntimeException('impossibile rimettere "' . $da . '"'); + } } - - $relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)); - - $handle = fopen($file->getPathname(), 'rb'); - if ($handle === false) { - continue; - } - - $disk->writeStream($relativePath, $handle); - - if (is_resource($handle)) { - fclose($handle); - } - - $restored++; - $onProgress && $onProgress($restored, $totale); + } catch (\Throwable $e) { + throw new RuntimeException( + ($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '') + . 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): ' + . 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.', + 0, + $causa ?? $e + ); } - return $restored; + $this->deleteDirectory($scambio['accantonati']); + } + + /** + * Le cartelle che il ripristino sostituisce: sottocartella dello staging + * => cartella vera. + * + * L'area KML solo con un archivio di formato 2, che la porta sempre (anche + * vuota, se l'installazione salvata non ne aveva). Un archivio di formato + * 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire + * buttare un file che con i dati ripristinati non ha nessun legame: nessuna + * riga lo cita, e' la mappa della citta'. + * + * @return array + */ + private function radiciDaSostituire(int $formato): array + { + $radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')]; + + if ($formato >= 2) { + $radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\'); + } + + return $radici; + } + + /** @return list Le voci di primo livello di una cartella (vuoto se non esiste). */ + private function vociDi(string $cartella): array + { + if (! is_dir($cartella)) { + return []; + } + + return array_values(array_diff(scandir($cartella) ?: [], ['.', '..'])); + } + + private function preparaCartella(string $cartella): void + { + if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) { + throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.'); + } + } + + private function sposta(string $da, string $a, array &$scambio): void + { + if (! @rename($da, $a)) { + throw new RuntimeException(sprintf( + 'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: ' + . 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.', + basename($da) + )); + } + + $scambio['spostamenti'][] = [$da, $a]; } /** diff --git a/app/Services/BackupOperationState.php b/app/Services/BackupOperationState.php index 7b81947..06f9a2a 100644 --- a/app/Services/BackupOperationState.php +++ b/app/Services/BackupOperationState.php @@ -2,6 +2,7 @@ namespace App\Services; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Support\Facades\Cache; /** @@ -23,6 +24,18 @@ class BackupOperationState public const TIPO_RESTORE = 'restore'; public const TIPO_RESET = 'reset'; + /** + * Gli import non sono operazioni di backup, ma occupano lo stesso posto: + * l'import XML svuota e riscrive le tabelle, l'import PDF sostituisce file + * e righe dei territori. Uno dei due in parallelo a un ripristino (o a un + * backup che sta copiando quegli stessi file) produce uno stato che nessuno + * ha chiesto. Il loro avanzamento non sta qui: l'import PDF ha + * TerritorioPdfImportState, l'import XML e' sincrono. Qui c'e' solo + * l'occupazione del posto. + */ + public const TIPO_IMPORT_PDF = 'import-pdf'; + public const TIPO_IMPORT_XML = 'import-xml'; + /** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */ public const FASE_ATTESA_DOWNLOAD = 'attesa-download'; @@ -48,6 +61,16 @@ class BackupOperationState private const CHIAVE_CORRENTE = 'backup-op:corrente'; private const TTL_CORRENTE = 3600; + /** + * Mutex attorno a "il posto e' libero? allora lo prendo". + * + * corrente() seguito da initialize() sono due passi: due richieste che + * arrivano insieme vedono entrambe il posto libero, ed entrambe lo prendono + * — la seconda sovrascrive il puntatore della prima, e i due lavori girano + * in parallelo proprio come il posto unico doveva impedire. + */ + private const CHIAVE_AVVIO = 'backup-op:avvio'; + public function initialize(string $id, string $tipo, string $fase, int $total = 0): array { $state = [ @@ -99,6 +122,66 @@ class BackupOperationState return $id; } + /** + * Prende il posto unico se e' libero, in un passo solo. + * + * Restituisce null se il posto e' stato preso (e l'operazione inizializzata + * come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa. + * Tutte le porte d'ingresso passano da qui proprio per il mutex: un + * controllo con corrente() e un initialize() separati lasciano aperta la + * finestra fra i due. + * + * $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a + * meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il + * posto resterebbe occupato per l'ora intera del TTL ordinario. + */ + public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string + { + try { + return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) { + $inCorso = $this->corrente(); + + if ($inCorso !== null) { + return $inCorso; + } + + $this->initialize($id, $tipo, $fase); + + if ($ttlPosto !== null) { + Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto); + } + + return null; + }); + } catch (LockTimeoutException) { + // Qualcun altro sta prendendo il posto proprio adesso: il mutex + // copre pochi millisecondi, quindi cinque secondi d'attesa vogliono + // dire che il posto non e' disponibile, non che va forzato. + return $this->corrente() ?? 'avvio-in-corso'; + } + } + + /** + * Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo + * blocca, perche' l'operazione in corso puo' essere stata avviata da un + * altro amministratore in un'altra zona della pagina. + */ + public function messaggioOccupato(string $idInCorso): string + { + $tipo = $this->get($idInCorso)['tipo'] ?? null; + + $cosa = match ($tipo) { + self::TIPO_BACKUP => ' (un backup)', + self::TIPO_RESTORE => ' (un ripristino)', + self::TIPO_RESET => ' (un azzeramento)', + self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)', + self::TIPO_IMPORT_XML => ' (un import XML)', + default => '', + }; + + return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.'; + } + public function get(string $id): ?array { return Cache::get($this->key($id)); diff --git a/app/Services/Piantine/PiantinaPdfService.php b/app/Services/Piantine/PiantinaPdfService.php index 61db6d9..d0a11d0 100644 --- a/app/Services/Piantine/PiantinaPdfService.php +++ b/app/Services/Piantine/PiantinaPdfService.php @@ -257,8 +257,11 @@ class PiantinaPdfService /** * Sposta il PDF attuale del territorio (se esiste) nello storico. + * + * Pubblica perché la usa anche l'import dei PDF da cartella: chi + * sostituisce un PDF lo archivia sempre allo stesso modo. */ - private function archiviaCorrente(Territorio $territorio, string $origine): void + public function archiviaCorrente(Territorio $territorio, string $origine): void { $disk = Storage::disk('public'); diff --git a/app/Services/Piantine/PiantineArea.php b/app/Services/Piantine/PiantineArea.php index 8df5020..1a71240 100644 --- a/app/Services/Piantine/PiantineArea.php +++ b/app/Services/Piantine/PiantineArea.php @@ -12,10 +12,10 @@ use RuntimeException; * attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati * restano al loro posto. * - * ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo - * disco `public` (storage/app/public), e questa cartella gli sta accanto, non - * dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal - * KML originale — che quindi conviene conservare fuori di qui. + * Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la + * impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il + * ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la + * contiene, e ripristinandolo l'area in uso resta com'e'. */ class PiantineArea { diff --git a/app/Services/Piantine/PiantineBackup.php b/app/Services/Piantine/PiantineBackup.php new file mode 100644 index 0000000..b112776 --- /dev/null +++ b/app/Services/Piantine/PiantineBackup.php @@ -0,0 +1,183 @@ +>> + */ + public function esporta(): array + { + return [ + self::TABELLA_MAPPE => $this->righe(self::TABELLA_MAPPE), + self::TABELLA_VERSIONI => $this->righe(self::TABELLA_VERSIONI), + ]; + } + + /** + * Controlla la struttura di piantine.json prima che il ripristino tocchi + * qualunque cosa. Null e' ammesso: e' l'archivio di formato 1, che le + * piantine non le conteneva. + */ + public function valida(?array $dati): void + { + if ($dati === null) { + return; + } + + foreach ([self::TABELLA_MAPPE, self::TABELLA_VERSIONI] as $tabella) { + if (! isset($dati[$tabella]) || ! is_array($dati[$tabella]) || ! array_is_list($dati[$tabella])) { + throw new RuntimeException('Archivio non valido: in piantine.json manca l\'elenco "' . $tabella . '".'); + } + + foreach ($dati[$tabella] as $posizione => $riga) { + if (! is_array($riga) || ! isset($riga['id']) || ! is_numeric($riga['id'])) { + throw new RuntimeException(sprintf( + 'Archivio non valido: la riga %d di "%s" in piantine.json non e\' leggibile.', + $posizione + 1, + $tabella + )); + } + } + } + } + + /** + * Sostituisce il contenuto delle due tabelle con quello del backup. + * + * Va chiamato dentro la transazione del ripristino e *dopo* l'import XML: + * servono i territori gia' ricreati per ritrovare i collegamenti. DELETE e + * non TRUNCATE: su MariaDB TRUNCATE e' DDL e fa commit implicito, cioe' + * chiuderebbe a meta' la transazione che deve poter annullare tutto. + * + * Con $dati null (archivio di formato 1) le tabelle si svuotano soltanto. + * Tenere le righe dell'installazione corrente vorrebbe dire piantine che + * citano immagini del disco appena sostituito — file che non ci sono piu' + * — accanto ai dati di un'altra epoca: proprio lo stato misto che il + * ripristino deve evitare. Lo storico, del resto, lo svuotava gia' la + * cascata sulla cancellazione dei territori. + * + * @return array{mappe: int, versioni: int, issues: list} + */ + public function ripristina(?array $dati): array + { + DB::table(self::TABELLA_VERSIONI)->delete(); + DB::table(self::TABELLA_MAPPE)->delete(); + + $esito = ['mappe' => 0, 'versioni' => 0, 'issues' => []]; + + if ($dati === null) { + return $esito; + } + + // withTrashed implicito: il query builder non conosce i soft delete, e + // una piantina collegata a un territorio nel cestino deve restarlo. + $territori = DB::table('territori')->pluck('id', 'numero') + ->mapWithKeys(fn ($id, $numero) => [trim((string) $numero) => (int) $id]) + ->all(); + + $colonneMappe = array_flip(Schema::getColumnListing(self::TABELLA_MAPPE)); + $collegati = []; + + foreach ($dati[self::TABELLA_MAPPE] as $riga) { + $numero = trim((string) ($riga['territorio_numero'] ?? '')); + $territorioId = null; + + if (($riga['territorio_id'] ?? null) !== null) { + $territorioId = $territori[$numero] ?? null; + + // territorio_id e' unico: due piantine sullo stesso numero + // (possibile solo se il backup aveva territori con numero + // ripetuto, che l'import scarta) non possono restare collegate + // entrambe. + if ($territorioId !== null && isset($collegati[$territorioId])) { + $territorioId = null; + } + + if ($territorioId === null) { + // Il disegno non si butta: resta scollegato, con il numero + // come etichetta per riconoscerlo. + if (trim((string) ($riga['nome'] ?? '')) === '' && $numero !== '') { + $riga['nome'] = 'Terr. ' . $numero; + } + $esito['issues'][] = $this->problema('piantina', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: piantina ripristinata scollegata'); + } else { + $collegati[$territorioId] = true; + } + } + + $riga['territorio_id'] = $territorioId; + DB::table(self::TABELLA_MAPPE)->insert(array_intersect_key($riga, $colonneMappe)); + $esito['mappe']++; + } + + $colonneVersioni = array_flip(Schema::getColumnListing(self::TABELLA_VERSIONI)); + + foreach ($dati[self::TABELLA_VERSIONI] as $riga) { + $numero = trim((string) ($riga['territorio_numero'] ?? '')); + $territorioId = $territori[$numero] ?? null; + + // Una versione senza territorio non e' ripristinabile da nessuna + // schermata (e la colonna non ammette NULL): si scarta, e lo si dice. + if ($territorioId === null) { + $esito['issues'][] = $this->problema('versione PDF', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: versione non ripristinata'); + + continue; + } + + $riga['territorio_id'] = $territorioId; + DB::table(self::TABELLA_VERSIONI)->insert(array_intersect_key($riga, $colonneVersioni)); + $esito['versioni']++; + } + + return $esito; + } + + private function righe(string $tabella): array + { + return DB::table($tabella) + ->leftJoin('territori', 'territori.id', '=', $tabella . '.territorio_id') + ->orderBy($tabella . '.id') + ->get([$tabella . '.*', 'territori.numero as territorio_numero']) + ->map(fn (object $riga) => (array) $riga) + ->all(); + } + + /** Stessa forma delle segnalazioni dell'import XML, per mostrarle insieme. */ + private function problema(string $entita, array $riga, string $motivo): array + { + return ['entity' => $entita, 'legacy_id' => (string) ($riga['id'] ?? ''), 'reason' => $motivo]; + } +} diff --git a/app/Services/TerritorioPdfImportDispatcher.php b/app/Services/TerritorioPdfImportDispatcher.php index a6cb74d..1380a5e 100644 --- a/app/Services/TerritorioPdfImportDispatcher.php +++ b/app/Services/TerritorioPdfImportDispatcher.php @@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher public function __construct( protected TerritorioPdfImportState $stateService, + protected BackupOperationState $postoUnico, ) { } - public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array + /** + * Prende il posto unico per questo import, o solleva spiegando chi lo occupa. + * + * L'import PDF sostituisce file e righe dei territori: in parallelo a un + * ripristino scriverebbe su tabelle che l'altro sta riscrivendo, e durante + * un backup l'archivio fotograferebbe meta' PDF vecchi e meta' nuovi (o + * fallirebbe su un file cancellato fra l'elenco e la compressione). Il posto + * si libera nel job, a fine lavoro o in failed(). + */ + public function occupaPosto(string $importId): void + { + $inCorso = $this->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); - ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId); + 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; } @@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher 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', @@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher $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 { @@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher continue; } - if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') { + if (self::daIgnorare($entryName)) { + $ignorati[] = basename($entryName); continue; } @@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher } if ($storedFiles === []) { + Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId); + throw new RuntimeException('Lo ZIP non contiene file PDF validi.'); } @@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher $importId, $storedFiles, $actorId, - 'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.' + 'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.', + $ignorati ); return $importId; diff --git a/bootstrap/app.php b/bootstrap/app.php index edd11e2..11f82f7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,13 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + // '*' is deliberate. The container is only reachable through Traefik + // (no published port in Dokploy), and nginx's real_ip module has + // already rewritten REMOTE_ADDR to the client's address. Restricting + // this to private ranges would therefore stop Laravel from honouring + // the X-Forwarded-Proto that nginx sets server-side, and every signed + // URL and secure cookie would break behind HTTPS. + // // X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client // set an arbitrary Host and have route()/url() emit links pointing at // an attacker's domain — including the public assignment PDF links, @@ -39,7 +46,16 @@ return Application::configure(basePath: dirname(__DIR__)) // Dopo il ripristino di un backup ogni account resta bloccato finche' // non imposta una password nuova. In coda al gruppo web, quindi la // sessione e' gia' avviata e l'utente risolto quando gira. + // + // AuthenticateSession prima: ogni sessione ricorda l'impronta della + // password, e quando la password cambia (profilo, cambio obbligatorio, + // l'amministratore che la reimposta) le sessioni aperte altrove si + // chiudono alla richiesta successiva. Quella che ha fatto il cambio + // resta aperta: il middleware ci registra l'impronta nuova. Senza, + // session()->regenerate() cambiava solo l'id della sessione corrente e + // le altre restavano valide su Redis. $middleware->web(append: [ + \Illuminate\Session\Middleware\AuthenticateSession::class, \App\Http\Middleware\RequirePasswordChange::class, ]); diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..4b0c11b --- /dev/null +++ b/config/queue.php @@ -0,0 +1,26 @@ + [ + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + // Più lungo del job più lungo (import PDF: 900 s). Con i 90 s del + // framework un backup, un ripristino o un import ancora in corso + // tornava disponibile in coda e poteva partire una seconda volta + // sugli stessi dati — per esempio dopo un riavvio del worker. + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 960), + 'block_for' => null, + 'after_commit' => false, + ], + + ], + +]; diff --git a/database/migrations/2026_10_12_000010_nomi_proclamatori_fuori_dal_log.php b/database/migrations/2026_10_12_000010_nomi_proclamatori_fuori_dal_log.php new file mode 100644 index 0000000..e7d4f07 --- /dev/null +++ b/database/migrations/2026_10_12_000010_nomi_proclamatori_fuori_dal_log.php @@ -0,0 +1,51 @@ + nome e cognome: + * dati che nel database sono cifrati, e che lì restavano leggibili anche + * dopo l'anonimizzazione. Al loro posto va l'id del proclamatore, preso + * dall'assegnazione a cui la voce si riferisce; il registro mostra il nome + * attuale. Se l'assegnazione non c'è più il nome si toglie e basta. + */ +return new class extends Migration +{ + public function up(): void + { + $tabella = config('activitylog.table_name', 'activity_log'); + + DB::table($tabella) + ->whereIn('description', ['assigned', 'returned']) + ->where('properties', 'like', '%"proclamatore"%') + ->orderBy('id') + ->each(function ($voce) use ($tabella) { + $proprieta = json_decode((string) $voce->properties, true); + + if (! is_array($proprieta) || ! array_key_exists('proclamatore', $proprieta)) { + return; + } + + unset($proprieta['proclamatore']); + + $idProclamatore = $voce->subject_id + ? DB::table('assegnazioni')->where('id', $voce->subject_id)->value('proclamatore_id') + : null; + if ($idProclamatore) { + $proprieta['proclamatore_id'] = (int) $idProclamatore; + } + + DB::table($tabella)->where('id', $voce->id)->update([ + 'properties' => json_encode($proprieta, JSON_UNESCAPED_UNICODE), + ]); + }); + } + + public function down(): void + { + // I nomi tolti non si rimettono: era proprio il punto. + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 4cef1df..1a91965 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,11 @@ services: - --tries=3 - --timeout=600 - --max-time=3600 + # Al riavvio (ogni deploy) il worker riceve SIGTERM e finisce il job in + # corso prima di uscire; Docker pero' di default aspetta 10 secondi e poi + # lo uccide. Un ripristino o un import interrotto cosi' lascia i dati a + # meta'. 16 minuti coprono il job piu' lungo (900 s). + stop_grace_period: 16m # Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'. healthcheck: test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"] @@ -166,9 +171,11 @@ services: - ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env} - --maxmemory - 256mb - # Qui vivono sessioni e cache: meglio sfrattare chiavi che esaurire memoria. + # Qui vivono sessioni, cache e la coda. volatile-lru sfratta solo le + # chiavi con scadenza (sessioni, cache): con allkeys-lru, a memoria + # piena, potevano sparire anche i job in coda, senza errori. - --maxmemory-policy - - allkeys-lru + - volatile-lru volumes: - redis_data:/data networks: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index b12533d..4d0c7a2 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -38,8 +38,16 @@ server { try_files $uri =404; } - # Uploaded territory PDFs and thumbnails (public/storage -> storage/app/public). - # Served directly by nginx rather than round-tripping through PHP. + # Territory files (PDFs, archived versions, thumbnails, map images) are NOT + # public: anyone could download /storage/territori-pdf/territorio-12-34.pdf + # without logging in, the names are guessable, and the public cache header + # below let Cloudflare keep them for a week. Logged-in users get them from + # TerritorioFileController, publishers from the expiring assignment link. + location ^~ /storage/territori- { + return 404; + } + + # What is left on the public disk (the congregation logo). location /storage/ { expires 7d; add_header Cache-Control "public"; diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..e2ca77e --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ +T2 diff --git a/resources/views/assignments/link-scaduto.blade.php b/resources/views/assignments/link-scaduto.blade.php index 1adb3b4..54dea47 100644 --- a/resources/views/assignments/link-scaduto.blade.php +++ b/resources/views/assignments/link-scaduto.blade.php @@ -4,6 +4,7 @@ Link scaduto — TerManager2 +