You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Lorenzo Pollutri 0acff68db9 Estesa notifica degli errori utente 2 weeks ago
app Estesa notifica degli errori utente 2 weeks ago
components rimosso lo sfondo bianco dal logo 2 weeks ago
data/fonts ripulita root del proggetto 2 weeks ago
factory Aggiornato preset default 2 weeks ago
lib Estesa notifica degli errori utente 2 weeks ago
public Primo commit 1 month ago
scripts Normalizzazione dei formati vidio e immagine immessi 1 month ago
types Testato e consolidato README.md, commentato tipo di card mai usato 2 weeks ago
.gitignore Initial commit from Create Next App 2 months ago
AGENTS.md Initial commit from Create Next App 2 months ago
CLAUDE.md Initial commit from Create Next App 2 months ago
README.md Testato e consolidato README.md, commentato tipo di card mai usato 2 weeks ago
eslint.config.mjs Initial commit from Create Next App 2 months ago
instrumentation.ts Auto unzip dello stato di factory 2 weeks ago
next.config.ts Aggiornata la gestione dei basepath 3 weeks ago
package-lock.json Normalizzazione dei formati vidio e immagine immessi 1 month ago
package.json Normalizzazione dei formati vidio e immagine immessi 1 month ago
postcss.config.mjs Initial commit from Create Next App 2 months ago
tsconfig.json Initial commit from Create Next App 2 months ago

README.md

Captive Portal CMS — Casa della Scuola

CMS per portali captive: gestione di card informative, gallerie, flip-book e contenuti kiosk a schermo intero, con un’area di amministrazione locale. Stack: Next.js 16 (App Router, Turbopack), React 19, TypeScript, Tailwind v4. Persistenza su file (nessun database). Pensato per girare su server offline.


Indice

  1. Prerequisiti di sistema
  2. Avvio
  3. Aggiornamento
  4. Configurazione (lib/config.ts)
  5. Tipi di card
  6. File consentiti negli upload
  7. Limiti di testo
  8. Sicurezza degli input
  9. Struttura dei dati (data/)
  10. Stato zero e compatibilità tra versioni
  11. Backup e ripristino
  12. Factory Preset (developer)
  13. Font
  14. Deploy sotto sotto-percorso (basePath) dietro Apache
  15. Protezione dell’amministrazione (Keycloak) e routing
  16. Risoluzione problemi

Prerequisiti di sistema

Sul server servono alcuni binari di sistema (richiamati direttamente, non via npm):

Binario A cosa serve Se manca
ffmpeg Transcodifica video non compatibili Upload video che richiedono ricodifica → 503
ffprobe Riconoscimento codec video Come sopra
zip Creazione backup / preset Pulsanti “Save backup (ZIP)” e “Save as Factory Preset (dev)” → 503
unzip Ripristino backup / factory reset Pulsanti di ripristino → 503

Verifica su server:

which ffmpeg ffprobe zip unzip

Se mancano, installali con il gestore pacchetti del sistema (i nomi dei pacchetti sono ffmpeg, zip, unzip).


Avvio

Installazione:

mkdir -p /data/service/captive-portal-cms
cd /data/service/captive-portal-cms
git clone https://git.afasystems.it/pollutri/captive-portal-cms.git
cd captive-portal-cms
npm install
npm run build

In fase di sviluppo avviarlo con:

npm run dev

Con BASE_PATH = '/cards' (default) apri http://localhost:3000/cards (portale pubblico) e http://localhost:3000/cards/admin (amministrazione). Le URL “nude” / e /admin reindirizzano automaticamente a quelle prefissate. Con BASE_PATH = '' l’app gira sulla radice (/ e /admin).

In produzione avviarlo con:

/conf/etc/rc.d/rc.custom start

Esempio di rc.custom per CPC:

. /etc/mnvars
. /etc/mnsuper.conf
. $MN_rcconfig

BIN="xxxxx"

return=$rc_done
case "$1" in
    start)
                cd /data/service/captive-portal-cms/captive-portal-cms && npm start &
        ;;
    stop)
                fuser -k 3000/tcp
;;
    restart)
        $0 stop && $0 start || return=$rc_failed
        ;;
    *)
        echo "Usage: $0 {start|stop|restart}"
        exit 1
esac

Server offline: la macchina di produzione non ha accesso a internet. NON eseguire npm install lì. Installa le dipendenze su una macchina con internet (stesso OS, Linux), poi copia l’intera cartella node_modules sul server insieme al progetto buildato. Sul server basta npm run build (se node_modules è presente) + npm start.

Prerequisiti per la produzione

Per un deploy reale del CPC, oltre a far girare il processo Next servono altri tre setup, ciascuno con la sua sezione dedicata:

  1. Apache reverse proxy che esponga il portale su https://<host>/cards/ → vedi Deploy sotto sotto-percorso (basePath) dietro Apache.
  2. Autenticazione Keycloak per /cards/admin e le API di scrittura → vedi Protezione dell’amministrazione (Keycloak) e routing. In sintesi: deve esistere in Keycloak un utente con username admin (lo username è la chiave di accesso). Per più amministratori si aggiungono altri username alla whitelist nei <Location> Apache. Tutti gli altri utenti (autenticati o no) vengono reindirizzati alla home pubblica.
  3. Binari di sistema (ffmpeg, ffprobe, zip, unzip) per upload video / backup → vedi Prerequisiti di sistema.

Aggiornamento

La procedura standard per applicare un nuovo rilascio del CPC in produzione è:

  1. Backup dei contenuti — dall’admin: Settings → Backup & Restore → Save backup (ZIP). Conserva lo zip per un eventuale rollback. (Stessa cosa via curl: curl -O http://localhost:3000/cards/api/admin/backup.)
  2. Aggiorna il codice sul server — entra nella cartella del progetto e tira la nuova versione:
    cd /data/service/captive-portal-cms/captive-portal-cms
    git pull
    
  3. Stop → rebuild → start del demone Next:
    /conf/etc/rc.d/rc.custom stop && npm run build && /conf/etc/rc.d/rc.custom start
    

Note operative:

  • I contenuti in data/ (card, configurazione del portale, media e font caricati) non vengono toccati dal rebuild. Il rebuild compila solo il codice; i dati restano dove sono.
  • Il bootstrap automatico controlla data/cards.txt all’avvio: se è vuoto (es. macchina nuova) e c'è un factory/preset.zip nel repo, lo srotola — vedi Factory Preset → Bootstrap automatico all’avvio.
  • Il sito è momentaneamente offline tra lo stop e lo start (npm run build può richiedere fino a un minuto).
  • Se ci sono dipendenze npm nuove tra una versione e l’altra: npm install deve girare su una macchina con internet (la produzione è offline). Su quella macchina lancia npm install, poi copi la cartella node_modules aggiornata sul server, e solo dopo fai il rebuild.

Configurazione (lib/config.ts)

Tutte le impostazioni globali sono flag build-time nel file lib/config.ts. Dopo ogni modifica serve ricostruire: npm run build.

Variabile Default Descrizione
EXTERNAL_LINK_ENABLED true Mostra il tipo di card “External Link” nel menu dell’admin. Le card di quel tipo già esistenti restano comunque visibili e cliccabili anche se messo a false.
FACTORY_PRESET_SAVE_ENABLED false Mostra il bottone “Save as Factory Preset (dev)” nell’admin (funzione developer per ricreare/aggiornare il preset). Il bottone “Factory Reset” e lo stato del preset sono sempre visibili indipendentemente da questo flag — vedi Factory Preset. L’endpoint API POST /api/admin/factory-preset resta attivo a prescindere.
DEFAULT_FONT '' Font di default se il portale non ne ha impostato uno. Stringa vuota = font di sistema (Arial). Altrimenti il nome esatto di un file in data/fonts/ (es. "Geist-Variable.woff2").
TEXT_LIMITS vedi sotto Limiti caratteri di tutti i campi testuali.
UPLOAD_LIMITS vedi sotto Dimensioni massime upload per famiglia di file.

Limiti testo (TEXT_LIMITS)

card: {
  title: 200,
  shortDescription: 500,
  fullContent: 20_000,
  actionUrl: 2000,
},
portal: {
  title: 200,
  welcomeText: 1000,
},

Limiti upload (UPLOAD_LIMITS)

image: 25 MB,
pdf:   20 MB,
video: 1 GB,
font:  5 MB,

Per cambiare un qualunque limite: modifica il numero in lib/config.ts e ricostruisci. Il valore è condiviso tra interfaccia (contatore / maxLength), validazione server e check di upload — un solo punto di verità.


Tipi di card

Tipo Nome in admin Comportamento
INFO_PAGE Info Page Pagina informativa: solo cover, niente galleria.
IMAGE_GALLERY Image Gallery Galleria di immagini/video/PDF sfogliabile a schermo intero.
BOOK Flip-Book Sfoglialibro in formato A4 (due pagine affiancate).
FULLSCREEN_LOCK Fullscreen Lock (kiosk) Takeover totale: se presente, il portale pubblico mostra SOLO il suo contenuto (immagine o video) a tutto schermo, senza hero, griglia o pulsanti di chiusura. Le altre card vengono nascoste. Utile per redirect/segnaletica kiosk.
EXTERNAL_LINK External Link Apre un URL esterno. L’URL è obbligatorio: il salvataggio è bloccato (lato UI e lato server) se manca. Visibile nel menu solo se EXTERNAL_LINK_ENABLED = true.

Note sulla Fullscreen Lock:

  • Se ci sono più card lock, viene usata la prima per ordine di visualizzazione.
  • /admin resta sempre accessibile anche con una lock attiva.
  • Per tornare al portale normale: elimina (o cambia tipo a) la card lock.

File consentiti negli upload

Gli upload passano per tre controlli in cascata. Se uno fallisce, nessun file viene salvato e l’admin riceve un messaggio d’errore.

1. Whitelist estensioni

Famiglia Estensioni Limite
Immagini png jpg jpeg gif webp 25 MB
Video mp4 m4v webm mov ogv ogg 1 GB
Documenti pdf 20 MB

Tutto il resto (es. heic, bmp, avi, exe) viene rifiutato.

Eccezione SVG: l’estensione .svg è ammessa solo per l’upload del logo del portale (Settings → Logo Image). Tutti gli altri upload (hero, cover card, gallery, ecc.) rifiutano l’SVG. Vedi SVG: regole speciali per il logo sotto.

2. Controllo “magic-bytes”

Il contenuto reale del file viene confrontato con l’estensione dichiarata. Esempi rifiutati:

  • Un PDF rinominato .jpg → rifiutato (contenuto ≠ estensione).
  • Un eseguibile rinominato .png → rifiutato (tipo non riconosciuto).
  • Un PNG rinominato .jpg → rifiutato (mismatch interno alla famiglia immagini).

mov e mp4 sono intercambiabili (entrambi container ISO BMFF).

3. Transcodifica video automatica

Solo i video possono essere ricodificati. Alla ricezione il server sonda i codec:

  • Video già compatibile (H.264 + AAC/MP3) → nessuna ricodifica, salvataggio immediato.
  • Video non compatibile (HEVC iPhone, VP9, AV1, audio Opus/Vorbis…) → messo in coda e ricodificato in background con ffmpeg verso MP4 H.264 + AAC, max 720p. L’admin vede un badge “Transcoding XX%” sulla miniatura; quando finisce il file diventa riproducibile su tutti i browser.

Le immagini e i PDF non vengono mai trasformati né compressi: sono salvati byte-per-byte identici al file caricato. Non esiste alcuna conversione AVIF/WebP lato server — un file PNG resta PNG con la stessa dimensione.

La transcodifica richiede ffmpeg/ffprobe sul server — vedi Prerequisiti. Se mancano, gli upload di video che richiedono ricodifica rispondono 503.

L’SVG è un formato vettoriale comodo per i loghi ma può contenere <script> JavaScript, event handler (onclick, onload, …) e riferimenti esterni che si attivano quando il browser renderizza l’immagine — è un classico vettore XSS. Per questo motivo:

  • L’SVG non è ammesso negli upload generici di card, gallery o hero.
  • È ammesso solo per il logo del portale (Settings → Logo Image), perché serve un caso d’uso ricorrente (loghi forniti come vettoriale) e l’admin è considerato fidato.

Quando viene caricato un SVG via POST /api/upload?context=logo il server applica:

Difesa Cosa fa
Whitelist contestuale l’estensione .svg è ammessa solo se la query string contiene context=logo. Senza, viene rifiutata come tutte le altre estensioni fuori dalla whitelist generale.
Validazione struttura il contenuto deve iniziare con <svg…> (eventualmente preceduto da XML declaration e commenti). File che non sembrano SVG vengono rifiutati.
Sanitizzazione (lib/svg-sanitize.ts) rimozione di tag pericolosi (script, foreignObject, iframe, embed, object, style, link, meta, set, animate*), di tutti gli event handler on*=…, di href/xlink:href con schemi non sicuri, e delle stringhe javascript: / data:text/html inline. Il risultato sanificato è quello che viene salvato a disco.
CSP defensivo sul GET /api/files quando il file servito è SVG, la risposta include Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data: — anche se un payload sfuggisse al sanitizer, lo script non potrebbe eseguire né caricare risorse esterne.
Limite dimensione si applica il limite della famiglia immagini (25 MB), ma in pratica un logo SVG è qualche KB.

Il sanitizer è regex-based (deliberatamente leggero, niente nuove dipendenze). È adeguato per il modello di minaccia “admin trusted carica il proprio logo” ma non è bulletproof contro payload SVG sofisticatissimi. Per scenari più esposti (upload pubblico), il sostituto consigliato è DOMPurify + jsdom, che sostituisce solo l’implementazione del sanitizer senza toccare il resto della pipeline.


Limiti di testo

Ogni campo compilabile dall’admin ha un limite (vedi TEXT_LIMITS). L’interfaccia mostra un contatore quando ci si avvicina al limite (rosso se superato) e blocca l’inserimento oltre il massimo. Lato server, una richiesta che sfora viene rifiutata con 400 e l’elenco dei campi fuori limite — nessun troncamento silenzioso.

Gli URL (actionUrl) accettano solo gli schemi http, https, mailto, tel. Schemi come javascript: vengono rifiutati. Per le card External Link l’URL è obbligatorio: il salvataggio viene rifiutato (UI + server 400) se il campo è vuoto.

Caratteri non-ASCII (emoji, cirillico, CJK…)

  • Nei campi di testo: conservati integralmente (il salvataggio è UTF-8). Due avvertenze: (1) il conteggio caratteri usa le unità UTF-16, quindi un emoji “pesa” 2 o più posizioni del limite (es. 🎉 ne conta 11), mentre cirillico/CJK contano 1 ciascuno; (2) se il font scelto non ha i glifi (es. un font latino con testo cirillico), il browser fa fallback a un font di sistema o mostra quadratini □.
  • Nei nomi dei file caricati: i caratteri non-ASCII vengono rimossi dalla normalizzazione del nome (città_🎉.jpg…-citta.jpg, 日本語.png…-file.png). Il file funziona sempre, ma il nome originale non-ASCII non viene preservato. Vedi Sicurezza degli input → Nomi file.

Sicurezza degli input

  • HTML delle card (fullContent): sanificato in scrittura con una whitelist (p, br, strong, em, b, i, u, ul, ol, li, a, h1–h6, blockquote, span). I link ricevono automaticamente rel="noopener noreferrer" target="_blank". Script e attributi pericolosi vengono rimossi.
  • Welcome text del portale: sanificato con whitelist ridotta (b, i, strong, em, br, p, div, span) — solo grassetto, corsivo e a-capo, nessun link. Si modifica con il mini-editor (pulsanti B/I) nelle impostazioni.
  • Colore tema (themeColor): accettato solo nel formato #RRGGBB.
  • Nomi file: normalizzati (accenti rimossi, niente caratteri speciali, niente path traversal) e resi univoci con timestamp + stringa casuale.

Struttura dei dati (data/)

Tutto lo stato applicativo vive nella cartella data/ alla radice del progetto. Non contiene codice, solo dati caricati dagli utenti:

data/
├── cards.txt              ← tutte le card (JSON array)
├── portals.txt            ← configurazione del portale (JSON array)
├── fonts/                 ← font caricati (.woff2/.woff/.ttf/.otf)
├── uploads/               ← media caricati (immagini, video, pdf)
│   └── .tmp/              ← buffer temporaneo upload/transcoding (svuotabile)
└── transcode-jobs.json    ← stato della coda di transcodifica (creato all'occorrenza)

Copiare via questa cartella = backup completo. Sostituirla = ripristino completo. Nessun database esterno.


Stato zero e compatibilità tra versioni

Cosa costituisce lo “stato zero”

Lo stato applicativo (i dati) è solo il contenuto di data/: cards.txt, portals.txt, uploads/, fonts/. Tutto il resto — codice sorgente, node_modules, e gli asset di default in public/ (es. hero-bg.jpg, logo.png) — non fa parte dello stato dati: arriva con il rilascio del software. “Azzerare” il CPC significa quindi sostituire data/ con uno stato noto.

Contenuti vs codice (due archivi distinti)

  • Backup dei contenuti: archivia solo data/ (lo fanno il pulsante “Save backup (ZIP)” e il comando zip documentato sotto). È ciò che si conserva e si ripristina.
  • Aggiornamento del codice: si sostituisce tutto tranne data/. I contenuti restano al loro posto.
  • Da CLI un reset conservativo è: mv data data.old && <estrai-l-archivio-dei-contenuti>. È l’equivalente del tar zxf citato dal QA — vedi nota su ZIP vs tar nella sezione Backup.

Compatibilità dei contenuti tra versioni

I contenuti salvati da versioni precedenti continuano a funzionare: in lettura vengono adattati al volo (es. il vecchio extraImages: string[] viene convertito in extraMedia: MediaItem[] in lib/db.ts). Convenzione per future modifiche di schema: aggiungere il branch di migrazione nella lettura (getCards/getPortals), senza mai ricompattare i dati legacy in scrittura senza un fallback in lettura — così un archivio di contenuti vecchio resta sempre ripristinabile. La procedura operativa di aggiornamento è descritta in Aggiornamento.

Far accompagnare lo stato zero ai rilasci

Per avere uno stato di partenza noto su ogni macchina nuova, includere nel pacchetto di rilascio un factory/preset.zip curato (vedi Factory Preset). Su una macchina nuova, il ripristino di quel preset porta allo stato zero ufficiale.


Backup e ripristino

Disponibile dall’admin in Settings → Backup & Restore.

Dall’interfaccia

  • Save backup (ZIP) — scarica interceptor-backup-YYYYMMDD-hhmmss.zip con card, configurazione, media e font (esclude i file temporanei).
  • Restore from ZIP — carica uno zip; dopo conferma sovrascrive lo stato attuale e ricarica la pagina. La cartella data/ precedente viene conservata come data.bak-<timestamp>/ come rete di sicurezza.

Da riga di comando (Linux)

Creare un backup (il cd data è essenziale: mette i file alla radice dello zip):

cd /percorso/del/progetto/data
zip -r ~/backup-$(date +%Y%m%d-%H%M%S).zip cards.txt portals.txt uploads fonts -x "uploads/.tmp/*"

Verificare la struttura (devi vedere cards.txt e portals.txt in cima, NON una cartella data/):

unzip -l ~/backup-*.zip

Lo zip così prodotto è caricabile direttamente dal pulsante “Restore from ZIP”.

Struttura obbligatoria: i file devono stare alla radice dello zip. Uno zip con tutto dentro una cartella data/ verrà rifiutato con “cards.txt assente”. Deve essere uno ZIP, non un .tar.

ZIP vs tar: il CPC usa archivi ZIP (via zip/unzip), non tar. La finalità è la stessa di un tar cf/tar zxf: un singolo archivio dei soli contenuti, ripristinabile in un colpo. Il restore accetta lo ZIP prodotto dal pulsante “Save backup (ZIP)” o dal comando zip qui sopra.


Factory Preset (developer)

Stato “di fabbrica” ripristinabile con un click. Pensato per preparare preset standard (es. banner + icona + una card di redirect) da distribuire a più MajorNet.

La sezione è sempre visibile in admin. Il bottone “Factory Reset” e lo stato del preset corrente sono mostrati sempre (è un’operazione lecita per qualunque amministratore). Il bottone “Save as Factory Preset (dev)” — che riscrive il preset di fabbrica — è invece gated da FACTORY_PRESET_SAVE_ENABLED = true in lib/config.ts (default false): tienilo attivo solo sulla macchina di sviluppo dove prepari/aggiorni il preset. Gli endpoint API restano comunque attivi a prescindere dal flag.

Il preset è un file fisso: factory/preset.zip alla radice del progetto (fuori da data/, quindi non viene toccato dai reset; fuori da public/, quindi non scaricabile via web).

Bootstrap automatico all’avvio

All’avvio del processo Next, un hook (instrumentation.tslib/bootstrap.ts) controlla data/cards.txt:

  • Se è assente o [] (zero card) E esiste factory/preset.zip, il preset viene estratto automaticamente sopra a data/ (stessa logica del Factory Reset; la eventuale data/ precedente viene rinominata data.bak-<timestamp>/).
  • In tutti gli altri casi (cards.txt esiste con contenuto, o il preset manca) il bootstrap non fa nulla.

Questo permette di committare nel repo un factory/preset.zip e avere ogni macchina appena clonata/distribuita che parte direttamente nello stato standard, senza interventi manuali in admin. Le righe [bootstrap] nei log del server segnalano se ha agito.

Dall’interfaccia

  • Factory Reset — sempre visibile. Ripristina tutto al preset (disabilitato se il preset non esiste). La data/ precedente resta come data.bak-<timestamp>/.
  • Save as Factory Preset (dev) — visibile solo con FACTORY_PRESET_SAVE_ENABLED = true. Congela lo stato corrente in factory/preset.zip.

Da riga di comando / API

URL con BASE_PATH = '/cards' (default). Se hai BASE_PATH = '' togli il /cards dal path.

# salva lo stato corrente come preset
curl -X POST http://localhost:3000/cards/api/admin/factory-preset

# esegui il factory reset
curl -X POST http://localhost:3000/cards/api/admin/factory-reset

# verifica se il preset esiste
curl http://localhost:3000/cards/api/admin/factory-preset

Creare il preset a mano (Linux)

cd /percorso/del/progetto/data
mkdir -p ../factory
zip -r ../factory/preset.zip cards.txt portals.txt uploads fonts -x "uploads/.tmp/*"

Distribuzione su altre macchine

Copia factory/preset.zip sulle altre installazioni: l’admin lo vedrà subito in Settings → Factory Preset come “Current preset: present · X MB · data/ora”, e potrà cliccare Factory Reset per allinearsi allo stato standard. Non serve attivare FACTORY_PRESET_SAVE_ENABLED sulle macchine target — quella flag riguarda solo la creazione del preset, non il suo ripristino.


Font

I font vivono in data/fonts/ nei formati .woff2, .woff, .ttf, .otf. Vengono inclusi automaticamente nei backup.

Caricarli dall’admin

Settings → Portal font → pulsante Upload font… Il font appena caricato viene auto-selezionato come font del portale. Sotto al dropdown c'è anche il link Delete selected font per rimuovere quello attivo (i portali che lo usavano fanno fallback al font di sistema).

L’upload è automaticamente protetto dal gate Apache: solo gli utenti in whitelist sul blocco <Location /cards/api/admin> possono chiamare POST e DELETE su /cards/api/admin/fonts. Il GET /cards/api/fonts (lettura/serving) resta pubblico per i client del portale.

Controlli e validazione lato server

Ogni upload passa per quattro controlli in cascata. Se anche solo uno fallisce, il file NON viene salvato e l’API risponde con un errore HTTP descrittivo:

Controllo Cosa verifica Errore in caso di fallimento
Estensione il file ha una delle estensioni ammesse: .woff2, .woff, .ttf, .otf 400 Unsupported font extension
Nome file è solo un basename (no path traversal), contiene solo [a-zA-Z0-9_.-], non comincia con . 400 Invalid font filename
Dimensione il file pesa al massimo 5 MB (vedi UPLOAD_LIMITS.font in lib/config.ts) 413 Font too large
Magic-bytes il contenuto reale del file corrisponde all’estensione dichiarata: i primi byte devono essere quelli di un font WOFF/WOFF2/TTF/OTF 400 Font content does not match extension (o 400 unknown format se non riconoscibile)

.ttf e .otf condividono il container SFNT, quindi sono trattati come intercambiabili dalla validazione magic-bytes (un file .ttf con header OTF passa e viceversa).

Convenzioni di naming (pesi e corsivi)

I file caricati conservano il nome originale (modulo la sanitizzazione di cui sopra). Questo è essenziale perché il rendering del portale cerca automaticamente i pesi e i corsivi sulla base di pattern del nome file:

  • File regular: Name.woff2
  • Italic: Name-Italic.woff2 o Name Italic.woff2
  • Bold: Name-Bold.woff2
  • Bold Italic: Name-BoldItalic.woff2

L’elenco mostrato nel dropdown esclude i file con italic/bold nel nome (si seleziona sempre la variante regular; i pesi/corsivi vengono associati automaticamente da app/layout.tsx).

Alternative all’upload UI

  • Copia diretta: si possono mettere i file in data/fonts/ via SCP/FTP, eseguendo manualmente le stesse regole di naming.
  • Curl (developer):
    curl -X POST -F "file=@Roboto.woff2" https://<host>/cards/api/admin/fonts
    curl -X DELETE "https://<host>/cards/api/admin/fonts?name=Roboto.woff2"
    
  • Default globale: se vuoi imporre un font su tutti i portali che non ne hanno scelto uno, imposta DEFAULT_FONT in lib/config.ts col nome esatto del file (stringa vuota = font di sistema).

Il welcome text formattato (grassetto/corsivo) eredita automaticamente il font selezionato dal portale.


Deploy sotto sotto-percorso (basePath) dietro Apache

Il portale può essere servito sotto un sotto-percorso (es. https://host/cards/) tramite reverse proxy. Servono due cose insieme: il basePath nel codice e il proxy configurato per non strippare il prefisso.

Lato codice

Imposta il percorso in lib/config.ts e ricostruisci:

export const BASE_PATH = '/cards';   // '' = servito sulla radice

È l’unica modifica necessaria: next.config.ts lo importa per basePath, e l’helper withBasePath lo applica a tutte le URL costruite a mano (chiamate API, sorgenti media, font, link). Gli URL salvati in data/ restano senza prefisso, quindi i backup restano portabili anche tra macchine con BASE_PATH diverso. Poi:

npm run build && npm start

Verifica diretta su Next (senza proxy): le pagine rispondono su http://localhost:3000/cards e …/cards/admin.

Lato Apache (reverse proxy)

Con basePath attivo, Next emette asset e API sotto /cards/...: il proxy deve preservare il prefisso (non strippare). Esempio di <Location>:

<Location "/cards">
    Header always unset X-Frame-Options
    Header set X-Frame-Options "ALLOWALL"
    Header always set Content-Security-Policy "frame-ancestors 'self' *"

    # Target CON /cards: il prefisso viene preservato (niente stripping)
    ProxyPass        "http://localhost:3000/cards" connectiontimeout=5 timeout=600 keepalive=on
    ProxyPassReverse "http://localhost:3000/cards"

    RewriteCond %{HTTPS} on
    RewriteRule .* - [E=DASH_PROTO:https]
    RewriteCond %{HTTPS} off
    RewriteRule .* - [E=DASH_PROTO:http]

    RequestHeader set X-Forwarded-Proto %{DASH_PROTO}e
</Location>

Punti chiave:

  • Target con /cards (non /): se il proxy strippa il prefisso, gli asset /cards/_next/... non vengono mappati e la pagina si rompe.
  • <Location "/cards"> senza slash finale: intercetta sia /cards (home canonica) sia /cards/..., evitando il redirect 308 di /cards//cards.
  • timeout=600: upload video fino a 1 GB e download dei backup ZIP possono superare i 30s.
  • BASE_PATH e il target del proxy devono combaciare. Per tornare alla radice: BASE_PATH = '' + proxy verso http://localhost:3000/.

Compatibilità senza proxy / alla radice

BASE_PATH è una scelta build-time (il prefisso viene inlinato nel bundle al npm run build), non runtime. Entrambi gli scenari sono supportati:

BASE_PATH Risponde su Proxy necessario?
'' / (radice) No
'/cards' /cards No per far girare l’app; sì solo per esporla pubblicamente sotto /cards/
  • Con BASE_PATH = '' l’app è identica alla versione senza sotto-percorso: gira sulla radice e l’helper withBasePath diventa un no-op. È il percorso di regressione.
  • Con BASE_PATH = '/cards' l’app vive sempre sotto /cards, anche senza proxy: in locale la raggiungi su http://localhost:3000/cards (la radice / dà 404). Il proxy serve solo a esporla pubblicamente.
  • Un singolo build non può rispondere contemporaneamente su / e su /cards: per cambiare percorso modifica BASE_PATH e ricompila.

Protezione dell’amministrazione (Keycloak) e routing

L’autorizzazione non è gestita dall’app Next ma da Apache mod_auth_openidc (Keycloak), già usato dal captive portal. L’app non contiene codice di auth: si fida del gate del reverse proxy.

Cosa è protetto

  • Pagina /cards/admin e API di scrittura: accessibili solo agli utenti Keycloak il cui preferred_username è nella whitelist Apache (di default: admin). Per più amministratori si aggiungono altri username, separati da spazi, alla stessa direttiva Require claim.
  • Non autenticato su /cards/admin → mandato al login Keycloak; al ritorno, se il suo username è in whitelist entra, altrimenti redirect alla home (nessun errore).
  • Autenticato ma con username non in whitelist → redirect alla home (403).
  • Restano pubbliche (servono al portale per tutti gli utenti WiFi): le GET di /cards/api/cards e /cards/api/portals, e /cards/api/files, /cards/api/fonts.
  • Protette su tutti i metodi: /cards/api/upload, /cards/api/transcode, /cards/api/admin/* (incluse le GET come backup e stato factory-preset).

Setup iniziale (step-by-step)

A — Configurazione Keycloak

  1. Apri l’Admin Console di Keycloak.
  2. Seleziona il realm del captive portal (es. interceptor1).
  3. Users → verifica che esista un utente con username admin (o crea l’utente se non c'è: dall’amministrazione majornet).
  4. Per più amministratori, NON è necessario fare nulla in Keycloak: basta aggiungere altri username alla whitelist Apache (vedi punto B). Crea solo gli utenti Keycloak corrispondenti.
  5. (Verifica opzionale) Clientsinterceptor1-client → tab Client scopesEvaluate → scegli l’utente → Generated ID token → controlla che il claim preferred_username valga admin. Quel claim è incluso di default nei token Keycloak.

B — Configurazione Apache (vhost del captive portal)

Apri il file vhost (quello con i segnaposto @@@REDIRECT@@@, @@@DIRECTORY@@@, …).

  1. Blocco proxy globale /cards — fuori dai vhost, in cima al file (dopo il LoadModule di mod_auth_openidc):

    <Location "/cards">
        Header always unset X-Frame-Options
        Header set X-Frame-Options "ALLOWALL"
        Header always set Content-Security-Policy "frame-ancestors 'self' *"
    
        ProxyPass        "http://localhost:3000/cards" connectiontimeout=5 timeout=600 keepalive=on
        ProxyPassReverse "http://localhost:3000/cards"
    
        RewriteCond %{HTTPS} on
        RewriteRule .* - [E=DASH_PROTO:https]
        RewriteCond %{HTTPS} off
        RewriteRule .* - [E=DASH_PROTO:http]
    
        RequestHeader set X-Forwarded-Proto %{DASH_PROTO}e
    </Location>
    

    <Location "/cards"> senza slash finale: intercetta sia /cards sia /cards/.... ProxyPass con target /cards (non /): preserva il prefisso.

  2. In ENTRAMBI i vhost (:80 e :443) cambia il cookie path:

    OIDCCookiePath /general    # ← era così
    OIDCCookiePath /           # ← cambia in questo
    

    Senza questa modifica il cookie del captive non raggiunge /cards e ricevi 401 anche se sei loggato.

  3. In ENTRAMBI i vhost aggiungi i blocchi di protezione dopo le <Location /general/...> esistenti e prima di OIDCRedirectURI:

    # Pagina admin: non autenticato → login Keycloak; autenticato non-admin → home
    <Location "/cards/admin">
        AuthType openid-connect
        OIDCUnAuthAction auth
        Require claim preferred_username:admin
        ErrorDocument 403 https://@@@REDIRECT@@@/cards/
    </Location>
    
    # cards/portals: GET pubblica, scrittura solo admin
    <Location "/cards/api/cards">
        <LimitExcept GET HEAD>
            AuthType openid-connect
            OIDCUnAuthAction 401
            Require claim preferred_username:admin
        </LimitExcept>
    </Location>
    <Location "/cards/api/portals">
        <LimitExcept GET HEAD>
            AuthType openid-connect
            OIDCUnAuthAction 401
            Require claim preferred_username:admin
        </LimitExcept>
    </Location>
    
    # upload / transcode / admin: tutti i metodi solo admin
    <Location "/cards/api/upload">
        AuthType openid-connect
        OIDCUnAuthAction 401
        Require claim preferred_username:admin
    </Location>
    <Location "/cards/api/transcode">
        AuthType openid-connect
        OIDCUnAuthAction 401
        Require claim preferred_username:admin
    </Location>
    <Location "/cards/api/admin">
        AuthType openid-connect
        OIDCUnAuthAction 401
        Require claim preferred_username:admin
    </Location>
    
  4. Valida la sintassi e ricarica Apache:

    apachectl configtest
    systemctl reload apache2    # o `apachectl graceful`, o l'equivalente del tuo sistema
    

C — Test (in finestra incognito, sessione pulita)

  1. Vai su https://<host>/cards/admin da non autenticato → ti porta al login Keycloak.
  2. Login con un utente il cui username NON è in whitelist → ritorni e finisci a https://<host>/cards/ (home, 403→redirect).
  3. Login con l’utente admin (o un altro username messo in whitelist) → ti entra nell’admin; salvataggi, upload, backup devono funzionare.
  4. GET https://<host>/cards/api/files?name=... da non autenticato deve restare pubblico (immagine visibile a tutti gli utenti del captive).

Note tecniche

  • Pagina admin usa OIDCUnAuthAction auth: il non autenticato viene mandato al login Keycloak. Questo è anche ciò che riemette il cookie di sessione al path / dopo aver impostato OIDCCookiePath /, sanando il classico 401 “anche da autenticato”. Solo ErrorDocument 403 (autenticato non-admin → home): non usare ErrorDocument 401, perché Apache non sa fare un redirect su un 401 (genera l’errore “a 401 error was encountered while trying to use an ErrorDocument”).
  • API usano OIDCUnAuthAction 401 e nessun ErrorDocument: un accesso non autorizzato riceve 401/403 (corretto per una chiamata fetch, che non va rediretta). L’admin, già autenticato come utente in whitelist, invia il cookie di sessione con le richieste e può salvare/caricare/fare backup.
  • Niente VirtualHost dedicato: la protezione sta nello stesso vhost dove l’OIDC è configurato (la sessione è condivisa col captive portal via cookie a path /).

Aggiungere o rimuovere amministratori

L’amministratore è identificato dal preferred_username Keycloak, controllato nei <Location> Apache. Per gestire più amministratori:

  1. In Keycloak (Admin Console → Users): crea/verifica gli utenti con gli username che vuoi promuovere ad admin (es. admin, pollutri, russi). Imposta le credenziali normalmente.
  2. In Apache (in tutti i blocchi <Location "/cards/admin">, <Location "/cards/api/...">): usa un blocco <RequireAny> con una riga Require claim per ogni username:
    <RequireAny>
        Require claim preferred_username:admin
        Require claim preferred_username:pollutri
        Require claim preferred_username:russi
    </RequireAny>
    
  3. Reload Apache: apachectl configtest && systemctl reload apache2.

Non usare la forma compatta Require claim preferred_username:admin pollutri russi sulla stessa riga: su alcune versioni di mod_auth_openidc (incluse quelle in uso sui server MajorNet) il parser dei valori space-separated è buggy e finisce per accettare solo il primo username. Il blocco <RequireAny> con una riga Require claim per username delega la OR-logic ad Apache (mod_authz_core) e funziona affidabilmente. Stesso schema anche dentro <LimitExcept GET HEAD>.

Rimuovere un admin: togli la riga Require claim preferred_username:<username> corrispondente da <RequireAny> (e ricarica Apache), oppure disabilita l’utente direttamente in Keycloak.

Variante avanzata: ruoli Keycloak invece di username

In linea di principio si potrebbe gestire la lista degli amministratori interamente da Keycloak (creando un realm role cpc-admin e assegnandolo agli utenti), in modo da non dover toccare Apache ogni volta. Apparentemente basterebbe sostituire la riga con Require claim realm_access.roles:cpc-admin.

Attenzione (limite osservato sul nostro mod_auth_openidc): la dot-notation realm_access.roles:cpc-admin punta a un claim annidato (un array dentro un oggetto JSON) e la versione installata sui server di produzione non la valuta correttamente — il risultato è che la <Location> viene di fatto bypassata e chiunque entra in /cards/admin. Per usare i ruoli serve quindi un Mapper Keycloak che appiattisca il claim:

  1. Keycloak → Clients → interceptor1-client → tab Client scopes → clicca lo scope dedicato del client (es. interceptor1-client-dedicated) → tab MappersAdd mapperBy configured typeUser Realm Role.
  2. Compila: Name=cpc-roles, Token Claim Name=cpc_roles, Claim JSON Type=String, Multivalued: ON, Add to ID token: ON, Add to access token: ON, Realm Role prefix=*(vuoto)*.
  3. Save. Crea il ruolo realm cpc-admin e assegnalo agli utenti admin.
  4. In Apache, in tutti i blocchi, sostituisci la riga Require claim con:
    Require claim cpc_roles:cpc-admin
    

    (Claim top-level con valore array → matcha qualunque elemento contenga cpc-admin.)

  5. Reload Apache. Gli utenti devono fare logout/login per ottenere un token con il nuovo claim.

A quel punto, ogni nuovo admin si gestisce solo in Keycloak (assegnando/rimuovendo cpc-admin), senza toccare Apache.

Quale scegliere: se hai pochi amministratori stabili → resta con preferred_username + <RequireAny>, più semplice e già funzionante. Se ne hai molti o ruotano → vale la pena configurare il Mapper e passare al ruolo.

Sulle build MajorNet attuali anche Require claim cpc_roles:cpc-admin può fallire, perché la stessa code-path che gestisce i valori space-separated gestisce anche il match contro claim di tipo array (cpc_roles è un array di stringhe). Se dopo aver configurato il Mapper e ricaricato Apache vedi che la <Location> viene bypassata (chiunque accede, anche senza il ruolo) → resta sul pattern <RequireAny> con preferred_username: è l’unico schema testato come affidabile su quella build.

Pagine inesistenti → redirect alla home

Qualsiasi percorso non corrispondente a una pagina o API reale del progetto non mostra una schermata d’errore: viene reindirizzato alla home. È gestito lato Next da una rotta catch-all (app/[...not_found]/page.tsx) che esegue redirect('/'). Le rotte reali (/, /admin, /api/*) hanno priorità; solo i path inesistenti finiscono lì. Le API inesistenti restano 404 (il redirect riguarda le pagine).

Risoluzione problemi

npm run build → “Module not found: file-type / sanitize-html” anche se sono installati: cache Turbopack corrotta. Soluzione:

rm -rf .next && npm run build

Un video caricato non parte nel browser: probabilmente la transcodifica è fallita o ffmpeg non è installato. Controlla which ffmpeg ffprobe e i log del server.

Il ripristino dice “cards.txt assente”: lo zip ha una cartella data/ di troppo al suo interno. Ricrealo facendo cd data PRIMA del comando zip (vedi Backup).

Recupero dopo un ripristino sbagliato: lo stato precedente è in data.bak-<timestamp>/. Ferma il server, rinomina quella cartella in data/ e riavvia.

Il bottone “Save as Factory Preset (dev)” non compare: è dietro il flag FACTORY_PRESET_SAVE_ENABLED in lib/config.ts. Mettilo a true e ricostruisci (di norma lo si tiene false in produzione). Il bottone “Factory Reset” e lo stato del preset restano comunque sempre visibili.