splitwise / src /lib /server /photos.ts
assafvayner's picture
assafvayner HF Staff
feat(ui): add expense form with live split editor and photo upload
4ae345a
Raw History Blame Contribute Delete
4.34 kB
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<ImageType, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
const TYPE_BY_EXT: Record<string, ImageType> = { 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/<expenseId>/<ulid>.<ext>. */
export async function storeUploadedPhoto(store: BucketStore, expenseId: string, file: File): Promise<StoredPhoto> {
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 ?? {}) } };
}