| @@ -317,7 +317,7 @@ export default function AdminDashboard() { | |||
| // Auto-seleziona il font appena caricato | |||
| if (data.name) setPortal(p => ({ ...p, fontFamily: data.name })); | |||
| } catch (err) { | |||
| showToast(`Network error: ${(err as Error).message}`, 'error'); | |||
| showToast(`Could not reach the server. Check your network connection and try again. Details: ${(err as Error).message}`, 'error'); | |||
| } finally { | |||
| setUploadingFont(false); | |||
| } | |||
| @@ -336,7 +336,7 @@ export default function AdminDashboard() { | |||
| await refreshFonts(); | |||
| if (portal.fontFamily === name) setPortal(p => ({ ...p, fontFamily: '' })); | |||
| } 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…`); | |||
| setTimeout(() => window.location.reload(), 1200); | |||
| } 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 { | |||
| setRestoring(false); | |||
| } | |||
| @@ -707,7 +707,7 @@ export default function AdminDashboard() { | |||
| showToast('Factory preset updated.'); | |||
| await refreshFactoryPreset(); | |||
| } 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 { | |||
| 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…`); | |||
| setTimeout(() => window.location.reload(), 1200); | |||
| } 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 { | |||
| setFactoryResetting(false); | |||
| } | |||
| @@ -16,7 +16,10 @@ function timestamp(): string { | |||
| export async function GET() { | |||
| 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. | |||
| @@ -31,7 +31,10 @@ export async function GET() { | |||
| // POST — crea il preset zippando lo stato corrente di data/. | |||
| export async function POST() { | |||
| 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 }); | |||
| @@ -58,7 +61,10 @@ export async function POST() { | |||
| // 0 = ok, 12 = "nothing to do" (data/ vuoto: accettiamo, sarà uno zip minimale) | |||
| if (exit !== 0 && exit !== 12) { | |||
| 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); | |||
| @@ -11,14 +11,17 @@ const PRESET_PATH = path.join(process.cwd(), 'factory', 'preset.zip'); | |||
| export async function POST() { | |||
| 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 { | |||
| await stat(PRESET_PATH); | |||
| } catch { | |||
| 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 }, | |||
| ); | |||
| } | |||
| @@ -37,24 +37,31 @@ export async function POST(request: Request) { | |||
| const formData = await request.formData(); | |||
| const file = formData.get('file') as File | null; | |||
| 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 ext = path.extname(safeName).toLowerCase(); | |||
| if (!ALLOWED_EXT.has(ext)) { | |||
| 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 }, | |||
| ); | |||
| } | |||
| 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) { | |||
| 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( | |||
| { 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 }, | |||
| ); | |||
| } | |||
| @@ -65,14 +72,14 @@ export async function POST(request: Request) { | |||
| const detected = await fileTypeFromBuffer(buffer); | |||
| if (!detected) { | |||
| 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 }, | |||
| ); | |||
| } | |||
| const allowed = ALLOWED_DETECTED[ext] ?? []; | |||
| if (!allowed.includes(detected.ext)) { | |||
| return NextResponse.json( | |||
| { error: `Font content does not match extension (${ext} declared, detected ${detected.ext}).` }, | |||
| { 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 }, | |||
| ); | |||
| } | |||
| @@ -83,7 +90,10 @@ export async function POST(request: Request) { | |||
| return NextResponse.json({ ok: true, name: safeName }, { status: 201 }); | |||
| } catch (error) { | |||
| console.error('Font upload error:', error); | |||
| return NextResponse.json({ error: 'Failed to upload font.' }, { status: 500 }); | |||
| 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 rawName = searchParams.get('name'); | |||
| 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 ext = path.extname(safeName).toLowerCase(); | |||
| 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 { | |||
| await unlink(path.join(FONTS_DIR, safeName)); | |||
| } catch (err) { | |||
| const e = err as NodeJS.ErrnoException; | |||
| if (e.code === 'ENOENT') { | |||
| return NextResponse.json({ error: 'Font not found.' }, { status: 404 }); | |||
| 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; | |||
| } | |||
| return NextResponse.json({ ok: true }); | |||
| } catch (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) { | |||
| 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(); | |||
| @@ -24,7 +27,10 @@ export async function POST(request: Request) { | |||
| const formData = await request.formData(); | |||
| const file = formData.get('file') as File | null; | |||
| 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 }); | |||
| @@ -41,7 +47,10 @@ export async function POST(request: Request) { | |||
| }); | |||
| } catch (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 { | |||
| try { await rm(sessionDir, { recursive: true, force: true }); } catch { /* ignore */ } | |||
| } | |||
| @@ -20,7 +20,10 @@ export async function POST(request: Request) { | |||
| const validation = validateCard(incomingCard); | |||
| 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') { | |||
| @@ -40,7 +43,10 @@ export async function POST(request: Request) { | |||
| revalidatePath('/'); // Force public portal to update instantly | |||
| return NextResponse.json(incomingCard, { status: 200 }); | |||
| } 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); | |||
| revalidatePath('/'); // Force public portal to update instantly | |||
| 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 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 filteredCards = cards.filter(c => c.id !== id); | |||
| @@ -69,7 +81,10 @@ export async function DELETE(request: Request) { | |||
| await saveCards(filteredCards); | |||
| revalidatePath('/'); // Force public portal to update instantly | |||
| 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); | |||
| 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, | |||
| // 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 } | |||
| { | |||
| 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 }); | |||
| } 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(); | |||
| } | |||
| // 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 { | |||
| const ext = extractExt(originalName); | |||
| 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; | |||
| 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); | |||
| @@ -85,15 +100,14 @@ export async function POST(request: Request) { | |||
| if (ext === 'svg') { | |||
| if (!isLogoContext) { | |||
| 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) { | |||
| const mb = (MAX_BYTES.image / (1024 * 1024)).toFixed(0); | |||
| 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'); | |||
| @@ -116,17 +130,27 @@ export async function POST(request: Request) { | |||
| const family = EXT_FAMILY[ext]; | |||
| if (!family) { | |||
| const allFormats = [ | |||
| ...EXT_BY_FAMILY.image, | |||
| ...EXT_BY_FAMILY.video, | |||
| ...EXT_BY_FAMILY.pdf, | |||
| 'SVG (logo only)', | |||
| ].join(', '); | |||
| 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]) { | |||
| 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( | |||
| { 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); | |||
| if (!detected) { | |||
| 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] ?? []; | |||
| if (!allowedMimes.includes(detected.mime)) { | |||
| 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. | |||
| if (!(await isFfmpegAvailable())) { | |||
| 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) { | |||
| 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 { | |||
| ok: false, | |||
| 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(' '), | |||
| }; | |||
| } | |||
| @@ -51,18 +51,26 @@ export async function restoreFromZipFile(zipPath: string): Promise<RestoreResult | |||
| try { | |||
| const raw = await readFile(path.join(extractDir, 'cards.txt'), 'utf-8'); | |||
| 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; | |||
| } 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 { | |||
| const raw = await readFile(path.join(extractDir, 'portals.txt'), 'utf-8'); | |||
| 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; | |||
| } 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. | |||
| @@ -41,12 +41,12 @@ export type SvgSanitizeResult = | |||
| export function sanitizeSvg(input: string): SvgSanitizeResult { | |||
| 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 | |||
| // commenti, e poi un tag <svg>. | |||
| 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; | |||
| @@ -317,7 +317,7 @@ async function runJob(job: TranscodeJob): Promise<void> { | |||
| await updateJob(job.id, { | |||
| status: 'failed', | |||
| 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, | |||
| }); | |||
| await cleanupTmpForJob(job); | |||
| @@ -36,47 +36,47 @@ 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' }); | |||
| errors.push({ field: 'title', message: 'Title is required. Enter a title before saving.' }); | |||
| } 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') { | |||
| 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) { | |||
| 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') { | |||
| 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) { | |||
| 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. | |||
| 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 (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) { | |||
| 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 { | |||
| 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.` }); | |||
| errors.push({ field: 'actionUrl', message: `URL scheme not allowed (${parsed.protocol}). Allowed schemes: http, https, mailto, tel.` }); | |||
| } | |||
| } 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)) { | |||
| 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 }; | |||
| @@ -87,17 +87,17 @@ export function validatePortal(portal: Partial<Portal>): ValidationResult { | |||
| if (portal.title !== undefined) { | |||
| 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) { | |||
| 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 (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) { | |||
| 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 }); | |||
| } | |||
| } | |||