import { ulid } from '../domain/ulid'; import type { ExpensePhoto } from '../domain/types'; import type { BucketStore } from './storage/store'; export const MAX_PHOTO_BYTES = 5 * 1024 * 1024; export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp'; const EXT: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }; const TYPE_BY_EXT: Record = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' }; export const PHOTO_KEY_RE = /^photos\/(e_[0-9A-HJKMNP-TV-Z]{26})\/([0-9A-HJKMNP-TV-Z]{26})\.(jpg|png|webp)$/; export function photoKey(expenseId: string, photoId: string, type: ImageType): string { return `photos/${expenseId}/${photoId}.${EXT[type]}`; } export function contentTypeForKey(key: string): string { const ext = key.slice(key.lastIndexOf('.') + 1); return Object.hasOwn(TYPE_BY_EXT, ext) ? TYPE_BY_EXT[ext] : 'application/octet-stream'; } export function sniffImageType(b: Uint8Array): ImageType | null { if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'; if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return 'image/png'; if (b.length >= 12 && ascii(b, 0, 4) === 'RIFF' && ascii(b, 8, 12) === 'WEBP') return 'image/webp'; return null; } export const MAX_IMAGE_DIMENSION = 20_000; export function imageDimensions(b: Uint8Array, type: ImageType): { width: number; height: number } | null { const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); try { if (type === 'image/png') { if (b.length < 24 || ascii(b, 12, 16) !== 'IHDR') return null; return check(dv.getUint32(16), dv.getUint32(20)); } if (type === 'image/jpeg') { let i = 2; while (i + 9 < b.length) { if (b[i] !== 0xff) { i++; continue; } const marker = b[i + 1]; if (marker === 0xff) { i++; continue; } const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; if (isSof) return check(dv.getUint16(i + 7), dv.getUint16(i + 5)); if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { i += 2; continue; } i += 2 + dv.getUint16(i + 2); } return null; } // WebP const chunk = ascii(b, 12, 16); if (chunk === 'VP8X' && b.length >= 30) return check(1 + u24le(b, 24), 1 + u24le(b, 27)); if (chunk === 'VP8 ' && b.length >= 30) return check(dv.getUint16(26, true) & 0x3fff, dv.getUint16(28, true) & 0x3fff); if (chunk === 'VP8L' && b.length >= 25) { if (b[20] !== 0x2f) return null; const w = 1 + (((b[22] & 0x3f) << 8) | b[21]); const h = 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)); return check(w, h); } return null; } catch { return null; } } function ascii(b: Uint8Array, start: number, end: number): string { return String.fromCharCode(...b.subarray(start, end)); } function u24le(b: Uint8Array, i: number): number { return b[i] | (b[i + 1] << 8) | (b[i + 2] << 16); } function check(width: number, height: number): { width: number; height: number } | null { return width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION ? { width, height } : null; } /** Returns the uploaded file for `name`, or null when the field is absent/empty. */ export function uploadedFile(form: FormData, name: string): File | null { const f = form.get(name); return f instanceof File && f.size > 0 ? f : null; } export type StoredPhoto = { ok: true; photo: ExpensePhoto } | { ok: false; error: string }; /** Validates and stores an uploaded image under photos//.. */ export async function storeUploadedPhoto(store: BucketStore, expenseId: string, file: File): Promise { if (file.size > MAX_PHOTO_BYTES) return { ok: false, error: 'Photo must be 5 MB or smaller' }; const bytes = new Uint8Array(await file.arrayBuffer()); const type = sniffImageType(bytes); if (!type) return { ok: false, error: 'Photo must be a JPEG, PNG or WebP image' }; const key = photoKey(expenseId, ulid(), type); await store.put(key, bytes, type); const dims = imageDimensions(bytes, type); return { ok: true, photo: { key, contentType: type, ...(dims ?? {}) } }; }