// Everything a render fetches - source tiles, glyphs, sprites, a remote style - // and the bounded cache that keeps the next tile of the same coast off the // network. // // Only from the origins the browser may fetch the same basemap from: this // instance's own and those `basemapOrigins` names for the CSP's `connect-src`. // The URLs come from documents the basemap host serves - a style, TileJSON's // `tiles`, a redirect - so without that list a host that turned hostile could // point the renderer at anything on its network. // // Requests to the basemap carry the headers the instance's own pages send when a // browser fetches the same tiles - `Origin` and `Referer` naming its `SITE_URL` - // so a keyed provider restricted to the instance's domain accepts this as it // accepts the browser, and a User-Agent saying what is asking. import { readFile } from "node:fs/promises"; import path from "node:path"; export const USER_AGENT = "OpenDiving-MapRenderer (+https://github.com/opendiving/opendiving-web)"; // What a coast's worth of tiles costs, a few times over: the vendored basemap's // glyph ranges, sprite and underlay are shared by every tile, and a vector tile // is a few hundred kilobytes at most. export const CACHE_BYTES = 32 * 1024 * 1024; const REQUEST_TIMEOUT_MS = 15_000; // Larger than any tile, glyph range or sprite sheet; a provider sending more is // broken, and holding it would be the cache's whole budget. Counted as the body // arrives, so a body that never ends costs this much memory and no more. const MAX_RESOURCE_BYTES = 16 * 1024 * 1024; // Each hop is checked against the origins like the first request. const MAX_REDIRECTS = 5; /** * A resource that could not be had. Fails the whole render, which is the point: * a tile drawn without its source tile would be stored and shown for weeks. */ export class ResourceError extends Error {} export interface ResourceOptions { /** This instance's own origin, which a relative URL resolves against. */ siteUrl: string; /** The image's `public/`, which `${SITE_URL}/basemap/…` is read from. */ publicDir: string; /** The basemap's origins, as `basemapOrigins` derives them for the CSP. */ origins: string[]; cacheBytes?: number; timeoutMs?: number; maxBytes?: number; fetch?: typeof fetch; } export interface Resources { /** * The bytes at `url`, or `null` for no content - a 404 or a 204, which is how * a provider says a tile is empty. A kept answer is reused until the cache * needs its room, or for `maxAgeMs` where that is given. */ get(url: string, options?: { maxAgeMs?: number }): Promise; } interface Entry { data: Buffer | null; size: number; expiresAt: number; } // What a log line may say about a URL: never its query, which is where a keyed // provider's credential lives. export function describe(url: string): string { try { const { protocol, origin, pathname } = new URL(url); return protocol === "data:" ? "a data: URL" : `${origin}${pathname}`; } catch { return "a malformed URL"; } } export function createResources({ siteUrl, publicDir, origins, cacheBytes = CACHE_BYTES, timeoutMs = REQUEST_TIMEOUT_MS, maxBytes = MAX_RESOURCE_BYTES, fetch: fetchImpl = fetch, }: ResourceOptions): Resources { const site = new URL(siteUrl); const allowed = new Set([site.origin, ...origins]); const headers = { Origin: site.origin, // What `Referrer-Policy: strict-origin-when-cross-origin` lets a page send // to another origin: the origin alone. Referer: `${site.origin}/`, "User-Agent": USER_AGENT, }; const basemapDir = path.resolve(publicDir, "basemap"); // Insertion order is recency: a hit is moved to the end, eviction takes the // front. const cache = new Map(); let cachedBytes = 0; const inFlight = new Map>(); function remember(key: string, data: Buffer | null, maxAgeMs?: number) { const size = (data?.length ?? 0) + key.length; if (size > cacheBytes) return; forget(key); cache.set(key, { data, size, expiresAt: maxAgeMs === undefined ? Infinity : Date.now() + maxAgeMs, }); cachedBytes += size; for (const [oldest, entry] of cache) { if (cachedBytes <= cacheBytes) break; cache.delete(oldest); cachedBytes -= entry.size; } } function forget(key: string) { const entry = cache.get(key); if (!entry) return; cache.delete(key); cachedBytes -= entry.size; } // The vendored styles' sprite, named against this instance's own origin as // the browser names it, is read from the files the web serves it from. async function readLocal(pathname: string): Promise { const file = path.resolve( basemapDir, ...decodeURIComponent(pathname.slice("/basemap/".length)).split("/"), ); if (!file.startsWith(basemapDir + path.sep)) return null; try { return await readFile(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } } function isAllowed(url: URL): boolean { return ( (url.protocol === "http:" || url.protocol === "https:") && allowed.has(url.origin) ); } // Redirects are followed by hand, so every hop answers to the same list as // the first request rather than `fetch` going wherever it is told. async function fetchRemote(start: URL): Promise { const signal = AbortSignal.timeout(timeoutMs); let url = start; for (let hops = 0; ; hops++) { let response: Response; try { response = await fetchImpl(url, { headers: url.protocol === "data:" ? undefined : headers, redirect: "manual", signal, }); } catch (error) { throw new ResourceError( `Could not fetch ${describe(url.href)}: ${(error as Error).message}`, ); } if (response.status === 204 || response.status === 404) { await response.body?.cancel(); return null; } const location = response.headers.get("location"); if (response.status >= 300 && response.status < 400 && location) { await response.body?.cancel(); let next: URL; try { next = new URL(location, url); } catch { throw new ResourceError( `${describe(url.href)} redirected to a malformed URL`, ); } if (!isAllowed(next)) { throw new ResourceError( `${describe(url.href)} redirected to ${describe(next.href)}, ` + `which is not one of the basemap's origins`, ); } if (hops === MAX_REDIRECTS) { throw new ResourceError( `Too many redirects from ${describe(start.href)}`, ); } url = next; continue; } if (!response.ok) { await response.body?.cancel(); throw new ResourceError( `${response.status} ${response.statusText} from ${describe(url.href)}`, ); } return readBody(url, response); } } async function readBody(url: URL, response: Response): Promise { const tooLarge = () => new ResourceError( `${describe(url.href)} sent more than ${maxBytes} bytes`, ); // `Number(null)` is 0 and a garbled value is NaN, both of which pass and // are counted below instead. if (Number(response.headers.get("content-length")) > maxBytes) { await response.body?.cancel(); throw tooLarge(); } if (!response.body) return Buffer.alloc(0); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; for (;;) { let chunk: ReadableStreamReadResult; try { chunk = await reader.read(); } catch (error) { throw new ResourceError( `Could not read ${describe(url.href)}: ${(error as Error).message}`, ); } if (chunk.done) return Buffer.concat(chunks, total); total += chunk.value.byteLength; if (total > maxBytes) { await reader.cancel(); throw tooLarge(); } chunks.push(chunk.value); } } async function load(key: string): Promise { let url: URL; try { url = new URL(key, site); } catch { throw new ResourceError(`Cannot request ${describe(key)}`); } if (url.origin === site.origin && url.pathname.startsWith("/basemap/")) { return readLocal(url.pathname); } if (url.protocol === "data:") return fetchRemote(url); if (!isAllowed(url)) { throw new ResourceError( `Cannot request ${describe(key)}, which is not on this instance or ` + `one of the basemap's origins`, ); } return fetchRemote(url); } return { get(key, { maxAgeMs } = {}) { const kept = cache.get(key); if (kept && kept.expiresAt > Date.now()) { cache.delete(key); cache.set(key, kept); return Promise.resolve(kept.data); } const pending = inFlight.get(key); if (pending) return pending; const request = load(key) .then((data) => { remember(key, data, maxAgeMs); return data; }) .finally(() => inFlight.delete(key)); inFlight.set(key, request); return request; }, }; }