| @@ -3,6 +3,19 @@ | |||||
| 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 } from '@/lib/config'; | import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT } from '@/lib/config'; | ||||
| import { CARD_LIMITS } from '@/lib/validation'; | |||||
| type CharCounterProps = { value: string | undefined; limit: number }; | |||||
| function CharCounter({ value, limit }: CharCounterProps) { | |||||
| const len = (value ?? '').length; | |||||
| if (len < limit * 0.8) return null; | |||||
| const overflow = len > limit; | |||||
| return ( | |||||
| <p className={`text-xs mt-1 text-right ${overflow ? 'text-red-600 font-semibold' : 'text-gray-500'}`}> | |||||
| {len} / {limit} | |||||
| </p> | |||||
| ); | |||||
| } | |||||
| function StyledSelect<T extends string>({ | function StyledSelect<T extends string>({ | ||||
| value, | value, | ||||
| @@ -191,6 +204,9 @@ export default function AdminDashboard() { | |||||
| const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null); | const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null); | ||||
| const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null); | const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null); | ||||
| const [availableFonts, setAvailableFonts] = useState<string[]>([]); | const [availableFonts, setAvailableFonts] = useState<string[]>([]); | ||||
| // Map: expected URL of the future-transcoded file → job state. | |||||
| // We key by URL (not jobId) so the rendering layer can look it up cheaply. | |||||
| const [transcodeJobs, setTranscodeJobs] = useState<Record<string, { jobId: string; status: string; progress: number }>>({}); | |||||
| // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config. | // External Link feature flag: priorità al setting del portale, fallback alla costante in lib/config. | ||||
| const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT; | const externalLinksOn = portal.externalLinkEnabled ?? EXTERNAL_LINK_DEFAULT; | ||||
| @@ -207,6 +223,61 @@ export default function AdminDashboard() { | |||||
| fetch('/api/fonts').then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([])); | fetch('/api/fonts').then(res => res.json()).then(setAvailableFonts).catch(() => setAvailableFonts([])); | ||||
| }, []); | }, []); | ||||
| // 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. | |||||
| useEffect(() => { | |||||
| const pendingEntries = Object.entries(transcodeJobs).filter( | |||||
| ([, j]) => j.status === 'queued' || j.status === 'running' | |||||
| ); | |||||
| if (pendingEntries.length === 0) return; | |||||
| let cancelled = false; | |||||
| const tick = async () => { | |||||
| for (const [url, j] of pendingEntries) { | |||||
| if (cancelled) return; | |||||
| try { | |||||
| const res = await fetch(`/api/transcode/${j.jobId}`); | |||||
| if (!res.ok) continue; | |||||
| const data = await res.json(); | |||||
| if (cancelled) return; | |||||
| if (data.status === 'done') { | |||||
| setTranscodeJobs(prev => { | |||||
| const next = { ...prev }; | |||||
| delete next[url]; | |||||
| return next; | |||||
| }); | |||||
| } else if (data.status === 'failed' || data.status === 'cancelled') { | |||||
| setTranscodeJobs(prev => { | |||||
| const next = { ...prev }; | |||||
| delete next[url]; | |||||
| return next; | |||||
| }); | |||||
| setIsEditing(prev => prev ? { | |||||
| ...prev, | |||||
| extraMedia: (prev.extraMedia || []).filter(m => m.url !== url), | |||||
| imageUrl: prev.imageUrl === url ? '' : prev.imageUrl, | |||||
| } : prev); | |||||
| const msg = data.status === 'failed' | |||||
| ? `Trascodifica fallita${data.error ? `: ${String(data.error).split('\n')[0]}` : ''}` | |||||
| : 'Trascodifica annullata'; | |||||
| showToast(msg, 'error'); | |||||
| } else { | |||||
| setTranscodeJobs(prev => prev[url] ? ({ ...prev, [url]: { ...prev[url], status: data.status, progress: data.progress ?? 0 } }) : prev); | |||||
| } | |||||
| } catch { | |||||
| // ignore network glitches; will retry next tick | |||||
| } | |||||
| } | |||||
| }; | |||||
| void tick(); | |||||
| const id = window.setInterval(() => { void tick(); }, 2000); | |||||
| return () => { cancelled = true; window.clearInterval(id); }; | |||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [Object.keys(transcodeJobs).join('|')]); | |||||
| const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => { | const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: string, isPortal = false) => { | ||||
| if (!e.target.files?.[0]) return; | if (!e.target.files?.[0]) return; | ||||
| setUploading(prev => ({ ...prev, [field]: true })); | setUploading(prev => ({ ...prev, [field]: true })); | ||||
| @@ -282,6 +353,11 @@ export default function AdminDashboard() { | |||||
| const data = await res.json(); | const data = await res.json(); | ||||
| if (!data.url) continue; | if (!data.url) continue; | ||||
| if (data?.transcoding?.jobId) { | |||||
| const { jobId, status } = data.transcoding; | |||||
| setTranscodeJobs(prev => ({ ...prev, [data.url]: { jobId, status, progress: 0 } })); | |||||
| } | |||||
| if (isVideoFile(file)) { | if (isVideoFile(file)) { | ||||
| // Video always goes to the gallery so users can play it. | // Video always goes to the gallery so users can play it. | ||||
| uploaded.push({ url: data.url }); | uploaded.push({ url: data.url }); | ||||
| @@ -367,11 +443,28 @@ export default function AdminDashboard() { | |||||
| if (!isEditing) return; | if (!isEditing) return; | ||||
| const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); | const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); | ||||
| const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card; | const newCard = { ...isEditing, id: isEditing.id || generateSafeId() } as Card; | ||||
| await fetch('/api/cards', { | |||||
| const res = await fetch('/api/cards', { | |||||
| method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard) | method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newCard) | ||||
| }); | }); | ||||
| if (!res.ok) { | |||||
| let message = 'Errore di salvataggio'; | |||||
| try { | |||||
| const body = await res.json(); | |||||
| if (res.status === 400 && Array.isArray(body?.errors) && body.errors.length > 0) { | |||||
| const first = body.errors[0]; | |||||
| message = first.limit != null | |||||
| ? `${first.field}: ${first.message} (${first.actual} / ${first.limit})` | |||||
| : `${first.field}: ${first.message}`; | |||||
| } else if (body?.error) { | |||||
| message = body.error; | |||||
| } | |||||
| } catch {} | |||||
| showToast(message, 'error'); | |||||
| return; // keep the editor open so the admin can fix | |||||
| } | |||||
| setCards(prev => { | setCards(prev => { | ||||
| const exists = prev.find(c => c.id === newCard.id); | const exists = prev.find(c => c.id === newCard.id); | ||||
| return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard]; | return exists ? prev.map(c => c.id === newCard.id ? newCard : c) : [...prev, newCard]; | ||||
| @@ -662,7 +755,8 @@ export default function AdminDashboard() { | |||||
| <div className="space-y-5"> | <div className="space-y-5"> | ||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label> | <label className="block text-sm font-semibold text-gray-800 mb-1">Title</label> | ||||
| <input type="text" value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" /> | |||||
| <input type="text" maxLength={CARD_LIMITS.title} value={isEditing.title || ''} onChange={e => setIsEditing({...isEditing, title: e.target.value})} className={inputClasses} placeholder="e.g., Local History" /> | |||||
| <CharCounter value={isEditing.title} limit={CARD_LIMITS.title} /> | |||||
| </div> | </div> | ||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label> | <label className="block text-sm font-semibold text-gray-800 mb-1">Card Type</label> | ||||
| @@ -683,21 +777,25 @@ export default function AdminDashboard() { | |||||
| <label className="block text-sm font-semibold text-gray-800 mb-1">URL</label> | <label className="block text-sm font-semibold text-gray-800 mb-1">URL</label> | ||||
| <input | <input | ||||
| type="url" | type="url" | ||||
| maxLength={CARD_LIMITS.actionUrl} | |||||
| value={isEditing.actionUrl || ''} | value={isEditing.actionUrl || ''} | ||||
| onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })} | onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })} | ||||
| className={inputClasses} | className={inputClasses} | ||||
| placeholder="https://esempio.it/pagina" | placeholder="https://esempio.it/pagina" | ||||
| /> | /> | ||||
| <CharCounter value={isEditing.actionUrl} limit={CARD_LIMITS.actionUrl} /> | |||||
| </div> | </div> | ||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label> | <label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label> | ||||
| <input | <input | ||||
| type="text" | type="text" | ||||
| maxLength={CARD_LIMITS.shortDescription} | |||||
| value={isEditing.shortDescription || ''} | value={isEditing.shortDescription || ''} | ||||
| onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} | onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} | ||||
| className={inputClasses} | className={inputClasses} | ||||
| placeholder="es. Visita il sito ufficiale" | placeholder="es. Visita il sito ufficiale" | ||||
| /> | /> | ||||
| <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} /> | |||||
| <p className="text-xs text-gray-500 mt-1">Testo visualizzato come link cliccabile nel modale. Se vuoto, viene mostrata l’URL stessa.</p> | <p className="text-xs text-gray-500 mt-1">Testo visualizzato come link cliccabile nel modale. Se vuoto, viene mostrata l’URL stessa.</p> | ||||
| </div> | </div> | ||||
| <div className="bg-gray-50 p-3 rounded-lg border border-gray-200"> | <div className="bg-gray-50 p-3 rounded-lg border border-gray-200"> | ||||
| @@ -718,7 +816,8 @@ export default function AdminDashboard() { | |||||
| ) : ( | ) : ( | ||||
| <div> | <div> | ||||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label> | <label className="block text-sm font-semibold text-gray-800 mb-1">Short Description</label> | ||||
| <textarea value={isEditing.shortDescription || ''} onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." /> | |||||
| <textarea maxLength={CARD_LIMITS.shortDescription} value={isEditing.shortDescription || ''} onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} className={`${inputClasses} h-24 resize-none`} placeholder="Brief summary..." /> | |||||
| <CharCounter value={isEditing.shortDescription} limit={CARD_LIMITS.shortDescription} /> | |||||
| </div> | </div> | ||||
| )} | )} | ||||
| {isEditing.cardType !== 'BOOK' && ( | {isEditing.cardType !== 'BOOK' && ( | ||||
| @@ -805,6 +904,8 @@ export default function AdminDashboard() { | |||||
| <div className="mt-3 space-y-2"> | <div className="mt-3 space-y-2"> | ||||
| {(isEditing.extraMedia || []).map((item, i) => { | {(isEditing.extraMedia || []).map((item, i) => { | ||||
| const video = isVideoUrl(item.url); | const video = isVideoUrl(item.url); | ||||
| const tc = transcodeJobs[item.url]; | |||||
| const isTranscoding = !!tc && (tc.status === 'queued' || tc.status === 'running'); | |||||
| return ( | return ( | ||||
| <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg"> | <div key={item.url + i} className="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded-lg"> | ||||
| <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0"> | <div className="relative w-16 h-16 rounded-md overflow-hidden bg-black shrink-0"> | ||||
| @@ -816,6 +917,12 @@ export default function AdminDashboard() { | |||||
| ) : ( | ) : ( | ||||
| <img src={item.url} className="w-full h-full object-cover" alt="" /> | <img src={item.url} className="w-full h-full object-cover" alt="" /> | ||||
| )} | )} | ||||
| {isTranscoding && ( | |||||
| <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/75 text-white text-[10px] font-semibold leading-tight gap-0.5"> | |||||
| <span>Transcoding</span> | |||||
| <span>{Math.round((tc.progress || 0) * 100)}%</span> | |||||
| </div> | |||||
| )} | |||||
| <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span> | <span className="absolute bottom-0 left-0 right-0 text-center text-white text-[10px] bg-black/60">{i + 1}</span> | ||||
| </div> | </div> | ||||
| <div className="flex-1 min-w-0"> | <div className="flex-1 min-w-0"> | ||||
| @@ -1,6 +1,8 @@ | |||||
| import { NextResponse } from 'next/server'; | import { NextResponse } from 'next/server'; | ||||
| import { revalidatePath } from 'next/cache'; | import { revalidatePath } from 'next/cache'; | ||||
| import { getCards, saveCards } from '@/lib/db'; | import { getCards, saveCards } from '@/lib/db'; | ||||
| import { validateCard } from '@/lib/validation'; | |||||
| import { sanitizeCardHtml } from '@/lib/sanitize'; | |||||
| import { Card } from '@/types'; | import { Card } from '@/types'; | ||||
| export const dynamic = 'force-dynamic'; | export const dynamic = 'force-dynamic'; | ||||
| @@ -15,19 +17,29 @@ export async function GET(request: Request) { | |||||
| export async function POST(request: Request) { | export async function POST(request: Request) { | ||||
| try { | try { | ||||
| const incomingCard: Card = await request.json(); | const incomingCard: Card = await request.json(); | ||||
| const validation = validateCard(incomingCard); | |||||
| if (!validation.valid) { | |||||
| return NextResponse.json({ error: 'Validation failed', errors: validation.errors }, { status: 400 }); | |||||
| } | |||||
| if (typeof incomingCard.fullContent === 'string') { | |||||
| incomingCard.fullContent = sanitizeCardHtml(incomingCard.fullContent); | |||||
| } | |||||
| const cards = await getCards(); | const cards = await getCards(); | ||||
| const existingIndex = cards.findIndex(c => c.id === incomingCard.id); | const existingIndex = cards.findIndex(c => c.id === incomingCard.id); | ||||
| if (existingIndex >= 0) { | if (existingIndex >= 0) { | ||||
| cards[existingIndex] = incomingCard; | cards[existingIndex] = incomingCard; | ||||
| } else { | } else { | ||||
| cards.push(incomingCard); | cards.push(incomingCard); | ||||
| } | } | ||||
| await saveCards(cards); | await saveCards(cards); | ||||
| revalidatePath('/'); // Force public portal to update instantly | revalidatePath('/'); // Force public portal to update instantly | ||||
| return NextResponse.json(incomingCard, { status: 200 }); | return NextResponse.json(incomingCard, { status: 200 }); | ||||
| } catch (error) { | |||||
| } catch { | |||||
| return NextResponse.json({ error: 'Failed to save card' }, { status: 500 }); | return NextResponse.json({ error: 'Failed to save card' }, { status: 500 }); | ||||
| } | } | ||||
| } | } | ||||
| @@ -9,13 +9,14 @@ const MIME: Record<string, string> = { | |||||
| '.jpg': 'image/jpeg', | '.jpg': 'image/jpeg', | ||||
| '.jpeg': 'image/jpeg', | '.jpeg': 'image/jpeg', | ||||
| '.gif': 'image/gif', | '.gif': 'image/gif', | ||||
| '.svg': 'image/svg+xml', | |||||
| '.webp': 'image/webp', | '.webp': 'image/webp', | ||||
| '.mp4': 'video/mp4', | '.mp4': 'video/mp4', | ||||
| '.webm': 'video/webm', | '.webm': 'video/webm', | ||||
| '.mov': 'video/quicktime', | '.mov': 'video/quicktime', | ||||
| '.m4v': 'video/x-m4v', | '.m4v': 'video/x-m4v', | ||||
| '.ogv': 'video/ogg', | '.ogv': 'video/ogg', | ||||
| '.ogg': 'video/ogg', | |||||
| '.pdf': 'application/pdf', | |||||
| }; | }; | ||||
| export async function GET(request: Request) { | export async function GET(request: Request) { | ||||
| @@ -23,7 +24,9 @@ export async function GET(request: Request) { | |||||
| const name = searchParams.get('name'); | const name = searchParams.get('name'); | ||||
| if (!name) return new NextResponse('File name required', { status: 400 }); | if (!name) return new NextResponse('File name required', { status: 400 }); | ||||
| const filePath = path.join(process.cwd(), 'data', 'uploads', name); | |||||
| // Strip any directory traversal attempt; we only ever serve from data/uploads/ | |||||
| const safeName = path.basename(name); | |||||
| const filePath = path.join(process.cwd(), 'data', 'uploads', safeName); | |||||
| let stat: fs.Stats; | let stat: fs.Stats; | ||||
| try { | try { | ||||
| @@ -32,8 +35,9 @@ export async function GET(request: Request) { | |||||
| return new NextResponse('File not found', { status: 404 }); | return new NextResponse('File not found', { status: 404 }); | ||||
| } | } | ||||
| const ext = path.extname(name).toLowerCase(); | |||||
| const ext = path.extname(safeName).toLowerCase(); | |||||
| const mimeType = MIME[ext] || 'application/octet-stream'; | const mimeType = MIME[ext] || 'application/octet-stream'; | ||||
| const disposition = `inline; filename="${safeName.replace(/"/g, '')}"`; | |||||
| const fileSize = stat.size; | const fileSize = stat.size; | ||||
| // Handle Range requests (essential for video seeking) | // Handle Range requests (essential for video seeking) | ||||
| @@ -66,6 +70,7 @@ export async function GET(request: Request) { | |||||
| 'Content-Range': `bytes ${start}-${end}/${fileSize}`, | 'Content-Range': `bytes ${start}-${end}/${fileSize}`, | ||||
| 'Accept-Ranges': 'bytes', | 'Accept-Ranges': 'bytes', | ||||
| 'Cache-Control': 'public, max-age=86400', | 'Cache-Control': 'public, max-age=86400', | ||||
| 'Content-Disposition': disposition, | |||||
| }, | }, | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| import { NextResponse } from 'next/server'; | import { NextResponse } from 'next/server'; | ||||
| import { revalidatePath } from 'next/cache'; // ADD THIS | import { revalidatePath } from 'next/cache'; // ADD THIS | ||||
| import { getPortals, savePortals } from '@/lib/db'; | import { getPortals, savePortals } from '@/lib/db'; | ||||
| import { isValidHexColor } from '@/lib/sanitize'; | |||||
| import { Portal } from '@/types'; | import { Portal } from '@/types'; | ||||
| export const dynamic = 'force-dynamic'; | export const dynamic = 'force-dynamic'; | ||||
| @@ -13,6 +14,16 @@ export async function GET() { | |||||
| export async function POST(request: Request) { | export async function POST(request: Request) { | ||||
| try { | try { | ||||
| const incomingPortal: Portal = await request.json(); | const incomingPortal: Portal = await request.json(); | ||||
| // themeColor goes into a <style dangerouslySetInnerHTML> in PublicGrid, | |||||
| // so reject anything that is not a strict #RRGGBB. | |||||
| if (incomingPortal.themeColor !== undefined && !isValidHexColor(incomingPortal.themeColor)) { | |||||
| return NextResponse.json( | |||||
| { error: 'Validation failed', errors: [{ field: 'themeColor', message: 'Colore non valido (atteso #RRGGBB)' }] }, | |||||
| { status: 400 } | |||||
| ); | |||||
| } | |||||
| const portals = await getPortals(); | const portals = await getPortals(); | ||||
| if (portals.length > 0) { | if (portals.length > 0) { | ||||
| @@ -27,7 +38,7 @@ export async function POST(request: Request) { | |||||
| revalidatePath('/', 'layout'); | revalidatePath('/', 'layout'); | ||||
| return NextResponse.json(portals[0], { status: 200 }); | return NextResponse.json(portals[0], { status: 200 }); | ||||
| } catch (error) { | |||||
| } catch { | |||||
| return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 }); | return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 }); | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,26 @@ | |||||
| import { NextResponse } from 'next/server'; | |||||
| import { getJob, cancelJob } from '@/lib/transcode'; | |||||
| export const dynamic = 'force-dynamic'; | |||||
| type Ctx = { params: Promise<{ id: string }> }; | |||||
| export async function GET(_req: Request, ctx: Ctx) { | |||||
| const { id } = await ctx.params; | |||||
| const job = await getJob(id); | |||||
| if (!job) return NextResponse.json({ error: 'Job not found' }, { status: 404 }); | |||||
| return NextResponse.json({ | |||||
| id: job.id, | |||||
| status: job.status, | |||||
| progress: job.progress, | |||||
| error: job.error, | |||||
| outputName: job.outputName, | |||||
| }); | |||||
| } | |||||
| export async function DELETE(_req: Request, ctx: Ctx) { | |||||
| const { id } = await ctx.params; | |||||
| const ok = await cancelJob(id); | |||||
| if (!ok) return NextResponse.json({ error: 'Cannot cancel (not found or already finished)' }, { status: 404 }); | |||||
| return NextResponse.json({ success: true }); | |||||
| } | |||||
| @@ -1,30 +1,175 @@ | |||||
| import { NextResponse } from 'next/server'; | |||||
| import { writeFile, mkdir } from 'fs/promises'; | |||||
| import path from 'path'; | |||||
| export async function POST(request: Request) { | |||||
| try { | |||||
| const formData = await request.formData(); | |||||
| const file = formData.get('file') as File; | |||||
| if (!file) { | |||||
| return NextResponse.json({ error: 'No file received.' }, { status: 400 }); | |||||
| } | |||||
| const buffer = Buffer.from(await file.arrayBuffer()); | |||||
| // Strip special characters to prevent URL breaking | |||||
| const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_'); | |||||
| const filename = `${Date.now()}-${safeName}`; | |||||
| // Save to data/uploads instead of public/uploads | |||||
| const uploadDir = path.join(process.cwd(), 'data', 'uploads'); | |||||
| await mkdir(uploadDir, { recursive: true }); | |||||
| await writeFile(path.join(uploadDir, filename), buffer); | |||||
| // Return a dynamic API route URL instead of a static path | |||||
| return NextResponse.json({ url: `/api/files?name=${filename}` }, { status: 201 }); | |||||
| } catch (error) { | |||||
| console.error('Upload Error:', error); | |||||
| return NextResponse.json({ error: 'Failed to upload image.' }, { status: 500 }); | |||||
| } | |||||
| } | |||||
| import { NextResponse } from 'next/server'; | |||||
| import { writeFile, mkdir, rename, unlink } from 'fs/promises'; | |||||
| import path from 'path'; | |||||
| import { fromBuffer as fileTypeFromBuffer } from 'file-type'; | |||||
| import { enqueueTranscode, needsTranscode, probeCodecs, isFfmpegAvailable } from '@/lib/transcode'; | |||||
| export const dynamic = 'force-dynamic'; | |||||
| export const maxDuration = 300; | |||||
| // Allowed extensions and their families (size + handling differ per family). | |||||
| type Family = 'image' | 'video' | 'pdf'; | |||||
| const EXT_FAMILY: Record<string, Family> = { | |||||
| png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', | |||||
| mp4: 'video', m4v: 'video', webm: 'video', mov: 'video', ogv: 'video', ogg: 'video', | |||||
| pdf: 'pdf', | |||||
| }; | |||||
| // Strict per family, permissive within a family (mov ↔ mp4 are both ISO BMFF). | |||||
| const ALLOWED_MIMES: Record<string, string[]> = { | |||||
| png: ['image/png'], | |||||
| jpg: ['image/jpeg'], | |||||
| jpeg: ['image/jpeg'], | |||||
| gif: ['image/gif'], | |||||
| webp: ['image/webp'], | |||||
| mp4: ['video/mp4', 'video/x-m4v', 'video/quicktime'], | |||||
| m4v: ['video/mp4', 'video/x-m4v'], | |||||
| webm: ['video/webm'], | |||||
| mov: ['video/quicktime', 'video/mp4'], | |||||
| ogv: ['video/ogg', 'application/ogg'], | |||||
| ogg: ['video/ogg', 'application/ogg', 'audio/ogg'], | |||||
| pdf: ['application/pdf'], | |||||
| }; | |||||
| const MAX_BYTES: Record<Family, number> = { | |||||
| image: 25 * 1024 * 1024, // 25 MB | |||||
| pdf: 20 * 1024 * 1024, // 20 MB (pdfjs lato browser non regge bene di più) | |||||
| video: 1024 * 1024 * 1024, // 1 GB (verrà ricodificato lato server se necessario) | |||||
| }; | |||||
| const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads'); | |||||
| const TMP_DIR = path.join(UPLOAD_DIR, '.tmp'); | |||||
| function extractExt(name: string): string { | |||||
| const lastDot = name.lastIndexOf('.'); | |||||
| if (lastDot < 0 || lastDot === name.length - 1) return ''; | |||||
| return name.slice(lastDot + 1).toLowerCase(); | |||||
| } | |||||
| export function normalizeFilename(originalName: string): string { | |||||
| const ext = extractExt(originalName); | |||||
| const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin'; | |||||
| const base = ext ? originalName.slice(0, originalName.length - ext.length - 1) : originalName; | |||||
| const slug = base | |||||
| .normalize('NFKD') | |||||
| .replace(/\p{Mn}/gu, '') | |||||
| .toLowerCase() | |||||
| .replace(/[\s/\\]+/g, '-') | |||||
| .replace(/[^a-z0-9_-]/g, '') | |||||
| .replace(/-+/g, '-') | |||||
| .replace(/^[-_]+|[-_]+$/g, '') | |||||
| .slice(0, 40) || 'file'; | |||||
| const ts = Date.now().toString(36); | |||||
| const rand = Math.random().toString(36).slice(2, 8); | |||||
| return `${ts}-${rand}-${slug}.${safeExt}`; | |||||
| } | |||||
| export async function POST(request: Request) { | |||||
| let tmpPath: string | null = null; | |||||
| 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 ext = extractExt(file.name); | |||||
| const family = EXT_FAMILY[ext]; | |||||
| if (!family) { | |||||
| return NextResponse.json( | |||||
| { error: `Estensione non permessa: .${ext || '(nessuna)'}` }, | |||||
| { status: 400 } | |||||
| ); | |||||
| } | |||||
| if (file.size > MAX_BYTES[family]) { | |||||
| const mb = (MAX_BYTES[family] / (1024 * 1024)).toFixed(0); | |||||
| return NextResponse.json( | |||||
| { error: `File troppo grande (max ${mb} MB per ${family}).` }, | |||||
| { status: 413 } | |||||
| ); | |||||
| } | |||||
| const buffer = Buffer.from(await file.arrayBuffer()); | |||||
| // Magic-bytes sniffing: reject if the file content doesn't match its claimed extension. | |||||
| const detected = await fileTypeFromBuffer(buffer); | |||||
| if (!detected) { | |||||
| return NextResponse.json( | |||||
| { error: 'Tipo del file non riconoscibile dal contenuto.' }, | |||||
| { status: 400 } | |||||
| ); | |||||
| } | |||||
| const allowedMimes = ALLOWED_MIMES[ext] ?? []; | |||||
| if (!allowedMimes.includes(detected.mime)) { | |||||
| return NextResponse.json( | |||||
| { error: `Contenuto del file non corrisponde all'estensione (.${ext} dichiarato, rilevato ${detected.mime}).` }, | |||||
| { status: 400 } | |||||
| ); | |||||
| } | |||||
| const storedName = normalizeFilename(file.name); | |||||
| await mkdir(TMP_DIR, { recursive: true }); | |||||
| tmpPath = path.join(TMP_DIR, storedName); | |||||
| await writeFile(tmpPath, buffer); | |||||
| // Image / PDF: rename atomically into the uploads dir and we're done. | |||||
| if (family !== 'video') { | |||||
| const finalPath = path.join(UPLOAD_DIR, storedName); | |||||
| await rename(tmpPath, finalPath); | |||||
| tmpPath = null; | |||||
| return NextResponse.json( | |||||
| { url: `/api/files?name=${encodeURIComponent(storedName)}` }, | |||||
| { status: 201 } | |||||
| ); | |||||
| } | |||||
| // Video: probe codecs. If already h264+aac/mp3 → fast path (rename). | |||||
| const codecs = await probeCodecs(tmpPath); | |||||
| if (!needsTranscode(codecs)) { | |||||
| const finalPath = path.join(UPLOAD_DIR, storedName); | |||||
| await rename(tmpPath, finalPath); | |||||
| tmpPath = null; | |||||
| return NextResponse.json( | |||||
| { url: `/api/files?name=${encodeURIComponent(storedName)}` }, | |||||
| { status: 201 } | |||||
| ); | |||||
| } | |||||
| // Needs transcoding: bail out early if ffmpeg is unavailable. | |||||
| if (!(await isFfmpegAvailable())) { | |||||
| return NextResponse.json( | |||||
| { error: 'Video richiede ricodifica ma ffmpeg non è disponibile sul server.' }, | |||||
| { status: 503 } | |||||
| ); | |||||
| } | |||||
| // The final file is always an .mp4 once transcoded — replace the extension. | |||||
| const finalMp4Name = storedName.replace(/\.[^.]+$/, '.mp4'); | |||||
| const job = await enqueueTranscode({ | |||||
| inputPath: tmpPath, | |||||
| outputName: finalMp4Name, | |||||
| originalUploadName: file.name, | |||||
| }); | |||||
| // The input is now owned by the transcode worker; don't unlink in our catch. | |||||
| tmpPath = null; | |||||
| return NextResponse.json( | |||||
| { | |||||
| url: `/api/files?name=${encodeURIComponent(finalMp4Name)}`, | |||||
| transcoding: { jobId: job.id, status: job.status }, | |||||
| }, | |||||
| { status: 201 } | |||||
| ); | |||||
| } catch (error) { | |||||
| console.error('Upload Error:', error); | |||||
| if (tmpPath) { | |||||
| try { await unlink(tmpPath); } catch {} | |||||
| } | |||||
| return NextResponse.json({ error: 'Failed to upload file.' }, { status: 500 }); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| import sanitizeHtml from 'sanitize-html'; | |||||
| const CARD_HTML_CONFIG: sanitizeHtml.IOptions = { | |||||
| allowedTags: [ | |||||
| 'p', 'br', 'strong', 'em', 'b', 'i', 'u', | |||||
| 'ul', 'ol', 'li', | |||||
| 'a', | |||||
| 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', | |||||
| 'blockquote', | |||||
| 'span', | |||||
| ], | |||||
| allowedAttributes: { | |||||
| a: ['href', 'title', 'target', 'rel'], | |||||
| span: ['class'], | |||||
| '*': [], | |||||
| }, | |||||
| allowedSchemes: ['http', 'https', 'mailto', 'tel'], | |||||
| allowedSchemesAppliedToAttributes: ['href'], | |||||
| transformTags: { | |||||
| a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }), | |||||
| }, | |||||
| disallowedTagsMode: 'discard', | |||||
| }; | |||||
| export function sanitizeCardHtml(input: string | null | undefined): string { | |||||
| if (!input) return ''; | |||||
| return sanitizeHtml(input, CARD_HTML_CONFIG); | |||||
| } | |||||
| const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; | |||||
| export function isValidHexColor(value: unknown): value is string { | |||||
| return typeof value === 'string' && HEX_COLOR_RE.test(value); | |||||
| } | |||||
| @@ -0,0 +1,338 @@ | |||||
| // Server-only video transcoding pipeline. | |||||
| // | |||||
| // Why asynchronous (single FIFO worker): | |||||
| // - HEVC iPhone clips can take 30–120 s to transcode → would exceed reverse-proxy | |||||
| // timeouts if done inline in the upload response. | |||||
| // - State is persisted in data/transcode-jobs.json so we can recover orphan jobs | |||||
| // after a server restart. | |||||
| // | |||||
| // Public API: | |||||
| // - enqueueTranscode(inputPath, finalName, originalUploadName) → registers a job | |||||
| // and kicks the worker if idle. The caller knows the final URL up-front. | |||||
| // - getJob(id), listJobs(), cancelJob(id) | |||||
| // | |||||
| // All filesystem writes go through a per-file lock (no concurrent writers). | |||||
| // Only one ffmpeg process runs at a time. | |||||
| import { spawn } from 'node:child_process'; | |||||
| import { mkdir, readFile, writeFile, rename, unlink, stat } from 'node:fs/promises'; | |||||
| import path from 'node:path'; | |||||
| import crypto from 'node:crypto'; | |||||
| const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads'); | |||||
| const TMP_DIR = path.join(UPLOAD_DIR, '.tmp'); | |||||
| const JOBS_PATH = path.join(process.cwd(), 'data', 'transcode-jobs.json'); | |||||
| export type TranscodeStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled'; | |||||
| export type TranscodeJob = { | |||||
| id: string; | |||||
| inputPath: string; // .tmp/<id>.<origExt> | |||||
| outputName: string; // final basename in data/uploads/ | |||||
| originalUploadName: string; | |||||
| status: TranscodeStatus; | |||||
| progress: number; // 0..1 | |||||
| durationSec?: number; | |||||
| error?: string; | |||||
| startedAt?: number; | |||||
| finishedAt?: number; | |||||
| pid?: number; | |||||
| }; | |||||
| let saving: Promise<void> = Promise.resolve(); | |||||
| let workerRunning = false; | |||||
| let ffmpegAvailable: boolean | null = null; | |||||
| async function ensureDirs() { | |||||
| await mkdir(UPLOAD_DIR, { recursive: true }); | |||||
| await mkdir(TMP_DIR, { recursive: true }); | |||||
| } | |||||
| async function loadJobs(): Promise<TranscodeJob[]> { | |||||
| try { | |||||
| const raw = await readFile(JOBS_PATH, 'utf-8'); | |||||
| const parsed = JSON.parse(raw); | |||||
| return Array.isArray(parsed) ? parsed : []; | |||||
| } catch (err) { | |||||
| const e = err as NodeJS.ErrnoException; | |||||
| if (e.code === 'ENOENT') return []; | |||||
| throw err; | |||||
| } | |||||
| } | |||||
| async function saveJobs(jobs: TranscodeJob[]): Promise<void> { | |||||
| // Serialize writes; readers always see a fully-written file. | |||||
| const next = saving.then(async () => { | |||||
| await mkdir(path.dirname(JOBS_PATH), { recursive: true }); | |||||
| const tmp = `${JOBS_PATH}.tmp`; | |||||
| await writeFile(tmp, JSON.stringify(jobs, null, 2), 'utf-8'); | |||||
| await rename(tmp, JOBS_PATH); | |||||
| }); | |||||
| saving = next.catch(() => {}); // never let the chain reject | |||||
| return next; | |||||
| } | |||||
| async function updateJob(id: string, patch: Partial<TranscodeJob>): Promise<void> { | |||||
| const jobs = await loadJobs(); | |||||
| const idx = jobs.findIndex(j => j.id === id); | |||||
| if (idx < 0) return; | |||||
| jobs[idx] = { ...jobs[idx], ...patch }; | |||||
| await saveJobs(jobs); | |||||
| } | |||||
| export async function getJob(id: string): Promise<TranscodeJob | null> { | |||||
| const jobs = await loadJobs(); | |||||
| return jobs.find(j => j.id === id) ?? null; | |||||
| } | |||||
| export async function listJobs(): Promise<TranscodeJob[]> { | |||||
| return loadJobs(); | |||||
| } | |||||
| async function checkFfmpeg(): Promise<boolean> { | |||||
| if (ffmpegAvailable !== null) return ffmpegAvailable; | |||||
| ffmpegAvailable = await new Promise<boolean>(resolve => { | |||||
| const p = spawn('ffmpeg', ['-version']); | |||||
| p.on('error', () => resolve(false)); | |||||
| p.on('exit', code => resolve(code === 0)); | |||||
| }); | |||||
| if (!ffmpegAvailable) { | |||||
| console.warn('[transcode] ffmpeg not found on PATH — video uploads requiring transcode will fail'); | |||||
| } | |||||
| return ffmpegAvailable; | |||||
| } | |||||
| export async function isFfmpegAvailable(): Promise<boolean> { | |||||
| return checkFfmpeg(); | |||||
| } | |||||
| type Codecs = { video?: string; audio?: string }; | |||||
| export async function probeCodecs(inputPath: string): Promise<Codecs> { | |||||
| const out = await new Promise<string>((resolve, reject) => { | |||||
| const p = spawn('ffprobe', [ | |||||
| '-v', 'error', | |||||
| '-show_entries', 'stream=codec_type,codec_name', | |||||
| '-of', 'default=noprint_wrappers=1', | |||||
| inputPath, | |||||
| ]); | |||||
| let buf = ''; | |||||
| p.stdout.on('data', d => { buf += d.toString(); }); | |||||
| p.on('error', reject); | |||||
| p.on('exit', () => resolve(buf)); | |||||
| }); | |||||
| const result: Codecs = {}; | |||||
| // Lines come in pairs: codec_name=..., codec_type=... | |||||
| const lines = out.split(/\r?\n/); | |||||
| let pendingName: string | undefined; | |||||
| for (const line of lines) { | |||||
| const m = /^(codec_(?:name|type))=(.+)$/.exec(line.trim()); | |||||
| if (!m) continue; | |||||
| if (m[1] === 'codec_name') pendingName = m[2]; | |||||
| else if (m[1] === 'codec_type') { | |||||
| if (pendingName) { | |||||
| if (m[2] === 'video' && !result.video) result.video = pendingName; | |||||
| else if (m[2] === 'audio' && !result.audio) result.audio = pendingName; | |||||
| } | |||||
| pendingName = undefined; | |||||
| } | |||||
| } | |||||
| return result; | |||||
| } | |||||
| export function needsTranscode(codecs: Codecs): boolean { | |||||
| const videoOk = codecs.video === 'h264'; | |||||
| const audioOk = !codecs.audio || codecs.audio === 'aac' || codecs.audio === 'mp3'; | |||||
| return !(videoOk && audioOk); | |||||
| } | |||||
| async function probeDuration(inputPath: string): Promise<number | undefined> { | |||||
| const out = await new Promise<string>((resolve, reject) => { | |||||
| const p = spawn('ffprobe', [ | |||||
| '-v', 'error', | |||||
| '-show_entries', 'format=duration', | |||||
| '-of', 'default=noprint_wrappers=1:nokey=1', | |||||
| inputPath, | |||||
| ]); | |||||
| let buf = ''; | |||||
| p.stdout.on('data', d => { buf += d.toString(); }); | |||||
| p.on('error', reject); | |||||
| p.on('exit', () => resolve(buf)); | |||||
| }); | |||||
| const n = parseFloat(out.trim()); | |||||
| return Number.isFinite(n) && n > 0 ? n : undefined; | |||||
| } | |||||
| export async function enqueueTranscode(args: { | |||||
| inputPath: string; | |||||
| outputName: string; | |||||
| originalUploadName: string; | |||||
| }): Promise<TranscodeJob> { | |||||
| await ensureDirs(); | |||||
| const available = await checkFfmpeg(); | |||||
| if (!available) { | |||||
| const err = new Error('ffmpeg non disponibile sul server'); | |||||
| (err as Error & { code?: number }).code = 503; | |||||
| throw err; | |||||
| } | |||||
| const id = crypto.randomUUID(); | |||||
| const duration = await probeDuration(args.inputPath).catch(() => undefined); | |||||
| const job: TranscodeJob = { | |||||
| id, | |||||
| inputPath: args.inputPath, | |||||
| outputName: args.outputName, | |||||
| originalUploadName: args.originalUploadName, | |||||
| status: 'queued', | |||||
| progress: 0, | |||||
| durationSec: duration, | |||||
| }; | |||||
| const jobs = await loadJobs(); | |||||
| jobs.push(job); | |||||
| await saveJobs(jobs); | |||||
| void kickWorker(); | |||||
| return job; | |||||
| } | |||||
| export async function cancelJob(id: string): Promise<boolean> { | |||||
| const job = await getJob(id); | |||||
| if (!job) return false; | |||||
| if (job.status === 'done' || job.status === 'failed' || job.status === 'cancelled') return false; | |||||
| if (job.status === 'running' && job.pid) { | |||||
| try { process.kill(job.pid, 'SIGTERM'); } catch {} | |||||
| } | |||||
| await updateJob(id, { status: 'cancelled', finishedAt: Date.now() }); | |||||
| await cleanupTmpForJob(job); | |||||
| return true; | |||||
| } | |||||
| async function cleanupTmpForJob(job: TranscodeJob) { | |||||
| // Best-effort cleanup of input + tmp output | |||||
| try { await unlink(job.inputPath); } catch {} | |||||
| try { await unlink(path.join(TMP_DIR, `${job.id}.mp4`)); } catch {} | |||||
| } | |||||
| // ──────────────────────────────────────────────────────────── | |||||
| // Worker | |||||
| async function kickWorker() { | |||||
| if (workerRunning) return; | |||||
| workerRunning = true; | |||||
| try { | |||||
| // Recover orphan "running" jobs from a prior crash. | |||||
| const jobs = await loadJobs(); | |||||
| let dirty = false; | |||||
| for (const j of jobs) { | |||||
| if (j.status === 'running') { | |||||
| j.status = 'queued'; | |||||
| j.pid = undefined; | |||||
| dirty = true; | |||||
| } | |||||
| } | |||||
| if (dirty) await saveJobs(jobs); | |||||
| // Drain the queue. | |||||
| while (true) { | |||||
| const next = (await loadJobs()).find(j => j.status === 'queued'); | |||||
| if (!next) break; | |||||
| await runJob(next); | |||||
| } | |||||
| } finally { | |||||
| workerRunning = false; | |||||
| } | |||||
| } | |||||
| async function runJob(job: TranscodeJob): Promise<void> { | |||||
| const outTmp = path.join(TMP_DIR, `${job.id}.mp4`); | |||||
| const outFinal = path.join(UPLOAD_DIR, job.outputName); | |||||
| await updateJob(job.id, { status: 'running', startedAt: Date.now(), progress: 0 }); | |||||
| const totalUs = job.durationSec ? job.durationSec * 1_000_000 : undefined; | |||||
| const args = [ | |||||
| '-y', | |||||
| '-i', job.inputPath, | |||||
| '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-pix_fmt', 'yuv420p', | |||||
| '-vf', "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2", | |||||
| '-c:a', 'aac', '-b:a', '128k', '-ac', '2', | |||||
| '-movflags', '+faststart', | |||||
| '-progress', 'pipe:1', | |||||
| outTmp, | |||||
| ]; | |||||
| const child = spawn('ffmpeg', args); | |||||
| await updateJob(job.id, { pid: child.pid }); | |||||
| // Parse stdout for `-progress` key=value pairs | |||||
| let buf = ''; | |||||
| child.stdout?.on('data', async chunk => { | |||||
| buf += chunk.toString(); | |||||
| const lines = buf.split(/\r?\n/); | |||||
| buf = lines.pop() ?? ''; | |||||
| for (const line of lines) { | |||||
| const m = /^out_time_ms=(\d+)/.exec(line); | |||||
| if (m && totalUs) { | |||||
| const cur = parseInt(m[1], 10); | |||||
| const p = Math.max(0, Math.min(1, cur / totalUs)); | |||||
| await updateJob(job.id, { progress: p }); | |||||
| } | |||||
| } | |||||
| }); | |||||
| let stderr = ''; | |||||
| child.stderr?.on('data', chunk => { | |||||
| stderr += chunk.toString(); | |||||
| if (stderr.length > 8000) stderr = stderr.slice(-8000); // bound memory | |||||
| }); | |||||
| const exitCode: number = await new Promise(resolve => { | |||||
| child.on('error', () => resolve(-1)); | |||||
| child.on('exit', code => resolve(code ?? -1)); | |||||
| }); | |||||
| // Check whether the job was cancelled while running (SIGTERM removed the pid) | |||||
| const latest = await getJob(job.id); | |||||
| if (latest?.status === 'cancelled') { | |||||
| try { await unlink(outTmp); } catch {} | |||||
| try { await unlink(job.inputPath); } catch {} | |||||
| return; | |||||
| } | |||||
| if (exitCode !== 0) { | |||||
| await updateJob(job.id, { | |||||
| status: 'failed', | |||||
| finishedAt: Date.now(), | |||||
| error: stderr.trim().split(/\r?\n/).slice(-5).join('\n'), | |||||
| pid: undefined, | |||||
| }); | |||||
| await cleanupTmpForJob(job); | |||||
| return; | |||||
| } | |||||
| // Sanity check: the output must exist and be non-trivial. | |||||
| try { | |||||
| const s = await stat(outTmp); | |||||
| if (s.size < 1024) throw new Error('output too small'); | |||||
| } catch (e) { | |||||
| await updateJob(job.id, { | |||||
| status: 'failed', | |||||
| finishedAt: Date.now(), | |||||
| error: `Output non valido: ${(e as Error).message}`, | |||||
| pid: undefined, | |||||
| }); | |||||
| await cleanupTmpForJob(job); | |||||
| return; | |||||
| } | |||||
| await rename(outTmp, outFinal); | |||||
| try { await unlink(job.inputPath); } catch {} | |||||
| await updateJob(job.id, { | |||||
| status: 'done', | |||||
| finishedAt: Date.now(), | |||||
| progress: 1, | |||||
| pid: undefined, | |||||
| }); | |||||
| } | |||||
| // Auto-recover orphans on module load (lazy via first import). | |||||
| void kickWorker(); | |||||
| @@ -0,0 +1,79 @@ | |||||
| import type { Card, CardType } from '@/types'; | |||||
| export const CARD_LIMITS = { | |||||
| title: 200, | |||||
| shortDescription: 500, | |||||
| fullContent: 20000, | |||||
| actionUrl: 2000, | |||||
| } as const; | |||||
| const VALID_CARD_TYPES: readonly CardType[] = [ | |||||
| 'INFO_PAGE', | |||||
| 'EXTERNAL_LINK', | |||||
| 'IMAGE_GALLERY', | |||||
| 'SERVICE_REQUEST', | |||||
| 'BOOK', | |||||
| ] as const; | |||||
| const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']); | |||||
| export type ValidationError = { | |||||
| field: string; | |||||
| message: string; | |||||
| limit?: number; | |||||
| actual?: number; | |||||
| }; | |||||
| export type ValidationResult = { | |||||
| valid: boolean; | |||||
| errors: ValidationError[]; | |||||
| }; | |||||
| function strLen(v: unknown): number { | |||||
| return typeof v === 'string' ? v.length : 0; | |||||
| } | |||||
| export function validateCard(card: Partial<Card>): ValidationResult { | |||||
| const errors: ValidationError[] = []; | |||||
| if (typeof card.title !== 'string' || card.title.trim().length === 0) { | |||||
| errors.push({ field: 'title', message: 'Il titolo è obbligatorio' }); | |||||
| } else if (card.title.length > CARD_LIMITS.title) { | |||||
| errors.push({ field: 'title', message: 'Titolo troppo lungo', limit: CARD_LIMITS.title, actual: card.title.length }); | |||||
| } | |||||
| if (card.shortDescription !== undefined && typeof card.shortDescription !== 'string') { | |||||
| errors.push({ field: 'shortDescription', message: 'Tipo non valido' }); | |||||
| } else if (strLen(card.shortDescription) > CARD_LIMITS.shortDescription) { | |||||
| errors.push({ field: 'shortDescription', message: 'Descrizione breve troppo lunga', limit: CARD_LIMITS.shortDescription, actual: strLen(card.shortDescription) }); | |||||
| } | |||||
| if (card.fullContent !== undefined && typeof card.fullContent !== 'string') { | |||||
| errors.push({ field: 'fullContent', message: 'Tipo non valido' }); | |||||
| } else if (strLen(card.fullContent) > CARD_LIMITS.fullContent) { | |||||
| errors.push({ field: 'fullContent', message: 'Contenuto troppo lungo', limit: CARD_LIMITS.fullContent, actual: strLen(card.fullContent) }); | |||||
| } | |||||
| if (card.actionUrl !== undefined && card.actionUrl !== '') { | |||||
| if (typeof card.actionUrl !== 'string') { | |||||
| errors.push({ field: 'actionUrl', message: 'Tipo non valido' }); | |||||
| } else if (card.actionUrl.length > CARD_LIMITS.actionUrl) { | |||||
| errors.push({ field: 'actionUrl', message: 'URL troppo lungo', limit: CARD_LIMITS.actionUrl, actual: card.actionUrl.length }); | |||||
| } else { | |||||
| try { | |||||
| const parsed = new URL(card.actionUrl); | |||||
| if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) { | |||||
| errors.push({ field: 'actionUrl', message: `Schema URL non ammesso (${parsed.protocol}). Usa http, https, mailto o tel.` }); | |||||
| } | |||||
| } catch { | |||||
| errors.push({ field: 'actionUrl', message: 'URL non valido' }); | |||||
| } | |||||
| } | |||||
| } | |||||
| if (card.cardType !== undefined && !VALID_CARD_TYPES.includes(card.cardType as CardType)) { | |||||
| errors.push({ field: 'cardType', message: `Tipo card non valido: ${String(card.cardType)}` }); | |||||
| } | |||||
| return { valid: errors.length === 0, errors }; | |||||
| } | |||||
| @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; | |||||
| const nextConfig: NextConfig = { | const nextConfig: NextConfig = { | ||||
| allowedDevOrigins: ['10.210.1.225'], | allowedDevOrigins: ['10.210.1.225'], | ||||
| serverExternalPackages: ['sanitize-html', 'file-type'], | |||||
| }; | }; | ||||
| export default nextConfig; | export default nextConfig; | ||||
| @@ -9,17 +9,20 @@ | |||||
| "version": "0.1.0", | "version": "0.1.0", | ||||
| "hasInstallScript": true, | "hasInstallScript": true, | ||||
| "dependencies": { | "dependencies": { | ||||
| "file-type": "^16.5.4", | |||||
| "geist": "^1.4.2", | "geist": "^1.4.2", | ||||
| "next": "16.2.4", | "next": "16.2.4", | ||||
| "pdfjs-dist": "^4.7.76", | "pdfjs-dist": "^4.7.76", | ||||
| "react": "19.2.4", | "react": "19.2.4", | ||||
| "react-dom": "19.2.4" | |||||
| "react-dom": "19.2.4", | |||||
| "sanitize-html": "^2.17.4" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@tailwindcss/postcss": "^4", | "@tailwindcss/postcss": "^4", | ||||
| "@types/node": "^20", | "@types/node": "^20", | ||||
| "@types/react": "^19", | "@types/react": "^19", | ||||
| "@types/react-dom": "^19", | "@types/react-dom": "^19", | ||||
| "@types/sanitize-html": "^2.16.1", | |||||
| "eslint": "^9", | "eslint": "^9", | ||||
| "eslint-config-next": "16.2.4", | "eslint-config-next": "16.2.4", | ||||
| "tailwindcss": "^4", | "tailwindcss": "^4", | ||||
| @@ -1766,6 +1769,12 @@ | |||||
| "tailwindcss": "4.2.2" | "tailwindcss": "4.2.2" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/@tokenizer/token": { | |||||
| "version": "0.3.0", | |||||
| "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", | |||||
| "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/@tybys/wasm-util": { | "node_modules/@tybys/wasm-util": { | ||||
| "version": "0.10.1", | "version": "0.10.1", | ||||
| "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", | "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", | ||||
| @@ -1828,6 +1837,16 @@ | |||||
| "@types/react": "^19.2.0" | "@types/react": "^19.2.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/@types/sanitize-html": { | |||||
| "version": "2.16.1", | |||||
| "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", | |||||
| "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", | |||||
| "dev": true, | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "htmlparser2": "^10.1" | |||||
| } | |||||
| }, | |||||
| "node_modules/@typescript-eslint/eslint-plugin": { | "node_modules/@typescript-eslint/eslint-plugin": { | ||||
| "version": "8.58.2", | "version": "8.58.2", | ||||
| "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", | "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", | ||||
| @@ -2392,6 +2411,18 @@ | |||||
| "win32" | "win32" | ||||
| ] | ] | ||||
| }, | }, | ||||
| "node_modules/abort-controller": { | |||||
| "version": "3.0.0", | |||||
| "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", | |||||
| "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "event-target-shim": "^5.0.0" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=6.5" | |||||
| } | |||||
| }, | |||||
| "node_modules/acorn": { | "node_modules/acorn": { | ||||
| "version": "8.16.0", | "version": "8.16.0", | ||||
| "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", | ||||
| @@ -2685,6 +2716,26 @@ | |||||
| "dev": true, | "dev": true, | ||||
| "license": "MIT" | "license": "MIT" | ||||
| }, | }, | ||||
| "node_modules/base64-js": { | |||||
| "version": "1.5.1", | |||||
| "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", | |||||
| "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", | |||||
| "funding": [ | |||||
| { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/feross" | |||||
| }, | |||||
| { | |||||
| "type": "patreon", | |||||
| "url": "https://www.patreon.com/feross" | |||||
| }, | |||||
| { | |||||
| "type": "consulting", | |||||
| "url": "https://feross.org/support" | |||||
| } | |||||
| ], | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/baseline-browser-mapping": { | "node_modules/baseline-browser-mapping": { | ||||
| "version": "2.10.19", | "version": "2.10.19", | ||||
| "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", | "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", | ||||
| @@ -2755,6 +2806,30 @@ | |||||
| "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" | "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/buffer": { | |||||
| "version": "6.0.3", | |||||
| "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", | |||||
| "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", | |||||
| "funding": [ | |||||
| { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/feross" | |||||
| }, | |||||
| { | |||||
| "type": "patreon", | |||||
| "url": "https://www.patreon.com/feross" | |||||
| }, | |||||
| { | |||||
| "type": "consulting", | |||||
| "url": "https://feross.org/support" | |||||
| } | |||||
| ], | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "base64-js": "^1.3.1", | |||||
| "ieee754": "^1.2.1" | |||||
| } | |||||
| }, | |||||
| "node_modules/call-bind": { | "node_modules/call-bind": { | ||||
| "version": "1.0.9", | "version": "1.0.9", | ||||
| "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", | ||||
| @@ -2975,6 +3050,12 @@ | |||||
| "url": "https://github.com/sponsors/ljharb" | "url": "https://github.com/sponsors/ljharb" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/dayjs": { | |||||
| "version": "1.11.21", | |||||
| "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", | |||||
| "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/debug": { | "node_modules/debug": { | ||||
| "version": "4.4.3", | "version": "4.4.3", | ||||
| "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | ||||
| @@ -3000,6 +3081,15 @@ | |||||
| "dev": true, | "dev": true, | ||||
| "license": "MIT" | "license": "MIT" | ||||
| }, | }, | ||||
| "node_modules/deepmerge": { | |||||
| "version": "4.3.1", | |||||
| "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", | |||||
| "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", | |||||
| "license": "MIT", | |||||
| "engines": { | |||||
| "node": ">=0.10.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/define-data-property": { | "node_modules/define-data-property": { | ||||
| "version": "1.1.4", | "version": "1.1.4", | ||||
| "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", | "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", | ||||
| @@ -3059,6 +3149,73 @@ | |||||
| "node": ">=0.10.0" | "node": ">=0.10.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/dom-serializer": { | |||||
| "version": "2.0.0", | |||||
| "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", | |||||
| "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "domelementtype": "^2.3.0", | |||||
| "domhandler": "^5.0.2", | |||||
| "entities": "^4.2.0" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" | |||||
| } | |||||
| }, | |||||
| "node_modules/dom-serializer/node_modules/entities": { | |||||
| "version": "4.5.0", | |||||
| "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", | |||||
| "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", | |||||
| "license": "BSD-2-Clause", | |||||
| "engines": { | |||||
| "node": ">=0.12" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/fb55/entities?sponsor=1" | |||||
| } | |||||
| }, | |||||
| "node_modules/domelementtype": { | |||||
| "version": "2.3.0", | |||||
| "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", | |||||
| "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", | |||||
| "funding": [ | |||||
| { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/fb55" | |||||
| } | |||||
| ], | |||||
| "license": "BSD-2-Clause" | |||||
| }, | |||||
| "node_modules/domhandler": { | |||||
| "version": "5.0.3", | |||||
| "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", | |||||
| "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", | |||||
| "license": "BSD-2-Clause", | |||||
| "dependencies": { | |||||
| "domelementtype": "^2.3.0" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">= 4" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/fb55/domhandler?sponsor=1" | |||||
| } | |||||
| }, | |||||
| "node_modules/domutils": { | |||||
| "version": "3.2.2", | |||||
| "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", | |||||
| "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", | |||||
| "license": "BSD-2-Clause", | |||||
| "dependencies": { | |||||
| "dom-serializer": "^2.0.0", | |||||
| "domelementtype": "^2.3.0", | |||||
| "domhandler": "^5.0.3" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/fb55/domutils?sponsor=1" | |||||
| } | |||||
| }, | |||||
| "node_modules/dunder-proto": { | "node_modules/dunder-proto": { | ||||
| "version": "1.0.1", | "version": "1.0.1", | ||||
| "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", | ||||
| @@ -3102,6 +3259,18 @@ | |||||
| "node": ">=10.13.0" | "node": ">=10.13.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/entities": { | |||||
| "version": "7.0.1", | |||||
| "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", | |||||
| "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", | |||||
| "license": "BSD-2-Clause", | |||||
| "engines": { | |||||
| "node": ">=0.12" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/fb55/entities?sponsor=1" | |||||
| } | |||||
| }, | |||||
| "node_modules/es-abstract": { | "node_modules/es-abstract": { | ||||
| "version": "1.24.2", | "version": "1.24.2", | ||||
| "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", | "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", | ||||
| @@ -3293,7 +3462,6 @@ | |||||
| "version": "4.0.0", | "version": "4.0.0", | ||||
| "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", | ||||
| "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", | "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", | ||||
| "dev": true, | |||||
| "license": "MIT", | "license": "MIT", | ||||
| "engines": { | "engines": { | ||||
| "node": ">=10" | "node": ">=10" | ||||
| @@ -3708,6 +3876,24 @@ | |||||
| "node": ">=0.10.0" | "node": ">=0.10.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/event-target-shim": { | |||||
| "version": "5.0.1", | |||||
| "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", | |||||
| "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", | |||||
| "license": "MIT", | |||||
| "engines": { | |||||
| "node": ">=6" | |||||
| } | |||||
| }, | |||||
| "node_modules/events": { | |||||
| "version": "3.3.0", | |||||
| "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", | |||||
| "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", | |||||
| "license": "MIT", | |||||
| "engines": { | |||||
| "node": ">=0.8.x" | |||||
| } | |||||
| }, | |||||
| "node_modules/fast-deep-equal": { | "node_modules/fast-deep-equal": { | ||||
| "version": "3.1.3", | "version": "3.1.3", | ||||
| "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | ||||
| @@ -3782,6 +3968,23 @@ | |||||
| "node": ">=16.0.0" | "node": ">=16.0.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/file-type": { | |||||
| "version": "16.5.4", | |||||
| "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", | |||||
| "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "readable-web-to-node-stream": "^3.0.0", | |||||
| "strtok3": "^6.2.4", | |||||
| "token-types": "^4.1.1" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=10" | |||||
| }, | |||||
| "funding": { | |||||
| "url": "https://github.com/sindresorhus/file-type?sponsor=1" | |||||
| } | |||||
| }, | |||||
| "node_modules/fill-range": { | "node_modules/fill-range": { | ||||
| "version": "7.1.1", | "version": "7.1.1", | ||||
| "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", | "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", | ||||
| @@ -4163,6 +4366,45 @@ | |||||
| "hermes-estree": "0.25.1" | "hermes-estree": "0.25.1" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/htmlparser2": { | |||||
| "version": "10.1.0", | |||||
| "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", | |||||
| "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", | |||||
| "funding": [ | |||||
| "https://github.com/fb55/htmlparser2?sponsor=1", | |||||
| { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/fb55" | |||||
| } | |||||
| ], | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "domelementtype": "^2.3.0", | |||||
| "domhandler": "^5.0.3", | |||||
| "domutils": "^3.2.2", | |||||
| "entities": "^7.0.1" | |||||
| } | |||||
| }, | |||||
| "node_modules/ieee754": { | |||||
| "version": "1.2.1", | |||||
| "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", | |||||
| "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", | |||||
| "funding": [ | |||||
| { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/feross" | |||||
| }, | |||||
| { | |||||
| "type": "patreon", | |||||
| "url": "https://www.patreon.com/feross" | |||||
| }, | |||||
| { | |||||
| "type": "consulting", | |||||
| "url": "https://feross.org/support" | |||||
| } | |||||
| ], | |||||
| "license": "BSD-3-Clause" | |||||
| }, | |||||
| "node_modules/ignore": { | "node_modules/ignore": { | ||||
| "version": "5.3.2", | "version": "5.3.2", | ||||
| "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", | "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", | ||||
| @@ -4485,6 +4727,15 @@ | |||||
| "url": "https://github.com/sponsors/ljharb" | "url": "https://github.com/sponsors/ljharb" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/is-plain-object": { | |||||
| "version": "5.0.0", | |||||
| "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", | |||||
| "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", | |||||
| "license": "MIT", | |||||
| "engines": { | |||||
| "node": ">=0.10.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/is-regex": { | "node_modules/is-regex": { | ||||
| "version": "1.2.1", | "version": "1.2.1", | ||||
| "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", | "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", | ||||
| @@ -4785,6 +5036,15 @@ | |||||
| "node": ">=0.10" | "node": ">=0.10" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/launder": { | |||||
| "version": "1.7.1", | |||||
| "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", | |||||
| "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "dayjs": "^1.11.7" | |||||
| } | |||||
| }, | |||||
| "node_modules/levn": { | "node_modules/levn": { | ||||
| "version": "0.4.1", | "version": "0.4.1", | ||||
| "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | ||||
| @@ -5532,6 +5792,12 @@ | |||||
| "node": ">=6" | "node": ">=6" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/parse-srcset": { | |||||
| "version": "1.0.2", | |||||
| "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", | |||||
| "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/path-exists": { | "node_modules/path-exists": { | ||||
| "version": "4.0.0", | "version": "4.0.0", | ||||
| "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", | ||||
| @@ -5571,6 +5837,19 @@ | |||||
| "@napi-rs/canvas": "^0.1.65" | "@napi-rs/canvas": "^0.1.65" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/peek-readable": { | |||||
| "version": "4.1.0", | |||||
| "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", | |||||
| "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", | |||||
| "license": "MIT", | |||||
| "engines": { | |||||
| "node": ">=8" | |||||
| }, | |||||
| "funding": { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/Borewit" | |||||
| } | |||||
| }, | |||||
| "node_modules/picocolors": { | "node_modules/picocolors": { | ||||
| "version": "1.1.1", | "version": "1.1.1", | ||||
| "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | ||||
| @@ -5604,7 +5883,6 @@ | |||||
| "version": "8.5.10", | "version": "8.5.10", | ||||
| "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", | ||||
| "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", | "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", | ||||
| "dev": true, | |||||
| "funding": [ | "funding": [ | ||||
| { | { | ||||
| "type": "opencollective", | "type": "opencollective", | ||||
| @@ -5639,6 +5917,15 @@ | |||||
| "node": ">= 0.8.0" | "node": ">= 0.8.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/process": { | |||||
| "version": "0.11.10", | |||||
| "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", | |||||
| "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", | |||||
| "license": "MIT", | |||||
| "engines": { | |||||
| "node": ">= 0.6.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/prop-types": { | "node_modules/prop-types": { | ||||
| "version": "15.8.1", | "version": "15.8.1", | ||||
| "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", | "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", | ||||
| @@ -5710,6 +5997,38 @@ | |||||
| "dev": true, | "dev": true, | ||||
| "license": "MIT" | "license": "MIT" | ||||
| }, | }, | ||||
| "node_modules/readable-stream": { | |||||
| "version": "4.7.0", | |||||
| "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", | |||||
| "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "abort-controller": "^3.0.0", | |||||
| "buffer": "^6.0.3", | |||||
| "events": "^3.3.0", | |||||
| "process": "^0.11.10", | |||||
| "string_decoder": "^1.3.0" | |||||
| }, | |||||
| "engines": { | |||||
| "node": "^12.22.0 || ^14.17.0 || >=16.0.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/readable-web-to-node-stream": { | |||||
| "version": "3.0.4", | |||||
| "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", | |||||
| "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "readable-stream": "^4.7.0" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=8" | |||||
| }, | |||||
| "funding": { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/Borewit" | |||||
| } | |||||
| }, | |||||
| "node_modules/reflect.getprototypeof": { | "node_modules/reflect.getprototypeof": { | ||||
| "version": "1.0.10", | "version": "1.0.10", | ||||
| "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", | "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", | ||||
| @@ -5853,6 +6172,26 @@ | |||||
| "url": "https://github.com/sponsors/ljharb" | "url": "https://github.com/sponsors/ljharb" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/safe-buffer": { | |||||
| "version": "5.2.1", | |||||
| "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", | |||||
| "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", | |||||
| "funding": [ | |||||
| { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/feross" | |||||
| }, | |||||
| { | |||||
| "type": "patreon", | |||||
| "url": "https://www.patreon.com/feross" | |||||
| }, | |||||
| { | |||||
| "type": "consulting", | |||||
| "url": "https://feross.org/support" | |||||
| } | |||||
| ], | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/safe-push-apply": { | "node_modules/safe-push-apply": { | ||||
| "version": "1.0.0", | "version": "1.0.0", | ||||
| "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", | "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", | ||||
| @@ -5888,6 +6227,21 @@ | |||||
| "url": "https://github.com/sponsors/ljharb" | "url": "https://github.com/sponsors/ljharb" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/sanitize-html": { | |||||
| "version": "2.17.4", | |||||
| "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz", | |||||
| "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "deepmerge": "^4.2.2", | |||||
| "escape-string-regexp": "^4.0.0", | |||||
| "htmlparser2": "^10.1.0", | |||||
| "is-plain-object": "^5.0.0", | |||||
| "launder": "^1.7.1", | |||||
| "parse-srcset": "^1.0.2", | |||||
| "postcss": "^8.3.11" | |||||
| } | |||||
| }, | |||||
| "node_modules/scheduler": { | "node_modules/scheduler": { | ||||
| "version": "0.27.0", | "version": "0.27.0", | ||||
| "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", | ||||
| @@ -6140,6 +6494,15 @@ | |||||
| "node": ">= 0.4" | "node": ">= 0.4" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/string_decoder": { | |||||
| "version": "1.3.0", | |||||
| "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", | |||||
| "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "safe-buffer": "~5.2.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/string.prototype.includes": { | "node_modules/string.prototype.includes": { | ||||
| "version": "2.0.1", | "version": "2.0.1", | ||||
| "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", | "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", | ||||
| @@ -6276,6 +6639,23 @@ | |||||
| "url": "https://github.com/sponsors/sindresorhus" | "url": "https://github.com/sponsors/sindresorhus" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/strtok3": { | |||||
| "version": "6.3.0", | |||||
| "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", | |||||
| "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "@tokenizer/token": "^0.3.0", | |||||
| "peek-readable": "^4.1.0" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=10" | |||||
| }, | |||||
| "funding": { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/Borewit" | |||||
| } | |||||
| }, | |||||
| "node_modules/styled-jsx": { | "node_modules/styled-jsx": { | ||||
| "version": "5.1.6", | "version": "5.1.6", | ||||
| "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", | ||||
| @@ -6407,6 +6787,23 @@ | |||||
| "node": ">=8.0" | "node": ">=8.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/token-types": { | |||||
| "version": "4.2.1", | |||||
| "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", | |||||
| "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "@tokenizer/token": "^0.3.0", | |||||
| "ieee754": "^1.2.1" | |||||
| }, | |||||
| "engines": { | |||||
| "node": ">=10" | |||||
| }, | |||||
| "funding": { | |||||
| "type": "github", | |||||
| "url": "https://github.com/sponsors/Borewit" | |||||
| } | |||||
| }, | |||||
| "node_modules/ts-api-utils": { | "node_modules/ts-api-utils": { | ||||
| "version": "2.5.0", | "version": "2.5.0", | ||||
| "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", | "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", | ||||
| @@ -10,17 +10,20 @@ | |||||
| "postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\"" | "postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\"" | ||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| "file-type": "^16.5.4", | |||||
| "geist": "^1.4.2", | "geist": "^1.4.2", | ||||
| "next": "16.2.4", | "next": "16.2.4", | ||||
| "pdfjs-dist": "^4.7.76", | "pdfjs-dist": "^4.7.76", | ||||
| "react": "19.2.4", | "react": "19.2.4", | ||||
| "react-dom": "19.2.4" | |||||
| "react-dom": "19.2.4", | |||||
| "sanitize-html": "^2.17.4" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@tailwindcss/postcss": "^4", | "@tailwindcss/postcss": "^4", | ||||
| "@types/node": "^20", | "@types/node": "^20", | ||||
| "@types/react": "^19", | "@types/react": "^19", | ||||
| "@types/react-dom": "^19", | "@types/react-dom": "^19", | ||||
| "@types/sanitize-html": "^2.16.1", | |||||
| "eslint": "^9", | "eslint": "^9", | ||||
| "eslint-config-next": "16.2.4", | "eslint-config-next": "16.2.4", | ||||
| "tailwindcss": "^4", | "tailwindcss": "^4", | ||||
| @@ -0,0 +1,85 @@ | |||||
| #!/usr/bin/env node | |||||
| // One-shot migration: sanitize existing card.fullContent in data/cards.txt. | |||||
| // Backups the original to data/cards.txt.bak-<timestamp> before rewriting. | |||||
| // Run: `node scripts/sanitize-existing-cards.mjs`. | |||||
| import { readFile, writeFile, copyFile } from 'node:fs/promises'; | |||||
| import { resolve, dirname } from 'node:path'; | |||||
| import { fileURLToPath } from 'node:url'; | |||||
| import sanitizeHtml from 'sanitize-html'; | |||||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |||||
| const CARDS_PATH = resolve(__dirname, '..', 'data', 'cards.txt'); | |||||
| // Keep this config in sync with lib/sanitize.ts | |||||
| const CARD_HTML_CONFIG = { | |||||
| allowedTags: [ | |||||
| 'p', 'br', 'strong', 'em', 'b', 'i', 'u', | |||||
| 'ul', 'ol', 'li', | |||||
| 'a', | |||||
| 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', | |||||
| 'blockquote', | |||||
| 'span', | |||||
| ], | |||||
| allowedAttributes: { | |||||
| a: ['href', 'title', 'target', 'rel'], | |||||
| span: ['class'], | |||||
| '*': [], | |||||
| }, | |||||
| allowedSchemes: ['http', 'https', 'mailto', 'tel'], | |||||
| allowedSchemesAppliedToAttributes: ['href'], | |||||
| transformTags: { | |||||
| a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }), | |||||
| }, | |||||
| disallowedTagsMode: 'discard', | |||||
| }; | |||||
| async function main() { | |||||
| let raw; | |||||
| try { | |||||
| raw = await readFile(CARDS_PATH, 'utf-8'); | |||||
| } catch (err) { | |||||
| if (err.code === 'ENOENT') { | |||||
| console.log(`No cards file at ${CARDS_PATH}, nothing to do.`); | |||||
| return; | |||||
| } | |||||
| throw err; | |||||
| } | |||||
| let cards; | |||||
| try { | |||||
| cards = JSON.parse(raw); | |||||
| } catch (err) { | |||||
| console.error('Cards file is not valid JSON; refusing to migrate.', err.message); | |||||
| process.exit(1); | |||||
| } | |||||
| if (!Array.isArray(cards)) { | |||||
| console.error('Cards file is not an array; refusing to migrate.'); | |||||
| process.exit(1); | |||||
| } | |||||
| const ts = new Date().toISOString().replace(/[:.]/g, '-'); | |||||
| const backupPath = `${CARDS_PATH}.bak-${ts}`; | |||||
| await copyFile(CARDS_PATH, backupPath); | |||||
| console.log(`Backup written: ${backupPath}`); | |||||
| let changed = 0; | |||||
| for (const card of cards) { | |||||
| if (typeof card?.fullContent === 'string' && card.fullContent.length > 0) { | |||||
| const cleaned = sanitizeHtml(card.fullContent, CARD_HTML_CONFIG); | |||||
| if (cleaned !== card.fullContent) { | |||||
| card.fullContent = cleaned; | |||||
| changed++; | |||||
| } | |||||
| } | |||||
| } | |||||
| await writeFile(CARDS_PATH, JSON.stringify(cards, null, 2), 'utf-8'); | |||||
| console.log(`Migration done. Cards changed: ${changed} / ${cards.length}.`); | |||||
| } | |||||
| main().catch(err => { | |||||
| console.error(err); | |||||
| process.exit(1); | |||||
| }); | |||||