diff --git a/README.md b/README.md index 5f10df2..e5280b9 100644 --- a/README.md +++ b/README.md @@ -316,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. -- 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. +### 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 `` 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 + +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:///cards/api/admin/fonts + curl -X DELETE "https:///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. --- diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 02ffed7..dd314b9 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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) => { + 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() { })), ]} /> +
+ + {portal.fontFamily && availableFonts.includes(portal.fontFamily) && ( + + )} +
+

Supported: .woff2, .woff, .ttf, .otf · max {(UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0)} MB

diff --git a/app/api/admin/fonts/route.ts b/app/api/admin/fonts/route.ts new file mode 100644 index 0000000..57f257b --- /dev/null +++ b/app/api/admin/fonts/route.ts @@ -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 = { + '.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=) +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 }); + } +} diff --git a/lib/config.ts b/lib/config.ts index 6b29964..51d868a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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;