| @@ -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 `<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 | |||
| 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. | |||
| --- | |||
| @@ -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> | |||
| @@ -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 }); | |||
| } | |||
| } | |||
| @@ -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; | |||