/** * Shows the thumbnails a browser can't load from their own site: those on plain * http, which an https page would block, and those behind hotlink protection. * It fetches and caches the images `GET /api/remote/image` serves, which the * frontend routes a thumbnail through when it is on http or when loading it * directly failed, and scales them down as the site's own are. * * @module */ import { fetchWithTimeout, readBytesCapped } from "./fetch.ts"; import type { ImageWidth } from "../../shared/image.ts"; import { scaleImageBytes } from "../lib/ffmpeg.ts"; import { scalableImage } from "../lib/upload.ts"; /** * Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends * it back under `contentType`. */ export interface ProxiedImage { bytes: Uint8Array; /** One of the raster types, without parameters. */ 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", ]); const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; /** * Proxied images, by URL and by the width they were scaled to, since every * viewer of a feed asks for the same thumbnails. The least recently used go * first, and the Map's order is that of use. */ 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); // Moves it to the end: 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; } /** Empties the cache, for the tests' reset between cases. */ 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 an error, or with anything but a raster image of 5 MB * at most. With `width`, it comes as a WebP copy scaled down to it, or as it * is when no copy can be made (`scalableImage`, and ffmpeg). The route checks * `url` with `isValidHttpUrl` first. * * @throws {Error} When the host can't be reached, as * {@link fetchWithTimeout} reports it. */ export async function fetchProxiedImage( url: string, width?: ImageWidth | null, ): Promise { if (!width) return await fetchOriginal(url); const key = `${url}#w${width}`; const cached = readImageCache(key); if (cached) return cached; const original = await fetchOriginal(url); if (!original) return null; const how = scalableImage(original.bytes); const bytes = how && await scaleImageBytes(original.bytes, width, how); if (!bytes) return original; writeImageCache(key, bytes, "image/webp"); return { bytes, contentType: "image/webp" }; } /** Returns the image at `url` as its host sends it: see above. */ async function fetchOriginal(url: string): Promise { const cached = readImageCache(url); if (cached) return cached; const res = await fetchWithTimeout(url, 8000); if (!res.ok) { await res.body?.cancel(); return null; } // Raster formats only: the bytes go out under this site's origin, where an // SVG, which can carry script, would run 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; const contentLength = Number(res.headers.get("content-length") ?? "0"); if (contentLength > MAX_SIZE) { await res.body?.cancel(); return null; } // Without a Content-Length, the cap is found by reading one byte past it. const bytes = await readBytesCapped(res, MAX_SIZE + 1); if (bytes.length > MAX_SIZE) return null; writeImageCache(url, bytes, contentType); return { bytes, contentType }; }