flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/lib/avatar-upload.ts

50 lines2,176 bytesCodeBlame
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. */
9export const MAX_AVATAR_BYTES = 1024 * 1024;
10
11/** What an `<input type="file">` offers to pick. SVG is left out on purpose. */
12export 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`. */
15export 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. */
26export 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
34export type AvatarUpload = { image: string } | { error: string };
35
36/** Reads the file in `field` of a submitted form. */
37export 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}