1119 lines
65 KiB
Markdown
1119 lines
65 KiB
Markdown
# Handoff — TerManager2
|
|
|
|
Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
|
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
|
può dare: perché le cose sono come sono, e cosa resta aperto.
|
|
|
|
Ultimo aggiornamento: **30 luglio 2026**.
|
|
|
|
---
|
|
|
|
## Stato attuale
|
|
|
|
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è
|
|
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti,
|
|
redeploy, login, tutte le pagine, code e scheduler.
|
|
|
|
| Aspetto | Stato |
|
|
|-----------------------------|-------|
|
|
| Deploy Dokploy (Compose) | ✅ verificato |
|
|
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
|
|
| Redeploy senza interventi | ✅ ~25 secondi |
|
|
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
|
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale |
|
|
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
|
|
| Test automatici | ⚠️ **impalcatura pronta, 57 test** — copertura ancora parziale |
|
|
|
|
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
|
|
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
|
|
di avanzamento reali e console di log (commit `f909791`, deploy verificato
|
|
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo**
|
|
su develop; la produzione non è stata toccata e resta sul suo deploy
|
|
precedente, senza quella variabile.
|
|
|
|
Il 30/07 sono state fatte due cose.
|
|
|
|
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
|
|
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
|
|
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
|
|
dell'immagine di produzione e non girano su un deploy: sono uno strumento
|
|
locale.
|
|
|
|
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con
|
|
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con
|
|
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le
|
|
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di
|
|
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in
|
|
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel
|
|
browser — apertura automatica della console, scorrimento, lampeggio delle zone —
|
|
non è stato provato**, perché richiede un browser: è la prima cosa da guardare
|
|
aprendo la pagina.
|
|
|
|
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché
|
|
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
|
|
e l'unica che richiede una decisione, non del lavoro.
|
|
|
|
---
|
|
|
|
## Da dove veniamo
|
|
|
|
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
|
|
l'applicazione in `/app-src`, ma il runtime girava da `/var/www/html`, popolato
|
|
solo dal bind mount del sorgente. **L'immagine costruita non veniva mai usata.**
|
|
L'entrypoint rieseguiva `composer install` e `npm run build` a ogni avvio dentro
|
|
il container. Su Dokploy, senza sorgente sull'host, significava build a runtime
|
|
a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
|
|
|
|
Da qui la ristrutturazione descritta sotto.
|
|
|
|
---
|
|
|
|
## Dove gira davvero
|
|
|
|
Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal
|
|
repository Gitea `francesco/termanager2` con **autoDeploy attivo**.
|
|
|
|
| Ambiente | Branch | Dominio | Compose (`appName`) |
|
|
|--------------|--------|-------------------------------------|----------------------------|
|
|
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` |
|
|
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` |
|
|
|
|
Conseguenze pratiche, tutte imparate sul campo:
|
|
|
|
- **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione,
|
|
che segue `main`. È la separazione su cui contare quando si vuole provare
|
|
qualcosa senza rischi.
|
|
|
|
- **Un "Rebuild deployment" NON scarica il codice nuovo**: ricostruisce il
|
|
checkout già presente e ci mette una ventina di secondi. Per prendere l'ultimo
|
|
commit serve un deploy vero (`compose.deploy` via API, o *Deploy*
|
|
nell'interfaccia). Un rebuild che finisce troppo in fretta è il segnale che si
|
|
sta ricostruendo il codice di prima.
|
|
|
|
- **Le variabili d'ambiente stanno in Dokploy, non nel repository.** Il `.env`
|
|
dell'ambiente lo genera l'interfaccia: una variabile aggiunta a
|
|
`.env.example` non arriva da sola sui deployment, va aggiunta a mano lì. È
|
|
successo con `MAX_UPLOAD_MB`, presente in develop e — deliberatamente — non in
|
|
produzione.
|
|
|
|
- **Verifica che il codice servito sia quello giusto** confrontando l'impronta
|
|
degli asset: `public/build/assets/app-*.css` e `-*.js` hanno il nome derivato
|
|
dal contenuto, quindi se coincide con quello della propria build, il frontend
|
|
in linea è lo stesso sorgente. È il controllo più rapido dopo un deploy, e non
|
|
richiede di autenticarsi.
|
|
|
|
---
|
|
|
|
## Decisioni architetturali
|
|
|
|
Le scelte non ovvie, con il motivo. Se una di queste ti sembra sbagliata, il
|
|
contesto per ridiscuterla è qui.
|
|
|
|
### L'immagine è autosufficiente
|
|
|
|
`vendor/` e `public/build` sono compilati in stage separati del `Dockerfile` e
|
|
copiati nell'immagine finale. I container di produzione **non montano il
|
|
sorgente** e non eseguono build all'avvio.
|
|
|
|
Conseguenza pratica: dopo aver modificato dipendenze PHP o asset frontend,
|
|
serve un rebuild dell'immagine, non un semplice restart.
|
|
|
|
### nginx e php-fpm nello stesso container
|
|
|
|
Con container separati, nginx avrebbe bisogno di una copia di `public/` e del
|
|
volume storage solo per servire file statici. Per questa scala è complessità
|
|
senza beneficio. Sono coordinati da supervisor
|
|
(`docker/supervisor/supervisord.conf`), che abbatte il container se uno dei due
|
|
muore definitivamente — altrimenti resterebbe "up" senza servire nulla.
|
|
|
|
### La configurazione arriva dall'ambiente, non da `.env` riscritto
|
|
|
|
Il vecchio entrypoint riscriveva `.env` con `sed` a ogni avvio. Ora Laravel
|
|
legge direttamente l'ambiente di processo, e `docker-compose.yml` si limita a
|
|
`env_file: .env` senza ridichiarare nulla.
|
|
|
|
Il `.env` è quindi l'**unica** fonte di configurazione. Una versione precedente
|
|
fissava nel compose i valori dettati dalla topologia (`DB_HOST: mariadb` e
|
|
simili) perché vincessero su un `.env` sbagliato, ma richiedeva gli anchor YAML
|
|
(`x-app-env`, `<<:`) ed è stata scartata a favore della leggibilità.
|
|
|
|
Il rovescio della medaglia: `DB_HOST` e `REDIS_HOST` devono valere `mariadb` e
|
|
`redis`, cioè i nomi dei servizi. Se qualcuno vi mette un indirizzo IP il
|
|
container non trova più il database. È documentato in `.env.example` e nel
|
|
README, ma è il punto fragile di questa scelta.
|
|
|
|
Nota: perché la configurazione da ambiente funzioni, `www.conf` ha
|
|
`clear_env = no`. Senza, php-fpm sanifica l'ambiente e Laravel non vedrebbe
|
|
nessuna variabile.
|
|
|
|
### Fail-fast sulle variabili mancanti
|
|
|
|
Lo stack si rifiuta di partire senza `APP_URL`, `DB_PASSWORD`,
|
|
`DB_ROOT_PASSWORD`, `REDIS_PASSWORD`. Le password sono validate
|
|
dall'interpolazione compose (`${VAR:?}`), `APP_URL` dall'entrypoint (dove il
|
|
messaggio d'errore è più chiaro).
|
|
|
|
Deliberato: un errore immediato è meglio di un database in produzione con
|
|
password `secret`.
|
|
|
|
### Modifica/cancellazione delle assegnazioni: solo amministratore
|
|
|
|
L'unico punto in cui un'assegnazione **esistente** si modifica o si cancella è
|
|
il CRUD del Registro (`Livewire/Registro`: `openCreate`/`openEdit`/`save`/
|
|
`askDelete`/`deleteConfirmed`), blindato su `settings.manage` a due livelli —
|
|
`abort_if(..., 403)` nel componente e `@can('settings.manage')` in
|
|
`registro.blade.php`, che nasconde pulsanti e modali. `settings.manage` ce l'ha
|
|
solo l'`amministratore`.
|
|
|
|
Di conseguenza gli altri ruoli **non** possono alterare le assegnazioni: la
|
|
pagina Assegna solo ne crea (`territori.assign`), la pagina Rientra solo le
|
|
chiude (`territori.return`). Nello specifico l'`assistente` (`registro.view` +
|
|
`territori.assign` + `territori.return`) vede il Registro, crea nuove
|
|
assegnazioni e le fa rientrare, ma non le modifica né le elimina.
|
|
|
|
Poiché il seeder concede in modo additivo e non revoca mai (vedi la nota su
|
|
`syncPermissions()` più sotto), un permesso `settings.manage` concesso a mano
|
|
all'`assistente` in passato sopravviverebbe a ogni re-seed. La migration
|
|
`2026_07_25_000001_revoke_settings_manage_from_assistente_role` chiude questo
|
|
drift per i database esistenti: revoca `settings.manage` dal ruolo `assistente`
|
|
se presente. È idempotente (no-op sul baseline) e volutamente irreversibile —
|
|
il `down()` non ripristina il permesso.
|
|
|
|
Stesso principio per le **campagne**: creazione e visualizzazione restano su
|
|
`campagne.manage` (che ha anche l'assistente), ma **modifica ed eliminazione
|
|
sono riservate all'amministratore** (`settings.manage`). Il gate è su tre
|
|
livelli: la route `campagne/{campagna}/edit` è nel gruppo `settings.manage`;
|
|
`CampagnaEdit::mount()`/`save()` e `CampagnaIndex::deleteCampagna()` chiamano
|
|
`$this->authorize('settings.manage')` (l'index è raggiungibile da chi ha
|
|
`campagne.manage`, quindi il delete va protetto nel metodo, non solo in route);
|
|
i pulsanti Modifica/Elimina in `campagna-index`/`campagna-show` sono sotto
|
|
`@can('settings.manage')`. Nota: `Campagna` non ha soft delete — l'eliminazione
|
|
è definitiva.
|
|
|
|
Per i **territori** vale un modello leggermente diverso, perché la
|
|
visualizzazione va aperta a tutti gli operativi: lista (`territori.index`) e
|
|
dettaglio (`territori.show`) sono ora su `territori.assign` (che hanno admin,
|
|
assistente e operatore); creazione/modifica/cestino restano su `territori.manage`
|
|
(solo admin). Attenzione a due punti:
|
|
|
|
- **Ordine delle rotte.** Le rotte letterali `territori/create` e
|
|
`territori/cestino` devono restare registrate PRIMA del wildcard
|
|
`territori/{territorio}`, altrimenti verrebbero catturate dalla rotta di
|
|
dettaglio.
|
|
|
|
- **`TerritorioIndex` perde la copertura del middleware persistente.** Il suo
|
|
componente è ora servito da una rotta rilassata (`territori.assign`), quindi i
|
|
suoi metodi di mutazione (`toggleActive`, `togglePriority`, `deleteTerritorio`)
|
|
NON sono più protetti dal middleware di route e hanno un
|
|
`$this->authorize('territori.manage')` interno — senza, un operativo potrebbe
|
|
invocarli via `/livewire/update` col pulsante nascosto. Per difesa in
|
|
profondità lo stesso check è stato aggiunto anche a `TerritorioCreate::save`,
|
|
`TerritorioEdit::save`/`removePdf`, `TerritorioCestino::restore`/`forceDelete`
|
|
(che restano comunque su rotte `territori.manage`). I pulsanti
|
|
Modifica/toggle/Elimina/Nuovo/Cestino in `territorio-index`/`territorio-show`
|
|
sono sotto `@can('territori.manage')`; il link "Dettaglio" resta visibile a
|
|
tutti. Il menu laterale mostra "Territori" a chi ha `territori.assign`.
|
|
|
|
### Sola lettura per proclamatori e campagne (non-admin)
|
|
|
|
Stessa impostazione dei territori estesa ad altre due sezioni:
|
|
|
|
- **Proclamatori**: `index` e `show` restano su `proclamatori.manage` (l'assistente
|
|
li vede in sola lettura); `create`/`edit`/`cestino` e tutte le mutazioni
|
|
(`ProclamatoreIndex::toggleActive`/`deleteProclamatore`/`anonimizza`,
|
|
`ProclamatoreCreate::save`, `ProclamatoreEdit::save`,
|
|
`ProclamatoreCestino::restore`/`forceDelete`) richiedono `settings.manage`.
|
|
`ProclamatoreIndex` è servito da una rotta rilassata, quindi i suoi metodi di
|
|
mutazione hanno il check interno; i pulsanti in `proclamatore-index`/`-show`/
|
|
`-cestino` sono sotto `@can('settings.manage')`. Le rotte letterali
|
|
create/cestino restano prima del wildcard `{proclamatore}`.
|
|
- **Campagne**: la **creazione** è ora admin-only come già modifica/eliminazione
|
|
— `CampagnaCreate::save` chiama `authorize('settings.manage')`, la rotta
|
|
`campagne/create` è nel gruppo `settings.manage` (registrata prima del
|
|
wildcard) e il pulsante "Nuova Campagna" è sotto `@can('settings.manage')`.
|
|
`index`/`show` restano su `campagne.manage` (sola lettura per l'assistente).
|
|
|
|
### Tabelle-elenco responsive su mobile (portrait)
|
|
|
|
Le liste principali usano una sola "politica" CSS invece di layout duplicati:
|
|
la classe `table-cards` (in `resources/css/app.css`) sotto `640px` nasconde il
|
|
`thead` e trasforma ogni riga in una scheda, con ogni cella resa come
|
|
"ETICHETTA … valore" (l'etichetta viene dall'attributo `data-label` della `<td>`).
|
|
Un telefono in portrait sta sotto la soglia → schede; ruotato in landscape la
|
|
larghezza supera i 640px → torna la tabella normale (per questo landscape "non ha
|
|
problemi"). Convenzioni: ogni `<td>` dati ha `data-label="..."`; la cella azioni
|
|
ha classe `cell-actions` (niente label, occupa la riga a piena larghezza); le
|
|
celle `colspan` (stato vuoto, form di modifica utente inline) restano `block` a
|
|
piena larghezza. Applicata a tutte le liste e sottopagine con tabelle: territori,
|
|
proclamatori, campagne, registro, utenti, audit-log, i due cestini, le tabelle
|
|
"storico assegnazioni" di territorio-show/proclamatore-show, "territori
|
|
conteggiati" di campagna-show e le due tabelle di esito import in xml-exchange
|
|
(una a classi Tailwind, una a stili inline — `border:none !important` nella regola
|
|
gestisce anche i bordi inline). La pagina *Assegnazioni* era già ottimizzata.
|
|
|
|
### Scadenza link PDF unificata con la soglia rientro
|
|
|
|
La "Validità link PDF (mesi)" è stata rimossa: il link condivisibile ora scade
|
|
esattamente quando l'assegnazione raggiunge la **soglia rientro**
|
|
(`giorni_per_smarrito`), cioè `assigned_at + giorni_per_smarrito` — la stessa
|
|
data di `Assegnazione::data_consegna_presunta` e del chip "Scadenza" nel viewer.
|
|
`AssignmentPdfController::linkScaduto` e `ShortPdfLinkController` calcolano ora
|
|
`addDays(giorni_per_smarrito)` invece di `addMonths(ttl)`; la scadenza vale solo
|
|
per i non autenticati e il link muore comunque al rientro (`is_aperta`). Rimossi
|
|
il campo Impostazioni e la proprietà `assignment_link_ttl_months` in
|
|
`SettingsEdit`. **La colonna `assignment_link_ttl_hours` resta nel DB** (niente
|
|
migration distruttiva): è ormai vestigiale per la logica ma l'export/import XML
|
|
continua a leggerla/scriverla senza effetti.
|
|
|
|
### Ricerca rapida nella lista link (pagina Assegna)
|
|
|
|
La sezione "Territori Assegnati" (i link PDF) ha un unico campo che filtra per
|
|
numero territorio, zona e proclamatore. Il filtro è **in memoria** (proprietà
|
|
`linkSearch`, collection `assegnazioniAperteVisibili`) perché nome/cognome del
|
|
proclamatore sono cifrati e non filtrabili via SQL — stesso approccio del
|
|
Registro. La sezione resta visibile anche a risultato vuoto (mostra "Nessun
|
|
risultato"), perché l'`@if` esterno guarda la lista non filtrata.
|
|
|
|
### Assegnare un territorio ne azzera il flag "prioritario"
|
|
|
|
`prioritario` è un flag **manuale**; `is_prioritario` è true se il flag è acceso
|
|
*oppure* se la giacenza supera la soglia — ma la parte a soglia vale solo per i
|
|
territori *in reparto* (`scopePrioritari`/`getIsPrioritarioAttribute` filtrano su
|
|
`inReparto`). Quindi un territorio assegnato può risultare prioritario **solo**
|
|
per via del flag manuale. Regola: alla creazione di un'assegnazione **aperta** il
|
|
flag viene spento. È implementata come evento `Assegnazione::created` (in
|
|
`booted()`), guardato su `returned_at === null` e `prioritario === true`, così
|
|
copre ogni via di creazione (pagina Assegna, CRUD del Registro, import XML) senza
|
|
duplicare la logica. Interazione con l'import XML: per i backup creati dopo questa
|
|
regola è un no-op (nessun territorio assegnato è prioritario); su backup vecchi
|
|
con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e
|
|
quale.
|
|
|
|
### Profilo utente, viewer PDF, privacy admin-only
|
|
|
|
Tre ritocchi minori ma con qualche implicazione:
|
|
|
|
- **Pagina profilo** (`Profile` → rotta `profilo`, nome `profile`) accessibile a
|
|
ogni utente loggato (nel gruppo `auth`, nessun permesso): l'utente corrente può
|
|
modificare il proprio nome/cognome ed email (`updateProfile`) e cambiare la
|
|
password (`updatePassword`, con verifica della password attuale via regola
|
|
`current_password`; il cast `hashed` cifra al salvataggio). Dopo il salvataggio
|
|
dei dati `updateProfile` fa `redirect(navigate:true)` sulla stessa pagina, così
|
|
header (nel layout, fuori dal componente) e card mostrano subito i nuovi valori.
|
|
Il link al profilo è nell'header su desktop e, su mobile, in fondo alla sidebar
|
|
(`lg:hidden`, dopo un separatore) — sull'header è `hidden sm:flex`. Non esiste
|
|
un campo "username" né "cognome" separato: `name` è il nome completo
|
|
(etichettato "Nome e cognome") e l'email fa da identificativo. La qualifica
|
|
(ruolo) non è modificabile dal profilo — resta in gestione all'admin.
|
|
- **Viewer PDF pubblico**: oltre a territorio/proclamatore/data mostra ora
|
|
*giorni assegnato*, *consegna prevista* e *scadenza*. Sono attributi calcolati
|
|
su `Assegnazione` (`data_consegna_presunta`, `giorni_alla_scadenza`) basati
|
|
sulla soglia `giorni_per_smarrito` — la stessa oltre cui `Territorio::stato`
|
|
diventa `da_rientrare`. Il viewer è servito solo per assegnazioni aperte, ma
|
|
gli accessor gestiscono comunque il caso `returned_at` (ritornano null).
|
|
- **Privacy solo admin**: la rotta `privacy` è passata dal solo `auth` al gruppo
|
|
`permission:settings.manage` e il link in sidebar è sotto
|
|
`@can('settings.manage')`.
|
|
|
|
### Backup completo `.zip`, ripristino, azzeramento
|
|
|
|
L'export XML da solo non ricostruisce un'installazione: `pdf_path`,
|
|
`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un
|
|
ripristino su volumi vuoti lascia ogni territorio che punta a un file
|
|
inesistente — link pubblici a 404 e miniature rotte. Il pacchetto `.zip`
|
|
(`Services/BackupArchive`, `Http/Controllers/Settings/BackupController`) mette
|
|
insieme `manifest.json`, `data.xml` (lo stesso XML dell'export) e `storage/…`
|
|
con **tutti** i file del disco `public`. L'export XML resta, etichettato come
|
|
parziale.
|
|
|
|
Scelte non ovvie:
|
|
|
|
- **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file
|
|
base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci
|
|
sta. `backup/file/{op}` e `backup/restore` sono due rotte normali nel gruppo
|
|
`settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge
|
|
dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero
|
|
per la memoria del processo.
|
|
|
|
- **Tutto in coda, con avanzamento reale.** Backup, ripristino e azzeramento
|
|
sono job (`Jobs/CreateBackupArchive`, `RestoreBackupArchive`,
|
|
`ResetApplication`) che scrivono su `Services/BackupOperationState`, e
|
|
`Livewire/Settings/BackupManager` lo interroga con `wire:poll`. In coda
|
|
perché un'installazione con qualche centinaio di PDF supera i 300 secondi di
|
|
`fastcgi_read_timeout` e il browser vedrebbe cadere la connessione a metà
|
|
compressione, senza modo di distinguerlo da un errore.
|
|
|
|
La barra conta unità di lavoro completate, non tempo. Per la compressione
|
|
questo costa qualcosa: `ZipArchive` non scrive niente finché non si chiama
|
|
`close()`, quindi contare le `addFile()` porterebbe al 100% in un istante per
|
|
poi restare fermo per tutta la scrittura. `BackupArchive::create()` chiude e
|
|
riapre l'archivio a lotti (`FLUSH_MASSIMI = 20`): si paga una ricopiatura
|
|
delle entry già presenti — byte a byte, senza ricomprimerle — e in cambio
|
|
ogni avanzamento corrisponde a byte davvero finiti sul disco. Il lotto è
|
|
`totale/20`, così la ricopiatura resta limitata a ~10 volte la dimensione
|
|
dell'archivio quanti che siano i file. Estrazione e riscrittura dei file sono
|
|
cicli nostri, lì l'avanzamento è per-file senza artifici.
|
|
|
|
- **L'azzeramento ha due tempi, separati dal download.** Il pulsante nella zona
|
|
pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in
|
|
`attesa-download`; la cancellazione parte da un secondo clic esplicito
|
|
(`confermaAzzeramento`), che rifiuta se lo stato non registra un
|
|
`servito_at` e se non riceve la password dell'amministratore. Il primo
|
|
tentativo faceva partire il reset dalla coda dello
|
|
`streamDownload`, "dopo l'ultimo byte" — **non funziona**: con
|
|
`fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo
|
|
disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi
|
|
prosegue anche se il browser ha chiuso la connessione al primo byte. Un
|
|
download annullato azzerava lo stesso. Il server può verificare di aver
|
|
*spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore,
|
|
e per questo la conferma è umana.
|
|
|
|
- **La password si richiede al secondo clic, e non passa dallo snapshot.** La
|
|
sessione è già autenticata, quindi la password non serve a stabilire *chi*
|
|
sia: serve a stabilire che davanti allo schermo ci sia ancora quella persona
|
|
— una scheda lasciata aperta su una postazione condivisa è lo scenario contro
|
|
cui protegge. Viaggia come **parametro** di `confermaAzzeramento($password)`,
|
|
non come proprietà `wire:model`: le proprietà pubbliche di Livewire vengono
|
|
serializzate nello snapshot e rispedite al browser a ogni risposta, e una
|
|
password non ha motivo di fare quel viaggio di ritorno.
|
|
|
|
Attenzione al dettaglio che costa un pomeriggio: la proprietà
|
|
`$passwordConferma` **esiste comunque, e resta sempre vuota**. Livewire
|
|
scarta dalla risposta ogni errore la cui chiave non corrisponda a una
|
|
proprietà del componente (`SupportValidation::dehydrate()` filtra su
|
|
`Utils::hasProperty`), quindi senza quella riga `addError('passwordConferma',
|
|
…)` sparisce in silenzio: il pulsante sembra non fare nulla, mentre il
|
|
controllo sta funzionando. Non collegarla al campo con `wire:model`, o la
|
|
password torna nello snapshot.
|
|
|
|
Cinque tentativi sbagliati in 5 minuti bloccano l'azione (`RateLimiter`,
|
|
chiave per utente). Senza, la richiesta di password su una sessione dirottata
|
|
sarebbe solo un rallentamento. Il blocco è sull'operazione, non sull'account:
|
|
nessuno resta chiuso fuori dall'applicazione.
|
|
|
|
- **Una sola operazione alla volta, con blocco che si autoripara.** Backup,
|
|
ripristino e azzeramento lavorano tutti sullo stesso database e sullo stesso
|
|
disco: due in parallelo — due schede, due amministratori — non si rallentano
|
|
soltanto, si sovrascrivono. `BackupOperationState::corrente()` fa da posto
|
|
unico; lo controllano sia il componente Livewire sia
|
|
`BackupController::upload()`, che sono due porte d'ingresso separate (la
|
|
seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, e
|
|
`corrente()` lo considera libero se l'operazione puntata non esiste più o è
|
|
terminata. In più i tre job hanno un `failed()` — Laravel lo chiama quando il
|
|
worker uccide il job per timeout o errore fatale, cioè proprio i casi che
|
|
`handle()` non può intercettare — che chiude l'operazione e libera il posto
|
|
invece di lasciare una barra ferma per un'ora.
|
|
|
|
- **La pagina si riaggancia all'operazione in corso.** `BackupManager::mount()`
|
|
legge `corrente()`. Senza, ricaricare la pagina durante un backup lungo
|
|
faceva sparire la barra, e — più grave — un azzeramento fermo in attesa di
|
|
conferma diventava irraggiungibile: nessuno poteva più né confermarlo né
|
|
annullarlo, e il posto restava occupato fino alla scadenza.
|
|
|
|
- **L'esito del ripristino si legge senza consumarlo.** Il job non ha una
|
|
sessione in cui lasciare un flash, quindi deposita il messaggio in cache a
|
|
nome di chi ha avviato il ripristino e la schermata di cambio password lo
|
|
mostra. Deve essere un `Cache::get` + `forget` a password cambiata, **non** un
|
|
`pull`: quando è il middleware a dirottare una richiesta di Livewire, il
|
|
client segue il 302 via `fetch` — ed è quella `fetch` a bruciare il messaggio
|
|
— per poi rifare la stessa GET con `window.location`, che troverebbe la cache
|
|
già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del
|
|
proprio ripristino.
|
|
|
|
- **Il tetto degli upload è una variabile sola, `MAX_UPLOAD_MB`.** Prima viveva
|
|
in tre posti scollegati — `client_max_body_size` in
|
|
`docker/nginx/default.conf`, `upload_max_filesize`/`post_max_size` in
|
|
`docker/php/php.ini`, e la regola `max:` nella validazione — e bastava che
|
|
uno restasse indietro perché il più piccolo vincesse nel modo peggiore.
|
|
Adesso `entrypoint.sh` genera i primi due (`conf.d/upload-limit.conf` e
|
|
`conf.d/zz-upload-limit.ini`) e `config/app.php` alimenta la terza.
|
|
|
|
**Non rimettere `client_max_body_size` nel blocco `server` di
|
|
`default.conf`**: una direttiva lì dentro vince su quella generata a livello
|
|
http e reintroduce in silenzio proprio il disallineamento che questa
|
|
disposizione esiste per evitare. La validazione tiene un 2% di margine sotto
|
|
il valore di nginx, così a fermare un pacchetto fuori misura è Laravel, che
|
|
sa spiegarsi, e non nginx.
|
|
|
|
Il sintomo, quando il limite viene superato, non assomiglia affatto a un
|
|
errore di dimensione: nginx risponde **413 dopo aver accettato zero byte** e
|
|
chiude. Il browser ha appena iniziato a spedire, la barra si pianta a una
|
|
percentuale bassa e nessun evento utile arriva all'`XMLHttpRequest` — pagina
|
|
muta, con l'aria di essersi bloccata. Da qui i tre presidi nella vista:
|
|
controllo della dimensione *prima* di caricare, `xhr.timeout` con i relativi
|
|
gestori di `timeout`/`abort`/`error` (senza, un caricamento fermo non produce
|
|
più nulla per sempre), e il codice HTTP dentro il messaggio, perché 413 e 419
|
|
non hanno un corpo JSON da cui ricavare una spiegazione.
|
|
|
|
- **La console di log non è un vezzo diagnostico.** Quasi tutti i modi in cui un
|
|
caricamento muore sono muti: Cloudflare o nginx chiudono la connessione e
|
|
nessun evento del browser porta con sé una spiegazione. Il pannello *Console*
|
|
nella scheda di ripristino registra file scelto e dimensione, limite del
|
|
server, avanzamento ogni 10%, codice HTTP della risposta, errori, timeout e
|
|
interruzioni; sotto, i log lato server dell'operazione. Un battito ogni 15
|
|
secondi scrive «nessun avanzamento da N s», che è l'unico modo di distinguere
|
|
*lento* da *fermo*. Il pulsante *Copia tutto* esiste perché il primo passo di
|
|
ogni diagnosi è farsi mandare quel testo.
|
|
|
|
- **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
|
|
disco è l'unico passo che può fallire per cause esterne (permessi sul
|
|
volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un
|
|
errore sui file lascerebbe database azzerato e disco pieno di PDF orfani.
|
|
Collegato: `svuotaDiscoPubblico()` ricontrolla il disco dopo la cancellazione
|
|
e solleva se qualcosa sopravvive, invece di restituire il conteggio iniziale.
|
|
Flysystem non solleva se una `unlink` fallisce, e basta un file scritto da un
|
|
processo con un altro utente (l'entrypoint lavora da root, php-fpm e il
|
|
worker da www-data) perché l'operazione si dichiari riuscita lasciando il
|
|
disco popolato.
|
|
|
|
- **L'archivio si controlla prima di consegnarlo.** Un backup è l'unico file di
|
|
cui non ci si accorge che era rotto finché non serve, quindi
|
|
`BackupArchive::create()` non si fida di aver scritto bene: controlla il
|
|
valore di ritorno di ogni `addFile()` (che torna `false`, senza sollevare, se
|
|
il file è sparito o è illeggibile — l'archivio finirebbe con un buco
|
|
silenzioso), e alla fine riapre l'archivio e verifica che contenga
|
|
`data.xml`, `manifest.json` e *esattamente* i file attesi. Se qualcosa non
|
|
torna, l'archivio viene cancellato e l'operazione fallisce: meglio nessun
|
|
backup che un backup che sembra buono.
|
|
|
|
Attenzione a una trappola di `ZipArchive`: dopo un `close()` fallito
|
|
l'oggetto resta inutilizzabile e ogni metodo successivo solleva `Invalid or
|
|
uninitialized Zip object`. Una chiusura di cortesia dentro un `catch`
|
|
sostituiva il messaggio vero con quello, e l'amministratore leggeva una
|
|
stringa incomprensibile al posto di «permesso negato su questo file». Da qui
|
|
`chiudi()`, che cattura `getStatusString()` prima che vada perso, e i
|
|
`close()` di pulizia avvolti in un try/catch che non può mascherare l'errore
|
|
originale.
|
|
|
|
- **Eliminare definitivamente un territorio porta via anche i suoi file.**
|
|
`TerritorioCestino::forceDelete()` cancellava la riga e lasciava mappa e
|
|
miniatura sul volume: invisibili in ogni schermata, non più raggiungibili da
|
|
nessuna cancellazione, e dentro ogni backup successivo. I file si rimuovono
|
|
*prima* della riga, così se la cancellazione fallisce il territorio resta nel
|
|
cestino e l'operazione si può ritentare.
|
|
|
|
- **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione
|
|
appena creata ha un amministratore; cancellarli tutti lascerebbe
|
|
l'applicazione senza nessuno che possa accedere fino al riavvio del container
|
|
(l'admin iniziale lo ricrea `entrypoint.sh` da `INITIAL_ADMIN_*`, che in
|
|
esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta
|
|
via Eloquent, non con un delete di massa: è il trait `HasRoles` di Spatie a
|
|
staccare ruoli e permessi nell'evento `deleting`, che un delete di massa non
|
|
fa scattare. Il registro attività si svuota **per ultimo** — quelle
|
|
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.
|
|
|
|
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
|
|
numero di entry, dimensione della singola entry e totale decompresso, pochi
|
|
MB possono riempire il volume (zip bomb). I nomi delle entry sono
|
|
normalizzati e ogni `..` o percorso assoluto viene rifiutato (zip slip) —
|
|
senza, un archivio ostile scriverebbe fuori dalla cartella di destinazione.
|
|
|
|
- **Dopo un restore tutti cambiano password.** Il pacchetto contiene gli hash
|
|
bcrypt di ogni utente: chi lo ha avuto in mano ha potuto tentarne il crack
|
|
offline, quindi da lì in poi nessuna vecchia password è più un segreto. Il
|
|
restore accende `password_change_required` su **tutte** le righe di `users`
|
|
(amministratore che sta ripristinando compreso) e azzera i `remember_token`,
|
|
che sono credenziali persistenti e stanno pure loro nel backup. Il
|
|
middleware `RequirePasswordChange` (in coda al gruppo `web`, quindi copre
|
|
anche `/livewire/update` — Livewire segue le risposte redirezionate) dirotta
|
|
su `password.change` tutto tranne quella pagina, il suo invio e il logout.
|
|
|
|
- **La schermata di cambio password non è Livewire e non chiede la password
|
|
attuale.** Non è Livewire perché deve restare raggiungibile mentre il
|
|
middleware respinge anche le POST di Livewire, e distinguere quale
|
|
componente sta dietro una `/livewire/update` è fragile; due rotte HTTP si
|
|
escludono per nome. Non chiede la password attuale perché il restore ha
|
|
appena sovrascritto gli hash con quelli del backup: la "password attuale" di
|
|
un account è quella dell'epoca del backup, che l'utente — l'amministratore
|
|
per primo — può benissimo non ricordare. La garanzia è la sessione: a quella
|
|
pagina ci si arriva già autenticati.
|
|
|
|
### Export/Import XML: backup completo e round-trip
|
|
|
|
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,
|
|
non più un sottoinsieme. L'export (`currentDataset` + `datasetToXml`) include
|
|
ora: utenti (con hash password, ruoli, `email_verified_at`, `remember_token`),
|
|
i campi settings prima omessi (`public_base_url`, `logo_path`,
|
|
`pdf_viewer_show_download`, `setup_completed`), lo stato soft-delete
|
|
(`deleted_at`) di proclamatori/territori, i path file dei territori
|
|
(`pdf_path`, `thumbnail_path`), le FK di audit delle assegnazioni
|
|
(`created_by`/`returned_by`, esportate come `legacy_*_id`), `link_sent`,
|
|
`pdf_access_code`, e i timestamp `created_at`/`updated_at` di ogni entità.
|
|
L'import (`importXmlFromContent`) legge simmetricamente tutti questi campi, così
|
|
un export si reimporta senza perdite.
|
|
|
|
Tre scelte non ovvie:
|
|
|
|
- **`legacy_id` derivato dall'`id` reale.** Il rimappaggio delle FK in import si
|
|
basa sull'attributo `legacy_id`. Prima veniva scritto solo dalla conversione
|
|
SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import
|
|
**perdeva territori e assegnazioni** (riferimenti non risolti). Ora
|
|
`addLegacyIdAttribute()` usa `legacy_id` se presente, altrimenti l'`id` reale.
|
|
|
|
- **Gli utenti sono UPSERT per email, non azzerati.** Tutte le altre entità
|
|
vengono cancellate e ricreate; gli utenti no. Azzerare la tabella `users`
|
|
durante un restore rischierebbe di **estromettere l'amministratore** che sta
|
|
importando (backup che precede il suo account, o file parziale). Gli account
|
|
esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene
|
|
eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è
|
|
senza perdite e non blocca fuori nessuno.
|
|
|
|
- **Hash password e timestamp scritti col query builder.** Dopo il `save()` del
|
|
modello, password/`remember_token`/`email_verified_at`/`created_at`/
|
|
`updated_at` vengono scritti con `DB::table()->update()`: preserva l'hash
|
|
esatto (il cast `hashed` rivaliderebbe il costo bcrypt e potrebbe rifiutare un
|
|
hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i
|
|
timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità.
|
|
|
|
### La pagina "Backup e import": cinque zone e una sola console
|
|
|
|
La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato,
|
|
la zona pericolosa a metà strada, **cinque** aree di log separate e pulsanti che
|
|
ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a
|
|
tre regole.
|
|
|
|
**Cinque zone numerate, in ordine di quanto sono definitive.** `1 Backup`,
|
|
`2 Ripristino`, `3 Import PDF`, `4 XML`, `5 Zona pericolosa`. Ognuna ha
|
|
un'ancora (`id="zona-…"`) e l'indice appiccicato in cima ci salta sopra. Un test
|
|
verifica che le cinque ancore esistano *e che siano in quell'ordine nel
|
|
sorgente*: l'ordine è la sostanza della sistemazione, non un dettaglio estetico.
|
|
|
|
**Il "focus" della pagina.** Premuto un pulsante — o scelta una voce
|
|
dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare
|
|
(`window.tmFocusZona`). Il focus da tastiera si sposta **solo** quando la
|
|
richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il
|
|
cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona
|
|
è marcato `data-zona-focus` ed è quello che lo riceve.
|
|
|
|
**Una sola console, agganciata in fondo alla finestra.** Prima la stessa
|
|
operazione si raccontava in tre punti diversi: un ripristino cominciava nel
|
|
riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio"
|
|
richiudibile nel pannello di avanzamento, in cima. Per capire dove si era
|
|
fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di
|
|
tempo, con la sorgente accanto a ogni riga, e la finestra è *fissa*: quasi tutti
|
|
i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è
|
|
interrotto il flusso di righe — un log che esce dallo schermo appena si scorre
|
|
non serve proprio nel momento in cui servirebbe.
|
|
|
|
Dettagli non ovvi della console:
|
|
|
|
- **Le righe del server viaggiano già scremate.** Il conto di quante ne sono
|
|
state spedite lo tiene il componente Livewire (`logSpediti`), non il browser:
|
|
la coda intera dentro ogni risposta significherebbe sessanta righe poco meno
|
|
di due volte al secondo, e per l'import PDF — la cui coda non ha tetto —
|
|
l'intera cronologia a ogni poll.
|
|
|
|
- **Per ogni operazione c'è un solo componente che la racconta**
|
|
(`SegueOperazioneBackup::tipiSeguiti()`). Backup e zona pericolosa guardano lo
|
|
stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi
|
|
le stesse righe, e la console le mostrerebbe due volte.
|
|
|
|
- **Il download automatico si ascolta in un posto solo.** `scarica-backup` è un
|
|
evento su `window`: due componenti che lo ascoltassero entrambi assegnerebbero
|
|
`window.location.href` due volte, e il pacchetto verrebbe scaricato due volte.
|
|
L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo
|
|
stesso motivo `scaricaOra()` esce subito se non è il proprietario a chiamarlo:
|
|
il segno "download già inviato" sta nello stato condiviso, quindi chi segue
|
|
l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto
|
|
e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca
|
|
la cancellazione.
|
|
|
|
- **Lo scorrimento automatico si sospende se si torna indietro a leggere.**
|
|
Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è
|
|
il modo più rapido di rendere inutile un log che scorre. Compare invece
|
|
"Torna in fondo".
|
|
|
|
- **Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in
|
|
fondo.** Senza, coprirebbe l'ultima zona — che è quella pericolosa.
|
|
|
|
- **Il JavaScript della console sta in un `<script>`, non dentro `x-data`.** È
|
|
la via d'uscita dalla trappola documentata più sotto: dentro un `<script>` le
|
|
virgolette doppie e gli apostrofi non hanno alcuna limitazione. Non sta in
|
|
`resources/js/app.js` perché quel bundle è un modulo ES, che il browser rimanda
|
|
a dopo il parsing del documento, mentre Alpine arriva col pacchetto di Livewire
|
|
in fondo al `<body>` ed è già avviato a quel punto: una `Alpine.data()`
|
|
registrata dopo l'avvio non viene applicata agli elementi già inizializzati. Un
|
|
`<script>` classico nella pagina viene eseguito prima, sempre — e c'è un test
|
|
che verifica proprio che la definizione preceda `livewire.js`, perché è un
|
|
rapporto che guardando i due file non si vede.
|
|
|
|
**Un solo sistema di pulsanti** — vedi la sezione dedicata qui sotto.
|
|
|
|
### Un solo pulsante per tutta l'applicazione
|
|
|
|
`resources/css/app.css` definisce `.tm-btn`;
|
|
`resources/views/components/btn.blade.php` è l'unico posto da cui si usa. Nessuna
|
|
vista scrive quelle classi a mano, e nessuna vista definisce più un pulsante per
|
|
conto proprio.
|
|
|
|
Da dove veniamo: **quattro vocabolari in circolazione insieme** — `style=`
|
|
inline con i colori scritti a mano (più `onmouseover`/`onmouseout` per simulare
|
|
l'hover), catene di utility Tailwind ripetute a memoria, `.btn-action*` in sei
|
|
tinte e `.btn-primary-*` in due. La stessa azione cambiava aspetto da una pagina
|
|
all'altra, l'ordine di *Salva* e *Annulla* si invertiva fra un form e l'altro, e
|
|
**nessuno** di quei pulsanti aveva uno stato di focus: chi naviga da tastiera non
|
|
vedeva dove si trovava.
|
|
|
|
Il sistema ha due assi ortogonali, non una tavolozza:
|
|
|
|
| Asse | Valori | Regola |
|
|
|------|--------|--------|
|
|
| `variante` | `primary` | l'azione principale. Una sola per schermata o zona. |
|
|
| | `secondary` | tutto il resto: dettaglio, modifica, scarica, filtra. |
|
|
| | `danger` | **solo** dove si cancellano o si sovrascrivono dati. |
|
|
| | `ghost` | annulla, chiudi, pulisci. |
|
|
| `tono` | `solido` | il pieno (default), per le azioni isolate. |
|
|
| | `tenue` | la tinta chiara, per le righe di elenco. |
|
|
| | `velato` | per i comandi sopra una superficie colorata: solo la testata. |
|
|
|
|
più `dimensione="sm"` per le righe e `icona` per i pulsanti di sola icona.
|
|
|
|
Le decisioni non ovvie prese convertendo:
|
|
|
|
- **`tenue` esiste per le liste.** In una riga i pulsanti sono tanti quanti i
|
|
record: cinquanta pulsanti pieni sono rumore, e il rosso pieno smette di
|
|
significare "attenzione" quando compare cinquanta volte di fila.
|
|
|
|
- **Il rosso è tornato a significare una cosa sola.** *Rientra* era rosso pieno
|
|
quanto un *Elimina*, in tre pagine diverse; anche *Scarica PDF* nella scheda
|
|
"Da rientrare" della dashboard lo era, solo perché lo è la scheda che lo
|
|
contiene. Nessuno dei due cancella niente.
|
|
|
|
- **Ordine unico: azione principale prima, uscita dopo.** Valeva già in metà dei
|
|
form, l'opposto nell'altra metà, e nelle modali del Registro c'era la terza
|
|
variante. Ora è lo stesso ovunque, form e modali comprese.
|
|
|
|
- **`icona` richiede `aria-label`.** I pulsanti di sola icona avevano solo un
|
|
`title`, che i lettori di schermo annunciano in modo inaffidabile: un pulsante
|
|
senza nome accessibile è un pulsante che non si può usare senza vederlo.
|
|
|
|
- **`velato` non è una quinta variante inventata.** La testata ha il suo
|
|
gradiente, e lì un pulsante bianco o indaco sarebbe una toppa: il colore lo dà
|
|
lo sfondo. Ha anche un anello di focus bianco, perché quello blu su indaco non
|
|
si distingue.
|
|
|
|
Attenzione a una trappola di Blade: dentro un tag di componente si scrive
|
|
`:disabled="$condizione"`, **non** `@disabled($condizione)`. Le direttive non
|
|
vengono compilate lì dentro: il tag `<x-btn>` resta letterale nel PHP generato
|
|
mentre il `</x-btn>` viene chiuso, e la vista muore con «syntax error, unexpected
|
|
token endif».
|
|
|
|
A tenere insieme tutto questo ci sono due test, perché una convenzione senza
|
|
guardia dura fino alla prossima pagina scritta copiando una vecchia:
|
|
|
|
- `tests/Unit/StileUnificatoTest.php` legge i sorgenti delle viste e rifiuta il
|
|
ritorno di `.btn-action*`, `.btn-primary-*`, `onmouseover`/`onmouseout`, degli
|
|
sfondi scritti a mano su `<button>`/`<a>`, e delle classi `tm-btn` messe
|
|
direttamente in una vista invece che dal componente.
|
|
- `tests/Feature/PagineApplicativeTest.php` apre **ogni** pagina e verifica che
|
|
non sia rimasto un `<x-btn` da compilare — perché un componente non compilato
|
|
non solleva: semplicemente il pulsante non c'è.
|
|
|
|
### Perché l'azzeramento è un componente a sé
|
|
|
|
`Settings/ZonaPericolosa` è nato da un problema di sola disposizione: la zona
|
|
pericolosa deve stare **in fondo** alla pagina, mentre backup e ripristino stanno
|
|
in cima, e i tre erano lo stesso componente. Reso da `BackupManager` l'azzeramento
|
|
finiva a metà pagina, fra il ripristino e l'import — l'ultimo posto in cui si
|
|
vuole trovare un pulsante che cancella tutto.
|
|
|
|
La logica condivisa non è stata duplicata: sta nel trait
|
|
`Livewire/Concerns/SegueOperazioneBackup` (posto unico, aggancio all'operazione
|
|
in corso, download automatico, righe verso la console). Duplicare `postoLibero()`
|
|
avrebbe significato duplicare la regola su cui si regge il fatto che due
|
|
operazioni non si sovrascrivano: è l'ultimo posto dove ha senso avere due copie.
|
|
|
|
Conseguenza da conoscere: **due componenti pollano lo stesso stato**. Il
|
|
proprietario a 750ms (5s in attesa-download), l'altro a 3s — non ha righe da
|
|
raccontare, ma deve accorgersi che il posto si è liberato, altrimenti il suo
|
|
pulsante resterebbe disabilitato fino al ricaricamento della pagina, con l'aria
|
|
di essersi rotto. Si avvisano anche a evento (`operazione-avviata`), indirizzato
|
|
con `->to()` e non gridato: senza, tornerebbe pure al mittente.
|
|
|
|
Il secondo tempo dell'azzeramento (verifica del backup, password, conferma)
|
|
compare adesso **dentro la zona**, dove è stato premuto il primo. Prima usciva
|
|
nel pannello di avanzamento in cima, a schermate di distanza dal pulsante che lo
|
|
aveva avviato.
|
|
|
|
### La suite di test ha un'immagine sua
|
|
|
|
Le dipendenze di sviluppo non stanno nell'immagine di produzione, e non devono
|
|
cominciare a starci: `--no-dev` è la ragione per cui il runtime non porta con sé
|
|
PHPUnit, Faker e Ignition. Da qui uno stage `test` nel `Dockerfile`, che
|
|
condivide con `runtime` lo stage `base` — stesse estensioni, stesso `php.ini` —
|
|
e si distingue solo per il `vendor/` completo. Un test che passa lì dentro non
|
|
può passare grazie a un ambiente diverso da quello vero.
|
|
|
|
Le scelte che vale la pena conoscere:
|
|
|
|
- **`runtime` deve restare l'ultimo stage del file.** `docker build` senza
|
|
`--target` costruisce l'ultimo, ed è così che lo invocano sia i tre servizi
|
|
del compose (nessuno dichiara `target`) sia Dokploy. Uno stage aggiunto in
|
|
fondo diventerebbe **in silenzio** l'immagine di produzione. Per questo `test`
|
|
sta *prima* di `runtime` pur essendo stato aggiunto dopo. L'avvertenza è anche
|
|
nel Dockerfile, sopra lo stage.
|
|
|
|
- **La suite non dipende da niente.** `phpunit.xml` impone SQLite in memoria,
|
|
cache `array` e code `sync`, e il servizio compose non ha `depends_on` né
|
|
`env_file`. Gira a stack spento, non tocca `db_data` né `storage_data`, e
|
|
soprattutto non può puntare per sbaglio al database di sviluppo: un `.env`
|
|
ereditato dal container è esattamente il modo in cui una suite di test
|
|
cancella dati veri.
|
|
|
|
Il rovescio: **SQLite non è MariaDB**. Resta un solo punto non verificabile
|
|
qui, l'ordinamento di `Registro::render()`, che usa `CAST(... AS UNSIGNED)`:
|
|
va provato sullo stack vero. Gli altri due sono stati tolti di mezzo perché
|
|
costavano poco e in cambio rendevano testabile una pagina intera — la
|
|
migrazione `2026_04_08_123500`, il cui `GREATEST(...)` faceva proprio morire
|
|
la costruzione dello schema, e la media di percorrenza in `Home`, che usava
|
|
`DATEDIFF`/`YEAR` e teneva fuori dalla suite la pagina più visitata
|
|
dell'applicazione. Entrambe danno lo stesso risultato di prima.
|
|
|
|
- **Il sorgente non è montato, e il `--build` non è facoltativo.** Con i bind
|
|
mount la stessa suite passa da ~15 secondi a ~2 minuti (ogni bootstrap di
|
|
Laravel legge una decina di file attraverso il layer di Docker Desktop), e in
|
|
cambio si guadagna solo di non ricostruire. Il comando canonico è
|
|
`docker compose --profile test run --build --rm test`: senza `--build` si
|
|
eseguono i test dell'immagine precedente, che possono essere di due modifiche
|
|
fa. Stesso genere di trappola del "Rebuild deployment" su Dokploy.
|
|
|
|
- **`APP_KEY` di test è fissa e sta in chiaro in `phpunit.xml`.** Nome e cognome
|
|
dei proclamatori hanno il cast `encrypted`: senza chiave nemmeno un test che
|
|
non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con
|
|
la chiave di produzione, che vive nell'ambiente e nel volume.
|
|
|
|
- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è
|
|
un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto:
|
|
renderla pubblica solo per poterla testare avrebbe allargato la superficie
|
|
del componente per comodità del test.
|
|
|
|
### Un solo container esegue le migrazioni
|
|
|
|
Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che
|
|
attende e non tocca lo schema. Due container in gara su `artisan migrate` è
|
|
il modo tipico di ritrovarsi con uno schema applicato a metà.
|
|
|
|
### `APP_KEY` risolta una volta sola all'avvio
|
|
|
|
Prima veniva rigenerata da `AppServiceProvider::boot()` a ogni richiesta se
|
|
mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
|
|
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
|
|
provider **solleva un'eccezione** se manca, invece di rigenerarla.
|
|
|
|
---
|
|
|
|
## Bug corretti — i più significativi
|
|
|
|
Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
|
|
|
|
| Bug | Effetto | Dove |
|
|
|-----|---------|------|
|
|
| `key:generate` a runtime | Rotazione silenziosa della chiave → **perdita irreversibile** dei dati cifrati | `AppServiceProvider` |
|
|
| Import PDF da cartella mai accodato | Upload completato, nessun territorio aggiornato, file orfani a crescita illimitata | `Livewire/Settings/XmlExchange` |
|
|
| Permessi non riapplicati su Livewire | Un permesso revocato restava attivo fino al reload della pagina | `AppServiceProvider` (`addPersistentMiddleware`) |
|
|
| Host header injection | Link generati (inclusi i PDF pubblici con codice d'accesso) verso domini arbitrari | `bootstrap/app.php`, `nginx/default.conf` |
|
|
| Default TTL link mai convertito | Link PDF validi 2 anni invece di 1 mese | migrazione `2026_07_18_000001` |
|
|
| `syncPermissions()` nel seeder | Un re-seed azzerava i permessi personalizzati dei ruoli | `RolesAndPermissionsSeeder` |
|
|
| `campagna_id` manipolabile | Rientro conteggiato su una campagna arbitraria | `Livewire/Assegnazioni/Rientra` (`#[Locked]`) |
|
|
| `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` |
|
|
| Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` |
|
|
| Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` |
|
|
| Export XML senza `legacy_id` | Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) | `Livewire/Settings/XmlExchange` (`addLegacyIdAttribute`) |
|
|
| Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti |
|
|
| `addFile()` senza controllo esito | Backup con un file mancante, **silenzioso**: ci si accorge solo al ripristino | `Services/BackupArchive` |
|
|
| Esito del ripristino con `Cache::pull` | L'amministratore non vedeva **mai** il riepilogo: la `fetch` che segue il 302 lo consumava prima della navigazione | `Auth/PasswordChangeController` |
|
|
| Nessun blocco fra operazioni | Backup, ripristino e azzeramento potevano sovrapporsi sullo stesso database e disco | `Services/BackupOperationState` (`corrente()`) |
|
|
| Job ucciso = operazione eterna | Timeout o crash del worker lasciavano lo stato "in corso" e il posto occupato | `failed()` sui tre job di backup |
|
|
| Cancellazione file senza verifica | «N file cancellati» con i file ancora sul disco (permessi) | `BackupArchive::svuotaDiscoPubblico()` |
|
|
| Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` |
|
|
| Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` |
|
|
| Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` |
|
|
| `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` |
|
|
| `@disabled` dentro `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` |
|
|
| Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` |
|
|
|
|
---
|
|
|
|
## Cosa resta aperto
|
|
|
|
In ordine di priorità.
|
|
|
|
### 0. Il ripristino da browser è limitato a 100 MB — decisione in sospeso
|
|
|
|
**È il punto da cui riprendere.** Cloudflare rifiuta i caricamenti oltre 100 MB
|
|
(vedi *Trappole da conoscere*), quindi il ripristino dall'interfaccia non
|
|
funziona per un pacchetto con molte mappe. L'applicazione è a posto: il muro sta
|
|
davanti.
|
|
|
|
Da decidere, in ordine di rapidità:
|
|
|
|
1. Togliere il dominio da Cloudflare (DNS-only), anche solo su un sottodominio
|
|
dedicato all'amministrazione — gratis e immediato.
|
|
2. Caricare puntando direttamente all'IP dell'origine, scavalcando Cloudflare.
|
|
3. Ripristino dai volumi (procedura nel README): nessun limite, serve accesso
|
|
al server.
|
|
4. Piano Cloudflare Business: 200 MB, spesso ancora insufficienti.
|
|
|
|
Resta da accertare **quanto pesa davvero il pacchetto** che si vuole
|
|
ripristinare: sotto i 100 MB il problema non sarebbe la dimensione ma il timeout
|
|
di 100 secondi di Cloudflare, e la soluzione sarebbe un'altra.
|
|
|
|
### 1. Copertura dei test ancora parziale
|
|
|
|
L'impalcatura ora c'è (vedi *La suite di test ha un'immagine sua*) e i tre
|
|
blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
|
|
`test` nel Dockerfile con le dipendenze di sviluppo, connessione `sqlite` in
|
|
`config/database.php`, `phpunit.xml`. Si lancia con:
|
|
|
|
```bash
|
|
docker compose --profile test run --build --rm test
|
|
```
|
|
|
|
**Coperto oggi — 233 test, tutti verdi:**
|
|
|
|
| File | Cosa protegge |
|
|
|------|---------------|
|
|
| `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio |
|
|
| `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata |
|
|
| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" compreso il caso in cui l'ultima del giro precedente è ancora aperta, esclusione di territori disattivati e cestinati, filtro sull'anno di servizio, ordinamento naturale dei numeri |
|
|
| `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede |
|
|
| `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
|
|
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
|
|
| `tests/Unit/StileUnificatoTest.php` | Che i vocabolari di stile paralleli non rientrino: `.btn-action*`, `.btn-primary-*`, l'hover simulato con `onmouseover`, gli sfondi scritti a mano sui pulsanti, le classi `tm-btn` messe in una vista invece che dal componente |
|
|
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
|
|
|
|
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
|
|
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
|
|
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
|
|
|
|
Quello che questi test **non** possono coprire: Alpine e il comportamento della
|
|
console — apertura automatica, scorrimento, evidenziazione delle zone — girano
|
|
nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
|
|
|
|
**Scoperto ancora, in ordine di quanto costerebbe sbagliarsi:**
|
|
|
|
- `Services/BackupArchive::extract()` sui **limiti anti zip-bomb**: numero di
|
|
entry, dimensione della singola entry, totale decompresso. Sono tre `if` che
|
|
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
|
|
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
|
|
l'ordine staging → database → file, il `failed()` che libera il posto, la
|
|
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il
|
|
`password_change_required` acceso su tutte le righe. È la logica il cui
|
|
errore si scopre solo quando è troppo tardi.
|
|
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza
|
|
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`.
|
|
- Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo
|
|
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`,
|
|
priorità automatica, percentuale di percorrenza delle campagne.
|
|
- Il permesso come contratto: che un `assistente` **non** possa modificare o
|
|
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
|
|
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
|
|
verificherebbe tutto insieme.
|
|
|
|
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
|
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
|
|
|
### 2. Race su doppia assegnazione
|
|
|
|
`Assegna::save()` controlla che il territorio sia libero e poi crea la riga, ma
|
|
nulla a livello di database impedisce due assegnazioni aperte sullo stesso
|
|
territorio (doppio click, due schede). `assegnazioneCorrente` usa `latestOfMany`,
|
|
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi.
|
|
|
|
Rimedio: transazione con `lockForUpdate()`, oppure un indice univoco su colonna
|
|
generata che imponga una sola assegnazione aperta per territorio.
|
|
|
|
### 3. Scrittura durante il rendering
|
|
|
|
`Assegnazione::shortPdfUrl()` genera e salva il codice d'accesso al primo
|
|
utilizzo, quindi una GET del registro può produrre decine di INSERT. Meglio
|
|
generare il codice alla creazione dell'assegnazione (observer) e far restituire
|
|
`null` al metodo quando manca.
|
|
|
|
### 4. Ricerca in memoria sui campi cifrati
|
|
|
|
Nome e cognome dei proclamatori sono cifrati, quindi la ricerca carica l'intera
|
|
tabella e filtra in PHP (`ProclamatoreIndex`, `Registro`). Accettabile sotto
|
|
qualche centinaio di record, non oltre. Se dovesse servire scalare: hash
|
|
deterministico ricercabile affiancato al campo cifrato.
|
|
|
|
### 5. Endpoint PDF pubblici senza throttle
|
|
|
|
`/p/{code}` e le rotte del visualizzatore sono senza autenticazione né rate
|
|
limit. Il controllo d'accesso è solido (confronto in tempo costante su codice a
|
|
~62 bit, più controlli di scadenza), ma un tentativo di enumerazione non
|
|
verrebbe né rallentato né registrato. Un `throttle:30,1` costerebbe una riga.
|
|
|
|
### 6. Restano da uniformare i colori che non sono pulsanti
|
|
|
|
I pulsanti sono a posto in tutta l'applicazione (vedi *Un solo pulsante per
|
|
tutta l'applicazione*), ma i **colori decorativi** sono ancora scritti a mano,
|
|
esadecimale per esadecimale, dentro gli attributi `style=`: le piastrelle delle
|
|
icone e le fasce delle schede nella dashboard, gli sfondi delle intestazioni di
|
|
tabella, i pannelli delle modali del Registro, i riquadri dei messaggi nel
|
|
layout.
|
|
|
|
Non sono un problema di accessibilità come lo erano i pulsanti — nessuno di
|
|
questi elementi si può mettere a fuoco — ma sono lo stesso genere di debito: lo
|
|
stesso verde compare in quattro tonalità leggermente diverse, e cambiare la
|
|
tinta dell'applicazione oggi significa cercare a mano tutte le occorrenze.
|
|
|
|
La via è la stessa già percorsa: dei token in `app.css` (o le variabili di tema
|
|
di Tailwind 4) e le viste che li citano invece di ripetere i valori. Il test
|
|
`StileUnificatoTest` è il posto dove aggiungere la guardia, quando si farà.
|
|
|
|
Fuori perimetro per scelta: `resources/views/vendor/pagination/*` sono viste
|
|
pubblicate da Laravel, e `resources/views/pdf/*` le impagina dompdf, che di
|
|
Tailwind e delle nostre classi non sa niente.
|
|
|
|
---
|
|
|
|
## Trappole da conoscere
|
|
|
|
**Cloudflare taglia i caricamenti a 100 MB, e `MAX_UPLOAD_MB` non c'entra
|
|
niente.** I domini pubblici passano da Cloudflare, che sul piano free rifiuta i
|
|
corpi di richiesta oltre **100 MB**. Rifiuta sulla base del `Content-Length`,
|
|
quindi il `413` arriva dopo che il browser ha spedito appena un megabyte: la
|
|
barra di caricamento si pianta a una percentuale bassa e — senza i gestori
|
|
aggiunti alla console — non compare alcun messaggio. È **il** motivo per cui il
|
|
ripristino di un pacchetto grande sembra bloccarsi senza spiegazione.
|
|
|
|
Misurato il 29/07/2026 su `demo-termanager.pyconetwork.it`:
|
|
|
|
| Payload | Esito |
|
|
|---------|----------------------------------------------------|
|
|
| 150 MB | `413 Payload Too Large`, `Server: cloudflare`, 2,7 s |
|
|
| 100 MB | `524` (timeout dell'origine dopo 100 s di Cloudflare) |
|
|
|
|
Il pacchetto **non raggiunge mai nginx**, quindi alzare `MAX_UPLOAD_MB` non
|
|
sposta nulla: quel valore governa solo ciò che accade *dopo* Cloudflare. Le
|
|
strade per superarlo sono togliere il dominio da Cloudflare (DNS-only, anche
|
|
solo su un sottodominio d'amministrazione), caricare puntando direttamente
|
|
all'IP dell'origine, oppure usare il ripristino dai volumi. Il piano Business
|
|
arriva a 200 MB, che per un archivio con tutte le mappe è spesso ancora poco.
|
|
|
|
Nota sul `524`: anche restando sotto i 100 MB, Cloudflare chiude dopo 100
|
|
secondi se l'origine non ha ancora risposto. Un caricamento lento può quindi
|
|
fallire pur essendo dentro il limite di dimensione.
|
|
|
|
**Il JavaScript che vive dentro un attributo HTML.** Vale ancora per i blocchi
|
|
`x-data` di `backup-manager`, `zona-pericolosa` e della zona Import PDF, che
|
|
gestiscono i caricamenti. La console, che era il blocco più grosso, è stata
|
|
portata fuori in un `<script>` proprio per uscirne (vedi *La pagina "Backup e
|
|
import"*); il resto resta soggetto alla regola.
|
|
`x-data` finisce alla **prima virgoletta doppia**, comprese quelle dentro un
|
|
commento JavaScript: un banale `// un generico "errore"` ha chiuso l'attributo a
|
|
metà e riversato tutto il codice restante a video come testo — è successo due
|
|
volte. Nello stesso blocco, un `\'` scritto a mano nel sorgente Blade *chiude* la
|
|
stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
|
|
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
|
|
da nessuna parte; apostrofi italiani solo dentro backtick.
|
|
|
|
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
|
|
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
|
|
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
|
|
|
|
**`APP_KEY` è il singolo punto di perdita dati.** I nomi dei proclamatori sono
|
|
cifrati con questa chiave. Il backup del database senza la chiave è inutile per
|
|
quei campi. Se non la imposti, l'entrypoint ne genera una e la salva nel volume
|
|
`storage_data`, stampandola nei log: se quel volume viene perso, i dati sono
|
|
irrecuperabili.
|
|
|
|
**`APP_URL` deve combaciare col dominio servito.** L'applicazione rifiuta
|
|
richieste con `Host` diverso (protezione host header injection) e restituisce
|
|
400 senza spiegazioni. È la causa più comune di "il deploy è andato ma il sito
|
|
non si apre". `localhost` e `127.0.0.1` sono ammessi perché li usano gli health
|
|
check interni.
|
|
|
|
**Il volume `storage_data` non è solo cache.** Contiene i PDF dei territori, le
|
|
miniature e la chiave di fallback. Non va trattato come rigenerabile.
|
|
|
|
**Modifiche a dipendenze o asset richiedono un rebuild.** Non è più sufficiente
|
|
riavviare il container: `vendor/` e `public/build` vivono dentro l'immagine.
|
|
|
|
**L'export XML contiene dati sensibili.** Il file di export include gli hash
|
|
delle password di tutti gli utenti e i nomi/cognomi dei proclamatori in chiaro.
|
|
Va trattato come un segreto: chi lo ottiene può tentare il crack offline degli
|
|
hash. La pagina di export lo segnala, ma il controllo resta umano — la
|
|
funzione è già riservata all'amministratore (`settings.manage`).
|
|
|
|
---
|
|
|
|
## Come verificare che tutto funzioni
|
|
|
|
Dopo una modifica sostanziale, questo giro copre i percorsi critici:
|
|
|
|
C'è un solo `docker-compose.yml`, usato sia in produzione sia in locale. In
|
|
locale serve creare una volta la rete esterna attesa da Dokploy.
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
# nel .env: APP_URL=http://localhost:8080, APP_ENV=local,
|
|
# SESSION_SECURE_COOKIE=false, e le tre password
|
|
docker network create dokploy-network
|
|
|
|
# 0. La suite passa (non serve nient'altro acceso, ~15 s)
|
|
docker compose --profile test run --build --rm test
|
|
|
|
# 1. L'immagine builda
|
|
# Senza --target si costruisce l'ultimo stage, che e' `runtime`: se questo
|
|
# comando producesse un'immagine con phpunit dentro, qualcuno ha aggiunto
|
|
# uno stage in fondo al Dockerfile.
|
|
docker build -f docker/php/Dockerfile -t termanager2-check .
|
|
|
|
# 2. Stack pulito da zero
|
|
docker compose --profile dev down -v
|
|
docker compose --profile dev up -d --build
|
|
docker compose --profile dev ps # tutti i servizi healthy
|
|
|
|
# 3. L'applicazione risponde
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/up # 200
|
|
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/login # 200
|
|
|
|
# 4. Il redeploy è idempotente
|
|
docker compose up -d --force-recreate app
|
|
docker compose logs app | grep -E "Migrations|Seed"
|
|
# atteso: "Migrations applied" + "Seed already applied"
|
|
|
|
# 5. La cifratura funziona
|
|
docker compose exec app php artisan tinker --execute="
|
|
\$p = App\Models\Proclamatore::create(['nome'=>'Test','cognome'=>'Verifica','attivo'=>true]);
|
|
\$raw = DB::table('proclamatori')->where('id',\$p->id)->value('nome');
|
|
echo \$raw === 'Test' ? 'ERRORE: dati in chiaro' : 'OK: cifrato';
|
|
"
|
|
```
|
|
|
|
È lo stesso file che gira in produzione: le uniche differenze sono i profili
|
|
`dev` (che aggiunge Mailpit) e `test` (che aggiunge la suite, effimera) e i
|
|
valori nel `.env`.
|
|
|
|
---
|
|
|
|
## Documentazione
|
|
|
|
| Documento | Contenuto |
|
|
|------------------------------------|--------------------------------------------|
|
|
| [README.md](README.md) | Deploy, configurazione, uso, riferimento |
|
|
| `HANDOFF.md` (questo file) | Stato, decisioni, debito tecnico |
|
|
|
|
La specifica funzionale originale (`TerManager2_v2.md`) è stata rimossa dal
|
|
working tree: descriveva funzionalità poi cambiate — in particolare un wizard
|
|
di primo avvio successivamente eliminato in favore della pagina Impostazioni —
|
|
e conteneva checklist di sviluppo ormai esaurite. Le parti ancora valide
|
|
(modello dati, regole di business, RBAC) sono confluite nel README.
|
|
|
|
Resta consultabile nella storia del repository:
|
|
|
|
```bash
|
|
git show 701f479:TerManager2_v2.md
|
|
```
|