| @@ -316,11 +316,48 @@ Copia `factory/preset.zip` sulle altre installazioni: l'admin lo vedrà subito i | |||||
| ## Font | ## 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 { useState, useEffect, useRef } from 'react'; | ||||
| import { Card, Portal, MediaItem, CardType } from '@/types'; | 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 { CARD_LIMITS, PORTAL_LIMITS } from '@/lib/validation'; | ||||
| import { withBasePath } from '@/lib/url'; | import { withBasePath } from '@/lib/url'; | ||||
| @@ -277,12 +277,62 @@ export default function AdminDashboard() { | |||||
| setTimeout(() => setToast(null), type === 'error' ? 6000 : 3000); | 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(() => { | useEffect(() => { | ||||
| fetch(withBasePath('/api/cards')).then(res => res.json()).then(setCards); | 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/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 | // 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 | // map; on 'failed' we additionally pull the media URL out of the editor so the | ||||
| // admin doesn't try to save a broken reference. | // admin doesn't try to save a broken reference. | ||||
| @@ -817,7 +867,7 @@ export default function AdminDashboard() { | |||||
| value={portal.fontFamily ?? ''} | value={portal.fontFamily ?? ''} | ||||
| onChange={(v) => setPortal({ ...portal, fontFamily: v })} | onChange={(v) => setPortal({ ...portal, fontFamily: v })} | ||||
| options={[ | options={[ | ||||
| { value: '', label: 'Sistema (Arial)' }, | |||||
| { value: '', label: 'System (Arial)' }, | |||||
| ...availableFonts.map(f => ({ | ...availableFonts.map(f => ({ | ||||
| value: f, | value: f, | ||||
| label: f.replace(/\.(woff2?|ttf|otf)$/i, ''), | 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> | ||||
| </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 | image: 25 * MB, // 25 MB | ||||
| pdf: 20 * MB, // 20 MB (pdfjs lato browser non regge bene molto di più) | pdf: 20 * MB, // 20 MB (pdfjs lato browser non regge bene molto di più) | ||||
| video: 1024 * MB, // 1 GB | video: 1024 * MB, // 1 GB | ||||
| font: 5 * MB, // 5 MB (i font web sono tipicamente 50-500 KB; cap di sicurezza) | |||||
| } as const; | } as const; | ||||