import { supabase } from './supabase'; const BUCKET = 'profile-avatars'; const MAX_DIM = 512; const QUALITY = 0.85; // Resizes the source image to a centred-cropped square ≤ MAX_DIM and // re-encodes as WebP. Falls back to JPEG if WebP isn't supported (rare). async function resizeToSquare(file: File): Promise { 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; }); const minSide = Math.min(img.naturalWidth, img.naturalHeight); const sx = (img.naturalWidth - minSide) / 2; const sy = (img.naturalHeight - minSide) / 2; const target = Math.min(MAX_DIM, minSide); const canvas = document.createElement('canvas'); canvas.width = target; canvas.height = target; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas context unavailable'); ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, target, target); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', QUALITY), ); if (blob) return blob; const jpeg = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', QUALITY), ); if (!jpeg) throw new Error('canvas toBlob returned null'); return jpeg; } finally { URL.revokeObjectURL(url); } } 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' : 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. const name = userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext; const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, { contentType: blob.type, cacheControl: '604800', upsert: false, }); if (upErr) throw upErr; const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name); return pub.publicUrl; } export const AVATAR_TARGET_DIM = MAX_DIM; export async function deleteAvatarObject(publicUrl: string): Promise { // Public URLs look like // https:///storage/v1/object/public/profile-avatars/ // Extract and remove. const marker = '/object/public/' + BUCKET + '/'; const idx = publicUrl.indexOf(marker); if (idx === -1) return; const path = publicUrl.slice(idx + marker.length); const { error } = await supabase.storage.from(BUCKET).remove([path]); if (error) throw error; }