65 KiB
Handoff — TerManager2
Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi 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 |
| 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
devnon tocca la produzione, che seguemain. È 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.deployvia 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
.envdell'ambiente lo genera l'interfaccia: una variabile aggiunta a.env.examplenon arriva da sola sui deployment, va aggiunta a mano lì. È successo conMAX_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-*.csse-*.jshanno 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/createeterritori/cestinodevono restare registrate PRIMA del wildcardterritori/{territorio}, altrimenti verrebbero catturate dalla rotta di dettaglio. -
TerritorioIndexperde 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/updatecol pulsante nascosto. Per difesa in profondità lo stesso check è stato aggiunto anche aTerritorioCreate::save,TerritorioEdit::save/removePdf,TerritorioCestino::restore/forceDelete(che restano comunque su rotteterritori.manage). I pulsanti Modifica/toggle/Elimina/Nuovo/Cestino interritorio-index/territorio-showsono sotto@can('territori.manage'); il link "Dettaglio" resta visibile a tutti. Il menu laterale mostra "Territori" a chi haterritori.assign.
Sola lettura per proclamatori e campagne (non-admin)
Stessa impostazione dei territori estesa ad altre due sezioni:
- Proclamatori:
indexeshowrestano suproclamatori.manage(l'assistente li vede in sola lettura);create/edit/cestinoe tutte le mutazioni (ProclamatoreIndex::toggleActive/deleteProclamatore/anonimizza,ProclamatoreCreate::save,ProclamatoreEdit::save,ProclamatoreCestino::restore/forceDelete) richiedonosettings.manage.ProclamatoreIndexè servito da una rotta rilassata, quindi i suoi metodi di mutazione hanno il check interno; i pulsanti inproclamatore-index/-show/-cestinosono 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::savechiamaauthorize('settings.manage'), la rottacampagne/createè nel grupposettings.manage(registrata prima del wildcard) e il pulsante "Nuova Campagna" è sotto@can('settings.manage').index/showrestano sucampagne.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→ rottaprofilo, nomeprofile) accessibile a ogni utente loggato (nel gruppoauth, 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 regolacurrent_password; il casthashedcifra al salvataggio). Dopo il salvataggio dei datiupdateProfilefaredirect(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 sogliagiorni_per_smarrito— la stessa oltre cuiTerritorio::statodiventada_rientrare. Il viewer è servito solo per assegnazioni aperte, ma gli accessor gestiscono comunque il casoreturned_at(ritornano null). - Privacy solo admin: la rotta
privacyè passata dal soloauthal gruppopermission:settings.managee 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
streamDownloaddi Livewire passa il file base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci sta.backup/file/{op}ebackup/restoresono due rotte normali nel grupposettings.manage. Per lo stesso motivoZipArchive::addFile()(che legge dal disco inclose()) 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 suServices/BackupOperationState, eLivewire/Settings/BackupManagerlo interroga conwire:poll. In coda perché un'installazione con qualche centinaio di PDF supera i 300 secondi difastcgi_read_timeoute 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:
ZipArchivenon scrive niente finché non si chiamaclose(), quindi contare leaddFile()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 inattesa-download; la cancellazione parte da un secondo clic esplicito (confermaAzzeramento), che rifiuta se lo stato non registra unservito_ate se non riceve la password dell'amministratore. Il primo tentativo faceva partire il reset dalla coda dellostreamDownload, "dopo l'ultimo byte" — non funziona: confastcgi_buffering on(il default, edocker/nginx/default.confnon 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à
$passwordConfermaesiste comunque, e resta sempre vuota. Livewire scarta dalla risposta ogni errore la cui chiave non corrisponda a una proprietà del componente (SupportValidation::dehydrate()filtra suUtils::hasProperty), quindi senza quella rigaaddError('passwordConferma', …)sparisce in silenzio: il pulsante sembra non fare nulla, mentre il controllo sta funzionando. Non collegarla al campo conwire: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 siaBackupController::upload(), che sono due porte d'ingresso separate (la seconda risponde 409). Il blocco non è eterno: ha un TTL di un'ora, ecorrente()lo considera libero se l'operazione puntata non esiste più o è terminata. In più i tre job hanno unfailed()— Laravel lo chiama quando il worker uccide il job per timeout o errore fatale, cioè proprio i casi chehandle()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()leggecorrente(). 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+forgeta password cambiata, non unpull: quando è il middleware a dirottare una richiesta di Livewire, il client segue il 302 viafetch— ed è quellafetcha bruciare il messaggio — per poi rifare la stessa GET conwindow.location, che troverebbe la cache già vuota. Con ilpulll'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_sizeindocker/nginx/default.conf,upload_max_filesize/post_max_sizeindocker/php/php.ini, e la regolamax:nella validazione — e bastava che uno restasse indietro perché il più piccolo vincesse nel modo peggiore. Adessoentrypoint.shgenera i primi due (conf.d/upload-limit.confeconf.d/zz-upload-limit.ini) econfig/app.phpalimenta la terza.Non rimettere
client_max_body_sizenel bloccoserverdidefault.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.timeoutcon i relativi gestori ditimeout/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 unaunlinkfallisce, 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 ogniaddFile()(che tornafalse, senza sollevare, se il file è sparito o è illeggibile — l'archivio finirebbe con un buco silenzioso), e alla fine riapre l'archivio e verifica che contengadata.xml,manifest.jsone 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 unclose()fallito l'oggetto resta inutilizzabile e ogni metodo successivo sollevaInvalid or uninitialized Zip object. Una chiusura di cortesia dentro uncatchsostituiva il messaggio vero con quello, e l'amministratore leggeva una stringa incomprensibile al posto di «permesso negato su questo file». Da quichiudi(), che catturagetStatusString()prima che vada perso, e iclose()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.shdaINITIAL_ADMIN_*, che in esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta via Eloquent, non con un delete di massa: è il traitHasRolesdi Spatie a staccare ruoli e permessi nell'eventodeleting, 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; poiimportXmlFromContent()in transazione; poi la sostituzione dei file. Il discopublicviene 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_requiredsu tutte le righe diusers(amministratore che sta ripristinando compreso) e azzera iremember_token, che sono credenziali persistenti e stanno pure loro nel backup. Il middlewareRequirePasswordChange(in coda al gruppoweb, quindi copre anche/livewire/update— Livewire segue le risposte redirezionate) dirotta supassword.changetutto 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_idderivato dall'idreale. Il rimappaggio delle FK in import si basa sull'attributolegacy_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). OraaddLegacyIdAttribute()usalegacy_idse presente, altrimenti l'idreale. -
Gli utenti sono UPSERT per email, non azzerati. Tutte le altre entità vengono cancellate e ricreate; gli utenti no. Azzerare la tabella
usersdurante 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_atvengono scritti conDB::table()->update(): preserva l'hash esatto (il casthashedrivaliderebbe il costo bcrypt e potrebbe rifiutare un hash generato conBCRYPT_ROUNDSdiverso) ed evita che Eloquent sovrascriva i timestamp importati. Stessa tecnica inapplyRowMeta()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 suwindow: due componenti che lo ascoltassero entrambi assegnerebberowindow.location.hrefdue volte, e il pacchetto verrebbe scaricato due volte. L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo stesso motivoscaricaOra()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 dentrox-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 inresources/js/app.jsperché 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: unaAlpine.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 precedalivewire.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:
-
tenueesiste 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.
-
iconarichiedearia-label. I pulsanti di sola icona avevano solo untitle, che i lettori di schermo annunciano in modo inaffidabile: un pulsante senza nome accessibile è un pulsante che non si può usare senza vederlo. -
velatonon è 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.phplegge 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 classitm-btnmesse direttamente in una vista invece che dal componente.tests/Feature/PagineApplicativeTest.phpapre ogni pagina e verifica che non sia rimasto un<x-btnda 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:
-
runtimedeve restare l'ultimo stage del file.docker buildsenza--targetcostruisce l'ultimo, ed è così che lo invocano sia i tre servizi del compose (nessuno dichiaratarget) sia Dokploy. Uno stage aggiunto in fondo diventerebbe in silenzio l'immagine di produzione. Per questoteststa prima diruntimepur essendo stato aggiunto dopo. L'avvertenza è anche nel Dockerfile, sopra lo stage. -
La suite non dipende da niente.
phpunit.xmlimpone SQLite in memoria, cachearraye codesync, e il servizio compose non hadepends_onnéenv_file. Gira a stack spento, non toccadb_datanéstorage_data, e soprattutto non può puntare per sbaglio al database di sviluppo: un.envereditato 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 usaCAST(... 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 migrazione2026_04_08_123500, il cuiGREATEST(...)faceva proprio morire la costruzione dello schema, e la media di percorrenza inHome, che usavaDATEDIFF/YEARe teneva fuori dalla suite la pagina più visitata dell'applicazione. Entrambe danno lo stesso risultato di prima. -
Il sorgente non è montato, e il
--buildnon è 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--buildsi eseguono i test dell'immagine precedente, che possono essere di due modifiche fa. Stesso genere di trappola del "Rebuild deployment" su Dokploy. -
APP_KEYdi test è fissa e sta in chiaro inphpunit.xml. Nome e cognome dei proclamatori hanno il castencrypted: 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à:
- Togliere il dominio da Cloudflare (DNS-only), anche solo su un sottodominio dedicato all'amministrazione — gratis e immediato.
- Caricare puntando direttamente all'IP dell'origine, scavalcando Cloudflare.
- Ripristino dai volumi (procedura nel README): nessun limite, serve accesso al server.
- 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:
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 treifche nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.Jobs/RestoreBackupArchiveeJobs/ResetApplicationnel loro insieme: l'ordine staging → database → file, ilfailed()che libera il posto, la sopravvivenza dell'amministratore che ha chiesto l'azzeramento, ilpassword_change_requiredacceso 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 conlegacy_id.- Le regole di business: anno teocratico (
AnnoTeocratico::perDataa cavallo del 1º settembre),Territorio::statoe la sogliagiorni_per_smarrito, priorità automatica, percentuale di percorrenza delle campagne. - Il permesso come contratto: che un
assistentenon 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/pdf/* le impagina dompdf, che di
Tailwind e delle nostre classi non sa niente. StileUnificatoTest esclude anche
resources/views/vendor/, che oggi non esiste — le viste di paginazione
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.
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.
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 | 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:
git show 701f479:TerManager2_v2.md