Skip to content
← Public packages

@kentcdodds/christ-paintings

Official Latter-day Saint paintings of Jesus Christ, one at a time, to show children during church, plus coloring pages of the Savior. Swipe to move. Works offline.

FollowForkOpen app

src/images.ts

83 lines · 3.2 KB · TypeScript
Raw
import { COLORING_PAGES } from './coloring.ts'
import { PAINTINGS } from './paintings.ts'

/**
 * Images are not stored in this repo. The app serves each one at a same-origin path
 * ({appBase}/img/p/{id}.jpg for paintings, {appBase}/img/c/{id}.jpg for coloring pages)
 * by fetching the Church's own file from churchofjesuschrist.org. Same origin matters:
 * the service worker can cache every image normally for offline use, and the coloring
 * canvas can read the line art without tainting. Only ids in the curated lists are
 * served (the path must match /img/(p|c)/<slug>.jpg and the slug must be in the list),
 * and the upstream URL always comes from that list, so this is not an open proxy.
 */
const SOURCES = new Map<string, string>([
	...PAINTINGS.map((p) => [`p/${p.id}`, p.imageUrl] as const),
	...COLORING_PAGES.map((c) => [`c/${c.id}`, c.imageUrl] as const),
])

export function imagePath(kind: 'p' | 'c', id: string) {
	return `/img/${kind}/${id}.jpg`
}

export function allImagePaths() {
	return [
		...PAINTINGS.map((p) => imagePath('p', p.id)),
		...COLORING_PAGES.map((c) => imagePath('c', c.id)),
	]
}

export function totalImageBytes() {
	return [...PAINTINGS, ...COLORING_PAGES].reduce((sum, item) => sum + item.bytes, 0)
}

export function imageSourceFor(path: string) {
	const match = /^\/img\/([pc]\/[a-z0-9-]+)\.jpg$/.exec(path)
	return match ? (SOURCES.get(match[1]) ?? null) : null
}

// The path names a fixed Church file, so browsers and shared caches may keep it for a
// year. Together with the edge cache below, each image is fetched from
// churchofjesuschrist.org rarely, not once per visitor.
const CACHE_CONTROL = 'public, max-age=31536000, immutable'

function headers(cache: 'hit' | 'miss') {
	return {
		'content-type': 'image/jpeg',
		'cache-control': CACHE_CONTROL,
		'x-content-type-options': 'nosniff',
		'x-image-cache': cache,
	}
}

export async function serveImage(request: Request, source: string) {
	const edge = (globalThis as { caches?: { default?: Cache } }).caches?.default
	const key = new Request(source)
	if (edge) {
		const hit = await edge.match(key).catch(() => undefined)
		if (hit) return withHeaders(hit, request, 'hit')
	}
	let upstream: Response
	try {
		// source always comes from the curated map, never from the request.
		upstream = await fetch(source, { headers: { accept: 'image/jpeg,image/*' }, redirect: 'manual' })
	} catch {
		return unavailable()
	}
	const type = upstream.headers.get('content-type') ?? ''
	if (!upstream.ok || !type.startsWith('image/')) return unavailable()
	const body = await upstream.arrayBuffer()
	if (edge) {
		const copy = new Response(body.slice(0), { headers: { 'content-type': 'image/jpeg', 'cache-control': CACHE_CONTROL } })
		await edge.put(key, copy).catch(() => undefined)
	}
	return withHeaders(new Response(body), request, 'miss')
}

function unavailable() {
	return new Response('Image unavailable', { status: 502, headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' } })
}

async function withHeaders(res: Response, request: Request, cache: 'hit' | 'miss') {
	const body = request.method === 'HEAD' ? null : await res.arrayBuffer()
	return new Response(body, { status: 200, headers: headers(cache) })
}