File size: 4,340 Bytes
4ae345a
 
 
 
0545b15
 
 
 
 
 
 
 
 
 
 
 
 
 
fa363c5
0545b15
 
 
 
 
 
 
 
 
fa363c5
 
0545b15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa363c5
0545b15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa363c5
0545b15
4ae345a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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 ?? {}) } };
}