g1t/apps/web/app/lib/avatar-upload.ts
| 1 | /** |
| 2 | * An avatar as it arrives from a form: read, checked and turned into the |
| 3 | * base64 the identity service takes. Identity checks it again, by its |
| 4 | * bytes, and is the one that decides; this only turns away what is |
| 5 | * plainly wrong before sending a megabyte across. |
| 6 | */ |
| 7 | |
| 8 | /** As `MAX_AVATAR_BYTES` in the contracts: kept here so tests run without them. */ |
| 9 | export const MAX_AVATAR_BYTES = 1024 * 1024; |
| 10 | |
| 11 | /** What an `<input type="file">` offers to pick. SVG is left out on purpose. */ |
| 12 | export const AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif"; |
| 13 | |
| 14 | /** The type an image's first bytes show it to be, or null. As identity's `sniff`. */ |
| 15 | export function sniffImage(bytes: Uint8Array): string | null { |
| 16 | const starts = (...prefix: number[]) => prefix.every((byte, i) => bytes[i] === byte); |
| 17 | const ascii = (at: number, text: string) => [...text].every((char, i) => bytes[at + i] === char.charCodeAt(0)); |
| 18 | if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png"; |
| 19 | if (starts(0xff, 0xd8, 0xff)) return "image/jpeg"; |
| 20 | if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif"; |
| 21 | if (bytes.length >= 12 && ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp"; |
| 22 | return null; |
| 23 | } |
| 24 | |
| 25 | /** Bytes as base64, in chunks so a large file does not overflow the stack. */ |
| 26 | export function toBase64(bytes: Uint8Array): string { |
| 27 | let binary = ""; |
| 28 | for (let i = 0; i < bytes.length; i += 0x8000) { |
| 29 | binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); |
| 30 | } |
| 31 | return btoa(binary); |
| 32 | } |
| 33 | |
| 34 | export type AvatarUpload = { image: string } | { error: string }; |
| 35 | |
| 36 | /** Reads the file in `field` of a submitted form. */ |
| 37 | export async function readAvatarUpload(form: FormData, field = "avatar"): Promise<AvatarUpload> { |
| 38 | const file = form.get(field); |
| 39 | if (!file || typeof file === "string" || file.size === 0) { |
| 40 | return { error: "Choose an image to upload." }; |
| 41 | } |
| 42 | if (file.size > MAX_AVATAR_BYTES) { |
| 43 | return { error: "Use an image of at most 1 MB." }; |
| 44 | } |
| 45 | const bytes = new Uint8Array(await file.arrayBuffer()); |
| 46 | if (!sniffImage(bytes)) { |
| 47 | return { error: "Use a PNG, JPEG, WebP or GIF image." }; |
| 48 | } |
| 49 | return { image: toBase64(bytes) }; |
| 50 | } |