feat(map): ingest audited department zone snapshot
This commit is contained in:
@@ -2,9 +2,12 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { loadZoneSourceProfile } from "./zone-source-snapshot.mjs";
|
||||
|
||||
const canonicalCesiumAssetIds = ["1", "2", "96188"];
|
||||
const cesiumVerificationTransportVersion = 4;
|
||||
@@ -20,6 +23,15 @@ const canonicalCesiumEgressHosts = new Set([
|
||||
"ecn.t3.tiles.virtualearth.net",
|
||||
]);
|
||||
const config = await readConfig();
|
||||
const zoneSourceProfileId = String(process.env.ZONE_SOURCE_PROFILE_ID || "moscow-pmd-slow-zones").trim();
|
||||
const zoneSourceRoot = resolve(String(
|
||||
process.env.ZONE_SOURCE_SNAPSHOT_ROOT
|
||||
|| resolve(dirname(fileURLToPath(import.meta.url)), "../zone-sources"),
|
||||
).trim());
|
||||
// The route contract is stable while the provider behind this profile is
|
||||
// replaceable: today it is the audited MMap snapshot, later an API adapter can
|
||||
// atomically materialise the same manifest + generation envelope.
|
||||
const zoneSource = await loadZoneSourceProfile(zoneSourceRoot, zoneSourceProfileId);
|
||||
const liveCache = createCacheStore("live", config.cacheDir, true);
|
||||
const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null;
|
||||
const inflightWrites = new Map();
|
||||
@@ -49,6 +61,13 @@ const server = createServer(async (request, response) => {
|
||||
if (requestUrl.pathname === "/api/map/admin/cesium-ion" && ["GET", "PUT"].includes(request.method || "")) {
|
||||
return await serveCesiumIonAdmin(request, response, requestUrl.pathname);
|
||||
}
|
||||
if (requestUrl.pathname === `/internal/zone-sources/v1/profiles/${encodeURIComponent(zoneSourceProfileId)}/current`) {
|
||||
if (!["GET", "HEAD"].includes(request.method || "")) {
|
||||
response.setHeader("Allow", "GET, HEAD");
|
||||
return writeJson(response, 405, { ok: false, error: "zone_source_method_not_allowed" });
|
||||
}
|
||||
return serveZoneSourceGeneration(request, response);
|
||||
}
|
||||
if (!isAllowedRequest(request)) return writeJson(response, 401, { ok: false, error: "map_gateway_auth_required" });
|
||||
|
||||
if (requestUrl.pathname === "/healthz" && request.method === "GET") {
|
||||
@@ -61,6 +80,15 @@ const server = createServer(async (request, response) => {
|
||||
ionConfigured: Boolean(activeCesiumIonToken),
|
||||
assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right),
|
||||
anonymousAccess: config.allowAnonymous,
|
||||
zoneSource: {
|
||||
profileId: zoneSourceProfileId,
|
||||
authorityId: zoneSource.generation.authorityId,
|
||||
datasetId: zoneSource.generation.datasetId,
|
||||
sourceRevision: zoneSource.generation.sourceRevision,
|
||||
contentDigest: zoneSource.generation.contentDigest,
|
||||
sourceZoneCount: zoneSource.generation.metadata.sourceZoneCount,
|
||||
publishedZoneCount: zoneSource.generation.metadata.publishedZoneCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,11 +125,27 @@ 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)`);
|
||||
console.log(`Zone source: ${zoneSourceProfileId}@${zoneSource.generation.sourceRevision} (${zoneSource.generation.metadata.publishedZoneCount} zones)`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => server.close());
|
||||
process.on("SIGINT", () => server.close());
|
||||
|
||||
function serveZoneSourceGeneration(request, response) {
|
||||
if (request.headers["if-none-match"] === zoneSource.etag) {
|
||||
response.writeHead(304, { ETag: zoneSource.etag, "Cache-Control": "private, max-age=60" });
|
||||
return response.end();
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": zoneSource.body.byteLength,
|
||||
"Cache-Control": "private, max-age=60",
|
||||
ETag: zoneSource.etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
return response.end(request.method === "HEAD" ? undefined : zoneSource.body);
|
||||
}
|
||||
|
||||
async 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");
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
|
||||
export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2";
|
||||
export const ZONE_SOURCE_MANIFEST_SCHEMA_VERSION = "nodedc.zone-source-manifest/v1";
|
||||
|
||||
const PROFILE_ID = /^[a-z][a-z0-9._-]{2,95}$/;
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/;
|
||||
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_ZONES = 5000;
|
||||
const MAX_SCHEDULE_ROWS = 50_000;
|
||||
const MAX_POSITIONS = 250_000;
|
||||
const PROFILE_POLICIES = Object.freeze({
|
||||
"moscow-pmd-slow-zones": Object.freeze({
|
||||
authorityId: "moscow-department-of-transport",
|
||||
datasetId: "pmd-slow-zones",
|
||||
adapterIds: Object.freeze(["depttrans-mmap-snapshot-v1", "depttrans-api-snapshot-v1"]),
|
||||
timezone: "Europe/Moscow",
|
||||
}),
|
||||
});
|
||||
|
||||
export async function loadZoneSourceProfile(root, profileId) {
|
||||
if (!PROFILE_ID.test(String(profileId || ""))) throw snapshotError("zone_source_profile_id_invalid");
|
||||
const profileRoot = withinRoot(root, profileId);
|
||||
const manifest = parseJson(
|
||||
await readBounded(withinRoot(profileRoot, "manifest.json"), 256 * 1024),
|
||||
"zone_source_manifest_json_invalid",
|
||||
);
|
||||
validateManifest(manifest, profileId);
|
||||
|
||||
const geometryPath = withinRoot(profileRoot, manifest.geometry.file);
|
||||
const schedulePath = withinRoot(profileRoot, manifest.schedule.file);
|
||||
const [geometryBytes, scheduleBytes] = await Promise.all([
|
||||
readBounded(geometryPath, MAX_SOURCE_BYTES),
|
||||
readBounded(schedulePath, MAX_SOURCE_BYTES),
|
||||
]);
|
||||
if (geometryBytes.byteLength + scheduleBytes.byteLength > MAX_SOURCE_BYTES) {
|
||||
throw snapshotError("zone_source_snapshot_bytes_exceeded");
|
||||
}
|
||||
|
||||
const geometryDigest = sha256(geometryBytes);
|
||||
const scheduleDigest = sha256(scheduleBytes);
|
||||
if (geometryDigest !== manifest.geometry.sha256) throw snapshotError("zone_source_geometry_digest_mismatch");
|
||||
if (scheduleDigest !== manifest.schedule.sha256) throw snapshotError("zone_source_schedule_digest_mismatch");
|
||||
|
||||
const geojson = parseJson(geometryBytes, "zone_source_geojson_invalid");
|
||||
const rawSchedule = parseJson(scheduleBytes, "zone_source_schedule_json_invalid");
|
||||
const generation = normalizeGeneration(manifest, geojson, rawSchedule, {
|
||||
geometryDigest,
|
||||
scheduleDigest,
|
||||
sourceBytes: geometryBytes.byteLength + scheduleBytes.byteLength,
|
||||
});
|
||||
const body = Buffer.from(`${JSON.stringify(generation)}\n`);
|
||||
if (body.byteLength > MAX_SOURCE_BYTES) throw snapshotError("zone_source_generation_bytes_exceeded");
|
||||
return Object.freeze({ generation: deepFreeze(generation), body, etag: `"sha256:${generation.contentDigest}"` });
|
||||
}
|
||||
|
||||
function normalizeGeneration(manifest, geojson, rawSchedule, digests) {
|
||||
if (!geojson || geojson.type !== "FeatureCollection" || !Array.isArray(geojson.features)) {
|
||||
throw snapshotError("zone_source_feature_collection_required");
|
||||
}
|
||||
if (!geojson.features.length || geojson.features.length > MAX_ZONES) {
|
||||
throw snapshotError("zone_source_zone_limit_exceeded");
|
||||
}
|
||||
if (!Array.isArray(rawSchedule) || !rawSchedule.length || rawSchedule.length > MAX_SCHEDULE_ROWS) {
|
||||
throw snapshotError("zone_source_schedule_limit_exceeded");
|
||||
}
|
||||
|
||||
const features = new Map();
|
||||
let positionCount = 0;
|
||||
for (const [index, feature] of geojson.features.entries()) {
|
||||
if (!feature || feature.type !== "Feature" || !isPlainObject(feature.properties)) {
|
||||
throw snapshotError(`zone_source_feature_${index}_invalid`);
|
||||
}
|
||||
const nativeId = nativeZoneId(feature.properties.pmd_slow_zone_id);
|
||||
if (features.has(nativeId)) throw snapshotError("zone_source_identity_duplicate");
|
||||
positionCount += validateGeometry(feature.geometry, index);
|
||||
if (positionCount > MAX_POSITIONS) throw snapshotError("zone_source_position_limit_exceeded");
|
||||
features.set(nativeId, feature);
|
||||
}
|
||||
|
||||
const scheduleByZone = new Map();
|
||||
const scheduleIds = new Set();
|
||||
const scheduleKeys = new Set();
|
||||
for (const [index, row] of rawSchedule.entries()) {
|
||||
if (!isPlainObject(row)) throw snapshotError(`zone_source_schedule_${index}_invalid`);
|
||||
const rowId = nativeScheduleId(row.id);
|
||||
if (scheduleIds.has(rowId)) throw snapshotError("zone_source_schedule_identity_duplicate");
|
||||
scheduleIds.add(rowId);
|
||||
const zoneId = nativeZoneId(row.pmd_slow_zone_id);
|
||||
if (!features.has(zoneId)) throw snapshotError("zone_source_schedule_zone_missing");
|
||||
const dayOfWeek = integer(row.day_of_week_num, 1, 7, "zone_source_schedule_day_invalid");
|
||||
const speedLimitKph = finite(row.speed_limit, 0, 200, "zone_source_schedule_speed_invalid");
|
||||
const startTime = clock(row.start_time, "zone_source_schedule_start_invalid");
|
||||
const endTime = clock(row.end_time, "zone_source_schedule_end_invalid");
|
||||
const key = `${zoneId}\u0000${dayOfWeek}\u0000${startTime}\u0000${endTime}`;
|
||||
if (scheduleKeys.has(key)) throw snapshotError("zone_source_schedule_interval_duplicate");
|
||||
scheduleKeys.add(key);
|
||||
const normalized = Object.freeze({ dayOfWeek, startTime, endTime, speedLimitKph });
|
||||
const entries = scheduleByZone.get(zoneId) || [];
|
||||
entries.push(normalized);
|
||||
scheduleByZone.set(zoneId, entries);
|
||||
}
|
||||
|
||||
const zones = [];
|
||||
let excludedZoneCount = 0;
|
||||
for (const [nativeId, feature] of features) {
|
||||
const properties = feature.properties;
|
||||
if (properties.archive_flg === true || properties.delete_flg === true) {
|
||||
excludedZoneCount += 1;
|
||||
continue;
|
||||
}
|
||||
const schedule = [...(scheduleByZone.get(nativeId) || [])].sort(compareSchedule);
|
||||
if (!schedule.length || new Set(schedule.map((entry) => entry.dayOfWeek)).size !== 7) {
|
||||
throw snapshotError("zone_source_schedule_week_incomplete");
|
||||
}
|
||||
const speeds = [...new Set(schedule.map((entry) => entry.speedLimitKph))];
|
||||
zones.push(compact({
|
||||
nativeId,
|
||||
displayName: requiredString(properties.pmd_slow_zone_nm, "zone_source_name_required"),
|
||||
geometry: structuredClone(feature.geometry),
|
||||
areaSquareMeters: optionalFinite(properties.area, 0, Number.MAX_SAFE_INTEGER),
|
||||
description: optionalString(properties.comment),
|
||||
appliesToKicksharing: boolean(properties.kick_sharing, "zone_source_kicksharing_flag_invalid"),
|
||||
appliesToCouriers: boolean(properties.courier, "zone_source_courier_flag_invalid"),
|
||||
sourceCreatedAt: optionalString(properties.create_dttm),
|
||||
sourceUpdatedAt: optionalString(properties.update_dttm),
|
||||
maxSpeedKph: speeds.length === 1 ? speeds[0] : undefined,
|
||||
weeklySpeedSchedule: schedule,
|
||||
}));
|
||||
}
|
||||
zones.sort((left, right) => compareNativeIds(left.nativeId, right.nativeId));
|
||||
|
||||
const contentDigest = sha256(Buffer.from(`${digests.geometryDigest}\n${digests.scheduleDigest}\n`));
|
||||
return {
|
||||
schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
|
||||
profileId: manifest.profileId,
|
||||
authorityId: manifest.authorityId,
|
||||
datasetId: manifest.datasetId,
|
||||
adapterId: manifest.adapterId,
|
||||
sourceRevision: manifest.sourceRevision,
|
||||
generatedAt: iso(manifest.generatedAt, "zone_source_generated_at_invalid"),
|
||||
timezone: manifest.timezone,
|
||||
complete: true,
|
||||
contentDigest,
|
||||
zones,
|
||||
metadata: {
|
||||
sourceZoneCount: features.size,
|
||||
publishedZoneCount: zones.length,
|
||||
excludedZoneCount,
|
||||
scheduleRowCount: rawSchedule.length,
|
||||
positionCount,
|
||||
sourceBytes: digests.sourceBytes,
|
||||
geometrySha256: digests.geometryDigest,
|
||||
scheduleSha256: digests.scheduleDigest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateManifest(value, requestedProfileId) {
|
||||
if (!isPlainObject(value) || value.schemaVersion !== ZONE_SOURCE_MANIFEST_SCHEMA_VERSION) {
|
||||
throw snapshotError("zone_source_manifest_schema_invalid");
|
||||
}
|
||||
if (value.profileId !== requestedProfileId || !PROFILE_ID.test(value.profileId)) {
|
||||
throw snapshotError("zone_source_manifest_profile_mismatch");
|
||||
}
|
||||
const policy = PROFILE_POLICIES[requestedProfileId];
|
||||
if (!policy) throw snapshotError("zone_source_profile_unsupported");
|
||||
for (const key of ["authorityId", "datasetId", "adapterId"]) {
|
||||
if (!IDENTIFIER.test(String(value[key] || ""))) throw snapshotError(`zone_source_manifest_${key}_invalid`);
|
||||
}
|
||||
if (value.authorityId !== policy.authorityId) throw snapshotError("zone_source_manifest_authority_mismatch");
|
||||
if (value.datasetId !== policy.datasetId) throw snapshotError("zone_source_manifest_dataset_mismatch");
|
||||
if (!policy.adapterIds.includes(value.adapterId)) throw snapshotError("zone_source_manifest_adapter_unsupported");
|
||||
requiredString(value.sourceRevision, "zone_source_manifest_revision_invalid");
|
||||
iso(value.generatedAt, "zone_source_manifest_generated_at_invalid");
|
||||
if (value.timezone !== policy.timezone) throw snapshotError("zone_source_manifest_timezone_invalid");
|
||||
for (const [name, artifact] of [["geometry", value.geometry], ["schedule", value.schedule]]) {
|
||||
if (!isPlainObject(artifact) || !safeFileName(artifact.file) || !SHA256.test(String(artifact.sha256 || ""))) {
|
||||
throw snapshotError(`zone_source_manifest_${name}_invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateGeometry(value, index) {
|
||||
if (!isPlainObject(value) || !new Set(["Polygon", "MultiPolygon"]).has(value.type)) {
|
||||
throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
|
||||
}
|
||||
const polygons = value.type === "Polygon" ? [value.coordinates] : value.coordinates;
|
||||
if (!Array.isArray(polygons) || !polygons.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
|
||||
let positions = 0;
|
||||
for (const polygon of polygons) {
|
||||
if (!Array.isArray(polygon) || !polygon.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
|
||||
for (const ring of polygon) {
|
||||
if (!Array.isArray(ring) || ring.length < 4) throw snapshotError(`zone_source_feature_${index}_ring_invalid`);
|
||||
for (const point of ring) {
|
||||
if (!Array.isArray(point) || point.length < 2) throw snapshotError(`zone_source_feature_${index}_position_invalid`);
|
||||
const longitude = Number(point[0]);
|
||||
const latitude = Number(point[1]);
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)
|
||||
|| longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
|
||||
throw snapshotError(`zone_source_feature_${index}_position_invalid`);
|
||||
}
|
||||
positions += 1;
|
||||
}
|
||||
const first = ring[0];
|
||||
const last = ring.at(-1);
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) throw snapshotError(`zone_source_feature_${index}_ring_unclosed`);
|
||||
}
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function compareSchedule(left, right) {
|
||||
return left.dayOfWeek - right.dayOfWeek
|
||||
|| left.startTime.localeCompare(right.startTime)
|
||||
|| left.endTime.localeCompare(right.endTime)
|
||||
|| left.speedLimitKph - right.speedLimitKph;
|
||||
}
|
||||
|
||||
function compareNativeIds(left, right) {
|
||||
const a = Number(left);
|
||||
const b = Number(right);
|
||||
return Number.isSafeInteger(a) && Number.isSafeInteger(b) ? a - b : left.localeCompare(right);
|
||||
}
|
||||
|
||||
function nativeZoneId(value) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_native_identity_invalid");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function nativeScheduleId(value) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_schedule_identity_invalid");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readBounded(path, maxBytes) {
|
||||
const value = await readFile(path);
|
||||
if (value.byteLength > maxBytes) throw snapshotError("zone_source_file_bytes_exceeded");
|
||||
return value;
|
||||
}
|
||||
|
||||
function withinRoot(root, child) {
|
||||
const base = resolve(root);
|
||||
const target = resolve(base, child);
|
||||
if (target !== base && !target.startsWith(`${base}${sep}`)) throw snapshotError("zone_source_path_escape_rejected");
|
||||
return target;
|
||||
}
|
||||
|
||||
function safeFileName(value) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
||||
}
|
||||
|
||||
function parseJson(value, code) {
|
||||
try { return JSON.parse(value.toString("utf8")); } catch { throw snapshotError(code); }
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function integer(value, min, max, code) {
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < min || number > max) throw snapshotError(code);
|
||||
return number;
|
||||
}
|
||||
|
||||
function finite(value, min, max, code) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < min || number > max) throw snapshotError(code);
|
||||
return number;
|
||||
}
|
||||
|
||||
function optionalFinite(value, min, max) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= min && number <= max ? number : undefined;
|
||||
}
|
||||
|
||||
function boolean(value, code) {
|
||||
if (typeof value !== "boolean") throw snapshotError(code);
|
||||
return value;
|
||||
}
|
||||
|
||||
function clock(value, code) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!TIME.test(normalized)) throw snapshotError(code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function iso(value, code) {
|
||||
const date = new Date(String(value ?? ""));
|
||||
if (Number.isNaN(date.getTime())) throw snapshotError(code);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function requiredString(value, code) {
|
||||
const normalized = optionalString(value);
|
||||
if (!normalized) throw snapshotError(code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function compact(value) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return value;
|
||||
}
|
||||
|
||||
function snapshotError(code) {
|
||||
return Object.assign(new Error(code), { code });
|
||||
}
|
||||
Reference in New Issue
Block a user