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)/.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([ ...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) }) }