/** * The image proxy's fetch and cache: remote thumbnails are served through * this site, so viewers' browsers never contact the hosts. * * @module */ import { fetchWithTimeout } from "./fetch.ts"; /** An image fetched for the proxy. */ export interface ProxiedImage { /** The image's bytes. */ bytes: Uint8Array; /** The image's MIME type, as its host sent it. */ contentType: string; } const PROXYABLE_IMAGE_TYPES = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "image/bmp", "image/x-icon", "image/vnd.microsoft.icon", ]); /** How long a proxied image is served from the cache. */ const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; /** The cache's size bound, in image bytes. */ const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; /** * Proxied images, by URL: every viewer of a feed asks for the same thumbnails, * which were fetched from their hosts again for each one. Bounded by total * size; the least recently used go first. Map order is recency order. */ const imageCache = new Map< string, { bytes: Uint8Array; contentType: string; at: number } >(); let imageCacheBytes = 0; function readImageCache( url: string, ): { bytes: Uint8Array; contentType: string } | undefined { const hit = imageCache.get(url); if (!hit) return undefined; imageCache.delete(url); if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) { imageCacheBytes -= hit.bytes.byteLength; return undefined; } imageCache.set(url, hit); // most recently used return hit; } function writeImageCache( url: string, bytes: Uint8Array, contentType: string, ): void { const old = imageCache.get(url); if (old) { imageCache.delete(url); imageCacheBytes -= old.bytes.byteLength; } while ( imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES && imageCache.size > 0 ) { const [oldestUrl, oldest] = imageCache.entries().next().value!; imageCache.delete(oldestUrl); imageCacheBytes -= oldest.bytes.byteLength; } imageCache.set(url, { bytes, contentType, at: Date.now() }); imageCacheBytes += bytes.byteLength; } /** Forgets every proxied image (for the tests). */ export function clearImageCache(): void { imageCache.clear(); imageCacheBytes = 0; } /** * Returns the image at `url`, from the cache or else from its host; null when * the host answers with something that isn't a raster image of 5 MB at most. * * @throws {Error} When the host can't be reached (see {@link fetchWithTimeout}). */ export async function fetchProxiedImage( url: string, ): Promise { const cached = readImageCache(url); if (cached) return cached; const res = await fetchWithTimeout(url, 8000); // Raster formats only. The bytes go out under this site's origin, so an // SVG (which can carry script) or anything else a browser might execute // would run with the app's origin if opened directly. const contentType = (res.headers.get("content-type") ?? "") .split(";")[0].trim().toLowerCase(); if (!PROXYABLE_IMAGE_TYPES.has(contentType)) { await res.body?.cancel(); return null; } const MAX_SIZE = 5 * 1024 * 1024; // 5 MB const contentLength = Number(res.headers.get("content-length") ?? "0"); if (contentLength > MAX_SIZE) { await res.body?.cancel(); return null; } const bytes = new Uint8Array(await res.arrayBuffer()); if (bytes.length > MAX_SIZE) return null; writeImageCache(url, bytes, contentType); return { bytes, contentType }; }