Sfoglia il codice sorgente

factory reset/preset implementati

main
parent
commit
240dc17f4d
3 ha cambiato i file con 322 aggiunte e 81 eliminazioni
  1. +254
    -23
      README.md
  2. +63
    -58
      app/admin/page.tsx
  3. +5
    -0
      lib/config.ts

+ 254
- 23
README.md Vedi File

@@ -1,41 +1,272 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Captive Portal CMS — Casa della Scuola

## Getting Started
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 Ubuntu offline**.

First, run the development server:
---

## 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. [Backup e ripristino](#backup-e-ripristino)
9. [Factory Preset (developer)](#factory-preset-developer)
10. [Font](#font)
11. [Prerequisiti di sistema](#prerequisiti-di-sistema)
12. [Risoluzione problemi](#risoluzione-problemi)

---

## Avvio

**Sviluppo:**
```bash
npm install
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Apri [http://localhost:3000](http://localhost:3000) (portale pubblico) e [http://localhost:3000/admin](http://localhost:3000/admin) (amministrazione).

**Produzione:**
```bash
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. Su Ubuntu 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`](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](#factory-preset-developer). 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`)
```ts
card: {
title: 200,
shortDescription: 500,
fullContent: 20_000,
actionUrl: 2000,
},
portal: {
title: 200,
welcomeText: 1000,
},
```

### Limiti upload (`UPLOAD_LIMITS`)
```ts
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. 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.

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
---

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## 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: sono salvati identici al file caricato.

La transcodifica richiede `ffmpeg`/`ffprobe` sul server — vedi [Prerequisiti](#prerequisiti-di-sistema). 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`](#limiti-testo-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.

---

## 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)
```

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Copiare via questa cartella = backup completo. Sostituirla = ripristino completo. Nessun database, nessuna migrazione.

## Learn More
---

To learn more about Next.js, take a look at the following resources:
## Backup e ripristino

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
Disponibile dall'admin in **Settings → Backup & Restore**.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
### 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.

## Deploy on Vercel
### Da riga di comando (Linux)

**Creare un backup** (il `cd data` è essenziale: mette i file alla radice dello zip):
```bash
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/`):
```bash
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`.

---

## 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
```bash
# 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)
```bash
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.

---

## 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 Ubuntu:
```bash
which ffmpeg ffprobe zip unzip
```
Se mancano:
```bash
sudo apt install ffmpeg zip unzip
```

---

## Risoluzione problemi

**`npm run build` → "Module not found: file-type / sanitize-html"** anche se sono installati: cache Turbopack corrotta. Soluzione:
```bash
rm -rf .next && npm run build
```

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
**Un video caricato non parte nel browser**: probabilmente la transcodifica è fallita o `ffmpeg` non è installato. Controlla `which ffmpeg ffprobe` e i log del server.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
**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](#backup-e-ripristino)).

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

Per configruare usare il file lib/config.ts
Per disattivare l'utilizzo di external link, settare a false la variabile EXTERNAL_LINK_ENABLED a false;
**La sezione Factory Preset non compare**: è dietro il flag `FACTORY_RESET_ENABLED` in `lib/config.ts`. Mettilo a `true` e ricostruisci.

+ 63
- 58
app/admin/page.tsx Vedi File

@@ -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 } from '@/lib/config';
import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT, FACTORY_RESET_ENABLED } from '@/lib/config';
import { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation';
type CharCounterProps = { value: string | undefined; limit: number };
@@ -581,6 +581,32 @@ export default function AdminDashboard() {
};
const [restoring, setRestoring] = useState(false);
const handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (!window.confirm('Il ripristino sovrascriverà tutti i dati attuali (card, portale, media, font). Continuare?')) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/restore', { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore ripristino (${res.status})`, 'error');
return;
}
showToast(`Ripristino completato: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
} finally {
setRestoring(false);
}
};
// Factory preset (developer): UI visibile solo se FACTORY_RESET_ENABLED.
const [factoryPreset, setFactoryPreset] = useState<{ exists: boolean; sizeBytes?: number; modifiedAt?: string } | null>(null);
const [savingPreset, setSavingPreset] = useState(false);
const [factoryResetting, setFactoryResetting] = useState(false);
@@ -591,7 +617,9 @@ export default function AdminDashboard() {
if (res.ok) setFactoryPreset(await res.json());
} catch { /* ignore */ }
};
useEffect(() => { void refreshFactoryPreset(); }, []);
useEffect(() => {
if (FACTORY_RESET_ENABLED) void refreshFactoryPreset();
}, []);
const handleSaveFactoryPreset = async () => {
const msg = factoryPreset?.exists
@@ -634,31 +662,6 @@ export default function AdminDashboard() {
}
};
const handleRestoreUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (!window.confirm('Il ripristino sovrascriverà tutti i dati attuali (card, portale, media, font). Continuare?')) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/admin/restore', { method: 'POST', body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showToast(data?.error || `Errore ripristino (${res.status})`, 'error');
return;
}
showToast(`Ripristino completato: ${data.restored?.cards ?? 0} card, ${data.restored?.portals ?? 0} portali. Ricarico…`);
setTimeout(() => window.location.reload(), 1200);
} catch (err) {
showToast(`Errore di rete: ${(err as Error).message}`, 'error');
} finally {
setRestoring(false);
}
};
// Shared Input Classes for high contrast
const inputClasses = "w-full border border-gray-300 p-2.5 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400";
@@ -893,38 +896,40 @@ export default function AdminDashboard() {
</div>
</div>
<div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset</h3>
<p className="text-xs text-gray-500 mb-2">
Stato &ldquo;di fabbrica&rdquo; sempre ripristinabile con un click. Utile per preparare preset standard da distribuire alle macchine MajorNet:
configura il portale come vuoi, salvalo come preset, poi copia <code>factory/preset.zip</code> sulle altre macchine.
</p>
<p className="text-xs text-gray-700 mb-4">
Preset attuale: {factoryPreset === null ? '…'
: factoryPreset.exists
? <span className="text-green-700 font-medium">presente · {((factoryPreset.sizeBytes ?? 0) / (1024 * 1024)).toFixed(1)} MB · {factoryPreset.modifiedAt ? new Date(factoryPreset.modifiedAt).toLocaleString('it-IT') : '?'}</span>
: <span className="text-gray-400 italic">nessun preset configurato</span>}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSaveFactoryPreset}
disabled={savingPreset}
className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60"
>
{savingPreset ? 'Salvataggio…' : '💾 Salva stato attuale come Factory Preset'}
</button>
<button
type="button"
onClick={handleFactoryReset}
disabled={factoryResetting || !factoryPreset?.exists}
title={factoryPreset?.exists ? undefined : 'Nessun preset configurato'}
className="bg-red-700 text-white px-5 py-2.5 rounded-lg hover:bg-red-800 font-medium shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
>
{factoryResetting ? 'Reset in corso…' : '🏭 Factory Reset'}
</button>
{FACTORY_RESET_ENABLED && (
<div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-sm font-bold uppercase tracking-wider text-gray-600 mb-3">Factory Preset <span className="text-[10px] bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded ml-1 tracking-normal">developer</span></h3>
<p className="text-xs text-gray-500 mb-2">
Stato &ldquo;di fabbrica&rdquo; sempre ripristinabile con un click. Utile per preparare preset standard da distribuire alle macchine MajorNet:
configura il portale come vuoi, salvalo come preset, poi copia <code>factory/preset.zip</code> sulle altre macchine.
</p>
<p className="text-xs text-gray-700 mb-4">
Preset attuale: {factoryPreset === null ? '…'
: factoryPreset.exists
? <span className="text-green-700 font-medium">presente · {((factoryPreset.sizeBytes ?? 0) / (1024 * 1024)).toFixed(1)} MB · {factoryPreset.modifiedAt ? new Date(factoryPreset.modifiedAt).toLocaleString('it-IT') : '?'}</span>
: <span className="text-gray-400 italic">nessun preset configurato</span>}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSaveFactoryPreset}
disabled={savingPreset}
className="bg-emerald-700 text-white px-5 py-2.5 rounded-lg hover:bg-emerald-800 font-medium shadow-sm disabled:opacity-60"
>
{savingPreset ? 'Salvataggio…' : '💾 Salva stato attuale come Factory Preset'}
</button>
<button
type="button"
onClick={handleFactoryReset}
disabled={factoryResetting || !factoryPreset?.exists}
title={factoryPreset?.exists ? undefined : 'Nessun preset configurato'}
className="bg-red-700 text-white px-5 py-2.5 rounded-lg hover:bg-red-800 font-medium shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
>
{factoryResetting ? 'Reset in corso…' : '🏭 Factory Reset'}
</button>
</div>
</div>
</div>
)}
<div className="mt-10 pt-6 border-t border-gray-200 flex justify-end">
<button onClick={handleSavePortal} disabled={savingPortal} className="bg-blue-600 text-white px-10 py-3 rounded-lg hover:bg-blue-700 font-bold shadow disabled:opacity-50 transition-colors">


+ 5
- 0
lib/config.ts Vedi File

@@ -2,6 +2,11 @@

export const EXTERNAL_LINK_ENABLED = true;

// Mostra la sezione "Factory Preset" (salva preset + factory reset) nell'admin.
// Funzione developer: tienila a false in produzione, true solo quando serve
// preparare/distribuire un preset standard. Gli endpoint API restano comunque attivi.
export const FACTORY_RESET_ENABLED = false;

// Font di default se il portale non ne ha impostato uno.
// Lascia stringa vuota per usare il font di sistema (Arial).
// Per usare un font, scrivi il nome esatto del file presente in data/fonts/ (es. "Geist-Variable.woff2").


Caricamento…
Annulla
Salva