NODEDC_PLATFORM/services/map-gateway/src/server.mjs

534 lines
24 KiB
JavaScript

import { createHash } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { dirname, join } from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
const config = readConfig();
const liveCache = createCacheStore("live", config.cacheDir, true);
const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null;
const inflightWrites = new Map();
await initialiseCacheStore(liveCache);
if (offlineSnapshot) await initialiseCacheStore(offlineSnapshot);
const server = createServer(async (request, response) => {
applyCors(request, response);
if (request.method === "OPTIONS") return response.writeHead(204).end();
try {
const requestUrl = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
if (!isAllowedRequest(request)) return writeJson(response, 401, { ok: false, error: "map_gateway_auth_required" });
if (requestUrl.pathname === "/healthz" && request.method === "GET") {
return writeJson(response, 200, {
ok: true,
service: "nodedc-map-gateway",
cache: await cacheStats(liveCache),
offlineSnapshot: offlineSnapshot ? await cacheStats(offlineSnapshot) : null,
ionConfigured: Boolean(config.cesiumIonToken),
assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right),
anonymousAccess: config.allowAnonymous,
});
}
const ionAssetMatch = requestUrl.pathname.match(/^\/api\/map\/ion\/assets\/(\d+)\/endpoint$/);
if (ionAssetMatch && request.method === "GET") {
return await serveIonEndpoint(response, ionAssetMatch[1]);
}
if (requestUrl.pathname === "/api/map/cache" && ["GET", "HEAD"].includes(request.method || "")) {
return await serveCachedUpstream(request, response, requestUrl);
}
return writeJson(response, 404, { ok: false, error: "map_gateway_route_not_found" });
} catch (error) {
// A client can cancel a streamed cache response after its headers have
// already been sent. Do not try to turn that into JSON: Node would throw
// ERR_HTTP_HEADERS_SENT and take the entire gateway process down.
if (response.headersSent || response.writableEnded) {
if (!response.writableEnded) response.destroy();
return;
}
const status = Number(error?.statusCode || 500);
return writeJson(response, Number.isInteger(status) && status >= 400 && status < 600 ? status : 500, {
ok: false,
error: error instanceof Error ? error.message : "map_gateway_error",
});
}
});
server.listen(config.port, "0.0.0.0", () => {
console.log(`NODE.DC Map Gateway listening on http://0.0.0.0:${config.port}`);
console.log(`Live map cache: ${config.cacheDir} (${config.mode}, max ${Math.round(config.maxCacheBytes / 1024 / 1024)} MB)`);
if (offlineSnapshot) console.log(`Offline map snapshot: ${config.offlineSnapshotDir} (read-only)`);
});
process.on("SIGTERM", () => server.close());
process.on("SIGINT", () => server.close());
function readConfig() {
const mode = String(process.env.MAP_CACHE_MODE || "readwrite").trim().toLowerCase();
if (!["readwrite", "readonly", "offline"].includes(mode)) throw new Error("invalid_map_cache_mode");
const allowlist = parseList(process.env.MAP_GATEWAY_UPSTREAM_ALLOWLIST || "api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net");
return {
port: parsePositiveInt(process.env.PORT, 18103),
cacheDir: String(process.env.MAP_CACHE_DIR || "/var/lib/nodedc-map-cache").trim(),
offlineSnapshotDir: String(process.env.MAP_OFFLINE_SNAPSHOT_DIR || "").trim(),
mode,
maxCacheBytes: parsePositiveInt(process.env.MAP_CACHE_MAX_MB, 20480) * 1024 * 1024,
maxObjectBytes: parsePositiveInt(process.env.MAP_CACHE_MAX_OBJECT_MB, 128) * 1024 * 1024,
defaultTtlMs: parsePositiveInt(process.env.MAP_CACHE_DEFAULT_TTL_SECONDS, 604800) * 1000,
upstreamTimeoutMs: parsePositiveInt(process.env.MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS, 30) * 1000,
cesiumIonToken: String(process.env.CESIUM_ION_TOKEN || "").trim(),
assetAllowlist: new Set(parseList(process.env.CESIUM_ION_ASSET_ALLOWLIST || "1,2,96188")),
upstreamAllowlist: new Set(allowlist),
legacyCacheHosts: new Set(parseList(process.env.MAP_GATEWAY_LEGACY_CACHE_HOSTS || "")),
offlineProviderAllowlist: new Set(parseList(process.env.MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST || "")),
corsOrigins: new Set(parseList(process.env.MAP_GATEWAY_CORS_ORIGIN || "http://127.0.0.1:3333,http://localhost:3333")),
allowAnonymous: parseBoolean(process.env.MAP_GATEWAY_ALLOW_ANONYMOUS, process.env.NODE_ENV !== "production"),
trustedSubjectHeader: String(process.env.MAP_GATEWAY_TRUSTED_SUBJECT_HEADER || "x-nodedc-user-id").toLowerCase(),
};
}
function parseList(value) {
return String(value || "").split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
}
function parsePositiveInt(value, fallback) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
function applyCors(request, response) {
const origin = String(request.headers.origin || "");
if (!origin || (!config.corsOrigins.has("*") && !config.corsOrigins.has(origin))) return;
response.setHeader("access-control-allow-origin", config.corsOrigins.has("*") ? "*" : origin);
response.setHeader("access-control-allow-methods", "GET, HEAD, OPTIONS");
response.setHeader("access-control-allow-headers", "Range, Content-Type");
response.setHeader("vary", "Origin");
}
function isAllowedRequest(request) {
return config.allowAnonymous || Boolean(request.headers[config.trustedSubjectHeader]);
}
async function serveIonEndpoint(response, assetId) {
if (!config.assetAllowlist.has(assetId)) return writeJson(response, 403, { ok: false, error: "cesium_asset_not_allowed" });
const cached = await readIonEndpointCache(assetId);
if (config.mode === "offline") {
if (!cached) return writeJson(response, 504, { ok: false, error: "cesium_ion_offline_endpoint_miss" });
if (!isOfflineProviderAllowed(cached.url)) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" });
return writeJson(response, 200, { ok: true, ...cached, cache: "offline-endpoint-hit" });
}
if (!config.cesiumIonToken) {
if (cached) return writeJson(response, 200, { ok: true, ...cached, cache: "cached-endpoint-no-master-token" });
return writeJson(response, 503, { ok: false, error: "cesium_ion_not_configured" });
}
try {
const endpoint = await fetchWithTimeout(`https://api.cesium.com/v1/assets/${assetId}/endpoint`, {
headers: { authorization: `Bearer ${config.cesiumIonToken}` },
});
if (!endpoint.ok) throw gatewayError("cesium_ion_endpoint_unavailable", endpoint.status || 502);
const body = await endpoint.json();
const next = body.type === "IMAGERY" && body.externalType === "BING"
? {
assetId,
type: body.type,
externalType: "BING",
options: {
url: validateUpstream(String(body.options?.url || "")).toString(),
key: String(body.options?.key || ""),
mapStyle: String(body.options?.mapStyle || "Aerial"),
},
attributions: Array.isArray(body.attributions) ? body.attributions : [],
savedAt: Date.now(),
}
: {
assetId,
type: body.type,
url: validateUpstream(String(body.url || "")).toString(),
accessToken: String(body.accessToken || ""),
attributions: Array.isArray(body.attributions) ? body.attributions : [],
savedAt: Date.now(),
};
if (!(next.externalType === "BING" ? next.options?.key : next.accessToken)) throw gatewayError("cesium_ion_endpoint_invalid", 502);
if (config.mode === "readwrite") await writeIonEndpointCache(next);
return writeJson(response, 200, { ok: true, ...next, cache: "ion-endpoint-online" });
} catch (error) {
if (cached) return writeJson(response, 200, { ok: true, ...cached, cache: "stale-ion-endpoint" });
throw error;
}
}
async function serveCachedUpstream(request, response, requestUrl) {
const rawTarget = requestUrl.searchParams.get("url");
if (!rawTarget) return writeJson(response, 400, { ok: false, error: "map_cache_url_required" });
const target = validateUpstream(rawTarget);
const cacheProfile = String(target.searchParams.get("nodedc_cache_profile") || "live").toLowerCase();
target.searchParams.delete("nodedc_cache_profile");
if (!new Set(["live", "offline"]).has(cacheProfile)) return writeJson(response, 400, { ok: false, error: "map_cache_profile_invalid" });
const store = cacheProfile === "offline" ? offlineSnapshot : liveCache;
if (!store) return writeJson(response, 409, { ok: false, error: "map_offline_snapshot_not_configured" });
const cacheMode = String(target.searchParams.get("nodedc_cache_mode") || "hybrid").toLowerCase();
target.searchParams.delete("nodedc_cache_mode");
if (!new Set(["hybrid", "passthrough"]).has(cacheMode)) return writeJson(response, 400, { ok: false, error: "map_cache_mode_invalid" });
const isLegacyCacheHost = config.legacyCacheHosts.has(target.hostname.toLowerCase());
const forceRefresh = target.searchParams.get("nodedc_cache_refresh") === "1";
target.searchParams.delete("nodedc_cache_refresh");
const cacheKey = createHash("sha256").update(canonicalCacheUrl(target)).digest("hex");
// "Live" without cache is still routed through Gateway — it is never a
// browser-side direct request — but it neither reads from nor writes to the
// persistent store. The immutable offline profile intentionally has no such
// bypass mode.
if (cacheMode === "passthrough" && cacheProfile === "live") {
return proxyUncachedRequest(request, response, target, "live-pass-through");
}
const cached = await getCachedEntry(store, cacheKey);
const fresh = cached && cached.expiresAt > Date.now();
if (cached && (!forceRefresh || !store.mutable) && (fresh || !store.mutable || config.mode !== "readwrite")) {
return serveCachedFile(request, response, store, cached, fresh ? `${store.name}-hit` : `${store.name}-stale`);
}
// A migrated Engine cache is a local sandbox snapshot, not an instruction to
// fetch arbitrary historic providers when an object is missing. The profile
// is selected by the visual adapter; the browser can never turn it into a
// new upstream fetch by changing a setting.
if (cacheProfile === "offline") return writeJson(response, 504, { ok: false, error: "map_offline_snapshot_miss" });
if (isLegacyCacheHost) return writeJson(response, 504, { ok: false, error: "map_legacy_cache_miss" });
if (config.mode === "offline") {
if (!isOfflineProviderAllowed(target.toString())) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" });
return writeJson(response, 504, { ok: false, error: "map_cache_offline_miss" });
}
if (config.mode === "readonly") {
return proxyUncachedRequest(request, response, target, "pass-through-readonly");
}
if (String(request.headers.range || "").trim()) {
return proxyRangeRequest(request, response, target, cached, store);
}
try {
const entry = await fetchAndCache(target, cacheKey, store);
return serveCachedFile(request, response, store, entry, forceRefresh ? "live-refresh-record" : "live-record");
} catch (error) {
if (cached) return serveCachedFile(request, response, store, cached, "live-stale-upstream-error");
throw error;
}
}
function isOfflineProviderAllowed(rawTarget) {
if (!config.offlineProviderAllowlist.size) return false;
try {
const target = new URL(rawTarget);
return config.offlineProviderAllowlist.has(target.hostname.toLowerCase());
} catch {
return false;
}
}
function validateUpstream(rawTarget) {
if (rawTarget.length > 8192) throw gatewayError("map_cache_url_too_long", 400);
let target;
try { target = new URL(rawTarget); } catch { throw gatewayError("invalid_map_cache_url", 400); }
const hostname = target.hostname.toLowerCase();
// Bing's metadata endpoint still returns HTTP tile templates. They are not
// allowed through as HTTP: upgrade only the known Bing tile hosts before
// the standard HTTPS and allowlist checks. This keeps the browser and the
// Gateway on encrypted transport while making the provider contract work.
if (target.protocol === "http:" && /^(ecn\.t[0-3]\.tiles\.virtualearth\.net|ecn\.tiles\.virtualearth\.net)$/.test(hostname)) {
target.protocol = "https:";
}
if (target.protocol !== "https:") throw gatewayError("map_cache_https_required", 400);
if (target.username || target.password || (!config.upstreamAllowlist.has(hostname) && !config.legacyCacheHosts.has(hostname))) {
throw gatewayError("map_cache_upstream_not_allowed", 403);
}
return target;
}
function canonicalCacheUrl(target) {
const url = new URL(target.toString());
// The old Engine snapshot distributes identical Bing tile semantics across
// t0…t3. A viewer can choose a different subdomain for the same quadkey, so
// use one logical host for the cache key while preserving the original URL
// only as provenance in the imported index entry.
if (/^ecn\.t[0-3]\.tiles\.virtualearth\.net$/i.test(url.hostname)) {
url.hostname = "ecn.tiles.virtualearth.net";
}
for (const key of [...url.searchParams.keys()]) {
if (["access_token", "accesstoken", "iontoken", "token", "key", "apikey", "api_key", "signature", "sig"].includes(key.toLowerCase())) {
url.searchParams.delete(key);
}
}
url.searchParams.sort();
return url.toString();
}
async function fetchAndCache(target, cacheKey, store) {
const inflightKey = `${store.name}:${cacheKey}`;
const existing = inflightWrites.get(inflightKey);
if (existing) return existing;
const task = downloadAndCache(target, cacheKey, store);
inflightWrites.set(inflightKey, task);
// Several Cesium requests can share one in-flight download. Keep a rejection
// observer on the shared promise, then rethrow to each HTTP caller below.
// A 4xx/5xx tile response must never become an unhandled rejection that stops
// the gateway process.
task.catch(() => undefined);
try {
return await task;
} finally {
if (inflightWrites.get(inflightKey) === task) inflightWrites.delete(inflightKey);
}
}
async function downloadAndCache(target, cacheKey, store) {
const upstream = await fetchWithTimeout(target, { headers: { accept: "application/json, application/octet-stream, image/*, */*;q=0.5" } });
if (!upstream.ok || !upstream.body) throw gatewayError("map_upstream_unavailable", upstream.status || 502);
const expectedBytes = Number.parseInt(upstream.headers.get("content-length") || "", 10);
if (Number.isFinite(expectedBytes) && expectedBytes > config.maxObjectBytes) throw gatewayError("map_cache_object_too_large", 413);
const relativePath = join("objects", cacheKey.slice(0, 2), `${cacheKey}.bin`);
const filePath = join(store.dir, relativePath);
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await mkdir(dirname(filePath), { recursive: true });
let bytes = 0;
const limit = new Transform({
transform(chunk, _encoding, callback) {
bytes += chunk.length;
if (bytes > config.maxObjectBytes) return callback(gatewayError("map_cache_object_too_large", 413));
callback(null, chunk);
},
});
try {
await pipeline(Readable.fromWeb(upstream.body), limit, createWriteStream(tempPath, { flags: "wx" }));
await rename(tempPath, filePath);
} catch (error) {
await rm(tempPath, { force: true });
throw error;
}
const now = Date.now();
const entry = {
key: cacheKey,
file: relativePath,
bytes,
contentType: upstream.headers.get("content-type") || "application/octet-stream",
etag: upstream.headers.get("etag") || null,
savedAt: now,
lastAccessAt: now,
expiresAt: now + responseTtl(upstream.headers.get("cache-control")),
};
store.index.entries[cacheKey] = entry;
await evictCache(store);
await writeCacheIndex(store);
return entry;
}
function responseTtl(cacheControl) {
const match = String(cacheControl || "").match(/max-age=(\d+)/i);
if (!match) return config.defaultTtlMs;
return Math.min(Number.parseInt(match[1], 10) * 1000, config.defaultTtlMs);
}
async function proxyRangeRequest(request, response, target, cached, store) {
const upstream = await fetchWithTimeout(target, { headers: { range: String(request.headers.range), accept: "*/*" } });
if (!upstream.ok || !upstream.body) {
if (cached) return serveCachedFile(request, response, store, cached, "stale-range-error");
throw gatewayError("map_upstream_range_unavailable", upstream.status || 502);
}
copyUpstreamHeaders(response, upstream.headers);
response.setHeader("x-nodedc-map-cache", "range-pass-through");
response.writeHead(upstream.status);
await pipeline(Readable.fromWeb(upstream.body), response);
}
async function proxyUncachedRequest(request, response, target, cacheState) {
const upstream = await fetchWithTimeout(target, {
headers: {
accept: String(request.headers.accept || "application/json, application/octet-stream, image/*, */*;q=0.5"),
...(request.headers.range ? { range: String(request.headers.range) } : {}),
},
});
if (!upstream.ok || !upstream.body) throw gatewayError("map_upstream_unavailable", upstream.status || 502);
copyUpstreamHeaders(response, upstream.headers);
response.setHeader("x-nodedc-map-cache", cacheState);
response.writeHead(upstream.status);
if (request.method === "HEAD") return response.end();
await pipeline(Readable.fromWeb(upstream.body), response);
}
async function serveCachedFile(request, response, store, entry, state) {
const filePath = join(store.dir, entry.file);
const info = await stat(filePath);
const range = parseRange(request.headers.range, info.size);
response.setHeader("content-type", entry.contentType);
response.setHeader("accept-ranges", "bytes");
response.setHeader("cache-control", "public, max-age=60");
response.setHeader("x-nodedc-map-cache", state);
response.setHeader("x-nodedc-map-cache-age", String(Math.max(0, Math.round((Date.now() - entry.savedAt) / 1000))));
if (store.mutable) {
entry.lastAccessAt = Date.now();
store.index.entries[entry.key] = entry;
void writeCacheIndex(store);
}
if (!range) {
response.setHeader("content-length", info.size);
response.writeHead(200);
if (request.method === "HEAD") return response.end();
return pipeline(createReadStream(filePath), response);
}
response.setHeader("content-length", range.end - range.start + 1);
response.setHeader("content-range", `bytes ${range.start}-${range.end}/${info.size}`);
response.writeHead(206);
if (request.method === "HEAD") return response.end();
return pipeline(createReadStream(filePath, range), response);
}
function parseRange(header, size) {
if (!header) return null;
const match = String(header).match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const start = match[1] ? Number.parseInt(match[1], 10) : 0;
const end = match[2] ? Number.parseInt(match[2], 10) : size - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= size) return null;
return { start, end };
}
async function getCachedEntry(store, cacheKey) {
const entry = store.index.entries[cacheKey];
if (!entry) return null;
try {
const info = await stat(join(store.dir, entry.file));
if (!info.isFile()) throw new Error("not_a_file");
return entry;
} catch {
if (store.mutable) {
delete store.index.entries[cacheKey];
await writeCacheIndex(store);
}
return null;
}
}
function createCacheStore(name, dir, mutable) {
return {
name,
dir,
mutable,
objectsDir: join(dir, "objects"),
ionEndpointsDir: join(dir, "ion-endpoints"),
indexPath: join(dir, "index.json"),
index: { version: 1, entries: {} },
pendingIndexWrite: Promise.resolve(),
};
}
async function initialiseCacheStore(store) {
if (store.mutable) {
await mkdir(store.objectsDir, { recursive: true });
await mkdir(store.ionEndpointsDir, { recursive: true });
}
store.index = await readCacheIndex(store);
}
async function readCacheIndex(store) {
try {
const parsed = JSON.parse(await readFile(store.indexPath, "utf8"));
if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") return parsed;
} catch (error) {
if (error?.code !== "ENOENT") console.warn("Map cache index ignored: invalid file");
}
return { version: 1, entries: {} };
}
function ionEndpointPath(assetId) {
return join(liveCache.ionEndpointsDir, `${assetId}.json`);
}
async function readIonEndpointCache(assetId) {
try {
const value = JSON.parse(await readFile(ionEndpointPath(assetId), "utf8"));
if (value?.assetId === assetId && ((value?.url && value?.accessToken) || (value?.externalType === "BING" && value?.options?.url && value?.options?.key))) return value;
} catch (error) {
if (error?.code !== "ENOENT") console.warn("Ion endpoint cache ignored: invalid file");
}
return null;
}
async function writeIonEndpointCache(endpoint) {
const target = ionEndpointPath(endpoint.assetId);
const temp = `${target}.${process.pid}.tmp`;
await writeFile(temp, `${JSON.stringify(endpoint)}\n`, { encoding: "utf8", mode: 0o600 });
await rename(temp, target);
}
function writeCacheIndex(store) {
if (!store.mutable) return Promise.resolve();
store.pendingIndexWrite = store.pendingIndexWrite.then(async () => {
const tempPath = `${store.indexPath}.${process.pid}.tmp`;
await writeFile(tempPath, `${JSON.stringify(store.index)}\n`, "utf8");
await rename(tempPath, store.indexPath);
}).catch((error) => console.warn("Map cache index write failed", error instanceof Error ? error.message : "unknown"));
return store.pendingIndexWrite;
}
async function evictCache(store) {
let entries = Object.values(store.index.entries);
let size = entries.reduce((sum, entry) => sum + Number(entry.bytes || 0), 0);
if (size <= config.maxCacheBytes) return;
entries = entries.sort((left, right) => Number(left.lastAccessAt || 0) - Number(right.lastAccessAt || 0));
for (const entry of entries) {
if (size <= config.maxCacheBytes) break;
await rm(join(store.dir, entry.file), { force: true });
delete store.index.entries[entry.key];
size -= Number(entry.bytes || 0);
}
}
async function cacheStats(store) {
const entries = Object.values(store.index.entries);
const bytes = entries.reduce((sum, entry) => sum + Number(entry.bytes || 0), 0);
return { name: store.name, mode: store.mutable ? config.mode : "readonly", entries: entries.length, bytes, maxBytes: store.mutable ? config.maxCacheBytes : null, persistent: true };
}
async function fetchWithTimeout(target, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.upstreamTimeoutMs);
try { return await fetch(target, { ...options, signal: controller.signal, redirect: "follow" }); }
catch (error) { throw gatewayError(error?.name === "AbortError" ? "map_upstream_timeout" : "map_upstream_fetch_failed", 502); }
finally { clearTimeout(timeout); }
}
function copyUpstreamHeaders(response, headers) {
for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"]) {
const value = headers.get(name);
if (value) response.setHeader(name, value);
}
}
function writeJson(response, status, value) {
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
response.end(JSON.stringify(value));
}
function gatewayError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}