125 lines
3.5 KiB
TypeScript
125 lines
3.5 KiB
TypeScript
/**
|
|
* Fetches and caches the remote images `GET /api/proxy-image` serves. The
|
|
* frontend routes a thumbnail through it when the image is on plain http,
|
|
* which an https page would block, or when loading it directly failed, often
|
|
* to hotlink protection.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { fetchWithTimeout } from "./fetch.ts";
|
|
|
|
/**
|
|
* Returned to `GET /api/proxy-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, 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 anything but a raster image of 5 MB at most. 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,
|
|
): Promise<ProxiedImage | null> {
|
|
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, 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;
|
|
}
|
|
|
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
if (bytes.length > MAX_SIZE) return null;
|
|
|
|
writeImageCache(url, bytes, contentType);
|
|
return { bytes, contentType };
|
|
}
|