e19a71e892
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
154 lines
5.8 KiB
TypeScript
154 lines
5.8 KiB
TypeScript
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<Blob> {
|
|
const url = URL.createObjectURL(file);
|
|
try {
|
|
const img = await new Promise<HTMLImageElement>((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<Blob | null>((resolve) =>
|
|
canvas.toBlob(resolve, 'image/webp', QUALITY),
|
|
);
|
|
if (blob) return blob;
|
|
|
|
const jpeg = await new Promise<Blob | null>((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<string> {
|
|
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<HTMLImageElement>((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<boolean> {
|
|
// 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<string> {
|
|
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<void> {
|
|
// Public URLs look like
|
|
// https://<host>/storage/v1/object/public/profile-avatars/<path>
|
|
// Extract <path> 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;
|
|
}
|