diff --git a/apps/desktop/src/lib/avatarUpload.ts b/apps/desktop/src/lib/avatarUpload.ts index 6e5f6d1..798dff6 100644 --- a/apps/desktop/src/lib/avatarUpload.ts +++ b/apps/desktop/src/lib/avatarUpload.ts @@ -43,19 +43,86 @@ async function resizeToSquare(file: File): Promise { } } +const MAX_ANIMATED_BYTES = 2 * 1024 * 1024; // 2 MB hard cap on animated uploads +const ANIMATED_MIMES = new Set(['image/gif', 'image/apng', 'image/webp', 'image/png']); + export async function uploadAvatar(userId: string, file: File): Promise { if (!file.type.startsWith('image/')) { throw new Error('only image files are accepted'); } + // Animated formats bypass the canvas re-encode (which would strip + // animation by sampling the first frame). We still validate dimensions + // and size so a 40-MB animated WebP can't slip through. + if (ANIMATED_MIMES.has(file.type) && (await isAnimated(file))) { + if (file.size > MAX_ANIMATED_BYTES) { + throw new Error('animated avatar too large (max 2 MB)'); + } + const dims = await readDimensions(file); + if (dims.width > MAX_DIM || dims.height > MAX_DIM) { + throw new Error('animated avatar exceeds ' + MAX_DIM + 'px (got ' + dims.width + 'x' + dims.height + ')'); + } + return uploadAvatarBlob(userId, file); + } const blob = await resizeToSquare(file); return uploadAvatarBlob(userId, blob); } +async function readDimensions(file: File): Promise<{ width: number; height: number }> { + const url = URL.createObjectURL(file); + try { + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = () => reject(new Error('image load failed')); + i.src = url; + }); + return { width: img.naturalWidth, height: img.naturalHeight }; + } finally { + URL.revokeObjectURL(url); + } +} + +async function isAnimated(file: File): Promise { + // GIF: any GIF89a/GIF87a header is treated as potentially animated. The + // static-GIF case (one image-descriptor block) is rare enough that + // re-encoding wouldn't save much, so we accept the false-positives. + if (file.type === 'image/gif') return true; + // APNG: presence of an 'acTL' chunk inside the PNG stream. Scan the + // first 64 KB — APNGs put acTL near the front, before IDAT. + if (file.type === 'image/apng' || file.type === 'image/png') { + const head = await file.slice(0, 65536).arrayBuffer(); + return containsBytes(head, [0x61, 0x63, 0x54, 0x4c]); // 'acTL' + } + // Animated WebP: 'ANIM' chunk in the RIFF container. + if (file.type === 'image/webp') { + const head = await file.slice(0, 65536).arrayBuffer(); + return containsBytes(head, [0x41, 0x4e, 0x49, 0x4d]); // 'ANIM' + } + return false; +} + +function containsBytes(buf: ArrayBuffer, needle: number[]): boolean { + const view = new Uint8Array(buf); + const len = view.length; + const nlen = needle.length; + outer: for (let i = 0; i + nlen <= len; i++) { + for (let j = 0; j < nlen; j++) { + if (view[i + j] !== needle[j]) continue outer; + } + return true; + } + return false; +} + // Upload an already-cropped Blob (e.g. from ImageCropDialog) without going // through the legacy center-crop. Caller is responsible for sizing — the // dialog already clamps to MAX_DIM via its outputWidth. export async function uploadAvatarBlob(userId: string, blob: Blob): Promise { - const ext = blob.type === 'image/webp' ? 'webp' : 'jpg'; + const ext = + blob.type === 'image/webp' ? 'webp' : + blob.type === 'image/gif' ? 'gif' : + blob.type === 'image/apng' || blob.type === 'image/png' ? 'png' : + 'jpg'; // Random filename so old uploads don't get overwritten before we update // the profile row — Supabase Storage CDN caches by URL, so a fresh path // also forces clients to fetch the new image.