2 Commits

4 arquivos alterados com 303 adições e 44 exclusões
Visão dividida
  1. +109
    -41
      README.md
  2. +76
    -3
      app/admin/page.tsx
  3. +117
    -0
      app/api/admin/fonts/route.ts
  4. +1
    -0
      lib/config.ts

+ 109
- 41
README.md Ver arquivo

@@ -6,43 +6,92 @@ CMS per portali captive: gestione di card informative, gallerie, flip-book e con

## Indice

1. [Avvio](#avvio)
2. [Configurazione (`lib/config.ts`)](#configurazione-libconfigts)
3. [Tipi di card](#tipi-di-card)
4. [File consentiti negli upload](#file-consentiti-negli-upload)
5. [Limiti di testo](#limiti-di-testo)
6. [Sicurezza degli input](#sicurezza-degli-input)
7. [Struttura dei dati (`data/`)](#struttura-dei-dati-data)
8. [Stato zero, rilasci e aggiornamenti del codice](#stato-zero-rilasci-e-aggiornamenti-del-codice)
9. [Backup e ripristino](#backup-e-ripristino)
10. [Factory Preset (developer)](#factory-preset-developer)
11. [Font](#font)
12. [Deploy sotto sotto-percorso (basePath) dietro Apache](#deploy-sotto-sotto-percorso-basepath-dietro-apache)
13. [Protezione dell'amministrazione (Keycloak) e routing](#protezione-dellamministrazione-keycloak-e-routing)
14. [Prerequisiti di sistema](#prerequisiti-di-sistema)
1. [Prerequisiti di sistema](#prerequisiti-di-sistema)
2. [Avvio](#avvio)
3. [Configurazione (`lib/config.ts`)](#configurazione-libconfigts)
4. [Tipi di card](#tipi-di-card)
5. [File consentiti negli upload](#file-consentiti-negli-upload)
6. [Limiti di testo](#limiti-di-testo)
7. [Sicurezza degli input](#sicurezza-degli-input)
8. [Struttura dei dati (`data/`)](#struttura-dei-dati-data)
9. [Stato zero, rilasci e aggiornamenti del codice](#stato-zero-rilasci-e-aggiornamenti-del-codice)
10. [Backup e ripristino](#backup-e-ripristino)
11. [Factory Preset (developer)](#factory-preset-developer)
12. [Font](#font)
13. [Deploy sotto sotto-percorso (basePath) dietro Apache](#deploy-sotto-sotto-percorso-basepath-dietro-apache)
14. [Protezione dell'amministrazione (Keycloak) e routing](#protezione-dellamministrazione-keycloak-e-routing)
15. [Risoluzione problemi](#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:
```bash
which ffmpeg ffprobe zip unzip
```
Se mancano, installali con il gestore pacchetti del sistema (i nomi dei pacchetti sono `ffmpeg`, `zip`, `unzip`).

---

## Avvio

**Sviluppo:**
**Installazione:**
```bash
mkdri -p /data/service/captive-portal-cms
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:**
```bash
npm run dev
```

Con `BASE_PATH = '/cards'` (default) apri [http://localhost:3000/cards](http://localhost:3000/cards) (portale pubblico) e [http://localhost:3000/cards/admin](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`).

**Produzione:**
**In produzione avviarlo con:**
```bash
/conf/etc/rc.d/rc.custom start
```

**Esempio di rc.custom per CPC:**
```bash
. /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
@@ -267,11 +316,48 @@ Copia `factory/preset.zip` sulle altre installazioni: l'admin lo vedrà subito i

## Font

I font vanno collocati in `data/fonts/` nei formati `.woff2`, `.woff`, `.ttf`, `.otf`. Vengono inclusi automaticamente nei backup.
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

- 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.
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`](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`](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):
```bash
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`](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.

---

@@ -346,7 +432,7 @@ L'autorizzazione **non** è gestita dall'app Next ma da **Apache `mod_auth_openi

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'è: bottone **Add user** → Username: `admin` → **Create** → imposta una password dal tab **Credentials**).
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)* **Clients** → `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.

@@ -451,7 +537,7 @@ L'amministratore è identificato dal **`preferred_username` Keycloak**, controll
1. **In Keycloak** (Admin Console → Users): crea/verifica gli utenti con gli username che vuoi promuovere ad admin (es. `admin`, `alice`, `bob`). Imposta le credenziali normalmente.
2. **In Apache** (in tutti i blocchi `<Location "/cards/admin">`, `<Location "/cards/api/...">`): elenca gli username separati da spazi sulla riga `Require claim`:
```apache
Require claim preferred_username:admin alice bob
Require claim preferred_username:admin pollutri russi
```
(I valori multipli sono in OR — passa chiunque abbia uno di quegli username.)
3. **Reload Apache**: `apachectl configtest && systemctl reload apache2`.
@@ -481,24 +567,6 @@ A quel punto, ogni nuovo admin si gestisce solo in Keycloak (assegnando/rimuoven
### 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](app/%5B...not_found%5D/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 | Pulsanti "Save backup (ZIP)" e "Save as Factory Preset (dev)" → `503` |
| `unzip` | Ripristino backup / factory reset | Pulsanti di ripristino → `503` |

Verifica su server:
```bash
which ffmpeg ffprobe zip unzip
```
Se mancano, installali con il gestore pacchetti del sistema (i nomi dei pacchetti sono `ffmpeg`, `zip`, `unzip`).

---

## Risoluzione problemi



+ 76
- 3
app/admin/page.tsx Ver arquivo

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from 'react';
import { Card, Portal, MediaItem, CardType } from '@/types';
import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT, FACTORY_PRESET_SAVE_ENABLED } from '@/lib/config';
import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT, FACTORY_PRESET_SAVE_ENABLED, UPLOAD_LIMITS } from '@/lib/config';
import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation';
import { withBasePath } from '@/lib/url';
@@ -277,12 +277,62 @@ export default function AdminDashboard() {
setTimeout(() => setToast(null), type === 'error' ? 6000 : 3000);
};
const refreshFonts = async () => {
try {
const res = await fetch(withBasePath('/api/fonts'));
if (res.ok) setAvailableFonts(await res.json());
} catch { setAvailableFonts([]); }
};
useEffect(() => {
fetch(withBasePath('/api/cards')).then(res => res.json()).then(setCards);
fetch(withBasePath('/api/portals')).then(res => res.json()).then(data => data && setPortal(data));
fetch(withBasePath('/api/fonts')).then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([]));
void refreshFonts();
}, []);
const [uploadingFont, setUploadingFont] = useState(false);
const handleFontUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploadingFont(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(withBasePath('/api/admin/fonts'), { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Upload error (${res.status})`, 'error');
return;
}
showToast(`Font uploaded: ${data.name}`);
await refreshFonts();
// Auto-seleziona il font appena caricato
if (data.name) setPortal(p => ({ ...p, fontFamily: data.name }));
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
} finally {
setUploadingFont(false);
}
};
const handleFontDelete = async (name: string) => {
if (!window.confirm(`Delete font "${name}"? Portals using this font will fall back to the system font.`)) return;
try {
const res = await fetch(withBasePath(`/api/admin/fonts?name=${encodeURIComponent(name)}`), { method: 'DELETE' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Delete error (${res.status})`, 'error');
return;
}
showToast('Font deleted.');
await refreshFonts();
if (portal.fontFamily === name) setPortal(p => ({ ...p, fontFamily: '' }));
} catch (err) {
showToast(`Network error: ${(err as Error).message}`, 'error');
}
};
// Poll pending transcode jobs every 2s. On 'done' we drop the entry from the
// map; on 'failed' we additionally pull the media URL out of the editor so the
// admin doesn't try to save a broken reference.
@@ -817,7 +867,7 @@ export default function AdminDashboard() {
value={portal.fontFamily ?? ''}
onChange={(v) => setPortal({ ...portal, fontFamily: v })}
options={[
{ value: '', label: 'Sistema (Arial)' },
{ value: '', label: 'System (Arial)' },
...availableFonts.map(f => ({
value: f,
label: f.replace(/\.(woff2?|ttf|otf)$/i, ''),
@@ -825,6 +875,29 @@ export default function AdminDashboard() {
})),
]}
/>
<div className="flex items-center gap-3 flex-wrap mt-2">
<label className="cursor-pointer bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold text-sm px-4 py-2 rounded-full transition-colors">
<input
type="file"
accept=".woff2,.woff,.ttf,.otf,font/woff2,font/woff,font/ttf,font/otf"
onChange={handleFontUpload}
disabled={uploadingFont}
hidden
/>
{uploadingFont ? 'Uploading…' : 'Upload font…'}
</label>
{portal.fontFamily && availableFonts.includes(portal.fontFamily) && (
<button
type="button"
onClick={() => handleFontDelete(portal.fontFamily!)}
className="text-xs text-red-600 hover:text-red-700 underline"
title={`Delete font "${portal.fontFamily}"`}
>
Delete selected font
</button>
)}
</div>
<p className="text-xs text-gray-500 mt-1">Supported: <code>.woff2</code>, <code>.woff</code>, <code>.ttf</code>, <code>.otf</code> · max {(UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0)} MB</p>
</div>
</div>


+ 117
- 0
app/api/admin/fonts/route.ts Ver arquivo

@@ -0,0 +1,117 @@
import { NextResponse } from 'next/server';
import { writeFile, mkdir, unlink } from 'fs/promises';
import path from 'path';
import { fromBuffer as fileTypeFromBuffer } from 'file-type';
import { UPLOAD_LIMITS } from '@/lib/config';

export const dynamic = 'force-dynamic';
export const maxDuration = 60;

const FONTS_DIR = path.join(process.cwd(), 'data', 'fonts');
const ALLOWED_EXT = new Set(['.woff2', '.woff', '.ttf', '.otf']);

// Magic-bytes: ttf e otf condividono il container SFNT, quindi accettiamo entrambi
// per entrambe le estensioni. woff/woff2 hanno header dedicati.
const ALLOWED_DETECTED: Record<string, string[]> = {
'.woff2': ['woff2'],
'.woff': ['woff'],
'.ttf': ['ttf', 'otf'],
'.otf': ['otf', 'ttf'],
};

// Sanitizza il nome del file: solo basename, solo caratteri sicuri, niente path traversal.
// Per i font conviene preservare il nome originale (la logica di lookup italic/bold in
// app/layout.tsx si basa sui pattern `Name-Italic.woff2`, `Name-Bold.woff2`, ecc.).
function sanitizeFontName(rawName: string): string {
const base = path.basename(rawName);
// Mantieni lettere, cifre, underscore, hyphen, punto. Tutto il resto → underscore.
const sanitized = base.replace(/[^a-zA-Z0-9_\-.]/g, '_');
// Niente percorsi nascosti / nomi vuoti
if (sanitized.startsWith('.') || sanitized.length === 0) return '';
return sanitized;
}

// POST — upload font
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file received.' }, { status: 400 });
}

const safeName = sanitizeFontName(file.name);
const ext = path.extname(safeName).toLowerCase();
if (!ALLOWED_EXT.has(ext)) {
return NextResponse.json(
{ error: `Unsupported font extension. Allowed: ${[...ALLOWED_EXT].join(', ')}` },
{ status: 400 },
);
}
if (!safeName || safeName === ext) {
return NextResponse.json({ error: 'Invalid font filename.' }, { status: 400 });
}
if (file.size > UPLOAD_LIMITS.font) {
const mb = (UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0);
return NextResponse.json(
{ error: `Font too large (max ${mb} MB).` },
{ status: 413 },
);
}

const buffer = Buffer.from(await file.arrayBuffer());

// Magic-bytes: rifiuta se il contenuto non è davvero un font del tipo dichiarato.
const detected = await fileTypeFromBuffer(buffer);
if (!detected) {
return NextResponse.json(
{ error: 'Font content not recognized (unknown format).' },
{ status: 400 },
);
}
const allowed = ALLOWED_DETECTED[ext] ?? [];
if (!allowed.includes(detected.ext)) {
return NextResponse.json(
{ error: `Font content does not match extension (${ext} declared, detected ${detected.ext}).` },
{ status: 400 },
);
}

await mkdir(FONTS_DIR, { recursive: true });
await writeFile(path.join(FONTS_DIR, safeName), buffer);

return NextResponse.json({ ok: true, name: safeName }, { status: 201 });
} catch (error) {
console.error('Font upload error:', error);
return NextResponse.json({ error: 'Failed to upload font.' }, { status: 500 });
}
}

// DELETE — rimuove un font (query ?name=<filename>)
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const rawName = searchParams.get('name');
if (!rawName) {
return NextResponse.json({ error: 'Missing name parameter.' }, { status: 400 });
}
const safeName = sanitizeFontName(rawName);
const ext = path.extname(safeName).toLowerCase();
if (!ALLOWED_EXT.has(ext) || !safeName) {
return NextResponse.json({ error: 'Invalid font name.' }, { status: 400 });
}
try {
await unlink(path.join(FONTS_DIR, safeName));
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') {
return NextResponse.json({ error: 'Font not found.' }, { status: 404 });
}
throw err;
}
return NextResponse.json({ ok: true });
} catch (error) {
console.error('Font delete error:', error);
return NextResponse.json({ error: 'Failed to delete font.' }, { status: 500 });
}
}

+ 1
- 0
lib/config.ts Ver arquivo

@@ -45,4 +45,5 @@ export const UPLOAD_LIMITS = {
image: 25 * MB, // 25 MB
pdf: 20 * MB, // 20 MB (pdfjs lato browser non regge bene molto di più)
video: 1024 * MB, // 1 GB
font: 5 * MB, // 5 MB (i font web sono tipicamente 50-500 KB; cap di sicurezza)
} as const;

Carregando…
Cancelar
Salvar