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 8978cc2a61 Aggiornata la gestione dei basepath 3 viikkoa sitten
app Validazione External Link card 4 viikkoa sitten
components Gestione Sotto Proxy 4 viikkoa sitten
data add font handling 1 kuukausi sitten
lib Validazione External Link card 4 viikkoa sitten
public Primo commit 1 kuukausi sitten
scripts Normalizzazione dei formati vidio e immagine immessi 1 kuukausi sitten
types Implementate: backup, edit del welcome text, card kiosk-mode 1 kuukausi sitten
.gitignore Initial commit from Create Next App 2 kuukautta sitten
AGENTS.md Initial commit from Create Next App 2 kuukautta sitten
CLAUDE.md Initial commit from Create Next App 2 kuukautta sitten
README.md Aggiornato il README.md con la documentazione per la gestione del admin 4 viikkoa sitten
eslint.config.mjs Initial commit from Create Next App 2 kuukautta sitten
next.config.ts Aggiornata la gestione dei basepath 3 viikkoa sitten
package-lock.json Normalizzazione dei formati vidio e immagine immessi 1 kuukausi sitten
package.json Normalizzazione dei formati vidio e immagine immessi 1 kuukausi sitten
postcss.config.mjs Initial commit from Create Next App 2 kuukautta sitten
tsconfig.json Initial commit from Create Next App 2 kuukautta sitten

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. Avvio
  2. Configurazione (lib/config.ts)
  3. Tipi di card
  4. File consentiti negli upload
  5. Limiti di testo
  6. Sicurezza degli input
  7. Struttura dei dati (data/)
  8. Stato zero, rilasci e aggiornamenti del codice
  9. Backup e ripristino
  10. Factory Preset (developer)
  11. Font
  12. Deploy sotto sotto-percorso (basePath) dietro Apache
  13. Protezione dell’amministrazione (Keycloak) e routing
  14. Prerequisiti di sistema
  15. Risoluzione problemi

Avvio

Sviluppo:

npm install
npm run dev

Apri http://localhost:3000 (portale pubblico) e http://localhost:3000/admin (amministrazione).

Produzione:

npm run build
npm start

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.


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_RESET_ENABLED false Mostra la sezione “Factory Preset” nell’admin (funzione developer). Vedi Factory Preset. Gli endpoint API restano attivi 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,

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. svg, heic, bmp, avi, exe) viene rifiutato. SVG è escluso di proposito (può contenere <script>).

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.


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, rilasci e aggiornamenti del codice

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 “Scarica backup” 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 dopo un update del codice

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 rcompattare i dati legacy in scrittura senza un fallback in lettura — così un archivio di contenuti vecchio resta sempre ripristinabile.

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

  • ⬇ Scarica backup ZIP — scarica interceptop-backup-<data>.zip con card, configurazione, media e font (esclude i file temporanei).
  • ⤴ Ripristina da 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 “Ripristina da 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 “Scarica backup” 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ù macchine MajorNet.

Visibile nell’admin solo se FACTORY_RESET_ENABLED = true in lib/config.ts. Gli endpoint API restano comunque attivi anche con il flag a false.

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).

Dall’interfaccia (con flag attivo)

  • 💾 Salva stato attuale come Factory Preset — congela lo stato corrente in factory/preset.zip.
  • 🏭 Factory Reset — ripristina tutto al preset (disabilitato se il preset non esiste). La data/ precedente resta come data.bak-<timestamp>/.

Da riga di comando / API

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

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

# verifica se il preset esiste
curl http://localhost:3000/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 (con flag attivo) vedrà il preset e potrà fare Factory Reset per allinearsi allo stato standard.


Font

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

  • L’elenco mostrato in admin esclude i file con italic/bold nel nome (si usa la variante “regular”; i pesi vengono gestiti dal browser).
  • Si seleziona il font del portale dalle impostazioni; in alternativa si imposta DEFAULT_FONT in lib/config.ts.
  • Il welcome text formattato (grassetto/corsivo) eredita il font selezionato.

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 all’utente Keycloak con preferred_username == admin.
  • Non autenticato su /cards/admin → mandato al login Keycloak; al ritorno, se è admin entra, altrimenti redirect alla home (nessun errore).
  • Autenticato ma non admin → 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).

Configurazione Apache

  1. Il cookie di sessione OIDC deve coprire /cards: nel vhost imposta OIDCCookiePath / (non un sotto-percorso come /general), altrimenti mod_auth_openidc non vede la sessione su /cards.
  2. Aggiungi nel/i vhost (dove l’OIDC è configurato) i blocchi di protezione:
# 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://<host>/cards/
</Location>

# API: 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>

Note:

  • 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 admin, 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 /).

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).

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 Pulsante “Scarica backup” e “Salva preset” → 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).


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.

La sezione Factory Preset non compare: è dietro il flag FACTORY_RESET_ENABLED in lib/config.ts. Mettilo a true e ricostruisci.