46 lines
2.6 KiB
TypeScript
46 lines
2.6 KiB
TypeScript
/** 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<SniffedType> {
|
|
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<string | null> {
|
|
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 };
|
|
}
|