/** Client-side validation for mockup / reference-screenshot attachments (the server re-checks from the bytes). */ export const ATTACH_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const; export const DEFAULT_MAX_UPLOAD_BYTES = 8 * 1024 * 1024; export type SniffedType = (typeof ATTACH_TYPES)[number] | null; /** Decide the type from the file's first bytes, never from its name or declared MIME. */ export async function sniffImageType(file: Blob): Promise { let b: Uint8Array; try { b = new Uint8Array(await file.slice(0, 12).arrayBuffer()); } catch { return null; } if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'; if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'; if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif'; if (b.length >= 12 && String.fromCharCode(...b.slice(0, 4)) === 'RIFF' && String.fromCharCode(...b.slice(8, 12)) === 'WEBP') return 'image/webp'; return null; } export function formatBytes(n: number): string { if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; if (n >= 1024) return `${Math.round(n / 1024)} KB`; return `${n} B`; } /** null when the file is acceptable; otherwise a plain-language reason. */ export async function validateAttachment(file: File, maxBytes = DEFAULT_MAX_UPLOAD_BYTES): Promise { const label = file.name ? `"${file.name.slice(0, 60)}"` : 'That file'; if (file.size === 0) return `${label} is empty.`; if (file.size > maxBytes) return `${label} is ${formatBytes(file.size)}, which is over the ${formatBytes(maxBytes)} limit. Use a smaller image.`; const t = await sniffImageType(file); if (!t) return `${label} is not a JPEG, PNG, WebP or GIF image (SVG, PDF and other formats are not accepted).`; return null; } /** Only http(s) URLs (or a bare domain) are accepted as a "site I like" label; it is never fetched. */ export function normalizeUrlLabel(raw: string): { ok: true; value: string } | { ok: false; message: string } { const v = raw.trim(); if (!v) return { ok: false, message: 'Enter the site address, for example example.com.' }; if (v.length > 300) return { ok: false, message: 'That address is too long (300 characters at most).' }; if (/[\u0000-\u001f<>"]/.test(v)) return { ok: false, message: 'The address contains characters that are not allowed.' }; if (/^[a-z][a-z0-9+.-]*:/i.test(v) && !/^https?:\/\//i.test(v)) { return { ok: false, message: 'Only http:// or https:// addresses are accepted.' }; } return { ok: true, value: v }; }