| @@ -317,7 +317,7 @@ export default function AdminDashboard() { | |||||
| // Auto-seleziona il font appena caricato | // Auto-seleziona il font appena caricato | ||||
| if (data.name) setPortal(p => ({ ...p, fontFamily: data.name })); | if (data.name) setPortal(p => ({ ...p, fontFamily: data.name })); | ||||
| } catch (err) { | } catch (err) { | ||||
| showToast(`Network error: ${(err as Error).message}`, 'error'); | |||||
| showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error'); | |||||
| } finally { | } finally { | ||||
| setUploadingFont(false); | setUploadingFont(false); | ||||
| } | } | ||||
| @@ -336,7 +336,7 @@ export default function AdminDashboard() { | |||||
| await refreshFonts(); | await refreshFonts(); | ||||
| if (portal.fontFamily === name) setPortal(p => ({ ...p, fontFamily: '' })); | if (portal.fontFamily === name) setPortal(p => ({ ...p, fontFamily: '' })); | ||||
| } catch (err) { | } catch (err) { | ||||
| showToast(`Network error: ${(err as Error).message}`, 'error'); | |||||
| showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error'); | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -669,7 +669,7 @@ export default function AdminDashboard() { | |||||
| showToast(`Restore completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`); | showToast(`Restore completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`); | ||||
| setTimeout(() => window.location.reload(), 1200); | setTimeout(() => window.location.reload(), 1200); | ||||
| } catch (err) { | } catch (err) { | ||||
| showToast(`Network error: ${(err as Error).message}`, 'error'); | |||||
| showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error'); | |||||
| } finally { | } finally { | ||||
| setRestoring(false); | setRestoring(false); | ||||
| } | } | ||||
| @@ -707,7 +707,7 @@ export default function AdminDashboard() { | |||||
| showToast('Factory preset updated.'); | showToast('Factory preset updated.'); | ||||
| await refreshFactoryPreset(); | await refreshFactoryPreset(); | ||||
| } catch (err) { | } catch (err) { | ||||
| showToast(`Network error: ${(err as Error).message}`, 'error'); | |||||
| showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error'); | |||||
| } finally { | } finally { | ||||
| setSavingPreset(false); | setSavingPreset(false); | ||||
| } | } | ||||
| @@ -726,7 +726,7 @@ export default function AdminDashboard() { | |||||
| showToast(`Factory reset completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`); | showToast(`Factory reset completed: ${data.restored?.cards ?? 0} cards, ${data.restored?.portals ?? 0} portals. Reloading…`); | ||||
| setTimeout(() => window.location.reload(), 1200); | setTimeout(() => window.location.reload(), 1200); | ||||
| } catch (err) { | } catch (err) { | ||||
| showToast(`Network error: ${(err as Error).message}`, 'error'); | |||||
| showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error'); | |||||
| } finally { | } finally { | ||||
| setFactoryResetting(false); | setFactoryResetting(false); | ||||
| } | } | ||||
| @@ -16,7 +16,10 @@ function timestamp(): string { | |||||
| export async function GET() { | export async function GET() { | ||||
| if (!(await checkSystemBin('zip', '-v'))) { | if (!(await checkSystemBin('zip', '-v'))) { | ||||
| return NextResponse.json({ error: "Binario 'zip' non disponibile sul server." }, { status: 503 }); | |||||
| return NextResponse.json( | |||||
| { error: "Cannot create the backup: the 'zip' command is not installed on the server. Ask the administrator to install it." }, | |||||
| { status: 503 }, | |||||
| ); | |||||
| } | } | ||||
| // -r ricorsivo, - stdout, -x esclude pattern relativi al cwd. | // -r ricorsivo, - stdout, -x esclude pattern relativi al cwd. | ||||
| @@ -31,7 +31,10 @@ export async function GET() { | |||||
| // POST — crea il preset zippando lo stato corrente di data/. | // POST — crea il preset zippando lo stato corrente di data/. | ||||
| export async function POST() { | export async function POST() { | ||||
| if (!(await checkSystemBin('zip', '-v'))) { | if (!(await checkSystemBin('zip', '-v'))) { | ||||
| return NextResponse.json({ error: "Binario 'zip' non disponibile sul server." }, { status: 503 }); | |||||
| return NextResponse.json( | |||||
| { error: "Cannot create the preset: the 'zip' command is not installed on the server. Ask the administrator to install it." }, | |||||
| { status: 503 }, | |||||
| ); | |||||
| } | } | ||||
| await mkdir(FACTORY_DIR, { recursive: true }); | await mkdir(FACTORY_DIR, { recursive: true }); | ||||
| @@ -58,7 +61,10 @@ export async function POST() { | |||||
| // 0 = ok, 12 = "nothing to do" (data/ vuoto: accettiamo, sarà uno zip minimale) | // 0 = ok, 12 = "nothing to do" (data/ vuoto: accettiamo, sarà uno zip minimale) | ||||
| if (exit !== 0 && exit !== 12) { | if (exit !== 0 && exit !== 12) { | ||||
| try { await unlink(tmpPath); } catch { /* ignore */ } | try { await unlink(tmpPath); } catch { /* ignore */ } | ||||
| return NextResponse.json({ error: `zip terminato con codice ${exit}` }, { status: 500 }); | |||||
| return NextResponse.json( | |||||
| { error: `Failed to create the preset archive: the 'zip' command exited with code ${exit}. Check the server logs for details. The previous preset (if any) has not been modified.` }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| await rename(tmpPath, PRESET_PATH); | await rename(tmpPath, PRESET_PATH); | ||||
| @@ -11,14 +11,17 @@ const PRESET_PATH = path.join(process.cwd(), 'factory', 'preset.zip'); | |||||
| export async function POST() { | export async function POST() { | ||||
| if (!(await checkSystemBin('unzip', '-v'))) { | if (!(await checkSystemBin('unzip', '-v'))) { | ||||
| return NextResponse.json({ error: "Binario 'unzip' non disponibile sul server." }, { status: 503 }); | |||||
| return NextResponse.json( | |||||
| { error: "Cannot perform Factory Reset: the 'unzip' command is not installed on the server. Ask the administrator to install it." }, | |||||
| { status: 503 }, | |||||
| ); | |||||
| } | } | ||||
| try { | try { | ||||
| await stat(PRESET_PATH); | await stat(PRESET_PATH); | ||||
| } catch { | } catch { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: 'Nessun factory preset configurato. Imposta prima lo stato attuale come preset.' }, | |||||
| { error: 'No factory preset is configured on this server: factory/preset.zip is missing. Ask a developer to create a preset first (either via "Save as Factory Preset" in admin or by uploading factory/preset.zip manually).' }, | |||||
| { status: 404 }, | { status: 404 }, | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -37,24 +37,31 @@ export async function POST(request: Request) { | |||||
| const formData = await request.formData(); | const formData = await request.formData(); | ||||
| const file = formData.get('file') as File | null; | const file = formData.get('file') as File | null; | ||||
| if (!file) { | if (!file) { | ||||
| return NextResponse.json({ error: 'No file received.' }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: 'No font file was received. Choose a font file before clicking Upload.' }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| const safeName = sanitizeFontName(file.name); | const safeName = sanitizeFontName(file.name); | ||||
| const ext = path.extname(safeName).toLowerCase(); | const ext = path.extname(safeName).toLowerCase(); | ||||
| if (!ALLOWED_EXT.has(ext)) { | if (!ALLOWED_EXT.has(ext)) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: `Unsupported font extension. Allowed: ${[...ALLOWED_EXT].join(', ')}` }, | |||||
| { error: `Font extension "${ext || '(none)'}" is not supported. Allowed formats: ${[...ALLOWED_EXT].join(', ')}.` }, | |||||
| { status: 400 }, | { status: 400 }, | ||||
| ); | ); | ||||
| } | } | ||||
| if (!safeName || safeName === ext) { | if (!safeName || safeName === ext) { | ||||
| return NextResponse.json({ error: 'Invalid font filename.' }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Invalid font filename. The file name must contain at least one letter, digit, hyphen or underscore before the extension.' }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| if (file.size > UPLOAD_LIMITS.font) { | if (file.size > UPLOAD_LIMITS.font) { | ||||
| const mb = (UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0); | |||||
| const limitMB = (UPLOAD_LIMITS.font / (1024 * 1024)).toFixed(0); | |||||
| const actualMB = (file.size / (1024 * 1024)).toFixed(1); | |||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: `Font too large (max ${mb} MB).` }, | |||||
| { error: `Font is too large: ${actualMB} MB (limit: ${limitMB} MB). Web fonts are typically under 500 KB — convert the file to WOFF2 (e.g. with a font-tools subsetter) before uploading.` }, | |||||
| { status: 413 }, | { status: 413 }, | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -65,14 +72,14 @@ export async function POST(request: Request) { | |||||
| const detected = await fileTypeFromBuffer(buffer); | const detected = await fileTypeFromBuffer(buffer); | ||||
| if (!detected) { | if (!detected) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: 'Font content not recognized (unknown format).' }, | |||||
| { error: 'Could not identify the font content. The file may be corrupted or not actually a font. Try re-exporting from your font tool.' }, | |||||
| { status: 400 }, | { status: 400 }, | ||||
| ); | ); | ||||
| } | } | ||||
| const allowed = ALLOWED_DETECTED[ext] ?? []; | const allowed = ALLOWED_DETECTED[ext] ?? []; | ||||
| if (!allowed.includes(detected.ext)) { | if (!allowed.includes(detected.ext)) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: `Font content does not match extension (${ext} declared, detected ${detected.ext}).` }, | |||||
| { error: `Font content does not match its extension: the file looks like ${detected.ext.toUpperCase()} but the extension is "${ext}". Rename the file or re-export it in the declared format.` }, | |||||
| { status: 400 }, | { status: 400 }, | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -83,7 +90,10 @@ export async function POST(request: Request) { | |||||
| return NextResponse.json({ ok: true, name: safeName }, { status: 201 }); | return NextResponse.json({ ok: true, name: safeName }, { status: 201 }); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Font upload error:', error); | console.error('Font upload error:', error); | ||||
| return NextResponse.json({ error: 'Failed to upload font.' }, { status: 500 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error while saving the font. The upload was aborted. Check the server logs for details and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| @@ -93,25 +103,37 @@ export async function DELETE(request: Request) { | |||||
| const { searchParams } = new URL(request.url); | const { searchParams } = new URL(request.url); | ||||
| const rawName = searchParams.get('name'); | const rawName = searchParams.get('name'); | ||||
| if (!rawName) { | if (!rawName) { | ||||
| return NextResponse.json({ error: 'Missing name parameter.' }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Missing "name" parameter. Specify which font file to delete.' }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| const safeName = sanitizeFontName(rawName); | const safeName = sanitizeFontName(rawName); | ||||
| const ext = path.extname(safeName).toLowerCase(); | const ext = path.extname(safeName).toLowerCase(); | ||||
| if (!ALLOWED_EXT.has(ext) || !safeName) { | if (!ALLOWED_EXT.has(ext) || !safeName) { | ||||
| return NextResponse.json({ error: 'Invalid font name.' }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: `Invalid font name "${rawName}". Expected a basename ending in ${[...ALLOWED_EXT].join(' / ')}.` }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| try { | try { | ||||
| await unlink(path.join(FONTS_DIR, safeName)); | await unlink(path.join(FONTS_DIR, safeName)); | ||||
| } catch (err) { | } catch (err) { | ||||
| const e = err as NodeJS.ErrnoException; | const e = err as NodeJS.ErrnoException; | ||||
| if (e.code === 'ENOENT') { | if (e.code === 'ENOENT') { | ||||
| return NextResponse.json({ error: 'Font not found.' }, { status: 404 }); | |||||
| return NextResponse.json( | |||||
| { error: `Font "${safeName}" was not found on the server. It may have already been deleted — refresh the page.` }, | |||||
| { status: 404 }, | |||||
| ); | |||||
| } | } | ||||
| throw err; | throw err; | ||||
| } | } | ||||
| return NextResponse.json({ ok: true }); | return NextResponse.json({ ok: true }); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Font delete error:', error); | console.error('Font delete error:', error); | ||||
| return NextResponse.json({ error: 'Failed to delete font.' }, { status: 500 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error while deleting the font. Check the server logs and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| @@ -13,7 +13,10 @@ const UPLOAD_STAGING = path.join(PROJECT_ROOT, '.restore-staging'); | |||||
| export async function POST(request: Request) { | export async function POST(request: Request) { | ||||
| if (!(await checkSystemBin('unzip', '-v'))) { | if (!(await checkSystemBin('unzip', '-v'))) { | ||||
| return NextResponse.json({ error: "Binario 'unzip' non disponibile sul server." }, { status: 503 }); | |||||
| return NextResponse.json( | |||||
| { error: "Restore is unavailable: the 'unzip' command is not installed on the server. Ask the administrator to install it." }, | |||||
| { status: 503 }, | |||||
| ); | |||||
| } | } | ||||
| const sessionId = crypto.randomUUID(); | const sessionId = crypto.randomUUID(); | ||||
| @@ -24,7 +27,10 @@ export async function POST(request: Request) { | |||||
| const formData = await request.formData(); | const formData = await request.formData(); | ||||
| const file = formData.get('file') as File | null; | const file = formData.get('file') as File | null; | ||||
| if (!file) { | if (!file) { | ||||
| return NextResponse.json({ error: 'Nessun file ricevuto.' }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: 'No backup file was received. Choose a .zip backup file before clicking Restore.' }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| await mkdir(sessionDir, { recursive: true }); | await mkdir(sessionDir, { recursive: true }); | ||||
| @@ -41,7 +47,10 @@ export async function POST(request: Request) { | |||||
| }); | }); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Restore error:', error); | console.error('Restore error:', error); | ||||
| return NextResponse.json({ error: 'Errore durante il ripristino.' }, { status: 500 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error during restore. Existing data was not changed. Check the server logs for details and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } finally { | } finally { | ||||
| try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ } | try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ } | ||||
| } | } | ||||
| @@ -20,7 +20,10 @@ export async function POST(request: Request) { | |||||
| const validation = validateCard(incomingCard); | const validation = validateCard(incomingCard); | ||||
| if (!validation.valid) { | if (!validation.valid) { | ||||
| return NextResponse.json({ error: 'Validation failed', errors: validation.errors }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: 'The card could not be saved because some fields are invalid. See the highlighted errors and fix them, then try again.', errors: validation.errors }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| if (typeof incomingCard.fullContent === 'string') { | if (typeof incomingCard.fullContent === 'string') { | ||||
| @@ -40,7 +43,10 @@ export async function POST(request: Request) { | |||||
| revalidatePath('/'); // Force public portal to update instantly | revalidatePath('/'); // Force public portal to update instantly | ||||
| return NextResponse.json(incomingCard, { status: 200 }); | return NextResponse.json(incomingCard, { status: 200 }); | ||||
| } catch { | } catch { | ||||
| return NextResponse.json({ error: 'Failed to save card' }, { status: 500 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error while saving the card. The card was not saved. Check the server logs for details and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| @@ -51,8 +57,11 @@ export async function PUT(request: Request) { | |||||
| await saveCards(updatedCards); | await saveCards(updatedCards); | ||||
| revalidatePath('/'); // Force public portal to update instantly | revalidatePath('/'); // Force public portal to update instantly | ||||
| return NextResponse.json({ success: true }, { status: 200 }); | return NextResponse.json({ success: true }, { status: 200 }); | ||||
| } catch (error) { | |||||
| return NextResponse.json({ error: 'Failed to reorder cards' }, { status: 500 }); | |||||
| } catch { | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error while saving the new card order. The order was not saved. Reload the page and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| @@ -61,7 +70,10 @@ export async function DELETE(request: Request) { | |||||
| const { searchParams } = new URL(request.url); | const { searchParams } = new URL(request.url); | ||||
| const id = searchParams.get('id'); | const id = searchParams.get('id'); | ||||
| if (!id) return NextResponse.json({ error: 'Card ID required' }, { status: 400 }); | |||||
| if (!id) return NextResponse.json( | |||||
| { error: 'Missing "id" parameter: cannot tell which card to delete. Reload the page and retry.' }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| const cards = await getCards(); | const cards = await getCards(); | ||||
| const filteredCards = cards.filter(c => c.id !== id); | const filteredCards = cards.filter(c => c.id !== id); | ||||
| @@ -69,7 +81,10 @@ export async function DELETE(request: Request) { | |||||
| await saveCards(filteredCards); | await saveCards(filteredCards); | ||||
| revalidatePath('/'); // Force public portal to update instantly | revalidatePath('/'); // Force public portal to update instantly | ||||
| return NextResponse.json({ success: true }, { status: 200 }); | return NextResponse.json({ success: true }, { status: 200 }); | ||||
| } catch (error) { | |||||
| return NextResponse.json({ error: 'Failed to delete card' }, { status: 500 }); | |||||
| } catch { | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error while deleting the card. The card was not deleted. Check the server logs and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| @@ -24,15 +24,21 @@ export async function POST(request: Request) { | |||||
| const { valid, errors } = validatePortal(incomingPortal); | const { valid, errors } = validatePortal(incomingPortal); | ||||
| if (!valid) { | if (!valid) { | ||||
| return NextResponse.json({ error: 'Validation failed', errors }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: 'The portal settings could not be saved because some fields are invalid. See the highlighted errors and fix them, then try again.', errors }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| // themeColor goes into a <style dangerouslySetInnerHTML> in PublicGrid, | // themeColor goes into a <style dangerouslySetInnerHTML> in PublicGrid, | ||||
| // so reject anything that is not a strict #RRGGBB. | // so reject anything that is not a strict #RRGGBB. | ||||
| if (incomingPortal.themeColor !== undefined && !isValidHexColor(incomingPortal.themeColor)) { | if (incomingPortal.themeColor !== undefined && !isValidHexColor(incomingPortal.themeColor)) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: 'Validation failed', errors: [{ field: 'themeColor', message: 'Colore non valido (atteso #RRGGBB)' }] }, | |||||
| { status: 400 } | |||||
| { | |||||
| error: 'Invalid theme color: expected a hex code in the form #RRGGBB (e.g. #1e3a8a).', | |||||
| errors: [{ field: 'themeColor', message: 'Theme color must be a hex code like #RRGGBB.' }], | |||||
| }, | |||||
| { status: 400 }, | |||||
| ); | ); | ||||
| } | } | ||||
| @@ -51,6 +57,9 @@ export async function POST(request: Request) { | |||||
| return NextResponse.json(portals[0], { status: 200 }); | return NextResponse.json(portals[0], { status: 200 }); | ||||
| } catch { | } catch { | ||||
| return NextResponse.json({ error: 'Failed to save portal settings' }, { status: 500 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error while saving the portal settings. The changes were not saved. Check the server logs and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| @@ -44,6 +44,18 @@ function extractExt(name: string): string { | |||||
| return name.slice(lastDot + 1).toLowerCase(); | return name.slice(lastDot + 1).toLowerCase(); | ||||
| } | } | ||||
| // Formatta bytes in MB con 1 decimale per messaggi all'utente (es. "23.4 MB"). | |||||
| function fmtMB(bytes: number): string { | |||||
| return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |||||
| } | |||||
| // Lista leggibile delle estensioni accettate per famiglia, per i messaggi di errore. | |||||
| const EXT_BY_FAMILY: Record<Family, string[]> = { | |||||
| image: ['PNG', 'JPG', 'JPEG', 'GIF', 'WEBP'], | |||||
| video: ['MP4', 'M4V', 'WEBM', 'MOV', 'OGV', 'OGG'], | |||||
| pdf: ['PDF'], | |||||
| }; | |||||
| export function normalizeFilename(originalName: string): string { | export function normalizeFilename(originalName: string): string { | ||||
| const ext = extractExt(originalName); | const ext = extractExt(originalName); | ||||
| const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin'; | const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : 'bin'; | ||||
| @@ -71,7 +83,10 @@ export async function POST(request: Request) { | |||||
| const file = formData.get('file') as File | null; | const file = formData.get('file') as File | null; | ||||
| if (!file) { | if (!file) { | ||||
| return NextResponse.json({ error: 'No file received.' }, { status: 400 }); | |||||
| return NextResponse.json( | |||||
| { error: 'No file was received. Please choose a file before uploading.' }, | |||||
| { status: 400 }, | |||||
| ); | |||||
| } | } | ||||
| const ext = extractExt(file.name); | const ext = extractExt(file.name); | ||||
| @@ -85,15 +100,14 @@ export async function POST(request: Request) { | |||||
| if (ext === 'svg') { | if (ext === 'svg') { | ||||
| if (!isLogoContext) { | if (!isLogoContext) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: 'SVG accettato solo per upload del logo.' }, | |||||
| { status: 400 } | |||||
| { error: 'SVG files can only be used for the portal logo. For other images use PNG, JPG, GIF or WEBP.' }, | |||||
| { status: 400 }, | |||||
| ); | ); | ||||
| } | } | ||||
| if (file.size > MAX_BYTES.image) { | if (file.size > MAX_BYTES.image) { | ||||
| const mb = (MAX_BYTES.image / (1024 * 1024)).toFixed(0); | |||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: `File troppo grande (max ${mb} MB per image).` }, | |||||
| { status: 413 } | |||||
| { error: `SVG is too large: ${fmtMB(file.size)} (limit: ${fmtMB(MAX_BYTES.image)}). Optimize the SVG (e.g. SVGO) or remove embedded raster images.` }, | |||||
| { status: 413 }, | |||||
| ); | ); | ||||
| } | } | ||||
| const text = Buffer.from(await file.arrayBuffer()).toString('utf-8'); | const text = Buffer.from(await file.arrayBuffer()).toString('utf-8'); | ||||
| @@ -116,17 +130,27 @@ export async function POST(request: Request) { | |||||
| const family = EXT_FAMILY[ext]; | const family = EXT_FAMILY[ext]; | ||||
| if (!family) { | if (!family) { | ||||
| const allFormats = [ | |||||
| ...EXT_BY_FAMILY.image, | |||||
| ...EXT_BY_FAMILY.video, | |||||
| ...EXT_BY_FAMILY.pdf, | |||||
| 'SVG (logo only)', | |||||
| ].join(', '); | |||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: `Estensione non permessa: .${ext || '(nessuna)'}` }, | |||||
| { status: 400 } | |||||
| { error: `File extension ".${ext || '(none)'}" is not allowed. Accepted formats: ${allFormats}.` }, | |||||
| { status: 400 }, | |||||
| ); | ); | ||||
| } | } | ||||
| if (file.size > MAX_BYTES[family]) { | if (file.size > MAX_BYTES[family]) { | ||||
| const mb = (MAX_BYTES[family] / (1024 * 1024)).toFixed(0); | |||||
| const hint = family === 'pdf' | |||||
| ? ' Compress the PDF (Ghostscript /ebook preset, Adobe "Reduce File Size", or an online PDF compressor) or split it into smaller documents.' | |||||
| : family === 'video' | |||||
| ? ' Re-encode the video at a lower bitrate or resolution before uploading.' | |||||
| : ' Resize or compress the image before uploading (e.g. lower resolution or convert to WEBP).'; | |||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: `File troppo grande (max ${mb} MB per ${family}).` }, | |||||
| { status: 413 } | |||||
| { error: `File is too large: ${fmtMB(file.size)} (limit for ${family.toUpperCase()}: ${fmtMB(MAX_BYTES[family])}).${hint}` }, | |||||
| { status: 413 }, | |||||
| ); | ); | ||||
| } | } | ||||
| @@ -136,15 +160,15 @@ export async function POST(request: Request) { | |||||
| const detected = await fileTypeFromBuffer(buffer); | const detected = await fileTypeFromBuffer(buffer); | ||||
| if (!detected) { | if (!detected) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: 'Tipo del file non riconoscibile dal contenuto.' }, | |||||
| { status: 400 } | |||||
| { error: `Could not identify the file content. The file may be corrupted, empty, or saved in an unsupported variant. Try re-exporting it as ${EXT_BY_FAMILY[family].join(' / ')}.` }, | |||||
| { status: 400 }, | |||||
| ); | ); | ||||
| } | } | ||||
| const allowedMimes = ALLOWED_MIMES[ext] ?? []; | const allowedMimes = ALLOWED_MIMES[ext] ?? []; | ||||
| if (!allowedMimes.includes(detected.mime)) { | if (!allowedMimes.includes(detected.mime)) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: `Contenuto del file non corrisponde all'estensione (.${ext} dichiarato, rilevato ${detected.mime}).` }, | |||||
| { status: 400 } | |||||
| { error: `File content does not match its extension: the file looks like ${detected.mime} but the extension is ".${ext}". Rename the file to the correct extension or re-export it in the declared format.` }, | |||||
| { status: 400 }, | |||||
| ); | ); | ||||
| } | } | ||||
| @@ -180,8 +204,8 @@ export async function POST(request: Request) { | |||||
| // Needs transcoding: bail out early if ffmpeg is unavailable. | // Needs transcoding: bail out early if ffmpeg is unavailable. | ||||
| if (!(await isFfmpegAvailable())) { | if (!(await isFfmpegAvailable())) { | ||||
| return NextResponse.json( | return NextResponse.json( | ||||
| { error: 'Video richiede ricodifica ma ffmpeg non è disponibile sul server.' }, | |||||
| { status: 503 } | |||||
| { error: 'This video uses a codec that browsers cannot play and needs to be transcoded, but ffmpeg is not installed on the server. Upload the video already encoded as H.264 + AAC in an .mp4 container, or ask the administrator to install ffmpeg.' }, | |||||
| { status: 503 }, | |||||
| ); | ); | ||||
| } | } | ||||
| @@ -207,6 +231,9 @@ export async function POST(request: Request) { | |||||
| if (tmpPath) { | if (tmpPath) { | ||||
| try { await unlink(tmpPath); } catch {} | try { await unlink(tmpPath); } catch {} | ||||
| } | } | ||||
| return NextResponse.json({ error: 'Failed to upload file.' }, { status: 500 }); | |||||
| return NextResponse.json( | |||||
| { error: 'Unexpected error while saving the file. The upload was aborted; nothing was changed. Check the server logs for details and try again.' }, | |||||
| { status: 500 }, | |||||
| ); | |||||
| } | } | ||||
| } | } | ||||
| @@ -41,7 +41,7 @@ export async function restoreFromZipFile(zipPath: string): Promise<RestoreResult | |||||
| return { | return { | ||||
| ok: false, | ok: false, | ||||
| status: 400, | status: 400, | ||||
| error: 'Impossibile estrarre lo ZIP.', | |||||
| error: 'Could not extract the archive. The file may not be a valid ZIP, or it may be corrupted. Verify the file by opening it locally before retrying.', | |||||
| detail: stderr.split('\n').slice(0, 3).join(' '), | detail: stderr.split('\n').slice(0, 3).join(' '), | ||||
| }; | }; | ||||
| } | } | ||||
| @@ -51,18 +51,26 @@ export async function restoreFromZipFile(zipPath: string): Promise<RestoreResult | |||||
| try { | try { | ||||
| const raw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8'); | const raw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8'); | ||||
| const parsed = JSON.parse(raw || '[]'); | const parsed = JSON.parse(raw || '[]'); | ||||
| if (!Array.isArray(parsed)) throw new Error('cards.txt non è un array JSON'); | |||||
| if (!Array.isArray(parsed)) throw new Error('cards.txt is not a JSON array'); | |||||
| cards = parsed.length; | cards = parsed.length; | ||||
| } catch (e) { | } catch (e) { | ||||
| return { ok: false, status: 400, error: `Backup non valido: cards.txt assente o malformato (${(e as Error).message}).` }; | |||||
| return { | |||||
| ok: false, | |||||
| status: 400, | |||||
| error: `Invalid backup: the archive is missing a valid cards.txt at its root (${(e as Error).message}). Make sure you are uploading a ZIP created by "Save backup (ZIP)", not just any archive.`, | |||||
| }; | |||||
| } | } | ||||
| try { | try { | ||||
| const raw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8'); | const raw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8'); | ||||
| const parsed = JSON.parse(raw || '[]'); | const parsed = JSON.parse(raw || '[]'); | ||||
| if (!Array.isArray(parsed)) throw new Error('portals.txt non è un array JSON'); | |||||
| if (!Array.isArray(parsed)) throw new Error('portals.txt is not a JSON array'); | |||||
| portals = parsed.length; | portals = parsed.length; | ||||
| } catch (e) { | } catch (e) { | ||||
| return { ok: false, status: 400, error: `Backup non valido: portals.txt assente o malformato (${(e as Error).message}).` }; | |||||
| return { | |||||
| ok: false, | |||||
| status: 400, | |||||
| error: `Invalid backup: the archive is missing a valid portals.txt at its root (${(e as Error).message}). Make sure you are uploading a ZIP created by "Save backup (ZIP)", not just any archive.`, | |||||
| }; | |||||
| } | } | ||||
| // Atomic swap: data → data.bak-<ts>, extract → data. | // Atomic swap: data → data.bak-<ts>, extract → data. | ||||
| @@ -41,12 +41,12 @@ export type SvgSanitizeResult = | |||||
| export function sanitizeSvg(input: string): SvgSanitizeResult { | export function sanitizeSvg(input: string): SvgSanitizeResult { | ||||
| if (typeof input !== 'string' || input.length === 0) { | if (typeof input !== 'string' || input.length === 0) { | ||||
| return { ok: false, error: 'Empty SVG content.' }; | |||||
| return { ok: false, error: 'The SVG file is empty. Choose a non-empty .svg file.' }; | |||||
| } | } | ||||
| // Deve essere un documento SVG: opzionalmente un XML declaration, opzionalmente | // Deve essere un documento SVG: opzionalmente un XML declaration, opzionalmente | ||||
| // commenti, e poi un tag <svg>. | // commenti, e poi un tag <svg>. | ||||
| if (!/^\s*(<\?xml[^?]*\?>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/i.test(input)) { | if (!/^\s*(<\?xml[^?]*\?>\s*)?(<!--[\s\S]*?-->\s*)*<svg[\s>]/i.test(input)) { | ||||
| return { ok: false, error: 'Not a valid SVG document.' }; | |||||
| return { ok: false, error: 'Not a valid SVG document: the file does not start with an <svg> tag. It may be corrupted or be a different format saved with an .svg extension.' }; | |||||
| } | } | ||||
| let out = input; | let out = input; | ||||
| @@ -317,7 +317,7 @@ async function runJob(job: TranscodeJob): Promise<void> { | |||||
| await updateJob(job.id, { | await updateJob(job.id, { | ||||
| status: 'failed', | status: 'failed', | ||||
| finishedAt: Date.now(), | finishedAt: Date.now(), | ||||
| error: `Output non valido: ${(e as Error).message}`, | |||||
| error: `Transcoding produced an invalid output: ${(e as Error).message}. The video may be corrupted; try re-exporting from your source tool.`, | |||||
| pid: undefined, | pid: undefined, | ||||
| }); | }); | ||||
| await cleanupTmpForJob(job); | await cleanupTmpForJob(job); | ||||
| @@ -36,47 +36,47 @@ export function validateCard(card: Partial<Card>): ValidationResult { | |||||
| const errors: ValidationError[] = []; | const errors: ValidationError[] = []; | ||||
| if (typeof card.title !== 'string' || card.title.trim().length === 0) { | if (typeof card.title !== 'string' || card.title.trim().length === 0) { | ||||
| errors.push({ field: 'title', message: 'Il titolo è obbligatorio' }); | |||||
| errors.push({ field: 'title', message: 'Title is required. Enter a title before saving.' }); | |||||
| } else if (card.title.length > CARD_LIMITS.title) { | } else if (card.title.length > CARD_LIMITS.title) { | ||||
| errors.push({ field: 'title', message: 'Titolo troppo lungo', limit: CARD_LIMITS.title, actual: card.title.length }); | |||||
| errors.push({ field: 'title', message: 'Title is too long', limit: CARD_LIMITS.title, actual: card.title.length }); | |||||
| } | } | ||||
| if (card.shortDescription !== undefined && typeof card.shortDescription !== 'string') { | if (card.shortDescription !== undefined && typeof card.shortDescription !== 'string') { | ||||
| errors.push({ field: 'shortDescription', message: 'Tipo non valido' }); | |||||
| errors.push({ field: 'shortDescription', message: 'Invalid type (expected text)' }); | |||||
| } else if (strLen(card.shortDescription) > CARD_LIMITS.shortDescription) { | } 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) }); | |||||
| errors.push({ field: 'shortDescription', message: 'Short description is too long', limit: CARD_LIMITS.shortDescription, actual: strLen(card.shortDescription) }); | |||||
| } | } | ||||
| if (card.fullContent !== undefined && typeof card.fullContent !== 'string') { | if (card.fullContent !== undefined && typeof card.fullContent !== 'string') { | ||||
| errors.push({ field: 'fullContent', message: 'Tipo non valido' }); | |||||
| errors.push({ field: 'fullContent', message: 'Invalid type (expected text)' }); | |||||
| } else if (strLen(card.fullContent) > CARD_LIMITS.fullContent) { | } else if (strLen(card.fullContent) > CARD_LIMITS.fullContent) { | ||||
| errors.push({ field: 'fullContent', message: 'Contenuto troppo lungo', limit: CARD_LIMITS.fullContent, actual: strLen(card.fullContent) }); | |||||
| errors.push({ field: 'fullContent', message: 'Content is too long', limit: CARD_LIMITS.fullContent, actual: strLen(card.fullContent) }); | |||||
| } | } | ||||
| // Le card External Link richiedono obbligatoriamente l'URL. | // Le card External Link richiedono obbligatoriamente l'URL. | ||||
| if (card.cardType === 'EXTERNAL_LINK' && (typeof card.actionUrl !== 'string' || card.actionUrl.trim() === '')) { | if (card.cardType === 'EXTERNAL_LINK' && (typeof card.actionUrl !== 'string' || card.actionUrl.trim() === '')) { | ||||
| errors.push({ field: 'actionUrl', message: "L'URL è obbligatorio per le card External Link" }); | |||||
| errors.push({ field: 'actionUrl', message: 'URL is required for External Link cards. Enter the destination URL before saving.' }); | |||||
| } | } | ||||
| if (card.actionUrl !== undefined && card.actionUrl !== '') { | if (card.actionUrl !== undefined && card.actionUrl !== '') { | ||||
| if (typeof card.actionUrl !== 'string') { | if (typeof card.actionUrl !== 'string') { | ||||
| errors.push({ field: 'actionUrl', message: 'Tipo non valido' }); | |||||
| errors.push({ field: 'actionUrl', message: 'Invalid type (expected text)' }); | |||||
| } else if (card.actionUrl.length > CARD_LIMITS.actionUrl) { | } else if (card.actionUrl.length > CARD_LIMITS.actionUrl) { | ||||
| errors.push({ field: 'actionUrl', message: 'URL troppo lungo', limit: CARD_LIMITS.actionUrl, actual: card.actionUrl.length }); | |||||
| errors.push({ field: 'actionUrl', message: 'URL is too long', limit: CARD_LIMITS.actionUrl, actual: card.actionUrl.length }); | |||||
| } else { | } else { | ||||
| try { | try { | ||||
| const parsed = new URL(card.actionUrl); | const parsed = new URL(card.actionUrl); | ||||
| if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) { | if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) { | ||||
| errors.push({ field: 'actionUrl', message: `Schema URL non ammesso (${parsed.protocol}). Usa http, https, mailto o tel.` }); | |||||
| errors.push({ field: 'actionUrl', message: `URL scheme not allowed (${parsed.protocol}). Allowed schemes: http, https, mailto, tel.` }); | |||||
| } | } | ||||
| } catch { | } catch { | ||||
| errors.push({ field: 'actionUrl', message: 'URL non valido' }); | |||||
| errors.push({ field: 'actionUrl', message: 'Invalid URL format. Use a complete URL such as https://example.com.' }); | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| if (card.cardType !== undefined && !VALID_CARD_TYPES.includes(card.cardType as CardType)) { | if (card.cardType !== undefined && !VALID_CARD_TYPES.includes(card.cardType as CardType)) { | ||||
| errors.push({ field: 'cardType', message: `Tipo card non valido: ${String(card.cardType)}` }); | |||||
| errors.push({ field: 'cardType', message: `Invalid card type "${String(card.cardType)}". Pick a type from the dropdown.` }); | |||||
| } | } | ||||
| return { valid: errors.length === 0, errors }; | return { valid: errors.length === 0, errors }; | ||||
| @@ -87,17 +87,17 @@ export function validatePortal(portal: Partial<Portal>): ValidationResult { | |||||
| if (portal.title !== undefined) { | if (portal.title !== undefined) { | ||||
| if (typeof portal.title !== 'string') { | if (typeof portal.title !== 'string') { | ||||
| errors.push({ field: 'title', message: 'Tipo non valido' }); | |||||
| errors.push({ field: 'title', message: 'Invalid type (expected text)' }); | |||||
| } else if (portal.title.length > PORTAL_LIMITS.title) { | } else if (portal.title.length > PORTAL_LIMITS.title) { | ||||
| errors.push({ field: 'title', message: 'Titolo portale troppo lungo', limit: PORTAL_LIMITS.title, actual: portal.title.length }); | |||||
| errors.push({ field: 'title', message: 'Portal title is too long', limit: PORTAL_LIMITS.title, actual: portal.title.length }); | |||||
| } | } | ||||
| } | } | ||||
| if (portal.welcomeText !== undefined) { | if (portal.welcomeText !== undefined) { | ||||
| if (typeof portal.welcomeText !== 'string') { | if (typeof portal.welcomeText !== 'string') { | ||||
| errors.push({ field: 'welcomeText', message: 'Tipo non valido' }); | |||||
| errors.push({ field: 'welcomeText', message: 'Invalid type (expected text)' }); | |||||
| } else if (portal.welcomeText.length > PORTAL_LIMITS.welcomeText) { | } else if (portal.welcomeText.length > PORTAL_LIMITS.welcomeText) { | ||||
| errors.push({ field: 'welcomeText', message: 'Testo di benvenuto troppo lungo', limit: PORTAL_LIMITS.welcomeText, actual: portal.welcomeText.length }); | |||||
| errors.push({ field: 'welcomeText', message: 'Welcome text is too long', limit: PORTAL_LIMITS.welcomeText, actual: portal.welcomeText.length }); | |||||
| } | } | ||||
| } | } | ||||