g1t/apps/web/app/lib/avatar-upload.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Workspace names and icons, and a component kit for every control | 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 | } |