|
|
2 недель назад | |
|---|---|---|
| app | 3 недель назад | |
| components | 2 недель назад | |
| data | 1 месяц назад | |
| factory | 2 недель назад | |
| lib | 2 недель назад | |
| public | 1 месяц назад | |
| scripts | 1 месяц назад | |
| types | 1 месяц назад | |
| .gitignore | 2 месяцев назад | |
| AGENTS.md | 2 месяцев назад | |
| CLAUDE.md | 2 месяцев назад | |
| README.md | 2 недель назад | |
| eslint.config.mjs | 2 месяцев назад | |
| instrumentation.ts | 2 недель назад | |
| next.config.ts | 3 недель назад | |
| package-lock.json | 1 месяц назад | |
| package.json | 1 месяц назад | |
| postcss.config.mjs | 2 месяцев назад | |
| tsconfig.json | 2 месяцев назад | |
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/)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).
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 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.
Per un deploy reale del CPC, oltre a far girare il processo Next servono altri tre setup, ciascuno con la sua sezione dedicata:
https://<host>/cards/ → vedi Deploy sotto sotto-percorso (basePath) dietro Apache./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.ffmpeg, ffprobe, zip, unzip) per upload video / backup → vedi Prerequisiti di sistema.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. |
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 “Save backup (ZIP)” 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.
interceptor-backup-YYYYMMDD-hhmmss.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 “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), 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 “Save backup (ZIP)” 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.
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).
All’avvio del processo Next, un hook (instrumentation.ts → lib/bootstrap.ts) controlla data/cards.txt:
[] (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>/).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.
data/ precedente resta come data.bak-<timestamp>/.FACTORY_PRESET_SAVE_ENABLED = true. Congela lo stato corrente in factory/preset.zip.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
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 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.
I font vivono in data/fonts/ nei formati .woff2, .woff, .ttf, .otf. Vengono inclusi automaticamente nei backup.
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.
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).
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:
Name.woff2Name-Italic.woff2 o Name Italic.woff2Name-Bold.woff2Name-BoldItalic.woff2L’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).
data/fonts/ via SCP/FTP, eseguendo manualmente le stesse regole di naming.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_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.
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 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./cards/admin → mandato al login Keycloak; al ritorno, se il suo username è in whitelist 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).interceptor1).admin (o crea l’utente se non c'è: dall’amministrazione majornet).interceptor1-client → tab Client scopes → Evaluate → scegli l’utente → Generated ID token → controlla che il claim preferred_username valga admin. Quel claim è incluso di default nei token Keycloak.Apri il file vhost (quello con i segnaposto @@@REDIRECT@@@, @@@DIRECTORY@@@, …).
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.
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.
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>
Valida la sintassi e ricarica Apache:
apachectl configtest
systemctl reload apache2 # o `apachectl graceful`, o l'equivalente del tuo sistema
https://<host>/cards/admin da non autenticato → ti porta al login Keycloak.https://<host>/cards/ (home, 403→redirect).admin (o un altro username messo in whitelist) → ti entra nell’admin; salvataggi, upload, backup devono funzionare.GET https://<host>/cards/api/files?name=... da non autenticato deve restare pubblico (immagine visibile a tutti gli utenti del captive).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 in whitelist, invia il cookie di sessione con le richieste e può salvare/caricare/fare backup./).L’amministratore è identificato dal preferred_username Keycloak, controllato nei <Location> Apache. Per gestire più amministratori:
admin, alice, bob). Imposta le credenziali normalmente.<Location "/cards/admin">, <Location "/cards/api/...">): elenca gli username separati da spazi sulla riga Require claim:
Require claim preferred_username:admin pollutri russi
(I valori multipli sono in OR — passa chiunque abbia uno di quegli username.)
apachectl configtest && systemctl reload apache2.Rimuovere un admin: togli lo username dalla riga Require claim di Apache (e ricarica), oppure disabilita l’utente in Keycloak.
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:
interceptor1-client → tab Client scopes → clicca lo scope dedicato del client (es. interceptor1-client-dedicated) → tab Mappers → Add mapper → By configured type → User Realm Role.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)*.cpc-admin e assegnalo agli utenti admin.Require claim con:
Require claim cpc_roles:cpc-admin
(Claim top-level con valore array → matcha qualunque elemento contenga cpc-admin.)
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, più semplice e già funzionante. Se ne hai molti o ruotano → vale la pena configurare il Mapper e passare al ruolo.
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).
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.