瀏覽代碼

Normalizzazione dei formati vidio e immagine immessi

main
Lorenzo Pollutri 1 月之前
父節點
當前提交
1e135e7681
共有 13 個文件被更改,包括 1287 次插入45 次删除
  1. +111
    -4
      app/admin/page.tsx
  2. +15
    -3
      app/api/cards/route.ts
  3. +8
    -3
      app/api/files/route.ts
  4. +12
    -1
      app/api/portals/route.ts
  5. +26
    -0
      app/api/transcode/[id]/route.ts
  6. +175
    -30
      app/api/upload/route.ts
  7. +33
    -0
      lib/sanitize.ts
  8. +338
    -0
      lib/transcode.ts
  9. +79
    -0
      lib/validation.ts
  10. +1
    -0
      next.config.ts
  11. +400
    -3
      package-lock.json
  12. +4
    -1
      package.json
  13. +85
    -0
      scripts/sanitize-existing-cards.mjs

+ 111
- 4
app/admin/page.tsx 查看文件

@@ -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&rsquo;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">


+ 15
- 3
app/api/cards/route.ts 查看文件

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


+ 8
- 3
app/api/files/route.ts 查看文件

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


+ 12
- 1
app/api/portals/route.ts 查看文件

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

+ 26
- 0
app/api/transcode/[id]/route.ts 查看文件

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

+ 175
- 30
app/api/upload/route.ts 查看文件

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

+ 33
- 0
lib/sanitize.ts 查看文件

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

+ 338
- 0
lib/transcode.ts 查看文件

@@ -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();

+ 79
- 0
lib/validation.ts 查看文件

@@ -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 };
}

+ 1
- 0
next.config.ts 查看文件

@@ -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;

+ 400
- 3
package-lock.json 查看文件

@@ -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",


+ 4
- 1
package.json 查看文件

@@ -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",


+ 85
- 0
scripts/sanitize-existing-cards.mjs 查看文件

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

Loading…
取消
儲存