|
|
3 viikkoa sitten | |
|---|---|---|
| app | 4 viikkoa sitten | |
| components | 4 viikkoa sitten | |
| data | 1 kuukausi sitten | |
| lib | 4 viikkoa sitten | |
| public | 1 kuukausi sitten | |
| scripts | 1 kuukausi sitten | |
| types | 1 kuukausi sitten | |
| .gitignore | 2 kuukautta sitten | |
| AGENTS.md | 2 kuukautta sitten | |
| CLAUDE.md | 2 kuukautta sitten | |
| README.md | 4 viikkoa sitten | |
| eslint.config.mjs | 2 kuukautta sitten | |
| next.config.ts | 3 viikkoa sitten | |
| package-lock.json | 1 kuukausi sitten | |
| package.json | 1 kuukausi sitten | |
| postcss.config.mjs | 2 kuukautta sitten | |
| tsconfig.json | 2 kuukautta sitten | |
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.
lib/config.ts)data/)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 installlì. Installa le dipendenze su una macchina con internet (stesso OS, Linux), poi copia l’intera cartellanode_modulessul server insieme al progetto buildato. Sul server bastanpm run build(senode_modulesè presente) +npm start.
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. |
TEXT_LIMITS)card: {
title: 200,
shortDescription: 500,
fullContent: 20_000,
actionUrl: 2000,
},
portal: {
title: 200,
welcomeText: 1000,
},
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à.
| 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:
/admin resta sempre accessibile anche con una lock attiva.Gli upload passano per tre controlli in cascata. Se uno fallisce, nessun file viene salvato e l’admin riceve un messaggio d’errore.
| 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>).
Il contenuto reale del file viene confrontato con l’estensione dichiarata. Esempi rifiutati:
.jpg → rifiutato (contenuto ≠ estensione)..png → rifiutato (tipo non riconosciuto)..jpg → rifiutato (mismatch interno alla famiglia immagini).mov e mp4 sono intercambiabili (entrambi container ISO BMFF).
Solo i video possono essere ricodificati. Alla ricezione il server sonda i codec:
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.
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.
🎉 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 □.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.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.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.themeColor): accettato solo nel formato #RRGGBB.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.
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.
data/ (lo fanno il pulsante “Scarica backup” e il comando zip documentato sotto). È ciò che si conserva e si ripristina.data/. I contenuti restano al loro posto.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.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.
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.
Disponibile dall’admin in Settings → Backup & Restore.
interceptop-backup-<data>.zip con card, configurazione, media e font (esclude i file temporanei).data/ precedente viene conservata come data.bak-<timestamp>/ come rete di sicurezza.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), nontar. La finalità è la stessa di untar 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 comandozipqui sopra.
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).
factory/preset.zip.data/ precedente resta come data.bak-<timestamp>/.# 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
cd /percorso/del/progetto/data
mkdir -p ../factory
zip -r ../factory/preset.zip cards.txt portals.txt uploads fonts -x "uploads/.tmp/*"
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.
I font vanno collocati in data/fonts/ nei formati .woff2, .woff, .ttf, .otf. Vengono inclusi automaticamente nei backup.
italic/bold nel nome (si usa la variante “regular”; i pesi vengono gestiti dal browser).DEFAULT_FONT in lib/config.ts.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.
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.
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:
/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/.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/ |
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.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./ e su /cards: per cambiare percorso modifica BASE_PATH e ricompila.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.
/cards/admin e API di scrittura: accessibili solo all’utente Keycloak con preferred_username == admin./cards/admin → mandato al login Keycloak; al ritorno, se è admin entra, altrimenti redirect alla home (nessun errore).403)./cards/api/cards e /cards/api/portals, e /cards/api/files, /cards/api/fonts./cards/api/upload, /cards/api/transcode, /cards/api/admin/* (incluse le GET come backup e stato factory-preset)./cards: nel vhost imposta OIDCCookiePath / (non un sotto-percorso come /general), altrimenti mod_auth_openidc non vede la sessione su /cards.# 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:
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”).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./).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).
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).
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.