| @@ -3,6 +3,19 @@ | |||
| import { useState, useEffect, useRef } from 'react'; | |||
| import { Card, Portal, MediaItem, CardType } from '@/types'; | |||
| import { EXTERNAL_LINK_ENABLED as EXTERNAL_LINK_DEFAULT } from '@/lib/config'; | |||
| import { 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>({ | |||
| value, | |||
| @@ -191,6 +204,9 @@ export default function AdminDashboard() { | |||
| const [confirmDialog, setConfirmDialog] = useState<{ message: string, onConfirm: () => void } | null>(null); | |||
| const [pdfProgress, setPdfProgress] = useState<{ name: string; page: number; total: number } | null>(null); | |||
| 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. | |||
| 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([])); | |||
| }, []); | |||
| // 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) => { | |||
| if (!e.target.files?.[0]) return; | |||
| setUploading(prev => ({ ...prev, [field]: true })); | |||
| @@ -282,6 +353,11 @@ export default function AdminDashboard() { | |||
| const data = await res.json(); | |||
| 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)) { | |||
| // Video always goes to the gallery so users can play it. | |||
| uploaded.push({ url: data.url }); | |||
| @@ -367,11 +443,28 @@ export default function AdminDashboard() { | |||
| if (!isEditing) return; | |||
| const generateSafeId = () => 'card-' + Date.now().toString(36) + Math.random().toString(36).substring(2); | |||
| 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) | |||
| }); | |||
| 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 => { | |||
| const exists = prev.find(c => c.id === newCard.id); | |||
| 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> | |||
| <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> | |||
| <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> | |||
| <input | |||
| type="url" | |||
| maxLength={CARD_LIMITS.actionUrl} | |||
| value={isEditing.actionUrl || ''} | |||
| onChange={e => setIsEditing({ ...isEditing, actionUrl: e.target.value })} | |||
| className={inputClasses} | |||
| placeholder="https://esempio.it/pagina" | |||
| /> | |||
| <CharCounter value={isEditing.actionUrl} limit={CARD_LIMITS.actionUrl} /> | |||
| </div> | |||
| <div> | |||
| <label className="block text-sm font-semibold text-gray-800 mb-1">Testo del link</label> | |||
| <input | |||
| type="text" | |||
| maxLength={CARD_LIMITS.shortDescription} | |||
| value={isEditing.shortDescription || ''} | |||
| onChange={e => setIsEditing({ ...isEditing, shortDescription: e.target.value })} | |||
| className={inputClasses} | |||
| 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> | |||
| </div> | |||
| <div className="bg-gray-50 p-3 rounded-lg border border-gray-200"> | |||
| @@ -718,7 +816,8 @@ export default function AdminDashboard() { | |||
| ) : ( | |||
| <div> | |||
| <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> | |||
| )} | |||
| {isEditing.cardType !== 'BOOK' && ( | |||
| @@ -805,6 +904,8 @@ export default function AdminDashboard() { | |||
| <div className="mt-3 space-y-2"> | |||
| {(isEditing.extraMedia || []).map((item, i) => { | |||
| const video = isVideoUrl(item.url); | |||
| const tc = transcodeJobs[item.url]; | |||
| const isTranscoding = !!tc && (tc.status === 'queued' || tc.status === 'running'); | |||
| return ( | |||
| <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"> | |||
| @@ -816,6 +917,12 @@ export default function AdminDashboard() { | |||
| ) : ( | |||
| <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> | |||
| </div> | |||
| <div className="flex-1 min-w-0"> | |||
| @@ -1,6 +1,8 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { revalidatePath } from 'next/cache'; | |||
| import { getCards, saveCards } from '@/lib/db'; | |||
| import { validateCard } from '@/lib/validation'; | |||
| import { sanitizeCardHtml } from '@/lib/sanitize'; | |||
| import { Card } from '@/types'; | |||
| export const dynamic = 'force-dynamic'; | |||
| @@ -15,19 +17,29 @@ export async function GET(request: Request) { | |||
| export async function POST(request: Request) { | |||
| try { | |||
| 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 existingIndex = cards.findIndex(c => c.id === incomingCard.id); | |||
| if (existingIndex >= 0) { | |||
| cards[existingIndex] = incomingCard; | |||
| } else { | |||
| cards.push(incomingCard); | |||
| } | |||
| await saveCards(cards); | |||
| revalidatePath('/'); // Force public portal to update instantly | |||
| return NextResponse.json(incomingCard, { status: 200 }); | |||
| } catch (error) { | |||
| } catch { | |||
| return NextResponse.json({ error: 'Failed to save card' }, { status: 500 }); | |||
| } | |||
| } | |||
| @@ -9,13 +9,14 @@ const MIME: Record<string, string> = { | |||
| '.jpg': 'image/jpeg', | |||
| '.jpeg': 'image/jpeg', | |||
| '.gif': 'image/gif', | |||
| '.svg': 'image/svg+xml', | |||
| '.webp': 'image/webp', | |||
| '.mp4': 'video/mp4', | |||
| '.webm': 'video/webm', | |||
| '.mov': 'video/quicktime', | |||
| '.m4v': 'video/x-m4v', | |||
| '.ogv': 'video/ogg', | |||
| '.ogg': 'video/ogg', | |||
| '.pdf': 'application/pdf', | |||
| }; | |||
| export async function GET(request: Request) { | |||
| @@ -23,7 +24,9 @@ export async function GET(request: Request) { | |||
| const name = searchParams.get('name'); | |||
| 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; | |||
| try { | |||
| @@ -32,8 +35,9 @@ export async function GET(request: Request) { | |||
| 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 disposition = `inline; filename="${safeName.replace(/"/g, '')}"`; | |||
| const fileSize = stat.size; | |||
| // Handle Range requests (essential for video seeking) | |||
| @@ -66,6 +70,7 @@ export async function GET(request: Request) { | |||
| 'Content-Range': `bytes ${start}-${end}/${fileSize}`, | |||
| 'Accept-Ranges': 'bytes', | |||
| 'Cache-Control': 'public, max-age=86400', | |||
| 'Content-Disposition': disposition, | |||
| }, | |||
| }); | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| import { NextResponse } from 'next/server'; | |||
| import { revalidatePath } from 'next/cache'; // ADD THIS | |||
| import { getPortals, savePortals } from '@/lib/db'; | |||
| import { isValidHexColor } from '@/lib/sanitize'; | |||
| import { Portal } from '@/types'; | |||
| export const dynamic = 'force-dynamic'; | |||
| @@ -13,6 +14,16 @@ export async function GET() { | |||
| export async function POST(request: Request) { | |||
| try { | |||
| 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(); | |||
| if (portals.length > 0) { | |||
| @@ -27,7 +38,7 @@ export async function POST(request: Request) { | |||
| revalidatePath('/', 'layout'); | |||
| return NextResponse.json(portals[0], { status: 200 }); | |||
| } catch (error) { | |||
| } catch { | |||
| 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 = { | |||
| allowedDevOrigins: ['10.210.1.225'], | |||
| serverExternalPackages: ['sanitize-html', 'file-type'], | |||
| }; | |||
| export default nextConfig; | |||
| @@ -9,17 +9,20 @@ | |||
| "version": "0.1.0", | |||
| "hasInstallScript": true, | |||
| "dependencies": { | |||
| "file-type": "^16.5.4", | |||
| "geist": "^1.4.2", | |||
| "next": "16.2.4", | |||
| "pdfjs-dist": "^4.7.76", | |||
| "react": "19.2.4", | |||
| "react-dom": "19.2.4" | |||
| "react-dom": "19.2.4", | |||
| "sanitize-html": "^2.17.4" | |||
| }, | |||
| "devDependencies": { | |||
| "@tailwindcss/postcss": "^4", | |||
| "@types/node": "^20", | |||
| "@types/react": "^19", | |||
| "@types/react-dom": "^19", | |||
| "@types/sanitize-html": "^2.16.1", | |||
| "eslint": "^9", | |||
| "eslint-config-next": "16.2.4", | |||
| "tailwindcss": "^4", | |||
| @@ -1766,6 +1769,12 @@ | |||
| "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": { | |||
| "version": "0.10.1", | |||
| "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", | |||
| @@ -1828,6 +1837,16 @@ | |||
| "@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": { | |||
| "version": "8.58.2", | |||
| "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", | |||
| @@ -2392,6 +2411,18 @@ | |||
| "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": { | |||
| "version": "8.16.0", | |||
| "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", | |||
| @@ -2685,6 +2716,26 @@ | |||
| "dev": true, | |||
| "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": { | |||
| "version": "2.10.19", | |||
| "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_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": { | |||
| "version": "1.0.9", | |||
| "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", | |||
| @@ -2975,6 +3050,12 @@ | |||
| "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": { | |||
| "version": "4.4.3", | |||
| "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | |||
| @@ -3000,6 +3081,15 @@ | |||
| "dev": true, | |||
| "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": { | |||
| "version": "1.1.4", | |||
| "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", | |||
| @@ -3059,6 +3149,73 @@ | |||
| "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": { | |||
| "version": "1.0.1", | |||
| "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", | |||
| @@ -3102,6 +3259,18 @@ | |||
| "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": { | |||
| "version": "1.24.2", | |||
| "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", | |||
| @@ -3293,7 +3462,6 @@ | |||
| "version": "4.0.0", | |||
| "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", | |||
| "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", | |||
| "dev": true, | |||
| "license": "MIT", | |||
| "engines": { | |||
| "node": ">=10" | |||
| @@ -3708,6 +3876,24 @@ | |||
| "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": { | |||
| "version": "3.1.3", | |||
| "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", | |||
| @@ -3782,6 +3968,23 @@ | |||
| "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": { | |||
| "version": "7.1.1", | |||
| "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", | |||
| @@ -4163,6 +4366,45 @@ | |||
| "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": { | |||
| "version": "5.3.2", | |||
| "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", | |||
| @@ -4485,6 +4727,15 @@ | |||
| "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": { | |||
| "version": "1.2.1", | |||
| "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", | |||
| @@ -4785,6 +5036,15 @@ | |||
| "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": { | |||
| "version": "0.4.1", | |||
| "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", | |||
| @@ -5532,6 +5792,12 @@ | |||
| "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": { | |||
| "version": "4.0.0", | |||
| "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", | |||
| @@ -5571,6 +5837,19 @@ | |||
| "@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": { | |||
| "version": "1.1.1", | |||
| "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |||
| @@ -5604,7 +5883,6 @@ | |||
| "version": "8.5.10", | |||
| "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", | |||
| "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", | |||
| "dev": true, | |||
| "funding": [ | |||
| { | |||
| "type": "opencollective", | |||
| @@ -5639,6 +5917,15 @@ | |||
| "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": { | |||
| "version": "15.8.1", | |||
| "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", | |||
| @@ -5710,6 +5997,38 @@ | |||
| "dev": true, | |||
| "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": { | |||
| "version": "1.0.10", | |||
| "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", | |||
| @@ -5853,6 +6172,26 @@ | |||
| "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": { | |||
| "version": "1.0.0", | |||
| "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" | |||
| } | |||
| }, | |||
| "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": { | |||
| "version": "0.27.0", | |||
| "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", | |||
| @@ -6140,6 +6494,15 @@ | |||
| "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": { | |||
| "version": "2.0.1", | |||
| "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" | |||
| } | |||
| }, | |||
| "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": { | |||
| "version": "5.1.6", | |||
| "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", | |||
| @@ -6407,6 +6787,23 @@ | |||
| "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": { | |||
| "version": "2.5.0", | |||
| "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')\"" | |||
| }, | |||
| "dependencies": { | |||
| "file-type": "^16.5.4", | |||
| "geist": "^1.4.2", | |||
| "next": "16.2.4", | |||
| "pdfjs-dist": "^4.7.76", | |||
| "react": "19.2.4", | |||
| "react-dom": "19.2.4" | |||
| "react-dom": "19.2.4", | |||
| "sanitize-html": "^2.17.4" | |||
| }, | |||
| "devDependencies": { | |||
| "@tailwindcss/postcss": "^4", | |||
| "@types/node": "^20", | |||
| "@types/react": "^19", | |||
| "@types/react-dom": "^19", | |||
| "@types/sanitize-html": "^2.16.1", | |||
| "eslint": "^9", | |||
| "eslint-config-next": "16.2.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); | |||
| }); | |||