Ctrl K
ComunidadGuías…

src/map-renderer/resources.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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<Buffer | null>;
}

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<string, Entry>();
  let cachedBytes = 0;
  const inFlight = new Map<string, Promise<Buffer | null>>();

  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<Buffer | null> {
    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<Buffer | null> {
    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<Buffer> {
    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<Uint8Array>;
      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<Buffer | null> {
    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;
    },
  };
}