Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri

Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:01:41 +02:00
parent 056d95cfcb
commit 426a95211d
78 changed files with 3971 additions and 429 deletions

View File

@@ -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` + `<x-avvisi>`; 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/<uuid>`, 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/<uuid>` 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.
---

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Livewire\Settings\XmlExchange;
use App\Models\Setting;
use App\Models\TerritorioMappa;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Elenca (e su richiesta cancella) i file dei territori che nessuna riga usa.
*
* Fino all'ottobre 2026 l'import XML ricreava i territori con id nuovi senza
* toccare i loro file: PDF, miniature e versioni dei territori di prima
* restavano sul disco, e passavano da un backup all'altro perché l'archivio
* copia tutto il disco pubblico. Oggi l'import li elimina da sé; questo
* comando serve a ripulire quelli accumulati prima.
*
* Di default non cancella niente: elenca. Con --elimina cancella.
*/
class TerritoriFileOrfani extends Command
{
protected $signature = 'territori:file-orfani {--elimina : Cancella i file trovati invece di elencarli}';
protected $description = 'Elenca (o cancella con --elimina) i file dei territori che nessuna riga usa più';
private const CARTELLE = ['territori-pdf', 'territori-thumbnails', 'territori-pdf-versioni', 'territori-mappe'];
public function handle(): int
{
$usati = array_flip(array_merge(
XmlExchange::fileDeiTerritori(),
array_filter([Setting::instance()->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;
}
}

View File

@@ -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();

View File

@@ -110,17 +110,22 @@ 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(
@@ -128,12 +133,22 @@ class BackupController extends Controller
$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());
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(

View File

@@ -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.']);
}
// 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)

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* PDF, miniature e versioni archiviate dei territori, solo per chi è dentro.
*
* I file stanno sul disco `public`, ma nginx non serve più le cartelle
* `territori-*` (docker/nginx/default.conf): prima chiunque scaricava
* /storage/territori-pdf/territorio-12-34.pdf senza login, con nomi facili da
* indovinare, e Cloudflare li teneva in cache pubblica per una settimana. I
* proclamatori continuano a riceverli dal link con codice e scadenza
* (AssignmentPdfController); da qui passano solo gli utenti autenticati, con i
* permessi della pagina che li mostra.
*/
class TerritorioFileController extends Controller
{
public function pdf(Territorio $territorio): Response
{
return $this->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);
}
}

View File

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

View File

@@ -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 = [];
foreach ($this->numeriNelNomeFile($filename) as $codice) {
if (isset($territoriMap[$codice])) {
foreach ($territoriMap[$codice] as $territorio) {
$trovati[$territorio->id] = $territorio;
}
$basename = pathinfo($filename, PATHINFO_FILENAME);
$normalizedBasename = mb_strtoupper($basename);
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
$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) {
$territorio = reset($trovati);
return [
'type' => 'ambiguous',
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
'type' => 'single',
'normalized_number' => $this->chiaveTerritorio($territorio->numero),
'matched_number' => $territorio->numero,
'territorio' => $territorio,
];
}
return $matches[0];
}
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<int, string>
*/
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('/(?<![\p{L}\p{N}])(\d+)(?:[ _.\-]?(\p{L}))?(?![\p{L}\p{N}])/u', $nome, $trovati, PREG_SET_ORDER);
$codici = [];
foreach ($trovati as $trovato) {
$codici[] = (ltrim($trovato[1], '0') ?: '0') . ($trovato[2] ?? '');
}
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
return array_values(array_unique($codici));
}
protected function normalizeTerritoryNumber(string $number): string
/**
* Forma di confronto di un numero di territorio: maiuscolo, senza
* separatori, senza zeri iniziali. "12 B", "12-b" e "012B" diventano tutti
* "12B"; due territori con la stessa chiave rendono ambiguo ogni file che
* la nomina.
*/
protected function chiaveTerritorio(string $numero): string
{
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
$chiave = preg_replace('/[^\p{L}\p{N}]+/u', '', mb_strtoupper($numero));
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
return ltrim($normalized, '0') ?: '0';
}
return $normalized;
return preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
}
}

View File

@@ -8,6 +8,8 @@ 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;
@@ -21,6 +23,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
@@ -72,12 +75,17 @@ class ResetApplication implements ShouldQueue
$stato->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);

View File

@@ -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,34 +80,47 @@ 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);
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']);
return;
throw new RuntimeException($result['error']);
}
$stato->avanza($this->operationId, 1, 1);
$esitoPiantine = $piantine->ripristina($estratto['piantine']);
$stato->fase($this->operationId, 'Ripristino dei file');
$fileRipristinati = $archivio->restoreFiles(
$staging,
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
);
$stato->fase($this->operationId, 'Blocco degli account', 1);
$utenti = $this->richiediCambioPasswordATutti();
$stato->avanza($this->operationId, 1, 1);
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
$scambio = $archivio->scambiaFile($estratto);
});
$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'
// caricata continuano a decidere gli accessi con quella di prima
@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
@@ -9,20 +10,27 @@ use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class XmlExchange extends Component
{
use Avvisa;
use WithFileUploads;
public array $importStats = [];
@@ -52,23 +60,113 @@ 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
{
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
}
/**
* 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();
if ($inCorso === null) {
return true;
}
$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'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
$importId = (string) Str::uuid();
$storedFiles = [];
$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));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
'local'
);
@@ -77,14 +175,34 @@ class XmlExchange extends Component
'stored_path' => $storedPath,
];
}
} catch (\Throwable $e) {
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
$this->pdfFolder = [];
throw $e;
}
$this->svuotaCartella();
if ($storedFiles === []) {
$dispatcher->liberaPosto($importId, 'Nessun PDF da importare.');
$this->avvisaErrore('Nella cartella non c\'è nessun PDF da importare'
. ($ignorati !== [] ? ' (ignorati ' . count($ignorati) . ' file di sistema o di altro tipo)' : '')
. ($scartati !== [] ? ' (' . count($scartati) . ' PDF non validi: ' . implode(', ', array_column($scartati, 'file')) . ')' : '')
. '.');
return false;
}
$this->dispatchPdfImport(
$importId,
$storedFiles,
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
$ignorati,
$scartati
);
return true;
}
public function refreshPdfImportStatus(): void
@@ -114,8 +232,9 @@ class XmlExchange extends Component
* contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e
* non partecipa al posto unico: puo' girare mentre un backup lavora.
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
* Del posto unico usa solo l'occupazione (BackupOperationState::occupa()):
* non gira mai insieme a un backup, un ripristino o un azzeramento.
*/
protected function spedisciImportAllaConsole(): void
{
@@ -156,10 +275,10 @@ class XmlExchange extends Component
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
@@ -172,13 +291,13 @@ class XmlExchange extends Component
$this->logPdfSpediti = 0;
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
$this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
$this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
return;
}
@@ -236,6 +355,67 @@ class XmlExchange extends Component
return $this->datasetToXml($dataset, $source);
}
/**
* I file sul disco `public` a cui puntano i territori e il loro storico PDF.
*
* Serve all'import XML per sapere, dopo, quali file ha reso orfani. L'import
* cancella tutti i territori e li ricrea con id nuovi; i PDF portano l'id nel
* nome (territorio-142-141.pdf), quindi un file che arriva da un'altra
* installazione, o da un dump della vecchia applicazione senza PDF, lascia i
* file dei territori cancellati sul disco: nessuna schermata li mostra,
* nessuna cancellazione li raggiunge, e finiscono dentro ogni backup
* successivo. E' cosi' che la cartella territori-pdf si era trovata con due
* PDF per territorio.
*
* @return array<int, string>
*/
public static function fileDeiTerritori(): array
{
return Territorio::withTrashed()->get(['pdf_path', 'thumbnail_path'])
->concat(TerritorioPdfVersione::query()->get(['pdf_path', 'thumbnail_path']))
->flatMap(fn ($riga) => [$riga->pdf_path, $riga->thumbnail_path])
->filter()
->unique()
->values()
->all();
}
/**
* Elimina i file che prima dell'import appartenevano a un territorio e che
* adesso nessuna riga richiama piu'.
*
* Si toccano solo i file elencati in $fileDiPrima: un file sul disco che
* nessuno conosceva non e' affare dell'import. E prima di cancellare si
* ricontrolla ogni riferimento possibile — anche logo e piantine — cosi' un
* XML che riporta gli stessi percorsi (un export di questa stessa
* installazione) non perde niente.
*
* @param array<int, string> $fileDiPrima
*/
public static function eliminaFileNonPiuUsati(array $fileDiPrima): int
{
$usati = array_flip(array_merge(
self::fileDeiTerritori(),
array_filter([Setting::instance()->logo_path]),
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
));
$disco = Storage::disk('public');
$eliminati = 0;
foreach ($fileDiPrima as $percorso) {
if (isset($usati[$percorso]) || ! $disco->exists($percorso)) {
continue;
}
if ($disco->delete($percorso)) {
$eliminati++;
}
}
return $eliminati;
}
public function importXmlFromContent(string $content): array
{
$this->importStats = [];
@@ -370,14 +550,20 @@ class XmlExchange extends Component
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
// Per chi sta importando password e token restano i suoi:
// quelli del file possono essere di mesi fa.
//
// Chi riceve l'hash dal file deve scegliere una password nuova,
// come dopo un ripristino: chi ha avuto il file fra le mani ha
// potuto tentarne il crack con calma. Il "ricordami" si azzera
// e basta: non si esporta più, e quello di un file vecchio
// sarebbe una credenziale persistente regalata a chi lo tiene.
$meta = [];
$passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash;
$meta['password_change_required'] = true;
}
if (! $eAttore) {
$rememberToken = (string) ($userNode->remember_token ?? '');
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
$meta['remember_token'] = null;
}
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
@@ -620,7 +806,8 @@ class XmlExchange extends Component
'email' => (string) $u->email,
'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
'remember_token' => (string) ($u->getRememberToken() ?? ''),
// remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(),
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
@@ -1030,7 +1217,6 @@ class XmlExchange extends Component
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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/<percorso> ogni file del disco `public`, con lo stesso percorso
* relativo che sta in pdf_path/thumbnail_path/logo_path
* piantine/<percorso> 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);
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, $daEstrarre);
}
$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<array{0: string, 1: string}>}
* 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);
}
$restored = 0;
$totale = iterator_count(new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
));
$onProgress && $onProgress(0, $totale);
// 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);
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
throw $e;
}
return $scambio;
}
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
public function confermaScambio(array $scambio): void
{
$this->deleteDirectory($scambio['accantonati']);
}
/**
* 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 . '"');
}
}
} 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
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
}
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
$handle = fopen($file->getPathname(), 'rb');
if ($handle === false) {
continue;
$this->deleteDirectory($scambio['accantonati']);
}
$disk->writeStream($relativePath, $handle);
/**
* 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<string, string>
*/
private function radiciDaSostituire(int $formato): array
{
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
if (is_resource($handle)) {
fclose($handle);
if ($formato >= 2) {
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
}
$restored++;
$onProgress && $onProgress($restored, $totale);
return $radici;
}
return $restored;
/** @return list<string> 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];
}
/**

View File

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

View File

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

View File

@@ -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
{

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Services\Piantine;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
/**
* Le tabelle della sotto-app delle piantine dentro il backup completo:
* i disegni (`territorio_mappe`) e lo storico dei PDF sostituiti
* (`territorio_pdf_versioni`).
*
* Non passano dall'XML del gestionale di proposito: l'export XML e' il formato
* di scambio dei dati gestionali, e la sotto-app deve restare rimovibile senza
* toccarlo. Viaggiano in `piantine.json` accanto a `data.xml`; i file che
* citano (immagini in territori-mappe/, PDF in territori-pdf-versioni/) stanno
* sul disco `public` e sono gia' nel pacchetto con tutti gli altri.
*
* Il collegamento al territorio si salva come *numero*, non solo come id:
* l'import XML ricrea i territori con id nuovi (autoincremento), quindi l'id
* del backup dopo il ripristino punterebbe al territorio sbagliato o a nessuno.
* Il numero e' quello che l'import usa per riconoscere i territori, ed e'
* unico.
*/
class PiantineBackup
{
public const TABELLA_MAPPE = 'territorio_mappe';
public const TABELLA_VERSIONI = 'territorio_pdf_versioni';
/**
* Le righe delle due tabelle cosi' come sono nel database, piu' il numero
* del territorio collegato.
*
* Query builder e non Eloquent: si vuole la riga grezza, timestamp
* compresi, da reinserire identica — non i valori passati per i cast.
*
* @return array<string, list<array<string, mixed>>>
*/
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<array{entity: string, legacy_id: string, reason: string}>}
*/
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];
}
}

View File

@@ -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<int, string> $ignorati nomi dei file scartati da daIgnorare(), solo per il log
* @param array<int, array{file: string, message: string}> $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);
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;

View File

@@ -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,
]);

26
config/queue.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/*
* Solo la connessione redis: il resto resta quello del framework (Laravel 11
* unisce queue.connections con i propri valori predefiniti).
*/
return [
'connections' => [
'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,
],
],
];

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Toglie dal registro attività i nomi dei proclamatori scritti in chiaro.
*
* Assegnazioni e rientri registravano "proclamatore" => 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.
}
};

View File

@@ -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:

View File

@@ -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";

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4a45a8"/><text x="16" y="21.5" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">T2</text></svg>

After

Width:  |  Height:  |  Size: 281 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Settings\TerritoryPdfImportController;
use App\Http\Controllers\Settings\XmlExchangeUploadController;
use App\Http\Controllers\AssignmentPdfController;
use App\Http\Controllers\ShortPdfLinkController;
use App\Http\Controllers\TerritorioFileController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\PasswordChangeController;
use App\Livewire\Home;
@@ -100,6 +101,22 @@ Route::middleware('auth')->group(function () {
Route::get('territori/{territorio}/edit', TerritorioEdit::class)->name('territori.edit');
});
// File dei territori: nginx non li serve più da /storage (vedi
// TerritorioFileController). withTrashed: cestino e registro mostrano
// anche territori cancellati.
Route::middleware('permission:territori.assign')->group(function () {
Route::get('file/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdf'])
->withTrashed()->name('territori.file.pdf');
});
Route::middleware('permission:territori.assign|registro.view')->group(function () {
Route::get('file/territori/{territorio}/miniatura', [TerritorioFileController::class, 'miniatura'])
->withTrashed()->name('territori.file.miniatura');
});
Route::middleware('permission:settings.manage')->group(function () {
Route::get('file/versioni-pdf/{versione}', [TerritorioFileController::class, 'versione'])
->name('territori.file.versione');
});
// Proclamatori — visualizzazione (lista + dettaglio) per chi ha
// proclamatori.manage; sola lettura per i non-admin.
Route::middleware('permission:proclamatori.manage')->group(function () {

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\ZoneIndex;
use App\Models\Territorio;
use App\Models\User;
use App\Models\Zona;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Gli esiti delle azioni che non cambiano pagina arrivano come evento
* "avviso". Con session()->flash() il messaggio non si vedeva subito e
* compariva alla pagina successiva, fuori contesto.
*/
class AvvisiTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$this->seed(RolesAndPermissionsSeeder::class);
$utente = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$utente->assignRole('amministratore');
return $utente;
}
public function test_un_eliminazione_rifiutata_avvisa_subito_e_non_lascia_flash(): void
{
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
Territorio::create(['numero' => '1', 'attivo' => true, 'zona_id' => $zona->id]);
Livewire::actingAs($this->admin())
->test(ZoneIndex::class)
->call('deleteZona', $zona->id)
->assertDispatched('avviso', tipo: 'errore', testo: "Impossibile eliminare: la zona 'Nord' ha territori associati.");
$this->assertFalse(session()->has('error'));
$this->assertNotNull($zona->fresh());
}
public function test_un_azione_riuscita_avvisa_con_un_conferma(): void
{
$zona = Zona::create(['nome' => 'Sud', 'attivo' => true]);
Livewire::actingAs($this->admin())
->test(ZoneIndex::class)
->call('deleteZona', $zona->id)
->assertDispatched('avviso', tipo: 'ok');
$this->assertFalse(session()->has('success'));
}
public function test_nessun_componente_lascia_flash_senza_cambiare_pagina(): void
{
// Un flash senza redirect subito dopo non si vede fino alla pagina
// successiva: in quei casi si usa il trait Avvisa.
foreach (glob(app_path('Livewire') . '/{,*/}*.php', GLOB_BRACE) as $file) {
$righe = file($file);
foreach ($righe as $i => $riga) {
if (! preg_match('/^\s*session\(\)->flash\(/', $riga)) {
continue;
}
$seguito = implode('', array_slice($righe, $i + 1, 5));
$this->assertStringContainsString('redirect', $seguito, basename($file) . ':' . ($i + 1) . ' usa un flash senza redirect');
}
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Tests\Feature;
use App\Jobs\ResetApplication;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* L'azzeramento porta via anche le piantine: righe, storico dei PDF, area
* della città e anteprime. Prima restavano piantine orfane e l'editor
* ripartiva con la città di prima.
*/
class AzzeramentoPiantineTest extends TestCase
{
use RefreshDatabase;
public function test_l_azzeramento_non_lascia_piantine_ne_area(): void
{
Storage::fake('public');
Storage::fake('local');
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$admin->assignRole('amministratore');
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => '{"type":"Polygon","coordinates":[]}']);
TerritorioMappa::create(['nome' => 'Scollegata', 'geojson' => '{"type":"Polygon","coordinates":[]}']);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
'origine' => 'piantine',
'sostituito_il' => now(),
]);
Storage::disk('local')->put('piantine/area.geojson', '{}');
Storage::disk('local')->put('piantine-anteprime/abc.pdf', '%PDF');
$stato = app(BackupOperationState::class);
$stato->initialize('azzeramento-di-prova', 'azzeramento', 'In attesa');
(new ResetApplication('azzeramento-di-prova', $admin->id))->handle($stato, app(BackupArchive::class));
$this->assertSame('completed', $stato->get('azzeramento-di-prova')['status'] ?? null, json_encode($stato->get('azzeramento-di-prova')));
$this->assertSame(0, TerritorioMappa::count());
$this->assertSame(0, TerritorioPdfVersione::count());
Storage::disk('local')->assertMissing('piantine/area.geojson');
Storage::disk('local')->assertMissing('piantine-anteprime/abc.pdf');
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature;
use App\Models\Setting;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
/**
* Cambio password: la password attuale si chiede a chi cambia di sua
* iniziativa, e cambiare la password chiude le sessioni aperte altrove.
*/
class CambioPasswordTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
}
private function utente(bool $obbligato = false): User
{
$utente = User::create([
'name' => 'Mario Rossi',
'email' => 'mario@example.test',
'password' => 'vecchia-password',
]);
$utente->forceFill(['password_change_required' => $obbligato])->save();
$utente->assignRole('operatore');
return $utente;
}
public function test_chi_cambia_di_sua_iniziativa_deve_dare_la_password_attuale(): void
{
$utente = $this->utente();
$this->actingAs($utente)
->post(route('password.change.store'), [
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertSessionHasErrors('current_password');
$this->actingAs($utente)
->post(route('password.change.store'), [
'current_password' => 'sbagliata',
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertSessionHasErrors('current_password');
$this->assertTrue(Hash::check('vecchia-password', $utente->fresh()->password));
$this->actingAs($utente)
->post(route('password.change.store'), [
'current_password' => 'vecchia-password',
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertRedirect(route('dashboard'));
$this->assertTrue(Hash::check('nuova-password-1', $utente->fresh()->password));
}
public function test_dopo_un_ripristino_la_password_attuale_non_si_chiede(): void
{
$utente = $this->utente(obbligato: true);
$this->actingAs($utente)
->get(route('password.change'))
->assertOk()
->assertDontSee('name="current_password"', false);
$this->actingAs($utente)
->post(route('password.change.store'), [
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertRedirect(route('dashboard'));
$this->assertFalse((bool) $utente->fresh()->password_change_required);
}
public function test_una_sessione_aperta_altrove_si_chiude_quando_cambia_la_password(): void
{
$utente = $this->utente();
$hashVecchio = $utente->password;
// La sessione "altrove" ha registrato l'impronta della password di allora.
$utente->update(['password' => 'cambiata-da-un-altra-parte']);
$this->actingAs($utente)
->withSession(['password_hash_web' => $hashVecchio])
->get(route('dashboard'))
->assertRedirect(route('login'));
$this->assertGuest();
}
public function test_la_sessione_che_cambia_la_password_resta_aperta(): void
{
$utente = $this->utente();
$this->actingAs($utente)
->withSession(['password_hash_web' => $utente->password])
->post(route('password.change.store'), [
'current_password' => 'vecchia-password',
'password' => 'nuova-password-1',
'password_confirmation' => 'nuova-password-1',
])
->assertRedirect(route('dashboard'));
$this->get(route('dashboard'))->assertOk();
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
/**
* Campagne: l'ultimo giorno è ancora campagna, e la percentuale conta i
* territori percorsi, non le assegnazioni.
*/
class CampagnaCalcoliTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_nell_ultimo_giorno_la_campagna_e_ancora_attiva(): void
{
$campagna = Campagna::create([
'descrizione' => 'Commemorazione',
'start_date' => '2026-03-01',
'end_date' => '2026-03-31',
]);
Carbon::setTestNow('2026-03-31 18:30:00');
$this->assertTrue($campagna->fresh()->is_attiva);
$this->assertSame($campagna->id, Campagna::attiva()?->id);
$this->assertSame(0, Campagna::completate()->count());
Carbon::setTestNow('2026-04-01 08:00:00');
$this->assertNull(Campagna::attiva());
$this->assertSame(1, Campagna::completate()->count());
}
public function test_la_percentuale_conta_i_territori_attivi_una_volta_sola(): void
{
$campagna = Campagna::create([
'descrizione' => 'Commemorazione',
'start_date' => '2026-03-01',
'end_date' => '2026-03-31',
]);
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$utente = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
$t1 = Territorio::create(['numero' => '1', 'attivo' => true]);
$t2 = Territorio::create(['numero' => '2', 'attivo' => true]);
Territorio::create(['numero' => '3', 'attivo' => true]);
Territorio::create(['numero' => '4', 'attivo' => true]);
$dismesso = Territorio::create(['numero' => '5', 'attivo' => true]);
$conta = fn (Territorio $t, string $uscita, string $rientro) => Assegnazione::create([
'anno_teocratico_id' => $anno->id,
'created_by' => $utente->id,
'territorio_id' => $t->id,
'proclamatore_id' => $proclamatore->id,
'assigned_at' => $uscita,
'returned_at' => $rientro,
'campaign_id' => $campagna->id,
'counted_in_campaign' => true,
]);
// Il territorio 1 percorso due volte, il 2 una, il 5 poi disattivato.
$conta($t1, '2026-03-02', '2026-03-10');
$conta($t1, '2026-03-12', '2026-03-20');
$conta($t2, '2026-03-03', '2026-03-15');
$conta($dismesso, '2026-03-04', '2026-03-16');
$dismesso->update(['attivo' => false]);
// 2 territori attivi percorsi su 4 attivi: prima diceva 4 su 4, 100%.
$this->assertSame(50.0, $campagna->fresh()->percentuale_percorrenza);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Coda e worker: un job lungo non deve ripartire mentre gira ancora, e un
* worker vivo da un'ora non deve lavorare con impostazioni vecchie.
*/
class CodaTest extends TestCase
{
use RefreshDatabase;
public function test_retry_after_supera_il_job_piu_lungo(): void
{
$piuLungo = 0;
foreach (glob(app_path('Jobs') . '/*.php') as $file) {
$classe = 'App\\Jobs\\' . basename($file, '.php');
$proprieta = (new \ReflectionClass($classe))->getDefaultProperties();
$piuLungo = max($piuLungo, (int) ($proprieta['timeout'] ?? 0));
}
$this->assertGreaterThan(0, $piuLungo);
$this->assertGreaterThan($piuLungo, config('queue.connections.redis.retry_after'));
}
public function test_il_worker_rilegge_le_impostazioni_a_ogni_job(): void
{
Setting::instance()->update(['congregazione_nome' => 'Prima']);
$this->assertSame('Prima', Setting::instance()->congregazione_nome);
// Cambiata da un altro processo (il browser): la cache statica di
// questo non lo sa.
DB::table('settings')->update(['congregazione_nome' => 'Dopo']);
$this->assertSame('Prima', Setting::instance()->congregazione_nome);
event(new JobProcessing('redis', $this->createMock(\Illuminate\Contracts\Queue\Job::class)));
$this->assertSame('Dopo', Setting::instance()->congregazione_nome);
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace Tests\Feature;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Livewire\Settings\XmlExchange;
use App\Models\Territorio;
use App\Models\User;
use App\Services\TerritorioPdfImportState;
use App\Services\TerritorioThumbnailService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
use ZipArchive;
/**
* Import dei PDF dei territori: da cartella e da ZIP.
*
* Il difetto che costa di piu' qui non fa rumore: un PDF abbinato al territorio
* sbagliato ne sostituisce la mappa, e ce ne si accorge solo quando qualcuno va
* in un territorio con la cartina di un altro. Per questo l'abbinamento scarta
* invece di indovinare, e questi test fissano la regola caso per caso.
*/
class ImportPdfTerritoriTest extends TestCase
{
use RefreshDatabase;
private const PDF = "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
private User $admin;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
// pdftoppm non serve a niente qui, e su un PDF finto fallirebbe comunque.
$this->mock(TerritorioThumbnailService::class, function ($mock) {
$mock->shouldReceive('generate')->andReturn(null);
$mock->shouldReceive('delete');
});
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-di-prova',
]);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
// ─── Abbinamento nome file → territorio ─────────────────────
/**
* Il caso segnalato: "1-A" conteneva "1", e senza un territorio 1A il PDF
* finiva sul territorio 1 sostituendone la mappa.
*/
public function test_un_file_1_A_non_sostituisce_il_pdf_del_territorio_1(): void
{
$uno = $this->territorioConPdf('1');
$stato = $this->importa(['1-A.pdf']);
$this->assertSame('territori-pdf/originale-1.pdf', $uno->fresh()->pdf_path);
Storage::disk('public')->assertExists('territori-pdf/originale-1.pdf');
$this->assertSame(1, $stato['stats']['skipped']);
$this->assertSame('unknown-suffix', $stato['issues'][0]['type']);
$this->assertSame(['1'], $stato['issues'][0]['matched_numbers']);
}
/**
* Prima il vecchio PDF si cancellava prima di scrivere il nuovo: un import
* sbagliato non si poteva disfare.
*/
public function test_il_pdf_sostituito_finisce_nello_storico(): void
{
$uno = $this->territorioConPdf('1');
$this->importa(['1.pdf']);
$uno->refresh();
$this->assertNotSame('territori-pdf/originale-1.pdf', $uno->pdf_path);
Storage::disk('public')->assertExists($uno->pdf_path);
$versione = \App\Models\TerritorioPdfVersione::where('territorio_id', $uno->id)->sole();
$this->assertSame('import', $versione->origine);
$this->assertSame('PDF ORIGINALE', Storage::disk('public')->get($versione->pdf_path));
}
public function test_un_file_1_A_va_al_territorio_1_A_quando_esiste(): void
{
$uno = $this->territorioConPdf('1');
$unoA = Territorio::create(['numero' => '1-A', 'attivo' => true]);
$this->importa(['1-A.pdf']);
$this->assertNotNull($unoA->fresh()->pdf_path);
$this->assertSame('territori-pdf/originale-1.pdf', $uno->fresh()->pdf_path);
}
/**
* "12 B", "12B" e "12-b" sono lo stesso numero, e non e' il 12: la lettera
* dopo il numero fa parte del numero.
*/
public function test_12_B_con_lo_spazio_e_il_territorio_12B_e_non_il_12(): void
{
$dodici = $this->territorioConPdf('12');
$dodiciB = Territorio::create(['numero' => '12B', 'attivo' => true]);
$stato = $this->importa(['12 B.pdf']);
$this->assertSame(1, $stato['stats']['updated']);
$this->assertNotNull($dodiciB->fresh()->pdf_path);
$this->assertSame('territori-pdf/originale-12.pdf', $dodici->fresh()->pdf_path);
}
/**
* Due territori che si scrivono diversi ma sono lo stesso numero: il file
* che li nomina non si puo' abbinare con certezza, quindi si scarta.
*/
public function test_due_territori_con_lo_stesso_numero_scritto_diverso_rendono_il_file_ambiguo(): void
{
$attaccato = Territorio::create(['numero' => '12B', 'attivo' => true]);
$staccato = Territorio::create(['numero' => '12 B', 'attivo' => true]);
$stato = $this->importa(['12b.pdf']);
$this->assertNull($attaccato->fresh()->pdf_path);
$this->assertNull($staccato->fresh()->pdf_path);
$this->assertSame('ambiguous', $stato['issues'][0]['type']);
$this->assertEqualsCanonicalizing(['12B', '12 B'], $stato['issues'][0]['matched_numbers']);
}
public function test_due_numeri_di_territori_esistenti_nello_stesso_nome_rendono_il_file_ambiguo(): void
{
$tre = Territorio::create(['numero' => '3', 'attivo' => true]);
$dodici = Territorio::create(['numero' => '12', 'attivo' => true]);
$stato = $this->importa(['Zona 3 - Territorio 12.pdf']);
$this->assertNull($tre->fresh()->pdf_path);
$this->assertNull($dodici->fresh()->pdf_path);
$this->assertSame('ambiguous', $stato['issues'][0]['type']);
}
/** Le forme di sempre devono continuare a funzionare. */
public function test_zeri_iniziali_testo_intorno_e_numeri_estranei_non_impediscono_l_abbinamento(): void
{
$sette = Territorio::create(['numero' => '7', 'attivo' => true]);
$dodici = Territorio::create(['numero' => '12', 'attivo' => true]);
$stato = $this->importa(['Territorio 007 - Via Roma.pdf', '2024_territorio_12.pdf']);
$this->assertSame(2, $stato['stats']['updated']);
$this->assertNotNull($sette->fresh()->pdf_path);
$this->assertNotNull($dodici->fresh()->pdf_path);
}
public function test_lo_stesso_territorio_scritto_in_due_modi_nella_stessa_cartella_e_un_doppione(): void
{
$dodiciB = Territorio::create(['numero' => '12B', 'attivo' => true]);
$stato = $this->importa(['12B.pdf', '12 B.pdf']);
$this->assertSame(1, $stato['stats']['updated']);
$this->assertSame('duplicate-in-batch', $stato['issues'][0]['type']);
$this->assertNotNull($dodiciB->fresh()->pdf_path);
}
// ─── Cartella ───────────────────────────────────────────────
/**
* Thumbs.db e simili facevano fallire la validazione `mimes:pdf` di tutta la
* cartella: nessun PDF importato per colpa di un file che Windows crea da sé.
*/
public function test_thumbs_db_e_i_file_nascosti_nella_cartella_non_bloccano_l_import(): void
{
Storage::fake('local');
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [
UploadedFile::fake()->createWithContent('1.pdf', self::PDF),
UploadedFile::fake()->createWithContent('Thumbs.db', 'non un pdf'),
UploadedFile::fake()->createWithContent('desktop.ini', '[.ShellClassInfo]'),
UploadedFile::fake()->createWithContent('.DS_Store', 'non un pdf'),
])
->call('importTerritoryPdfFolder')
->assertHasNoErrors()
->assertSet('pdfImportStatus', 'completed');
$this->assertNotNull($uno->fresh()->pdf_path);
Storage::disk('public')->assertExists($uno->fresh()->pdf_path);
}
public function test_un_pdf_non_valido_nella_cartella_si_scarta_da_solo(): void
{
Storage::fake('local');
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
$due = Territorio::create(['numero' => '2', 'attivo' => true]);
$componente = Livewire::test(XmlExchange::class)
->set('pdfFolder', [
UploadedFile::fake()->createWithContent('1.pdf', self::PDF),
// Un'immagine rinominata. Nei test Livewire prende il tipo
// dichiarato dal file finto, non dal contenuto: va detto.
UploadedFile::fake()->createWithContent('2.pdf', "\x89PNG\r\n\x1a\n" . str_repeat("\0", 64))->mimeType('image/png'),
])
->call('importTerritoryPdfFolder')
->assertHasNoErrors();
$this->assertNotNull($uno->fresh()->pdf_path);
$this->assertNull($due->fresh()->pdf_path);
$this->assertSame('invalid-file', $componente->get('pdfImportIssues')[0]['type']);
}
/**
* Con wire:model la cartella partiva in una richiesta sola, e PHP ne teneva
* i primi `max_file_uploads` (20) scartando gli altri in silenzio. Il browser
* la spedisce a lotti: la pagina deve dirgli quanto grandi, e l'input non
* deve avere wire:model.
*/
public function test_la_cartella_parte_a_lotti_dentro_il_limite_di_php(): void
{
$this->withoutVite();
$lotto = min(20, (int) ini_get('max_file_uploads'));
$this->get(route('xml.exchange'))
->assertOk()
->assertSee('data-file-per-lotto="' . $lotto . '"', false)
->assertSee('uploadMultiple(', false)
->assertDontSee('wire:model="pdfFolder"', false);
}
/** Lotti aggiunti uno dopo l'altro: il server deve importarli tutti. */
public function test_una_cartella_di_piu_di_venti_pdf_viene_importata_per_intero(): void
{
Storage::fake('local');
$file = [];
for ($numero = 1; $numero <= 25; $numero++) {
Territorio::create(['numero' => (string) $numero, 'attivo' => true]);
$file[] = UploadedFile::fake()->createWithContent($numero . '.pdf', self::PDF);
}
Livewire::test(XmlExchange::class)
->set('pdfFolder', $file)
->call('importTerritoryPdfFolder')
->assertHasNoErrors();
$this->assertSame(25, Territorio::whereNotNull('pdf_path')->count());
}
// ─── ZIP ────────────────────────────────────────────────────
/**
* Uno ZIP fatto su macOS porta con se' __MACOSX/._1.pdf: estensione giusta,
* contenuto spazzatura. Arrivando per primo, diventava la mappa del
* territorio 1 e il PDF vero veniva scartato come doppione.
*/
public function test_lo_zip_ignora_file_di_sistema_e_copie_macosx(): void
{
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
$zip = $this->zip([
'__MACOSX/._1.pdf' => 'risorse di macOS, non un PDF',
'Thumbs.db' => 'non un pdf',
'territori/1.pdf' => self::PDF,
]);
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $zip])
->assertOk();
$percorso = $uno->fresh()->pdf_path;
$this->assertNotNull($percorso);
$this->assertSame(self::PDF, Storage::disk('public')->get($percorso));
}
// ─── Supporto ───────────────────────────────────────────────
private function territorioConPdf(string $numero): Territorio
{
$percorso = 'territori-pdf/originale-' . Str::slug($numero) . '.pdf';
Storage::disk('public')->put($percorso, 'PDF ORIGINALE');
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => $percorso]);
}
/**
* Esegue il job su file con i nomi dati, come se fossero arrivati da una
* cartella, e restituisce lo stato finale dell'import.
*/
private function importa(array $nomi): array
{
Storage::fake('local');
$importId = (string) Str::uuid();
$file = [];
foreach (array_values($nomi) as $indice => $nome) {
$percorso = 'bulk-territori-imports/' . $importId . '/' . $indice . '.pdf';
Storage::disk('local')->put($percorso, self::PDF);
$file[] = ['original_name' => $nome, 'stored_path' => $percorso];
}
app(TerritorioPdfImportState::class)->initialize($importId, count($file));
ImportTerritoryPdfFolder::dispatchSync($importId, $file, $this->admin->id);
return app(TerritorioPdfImportState::class)->get($importId);
}
private function zip(array $voci): UploadedFile
{
$percorso = tempnam(sys_get_temp_dir(), 'zip-pdf-');
$zip = new ZipArchive();
$zip->open($percorso, ZipArchive::OVERWRITE);
foreach ($voci as $nome => $contenuto) {
$zip->addFromString($nome, $contenuto);
}
$zip->close();
return new UploadedFile($percorso, 'territori.zip', 'application/zip', null, true);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\XmlExchange;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* L'import XML non lascia file orfani sul disco.
*
* L'import cancella i territori e li ricrea con id nuovi, e i PDF portano l'id
* nel nome. Un XML con percorsi diversi da quelli correnti — da un'altra
* installazione, o un dump convertito della vecchia applicazione — lasciava sul
* disco i PDF e le miniature dei territori cancellati: invisibili, irraggiungibili
* da ogni cancellazione, e copiati in ogni backup successivo. Sul container di
* sviluppo erano 545 PDF per 276 territori (territorio-142-141.pdf accanto a
* territorio-142-415.pdf).
*/
class ImportXmlFileOrfaniTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-di-prova',
]);
$admin->assignRole('amministratore');
$this->actingAs($admin);
}
public function test_l_import_elimina_i_file_dei_territori_che_sostituisce(): void
{
$disco = Storage::disk('public');
$disco->put('territori-pdf/territorio-142-141.pdf', 'vecchio');
$disco->put('territori-thumbnails/territorio-142-141.png', 'vecchia');
$disco->put('territori-pdf-versioni/territorio-141-20260101.pdf', 'storico');
$disco->put('territori-pdf/caricato-a-mano.pdf', 'di nessuno');
$territorio = Territorio::create([
'numero' => '142',
'attivo' => true,
'pdf_path' => 'territori-pdf/territorio-142-141.pdf',
'thumbnail_path' => 'territori-thumbnails/territorio-142-141.png',
]);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/territorio-141-20260101.pdf',
'sostituito_il' => now(),
]);
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('142', 'territori-pdf/territorio-142-415.pdf')])
->assertSessionHasNoErrors();
$disco->assertMissing('territori-pdf/territorio-142-141.pdf');
$disco->assertMissing('territori-thumbnails/territorio-142-141.png');
// Lo storico se ne va con il territorio (cascata sulla riga): il file
// restava sul disco.
$disco->assertMissing('territori-pdf-versioni/territorio-141-20260101.pdf');
// Un file che nessun territorio richiamava non e' affare dell'import.
$disco->assertExists('territori-pdf/caricato-a-mano.pdf');
}
/** Il caso di tutti i giorni: reimportare l'export di questa stessa installazione. */
public function test_reimportare_l_export_della_stessa_installazione_non_cancella_niente(): void
{
$disco = Storage::disk('public');
$disco->put('territori-pdf/territorio-7-3.pdf', 'pdf');
$disco->put('territori-thumbnails/territorio-7-3.png', 'png');
Territorio::create([
'numero' => '7',
'attivo' => true,
'pdf_path' => 'territori-pdf/territorio-7-3.pdf',
'thumbnail_path' => 'territori-thumbnails/territorio-7-3.png',
]);
$export = (new XmlExchange())->currentXmlBackup();
$this->post(route('xml.import-xml'), ['xmlImport' => UploadedFile::fake()->createWithContent('export.xml', $export)])
->assertSessionHasNoErrors();
$disco->assertExists('territori-pdf/territorio-7-3.pdf');
$disco->assertExists('territori-thumbnails/territorio-7-3.png');
$this->assertSame('territori-pdf/territorio-7-3.pdf', Territorio::sole()->pdf_path);
}
public function test_un_import_rifiutato_non_cancella_niente(): void
{
$disco = Storage::disk('public');
$disco->put('territori-pdf/territorio-1-1.pdf', 'pdf');
Territorio::create(['numero' => '1', 'attivo' => true, 'pdf_path' => 'territori-pdf/territorio-1-1.pdf']);
$this->post(route('xml.import-xml'), [
'xmlImport' => UploadedFile::fake()->createWithContent('estraneo.xml', '<?xml version="1.0"?><sitemap/>'),
])->assertSessionHasErrors('xmlImport');
$disco->assertExists('territori-pdf/territorio-1-1.pdf');
}
private function xml(string $numero, string $pdfPath): UploadedFile
{
$xml = '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
. '<settings/><users/><zones/><tipologie/><proclamatori/>'
. '<territori><territorio legacy_id="1"><numero>' . $numero . '</numero><attivo>1</attivo>'
. '<pdf_path>' . $pdfPath . '</pdf_path></territorio></territori>'
. '<anni_teocratici/><campagne/><assegnazioni/>'
. '</termanager2>';
return UploadedFile::fake()->createWithContent('export.xml', $xml);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Tests\Feature;
use App\Livewire\AuditLog;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Nel registro attività niente nomi di proclamatori in chiaro: sono cifrati
* nel database e devono sparire con l'anonimizzazione.
*/
class LogSenzaNomiTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Assegnazione $assegnazione;
private Proclamatore $proclamatore;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolesAndPermissionsSeeder::class);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
$this->proclamatore = Proclamatore::create(['nome' => 'Giuseppe', 'cognome' => 'Verdi', 'attivo' => true]);
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
$this->assegnazione = Assegnazione::create([
'anno_teocratico_id' => $anno->id,
'territorio_id' => $territorio->id,
'proclamatore_id' => $this->proclamatore->id,
'assigned_at' => '2026-01-10',
'created_by' => $this->admin->id,
]);
}
public function test_le_voci_vecchie_perdono_il_nome_e_tengono_l_id(): void
{
activity()->performedOn($this->assegnazione)
->withProperties(['territorio' => '7', 'proclamatore' => 'Giuseppe Verdi'])
->log('assigned');
$migrazione = require database_path('migrations/2026_10_12_000010_nomi_proclamatori_fuori_dal_log.php');
$migrazione->up();
$proprieta = json_decode(DB::table('activity_log')->where('description', 'assigned')->value('properties'), true);
$this->assertArrayNotHasKey('proclamatore', $proprieta);
$this->assertSame($this->proclamatore->id, $proprieta['proclamatore_id']);
$this->assertStringNotContainsString('Verdi', DB::table('activity_log')->where('description', 'assigned')->value('properties'));
}
public function test_il_registro_mostra_il_nome_di_oggi_anche_dopo_l_anonimizzazione(): void
{
activity()->performedOn($this->assegnazione)
->withProperties(['territorio' => '7', 'proclamatore_id' => $this->proclamatore->id])
->log('assigned');
Livewire::actingAs($this->admin)->test(AuditLog::class)->assertSee('Verdi Giuseppe');
$this->proclamatore->anonimizza();
Livewire::actingAs($this->admin)->test(AuditLog::class)
->assertDontSee('Verdi Giuseppe')
->assertSee('Anonimo');
}
public function test_assegnare_non_scrive_il_nome_nel_log(): void
{
$sorgente = file_get_contents(app_path('Livewire/Assegnazioni/Assegna.php'))
. file_get_contents(app_path('Livewire/Assegnazioni/Rientra.php'));
$this->assertStringNotContainsString("'proclamatore' => \$proclamatore->nome_completo", $sorgente);
}
}

View File

@@ -57,6 +57,8 @@ class PiantineEditorTest extends TestCase
$utente->assignRole('utente');
$this->actingAs($utente)->get(route('piantine.editor'))->assertForbidden();
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
$this->flushSession();
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
}

View File

@@ -44,6 +44,8 @@ class PiantineManagerTest extends TestCase
$utente->assignRole('utente');
$this->actingAs($utente)->get(route('piantine.index'))->assertForbidden();
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
$this->flushSession();
$this->actingAs($this->admin())->get(route('piantine.index'))->assertOk();
}

View File

@@ -0,0 +1,268 @@
<?php
namespace Tests\Feature;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Jobs\RestoreBackupArchive;
use App\Livewire\Settings\BackupManager;
use App\Livewire\Settings\XmlExchange;
use App\Livewire\Settings\ZonaPericolosa;
use App\Models\Territorio;
use App\Models\User;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioThumbnailService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
use ZipArchive;
/**
* Il posto unico vale anche per gli import.
*
* Backup, ripristino e azzeramento si escludevano a vicenda, ma l'import XML e
* l'import PDF passavano accanto al blocco: un import XML durante un ripristino
* riscriveva le stesse tabelle, un import PDF durante un backup cambiava i file
* mentre l'archivio li copiava. Qui si verifica il blocco nelle due direzioni e
* che il posto si liberi anche quando l'import fallisce.
*/
class PostoUnicoImportTest extends TestCase
{
use RefreshDatabase;
private const PDF = "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
private User $admin;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Storage::fake('local');
$this->mock(TerritorioThumbnailService::class, function ($mock) {
$mock->shouldReceive('generate')->andReturn(null);
$mock->shouldReceive('delete');
});
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-di-prova',
]);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
// ─── Un'operazione di backup blocca gli import ──────────────
public function test_l_import_pdf_da_cartella_non_parte_durante_un_ripristino(): void
{
Queue::fake();
app(BackupOperationState::class)->initialize('op-restore', BackupOperationState::TIPO_RESTORE, 'Estrazione', 10);
Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder')
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore'
&& str_contains($parametri['testo'], 'ripristino'));
Queue::assertNotPushed(ImportTerritoryPdfFolder::class);
$this->assertSame('op-restore', app(BackupOperationState::class)->corrente());
}
public function test_il_controllo_prima_di_caricare_la_cartella_avvisa_se_il_posto_e_occupato(): void
{
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
Livewire::test(XmlExchange::class)
->call('cartellaPuoPartire')
->assertReturned(false)
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore');
}
public function test_l_import_zip_non_parte_durante_un_azzeramento(): void
{
Queue::fake();
app(BackupOperationState::class)->initialize('op-reset', BackupOperationState::TIPO_RESET, 'Backup preventivo', 3);
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $this->zip(['1.pdf' => self::PDF])])
->assertStatus(422)
->assertJsonFragment(['message' => app(BackupOperationState::class)->messaggioOccupato('op-reset')]);
Queue::assertNotPushed(ImportTerritoryPdfFolder::class);
$this->assertSame('op-reset', app(BackupOperationState::class)->corrente());
}
public function test_l_import_xml_non_parte_durante_un_backup_e_non_tocca_i_dati(): void
{
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
Territorio::create(['numero' => '99', 'attivo' => true]);
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
->assertSessionHasErrors('xmlImport');
$this->assertSame(['99'], Territorio::pluck('numero')->all());
$this->assertSame('op-backup', app(BackupOperationState::class)->corrente());
}
// ─── Un import blocca le operazioni di backup ───────────────
public function test_un_import_pdf_in_coda_blocca_backup_ripristino_e_azzeramento(): void
{
Queue::fake();
Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder');
Queue::assertPushed(ImportTerritoryPdfFolder::class);
$stato = app(BackupOperationState::class);
$import = $stato->corrente();
$this->assertNotNull($import, 'L\'import PDF deve occupare il posto unico finche\' il job non finisce.');
$this->assertSame(BackupOperationState::TIPO_IMPORT_PDF, $stato->get($import)['tipo']);
Livewire::test(BackupManager::class)
->call('avviaBackup')
->assertSet('erroreOperazione', $stato->messaggioOccupato($import));
Queue::assertNotPushed(CreateBackupArchive::class);
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento');
Queue::assertNotPushed(CreateBackupArchive::class);
$this->postJson(route('backup.restore'), [
'backupZip' => $this->zip(['manifest.json' => '{}']),
])->assertStatus(409);
Queue::assertNotPushed(RestoreBackupArchive::class);
$this->assertSame($import, $stato->corrente());
}
public function test_un_secondo_import_non_parte_mentre_il_primo_e_in_coda(): void
{
Queue::fake();
Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder');
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
->assertSessionHasErrors('xmlImport');
Queue::assertPushed(ImportTerritoryPdfFolder::class, 1);
}
// ─── Il posto si libera ─────────────────────────────────────
public function test_l_import_pdf_libera_il_posto_quando_finisce(): void
{
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
Livewire::test(XmlExchange::class)
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
->call('importTerritoryPdfFolder')
->assertSet('pdfImportStatus', 'completed');
$this->assertNotNull($uno->fresh()->pdf_path);
$this->assertNull(app(BackupOperationState::class)->corrente());
}
/**
* failed() e' quello che Laravel chiama quando il worker uccide il job per
* timeout: handle() non arriva a liberare niente.
*/
public function test_un_import_pdf_ucciso_dal_worker_libera_il_posto(): void
{
app(TerritorioPdfImportDispatcher::class)->occupaPosto('import-1');
$this->assertSame('import-1', app(BackupOperationState::class)->corrente());
(new ImportTerritoryPdfFolder('import-1', [], $this->admin->id))
->failed(new \RuntimeException('Job has timed out.'));
$this->assertNull(app(BackupOperationState::class)->corrente());
}
public function test_l_import_xml_libera_il_posto_quando_riesce_e_quando_fallisce(): void
{
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
->assertSessionHasNoErrors();
$this->assertNull(app(BackupOperationState::class)->corrente());
$this->post(route('xml.import-xml'), [
'xmlImport' => UploadedFile::fake()->createWithContent('rotto.xml', '<?xml version="1.0"?><sitemap/>'),
])->assertSessionHasErrors('xmlImport');
$this->assertNull(app(BackupOperationState::class)->corrente());
}
public function test_uno_zip_senza_pdf_libera_il_posto(): void
{
// L'estrazione legge lo ZIP da storage_path(), non dal disco finto.
Storage::forgetDisk('local');
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $this->zip(['Thumbs.db' => 'x', '.DS_Store' => 'x'])])
->assertStatus(422)
->assertJsonFragment(['message' => 'Lo ZIP non contiene file PDF validi.']);
$this->assertNull(app(BackupOperationState::class)->corrente());
}
// ─── Presa del posto ────────────────────────────────────────
/**
* La presa e' un passo solo: chi arriva secondo trova il posto occupato
* anche se il primo non ha ancora finito di avviare il proprio lavoro.
*/
public function test_chi_arriva_secondo_trova_il_posto_occupato(): void
{
$stato = app(BackupOperationState::class);
$this->assertNull($stato->occupa('primo', BackupOperationState::TIPO_IMPORT_PDF, 'In coda'));
$this->assertSame('primo', $stato->occupa('secondo', BackupOperationState::TIPO_RESTORE, 'In coda'));
$this->assertNull($stato->get('secondo'), 'Chi non prende il posto non deve lasciare un\'operazione fantasma.');
$stato->markCompleted('primo');
$this->assertNull($stato->occupa('terzo', BackupOperationState::TIPO_BACKUP, 'In coda'));
}
// ─── Supporto ───────────────────────────────────────────────
private function xml(string $numero): UploadedFile
{
$xml = '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
. '<settings/><users/><zones/><tipologie/>'
. '<proclamatori/>'
. '<territori><territorio legacy_id="1"><numero>' . $numero . '</numero><attivo>1</attivo></territorio></territori>'
. '<anni_teocratici/><campagne/><assegnazioni/>'
. '</termanager2>';
return UploadedFile::fake()->createWithContent('export.xml', $xml);
}
private function zip(array $voci): UploadedFile
{
$percorso = tempnam(sys_get_temp_dir(), 'zip-pdf-');
$zip = new ZipArchive();
$zip->open($percorso, ZipArchive::OVERWRITE);
foreach ($voci as $nome => $contenuto) {
$zip->addFromString($nome, $contenuto);
}
$zip->close();
return new UploadedFile($percorso, 'territori.zip', 'application/zip', null, true);
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace Tests\Feature;
use App\Jobs\RestoreBackupArchive;
use App\Livewire\Settings\XmlExchange;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use App\Models\Zona;
use App\Services\BackupArchive;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
use ZipArchive;
/**
* Il ripristino da .zip, job intero: tutto o niente, piantine comprese.
*
* Prima import, file e blocco degli account erano tre passi separati e il
* disco si svuotava prima di ricopiarlo: un errore a meta' lasciava il
* database del backup con i file a meta'. E il backup non conteneva ne'
* le piantine ne' lo storico dei PDF ne' l'area KML.
*
* Ogni test fa un backup "vero" (stessi servizi del job di backup), cambia
* qualcosa, e ripristina.
*/
class RipristinoBackupTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $altro;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Storage::fake('local');
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'admin-al-backup']);
$this->admin->assignRole('amministratore');
$this->altro = User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'altro-al-backup']);
}
protected function tearDown(): void
{
$archivio = new BackupArchive();
foreach (['backup-restore', 'backup-precedenti', 'testing-ripristino'] as $cartella) {
$archivio->deleteDirectory(storage_path('app/' . $cartella));
}
parent::tearDown();
}
// ─── Piantine nel backup ────────────────────────────────────
public function test_piantine_storico_e_area_kml_sopravvivono_al_ripristino(): void
{
[$territorio] = $this->installazioneConPiantine();
$zip = $this->backup();
// Dopo il backup cambia tutto: piantine e storico spariscono, l'area
// e' un'altra, compare un file estraneo.
TerritorioMappa::query()->delete();
TerritorioPdfVersione::query()->delete();
Storage::disk('local')->put('piantine/area.kml', 'area successiva');
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
// L'import ricrea i territori con id nuovi: il collegamento si ritrova
// dal numero, non dall'id del backup.
$nuovo = Territorio::where('numero', '12')->firstOrFail();
$this->assertNotSame($territorio->id, $nuovo->id);
$mappa = TerritorioMappa::sole();
$this->assertSame($nuovo->id, $mappa->territorio_id);
$this->assertSame('{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}', $mappa->geojson);
$this->assertSame('{"type":"Polygon"}', $mappa->geojson_pubblicato);
$this->assertSame(str_repeat('a', 64), $mappa->impronta_pubblicata);
$this->assertSame('2026-10-01 10:00:00', $mappa->pubblicata_at->format('Y-m-d H:i:s'));
$this->assertSame('territori-mappe/piantina-1.jpg', $mappa->image_path);
$versione = TerritorioPdfVersione::sole();
$this->assertSame($nuovo->id, $versione->territorio_id);
$this->assertTrue(Storage::disk('public')->exists($versione->pdf_path));
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
$this->assertFalse(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'));
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
}
/**
* Compatibilita' all'indietro: un archivio di formato 1 si ripristina.
* Non contiene piantine e storico, che restano vuoti — tenere quelle
* correnti vorrebbe dire righe che citano immagini del disco appena
* sostituito. L'area KML non e' legata a nessuna riga e non si tocca.
*/
public function test_un_archivio_di_formato_1_si_ripristina_con_piantine_vuote(): void
{
$this->installazioneConPiantine();
$zip = $this->archivioFormato1();
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertTrue(Territorio::where('numero', '12')->exists());
$this->assertSame(0, TerritorioMappa::count());
$this->assertSame(0, TerritorioPdfVersione::count());
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
$this->assertSame(['territori-pdf/dal-formato-1.pdf'], Storage::disk('public')->allFiles());
}
// ─── Tutto o niente ─────────────────────────────────────────
/**
* Lo scambio dei file fallisce (la cartella dell'area e' occupata da un
* file e non si puo' creare) dopo che l'import ha gia' riscritto le
* tabelle. Prima l'import era gia' confermato a quel punto e il disco gia'
* svuotato: database del backup, file a meta'. Adesso torna tutto com'era,
* database e file.
*/
public function test_un_errore_sui_file_annulla_anche_il_database(): void
{
$this->installazioneConPiantine();
$zip = $this->backup();
$this->cambiaDopoIlBackup();
Storage::disk('local')->deleteDirectory('piantine');
Storage::disk('local')->put('piantine', 'un file al posto della cartella');
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
/**
* Un dato delle piantine che il database rifiuta arriva dopo l'import
* XML, nella stessa transazione: deve trascinare indietro anche l'import,
* e i file non devono essere stati toccati.
*/
public function test_un_errore_sulle_piantine_annulla_l_import_e_non_tocca_i_file(): void
{
$this->installazioneConPiantine();
$zip = $this->backup(function (array $piantine) {
$piantine['territorio_mappe'][0]['geojson'] = null;
return $piantine;
});
$this->cambiaDopoIlBackup();
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
/**
* Un archivio troncato o alterato si ferma all'estrazione: niente e'
* stato ancora toccato.
*/
public function test_un_archivio_corrotto_non_tocca_niente(): void
{
$this->installazioneConPiantine();
$zip = $this->backup();
$this->cambiaDopoIlBackup();
// Tronca l'archivio: la directory centrale sta in fondo, e senza
// quella non si apre.
$contenuto = file_get_contents($zip);
file_put_contents($zip, substr($contenuto, 0, (int) (strlen($contenuto) * 0.6)));
$stato = $this->ripristina($zip);
$this->assertSame('failed', $stato['status']);
$this->assertSituazioneDopoIlBackupIntatta();
}
// ─── Chi ripristina resta dentro ────────────────────────────
/**
* Con AuthenticateSession attivo, un hash della password cambiato chiude
* la sessione: se il ripristino desse all'amministratore che lo esegue
* l'hash del backup, verrebbe buttato fuori e dovrebbe rientrare con una
* password di mesi fa. Gli altri prendono quella del backup — e devono
* comunque cambiarla, attore compreso.
*/
public function test_chi_ripristina_conserva_la_sua_password_gli_altri_prendono_quella_del_backup(): void
{
$hashAltroAlBackup = $this->hash($this->altro);
$zip = $this->backup();
$this->admin->update(['password' => 'admin-attuale']);
$this->altro->update(['password' => 'altro-attuale']);
$hashAdminAttuale = $this->hash($this->admin);
$stato = $this->ripristina($zip);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertSame($hashAdminAttuale, $this->hash($this->admin));
$this->assertSame($hashAltroAlBackup, $this->hash($this->altro));
$this->assertTrue($this->admin->fresh()->hasRole('amministratore'));
$this->assertSame(2, User::where('password_change_required', true)->count());
}
/**
* L'attore non c'e' nel backup (account creato dopo): l'import fa upsert
* per email e non cancella nessuno, quindi il suo account resta com'e',
* password e ruolo compresi.
*/
public function test_chi_ripristina_e_non_e_nel_backup_resta_con_password_e_ruolo(): void
{
$zip = $this->backup();
$nuovoAdmin = User::create(['name' => 'Nuovo', 'email' => 'nuovo@example.test', 'password' => 'nuovo-admin']);
$nuovoAdmin->assignRole('amministratore');
$hashNuovo = $this->hash($nuovoAdmin);
$stato = $this->ripristina($zip, $nuovoAdmin);
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
$this->assertSame($hashNuovo, $this->hash($nuovoAdmin));
$this->assertTrue($nuovoAdmin->fresh()->hasRole('amministratore'));
$this->assertTrue((bool) $nuovoAdmin->fresh()->password_change_required);
}
// ─── helper ─────────────────────────────────────────────────
/** @return array{0: Territorio} */
private function installazioneConPiantine(): array
{
$zona = Zona::create(['nome' => 'Centro', 'attivo' => true]);
Storage::disk('public')->put('territori-pdf/piantina-12.pdf', 'pdf in vigore');
Storage::disk('public')->put('territori-pdf-versioni/territorio-12-vecchio.pdf', 'pdf sostituito');
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'immagine');
Storage::disk('local')->put('piantine/area.kml', '<kml>area al backup</kml>');
$territorio = Territorio::create([
'numero' => '12',
'zona_id' => $zona->id,
'attivo' => true,
'pdf_path' => 'territori-pdf/piantina-12.pdf',
]);
TerritorioMappa::create([
'territorio_id' => $territorio->id,
'geojson' => '{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}',
'geojson_pubblicato' => '{"type":"Polygon"}',
'impronta_pubblicata' => str_repeat('a', 64),
'pubblicata_at' => '2026-10-01 10:00:00',
'image_path' => 'territori-mappe/piantina-1.jpg',
]);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/territorio-12-vecchio.pdf',
'origine' => 'piantine',
'sostituito_il' => '2026-09-01 09:00:00',
]);
return [$territorio];
}
/** Come CreateBackupArchive, con la possibilita' di alterare piantine.json. */
private function backup(?callable $altera = null): string
{
$piantine = app(PiantineBackup::class)->esporta();
if ($altera) {
$piantine = $altera($piantine);
}
$destinazione = storage_path('app/testing-ripristino/backup.zip');
(new BackupArchive())->create((new XmlExchange())->currentXmlBackup(), $piantine, $destinazione);
return $destinazione;
}
/** Un pacchetto come li produceva l'applicazione prima delle piantine. */
private function archivioFormato1(): string
{
$destinazione = storage_path('app/testing-ripristino/formato-1.zip');
@mkdir(dirname($destinazione), 0775, true);
$zip = new ZipArchive();
$zip->open($destinazione, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('manifest.json', json_encode(['format_version' => 1, 'application' => 'TerManager2', 'files_count' => 1]));
$zip->addFromString('data.xml', (new XmlExchange())->currentXmlBackup());
$zip->addFromString('storage/territori-pdf/dal-formato-1.pdf', 'pdf');
$zip->close();
return $destinazione;
}
private function cambiaDopoIlBackup(): void
{
Territorio::create(['numero' => '99', 'attivo' => true]);
TerritorioMappa::create(['nome' => 'Disegnata dopo', 'geojson' => '{}']);
$this->altro->update(['password' => 'altro-attuale']);
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
}
/** Lo stato lasciato da cambiaDopoIlBackup(), che un ripristino fallito deve conservare. */
private function assertSituazioneDopoIlBackupIntatta(): void
{
$this->assertTrue(Territorio::where('numero', '99')->exists(), 'L\'import non e\' stato annullato.');
$this->assertTrue(TerritorioMappa::where('nome', 'Disegnata dopo')->exists());
$this->assertSame(2, TerritorioMappa::count());
$this->assertSame(1, TerritorioPdfVersione::count());
$this->assertTrue(password_verify('altro-attuale', $this->hash($this->altro)));
$this->assertSame(0, User::where('password_change_required', true)->count(), 'Gli account non vanno bloccati se il ripristino non avviene.');
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'), 'I file sono stati sostituiti nonostante il fallimento.');
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/piantina-12.pdf'));
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
$this->assertSame([], glob(storage_path('app/backup-precedenti/*')) ?: []);
}
private function ripristina(string $zip, ?User $attore = null): array
{
$operazione = 'ripristino-di-prova';
app(BackupOperationState::class)->initialize($operazione, BackupOperationState::TIPO_RESTORE, 'In coda');
RestoreBackupArchive::dispatchSync($operazione, $zip, ($attore ?? $this->admin)->id);
return app(BackupOperationState::class)->get($operazione);
}
private function hash(User $utente): string
{
return (string) DB::table('users')->where('id', $utente->id)->value('password');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class TerritoriFileOrfaniTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$disco = Storage::disk('public');
$disco->put('territori-pdf/usato.pdf', 'x');
$disco->put('territori-pdf/orfano.pdf', 'x');
$disco->put('territori-thumbnails/usata.jpg', 'x');
$disco->put('territori-pdf-versioni/versione.pdf', 'x');
$disco->put('logo.png', 'x');
$territorio = Territorio::create([
'numero' => '1',
'attivo' => true,
'pdf_path' => 'territori-pdf/usato.pdf',
'thumbnail_path' => 'territori-thumbnails/usata.jpg',
]);
TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/versione.pdf',
'origine' => 'import',
'sostituito_il' => now(),
]);
}
public function test_senza_elimina_elenca_e_non_tocca_niente(): void
{
$this->artisan('territori:file-orfani')
->expectsOutputToContain('territori-pdf/orfano.pdf')
->assertSuccessful();
Storage::disk('public')->assertExists('territori-pdf/orfano.pdf');
}
public function test_con_elimina_cancella_solo_gli_orfani(): void
{
$this->artisan('territori:file-orfani --elimina')->assertSuccessful();
$disco = Storage::disk('public');
$disco->assertMissing('territori-pdf/orfano.pdf');
$disco->assertExists('territori-pdf/usato.pdf');
$disco->assertExists('territori-thumbnails/usata.jpg');
$disco->assertExists('territori-pdf-versioni/versione.pdf');
$disco->assertExists('logo.png');
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* I file dei territori si scaricano solo da dentro, con i permessi della
* pagina che li mostra. Prima nginx li serviva a chiunque da /storage.
*/
class TerritorioFileTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$this->seed(RolesAndPermissionsSeeder::class);
}
private function utente(string $ruolo): User
{
$utente = User::create([
'name' => ucfirst($ruolo),
'email' => $ruolo . '@example.test',
'password' => 'password-di-prova',
]);
$utente->assignRole($ruolo);
return $utente;
}
private function territorioConFile(): Territorio
{
Storage::disk('public')->put('territori-pdf/territorio-1-1.pdf', '%PDF-1.4 prova');
Storage::disk('public')->put('territori-thumbnails/territorio-1.jpg', "\xFF\xD8\xFFprova");
return Territorio::create([
'numero' => '12',
'attivo' => true,
'pdf_path' => 'territori-pdf/territorio-1-1.pdf',
'thumbnail_path' => 'territori-thumbnails/territorio-1.jpg',
]);
}
public function test_senza_login_i_file_non_si_scaricano(): void
{
$territorio = $this->territorioConFile();
$this->get($territorio->pdfUrl())->assertRedirect(route('login'));
$this->get($territorio->miniaturaUrl())->assertRedirect(route('login'));
}
public function test_un_operatore_scarica_pdf_e_miniatura_in_cache_privata(): void
{
$territorio = $this->territorioConFile();
$operatore = $this->utente('operatore');
$risposta = $this->actingAs($operatore)->get($territorio->pdfUrl());
$risposta->assertOk();
$risposta->assertHeader('Content-Type', 'application/pdf');
$this->assertStringContainsString('private', $risposta->headers->get('Cache-Control'));
$this->assertStringContainsString('territorio-12.pdf', $risposta->headers->get('Content-Disposition'));
$this->actingAs($operatore)->get($territorio->miniaturaUrl())->assertOk();
}
public function test_un_territorio_nel_cestino_resta_raggiungibile(): void
{
$territorio = $this->territorioConFile();
$territorio->delete();
$this->actingAs($this->utente('assistente'))
->get(route('territori.file.miniatura', $territorio->id))
->assertOk();
}
public function test_un_file_mancante_risponde_404(): void
{
$territorio = Territorio::create(['numero' => '13', 'attivo' => true, 'pdf_path' => 'territori-pdf/sparito.pdf']);
$this->actingAs($this->utente('operatore'))
->get(route('territori.file.pdf', $territorio->id))
->assertNotFound();
}
public function test_le_versioni_archiviate_sono_solo_dell_amministratore(): void
{
$territorio = $this->territorioConFile();
Storage::disk('public')->put('territori-pdf-versioni/vecchia.pdf', '%PDF-1.4 vecchia');
$versione = TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
'origine' => 'piantine',
'sostituito_il' => now(),
]);
$this->actingAs($this->utente('operatore'))
->get(route('territori.file.versione', $versione))
->assertForbidden();
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
$this->flushSession();
$this->actingAs($this->utente('amministratore'))
->get(route('territori.file.versione', $versione))
->assertOk();
}
public function test_l_indirizzo_cambia_quando_cambia_il_file(): void
{
$territorio = $this->territorioConFile();
$prima = $territorio->pdfUrl();
$territorio->update(['pdf_path' => 'territori-pdf/territorio-1-2.pdf']);
$this->assertNotSame($prima, $territorio->pdfUrl());
$this->assertNull(Territorio::create(['numero' => '99', 'attivo' => true])->pdfUrl());
}
/** nginx non deve servire le cartelle dei territori (il logo sì). */
public function test_nginx_blocca_le_cartelle_dei_territori(): void
{
$conf = file_get_contents(base_path('docker/nginx/default.conf'));
$this->assertMatchesRegularExpression('#location \^~ /storage/territori- \{\s*return 404;#', $conf);
}
}

View File

@@ -186,6 +186,41 @@ class XmlExchangeImportTest extends TestCase
$this->assertTrue($altro->hasRole('assistente'));
}
/**
* Le credenziali che arrivano da un file non sono più segrete: chi riceve
* l'hash dal file deve cambiare password, e il "ricordami" non viaggia.
*/
public function test_le_credenziali_del_file_obbligano_a_cambiare_password(): void
{
$this->admin->forceFill(['remember_token' => 'token-attuale'])->save();
$hash = Hash::make('password-del-file');
$xml = $this->xmlMinimo(
utenti: '<user legacy_id="9"><name>Amministratore</name><email>admin@example.test</email><password>' . $hash . '</password><remember_token>vecchio</remember_token><roles></roles></user>'
. '<user legacy_id="10"><name>Altro</name><email>altro@example.test</email><password>' . $hash . '</password><remember_token>rubato</remember_token><roles><role>assistente</role></roles></user>',
);
$this->assertArrayNotHasKey('error', $this->importa($xml));
$altro = User::where('email', 'altro@example.test')->sole();
$this->assertTrue((bool) $altro->password_change_required);
$this->assertNull($altro->remember_token);
// Chi importa resta com'era.
$admin = $this->admin->fresh();
$this->assertFalse((bool) $admin->password_change_required);
$this->assertSame('token-attuale', $admin->getRememberToken());
}
public function test_l_export_non_contiene_i_remember_token(): void
{
$this->admin->forceFill(['remember_token' => 'token-segreto'])->save();
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertStringNotContainsString('remember_token', $xml);
$this->assertStringNotContainsString('token-segreto', $xml);
}
// ─── helper ─────────────────────────────────────────────────
private function reimporta(?string $xml = null): void

View File

@@ -19,6 +19,11 @@ abstract class TestCase extends BaseTestCase
$app->make(Kernel::class)->bootstrap();
// Setting tiene l'istanza in una variabile statica, che sopravvive da
// un test all'altro nello stesso processo: un test si ritrovava il
// modello di quello prima, con una riga che nel suo database non c'e'.
\App\Models\Setting::flushCache();
return $app;
}
}

View File

@@ -21,6 +21,9 @@ use ZipArchive;
*/
class BackupArchiveTest extends TestCase
{
/** piantine.json di un'installazione senza piantine. */
private const PIANTINE_VUOTE = ['territorio_mappe' => [], 'territorio_pdf_versioni' => []];
private BackupArchive $archivio;
/** Cartella dei file .zip prodotti dai test, ripulita a ogni giro. */
@@ -31,6 +34,9 @@ class BackupArchiveTest extends TestCase
parent::setUp();
Storage::fake('public');
// Anche il disco `local`: l'archivio ci legge l'area KML delle
// piantine, e senza finto prenderebbe quella vera della macchina.
Storage::fake('local');
$this->archivio = new BackupArchive();
$this->lavoro = storage_path('app/testing-backup');
@@ -47,6 +53,7 @@ class BackupArchiveTest extends TestCase
// reale, non il disco finto, quindi va ripulito a mano.
$this->archivio->deleteDirectory($this->lavoro);
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
$this->archivio->deleteDirectory(storage_path('app/backup-precedenti'));
parent::tearDown();
}
@@ -60,7 +67,7 @@ class BackupArchiveTest extends TestCase
Storage::disk('public')->put('logo.svg', 'logo');
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
$this->assertFileExists($destinazione);
@@ -68,9 +75,9 @@ class BackupArchiveTest extends TestCase
$this->assertSame('<dataset/>', $esito['xml']);
$this->assertSame(3, $esito['files']);
$this->assertStringEqualsFile($esito['staging'] . '/territori-pdf/1.pdf', 'contenuto-uno');
$this->assertStringEqualsFile($esito['staging'] . '/territori-thumbnails/1.png', 'miniatura-uno');
$this->assertStringEqualsFile($esito['staging'] . '/logo.svg', 'logo');
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-pdf/1.pdf', 'contenuto-uno');
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-thumbnails/1.png', 'miniatura-uno');
$this->assertStringEqualsFile($esito['pubblico'] . '/logo.svg', 'logo');
}
public function test_il_manifest_dichiara_versione_di_formato_e_conteggio(): void
@@ -79,7 +86,7 @@ class BackupArchiveTest extends TestCase
Storage::disk('public')->put('territori-pdf/2.pdf', 'y');
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
$zip = new ZipArchive();
$this->assertTrue($zip->open($destinazione) === true);
@@ -94,7 +101,7 @@ class BackupArchiveTest extends TestCase
public function test_il_pacchetto_di_un_installazione_senza_file_contiene_comunque_i_dati(): void
{
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
$esito = $this->archivio->extract($destinazione);
@@ -116,6 +123,7 @@ class BackupArchiveTest extends TestCase
$avanzamenti = [];
$this->archivio->create(
'<dataset/>',
self::PIANTINE_VUOTE,
$this->lavoro . '/backup.zip',
function (int $fatti, int $totale) use (&$avanzamenti) {
$avanzamenti[] = [$fatti, $totale];
@@ -156,7 +164,7 @@ class BackupArchiveTest extends TestCase
$destinazione = $this->lavoro . '/backup.zip';
try {
$this->archivio->create('<dataset/>', $destinazione);
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
$this->fail('create() avrebbe dovuto sollevare su un file non leggibile.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('territori-pdf/sparito.pdf', $e->getMessage());
@@ -290,9 +298,9 @@ class BackupArchiveTest extends TestCase
$esito = $this->archivio->extract($zip);
$this->assertSame(1, $esito['files']);
$this->assertFileExists($esito['staging'] . '/territori-pdf/1.pdf');
$this->assertFileDoesNotExist($esito['staging'] . '/README.txt');
$this->assertDirectoryDoesNotExist($esito['staging'] . '/appunti');
$this->assertFileExists($esito['pubblico'] . '/territori-pdf/1.pdf');
$this->assertFileDoesNotExist($esito['pubblico'] . '/README.txt');
$this->assertDirectoryDoesNotExist($esito['pubblico'] . '/appunti');
}
// ─── safeRelativePath(): la regola dello zip slip ───────────
@@ -335,7 +343,109 @@ class BackupArchiveTest extends TestCase
];
}
// ─── restoreFiles() / svuotaDiscoPubblico() ─────────────────
/**
* Un'entry alterata dentro l'archivio deve fermare il ripristino durante
* l'estrazione, a installazione intatta. Prima il risultato della copia
* non si guardava: un file troncato o corrotto finiva nello staging e da
* li' al posto del PDF buono, con il ripristino dichiarato riuscito.
*/
public function test_un_entry_corrotta_ferma_l_estrazione(): void
{
$path = $this->lavoro . '/corrotto.zip';
$zip = new ZipArchive();
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('manifest.json', $this->manifest());
$zip->addFromString('data.xml', '<dataset/>');
$zip->addFromString('piantine.json', json_encode(self::PIANTINE_VUOTE));
$zip->addFromString('storage/territori-pdf/1.pdf', 'contenuto-originale-del-pdf');
// Senza compressione il contenuto sta in chiaro nel file: lo si puo'
// alterare a mano lasciando intatti intestazioni e CRC dichiarato.
$zip->setCompressionName('storage/territori-pdf/1.pdf', ZipArchive::CM_STORE);
$zip->close();
file_put_contents($path, str_replace(
'contenuto-originale-del-pdf',
'contenuto-ALTERATO--del-pdf',
file_get_contents($path)
));
try {
$this->archivio->extract($path);
$this->fail('extract() avrebbe dovuto accorgersi dell\'entry alterata.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('territori-pdf/1.pdf', $e->getMessage());
}
$radice = storage_path('app/backup-restore');
$this->assertTrue(! is_dir($radice) || glob($radice . '/*') === []);
}
// ─── piantine nel pacchetto ─────────────────────────────────
/**
* L'area KML sta in storage/app/piantine, accanto al disco `public` e non
* dentro: prima nessun backup la conteneva, e dopo un ripristino su volumi
* vuoti andava ricaricata a mano.
*/
public function test_il_pacchetto_porta_l_area_kml_e_i_dati_delle_piantine(): void
{
Storage::disk('local')->put('piantine/area.kml', '<kml/>');
Storage::disk('local')->put('piantine/config.json', '{"kml_nome":"citta.kml"}');
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'immagine');
$dati = ['territorio_mappe' => [['id' => 1, 'geojson' => '{}']], 'territorio_pdf_versioni' => []];
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $dati, $destinazione);
$esito = $this->archivio->extract($destinazione);
$this->assertSame($dati, $esito['piantine']);
$this->assertSame(1, $esito['files']);
$this->assertSame(2, $esito['file_piantine']);
$this->assertStringEqualsFile($esito['staging'] . '/piantine/area.kml', '<kml/>');
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-mappe/piantina-1.jpg', 'immagine');
}
public function test_dal_formato_2_piantine_json_e_obbligatorio(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => '<dataset/>',
'piantine.json' => null,
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/piantine\.json/');
$this->archivio->extract($zip);
}
/**
* Compatibilita' all'indietro: un archivio di formato 1 non ha
* piantine.json, e si estrae lo stesso. Le entry piantine/ non si
* prendono: quel formato non le prevedeva.
*/
public function test_un_archivio_di_formato_1_si_estrae_senza_piantine(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(1),
'data.xml' => '<dataset/>',
'piantine.json' => null,
'storage/territori-pdf/1.pdf' => 'pdf',
'piantine/area.kml' => '<kml/>',
]);
$esito = $this->archivio->extract($zip);
$this->assertNull($esito['piantine']);
$this->assertSame(1, $esito['formato']);
$this->assertSame(0, $esito['file_piantine']);
$this->assertDirectoryDoesNotExist($esito['staging'] . '/piantine');
}
// ─── scambiaFile() / svuotaDiscoPubblico() ──────────────────
/**
* La sostituzione del disco e' totale, non un merge: i percorsi vivono
@@ -346,20 +456,89 @@ class BackupArchiveTest extends TestCase
{
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'da buttare');
Storage::disk('public')->put('logo.svg', 'logo vecchio');
Storage::disk('local')->put('piantine/area.kml', 'area vecchia');
$staging = $this->lavoro . '/staging';
mkdir($staging . '/territori-pdf', 0775, true);
file_put_contents($staging . '/territori-pdf/nuovo.pdf', 'da tenere');
file_put_contents($staging . '/logo.svg', 'logo nuovo');
$estratto = $this->staging([
'pubblico/territori-pdf/nuovo.pdf' => 'da tenere',
'pubblico/logo.svg' => 'logo nuovo',
]);
$ripristinati = $this->archivio->restoreFiles($staging);
$scambio = $this->archivio->scambiaFile($estratto);
$this->archivio->confermaScambio($scambio);
$this->assertSame(2, $ripristinati);
$this->assertSame(2, $scambio['file']);
$this->assertSame(
['logo.svg', 'territori-pdf/nuovo.pdf'],
collect(Storage::disk('public')->allFiles())->sort()->values()->all()
);
$this->assertSame('logo nuovo', Storage::disk('public')->get('logo.svg'));
// Formato 2 senza area nell'archivio: l'installazione salvata non ne
// aveva, e il ripristino la rispecchia.
$this->assertSame([], Storage::disk('local')->allFiles('piantine'));
$this->assertDirectoryDoesNotExist($scambio['accantonati'], 'A ripristino confermato i file vecchi non servono piu\'.');
}
public function test_un_archivio_di_formato_1_non_tocca_l_area_kml(): void
{
Storage::disk('local')->put('piantine/area.kml', 'area in uso');
$this->archivio->confermaScambio($this->archivio->scambiaFile($this->staging([], 1)));
$this->assertSame('area in uso', Storage::disk('local')->get('piantine/area.kml'));
}
/**
* Il cuore della correzione: prima il disco si svuotava e poi si
* ricopiava file per file, quindi un errore a meta' lasciava una parte
* dei file nuovi e nessuno dei vecchi. Qui lo scambio fallisce dopo aver
* gia' sostituito il disco `public` (la cartella dell'area e' occupata da
* un file, mkdir non puo' crearla) e deve rimettere tutto com'era.
*/
public function test_uno_scambio_fallito_a_meta_rimette_i_file_vecchi(): void
{
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'pdf in uso');
Storage::disk('local')->put('piantine', 'un file al posto della cartella');
$estratto = $this->staging([
'pubblico/territori-pdf/nuovo.pdf' => 'pdf del backup',
'piantine/area.kml' => 'area del backup',
]);
try {
$this->archivio->scambiaFile($estratto);
$this->fail('scambiaFile() avrebbe dovuto sollevare.');
} catch (RuntimeException) {
// atteso
}
$this->assertSame(['territori-pdf/vecchio.pdf'], Storage::disk('public')->allFiles());
$this->assertSame('pdf in uso', Storage::disk('public')->get('territori-pdf/vecchio.pdf'));
$this->assertFileExists($estratto['staging'] . '/pubblico/territori-pdf/nuovo.pdf');
}
/**
* Lo scambio e' riuscito ma la transazione non va in porto (commit
* fallito): il job chiama annullaScambio() e i file devono seguire il
* database, cioe' tornare quelli di prima.
*/
public function test_l_annullamento_dopo_uno_scambio_riuscito_rimette_i_file_vecchi(): void
{
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'pdf in uso');
Storage::disk('local')->put('piantine/area.kml', 'area in uso');
$scambio = $this->archivio->scambiaFile($this->staging([
'pubblico/territori-pdf/nuovo.pdf' => 'pdf del backup',
'piantine/area.kml' => 'area del backup',
]));
$this->assertSame(['territori-pdf/nuovo.pdf'], Storage::disk('public')->allFiles());
$this->archivio->annullaScambio($scambio);
$this->assertSame(['territori-pdf/vecchio.pdf'], Storage::disk('public')->allFiles());
$this->assertSame('area in uso', Storage::disk('local')->get('piantine/area.kml'));
$this->assertDirectoryDoesNotExist($scambio['accantonati']);
}
public function test_lo_svuotamento_conta_i_file_rimossi_e_lascia_il_disco_vuoto(): void
@@ -382,10 +561,18 @@ class BackupArchiveTest extends TestCase
// ─── helper ─────────────────────────────────────────────────
/**
* Un archivio con data.xml riceve anche un piantine.json vuoto, se non
* ne ha uno: dal formato 2 e' obbligatorio, e senza ogni test sui rifiuti
* si fermerebbe su quello invece che sul difetto che vuole provare.
*
* @param array<string, string> $entries
*/
private function zipCon(array $entries): string
{
if (isset($entries['data.xml']) && ! array_key_exists('piantine.json', $entries)) {
$entries['piantine.json'] = json_encode(self::PIANTINE_VUOTE);
}
$path = $this->lavoro . '/costruito-' . count($entries) . '-' . md5(serialize($entries)) . '.zip';
$zip = new ZipArchive();
@@ -395,14 +582,47 @@ class BackupArchiveTest extends TestCase
);
foreach ($entries as $nome => $contenuto) {
// null: l'entry non c'e' (serve a togliere piantine.json).
if ($contenuto !== null) {
$zip->addFromString($nome, $contenuto);
}
}
$zip->close();
return $path;
}
/**
* Uno staging come lo lascia extract(), costruito a mano.
*
* @param array<string, string> $file percorso nello staging => contenuto
*/
private function staging(array $file, int $formato = BackupArchive::FORMAT_VERSION): array
{
$staging = storage_path('app/backup-restore/prova-' . md5(serialize($file) . $formato));
$pubblici = 0;
$piantine = 0;
foreach ($file as $percorso => $contenuto) {
@mkdir(dirname($staging . '/' . $percorso), 0775, true);
file_put_contents($staging . '/' . $percorso, $contenuto);
if (str_starts_with($percorso, 'piantine/')) {
$piantine++;
} else {
$pubblici++;
}
}
return [
'formato' => $formato,
'staging' => $staging,
'pubblico' => $staging . '/pubblico',
'files' => $pubblici,
'file_piantine' => $piantine,
];
}
private function manifest(?int $formatVersion = null): string
{
return json_encode([