Initial XPro map visualizer
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const PORT = Number(process.env.PORT || 4173);
|
||||
const ROOT = __dirname;
|
||||
const DEFAULT_WMS_ENDPOINT = "https://xpro-viz.jfig.net/geoserver/xpro/wms";
|
||||
const WMS_ENDPOINT = normalizeEndpoint(process.env.WMS_ENDPOINT || DEFAULT_WMS_ENDPOINT);
|
||||
const ALLOWED_WMS_ENDPOINTS = new Set(
|
||||
`${process.env.ALLOWED_WMS_ENDPOINTS || ""},${WMS_ENDPOINT}`
|
||||
.split(",")
|
||||
.map((endpoint) => normalizeEndpoint(endpoint.trim()))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const GEOSERVER_AUTH_HEADER = buildGeoserverAuthHeader();
|
||||
const VENDOR_FILES = {
|
||||
"/vendor/ol.css": path.join(ROOT, "node_modules", "ol", "ol.css"),
|
||||
"/vendor/ol.js": path.join(ROOT, "node_modules", "ol", "dist", "ol.js"),
|
||||
};
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
if (requestUrl.pathname === "/api/capabilities") {
|
||||
await proxyCapabilities(req, res, requestUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === "/api/wms") {
|
||||
await proxyWms(req, res, requestUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (VENDOR_FILES[requestUrl.pathname]) {
|
||||
serveFile(VENDOR_FILES[requestUrl.pathname], res);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(requestUrl.pathname, res);
|
||||
} catch (error) {
|
||||
if (!error.statusCode || error.statusCode >= 500) {
|
||||
console.error(error);
|
||||
}
|
||||
json(res, error.statusCode || 500, {
|
||||
error: error.statusCode ? error.message : "Internal server error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`XPro Map Visualizer running at http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
async function proxyCapabilities(req, res, requestUrl) {
|
||||
const upstream = buildUpstreamUrl(requestUrl.searchParams.get("url"));
|
||||
upstream.searchParams.set("service", "WMS");
|
||||
upstream.searchParams.set("request", "GetCapabilities");
|
||||
|
||||
const upstreamResponse = await fetchUpstream(upstream);
|
||||
res.writeHead(upstreamResponse.statusCode, {
|
||||
"Content-Type": upstreamResponse.contentType || "text/xml; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(upstreamResponse.body);
|
||||
}
|
||||
|
||||
async function proxyWms(req, res, requestUrl) {
|
||||
const upstream = buildUpstreamUrl(requestUrl.searchParams.get("url"));
|
||||
requestUrl.searchParams.forEach((value, key) => {
|
||||
if (key !== "url") {
|
||||
upstream.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const upstreamResponse = await fetchUpstream(upstream);
|
||||
res.writeHead(upstreamResponse.statusCode, {
|
||||
"Content-Type": upstreamResponse.contentType || "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(upstreamResponse.body);
|
||||
}
|
||||
|
||||
function buildUpstreamUrl(rawUrl) {
|
||||
const endpoint = normalizeEndpoint(rawUrl || WMS_ENDPOINT);
|
||||
if (!ALLOWED_WMS_ENDPOINTS.has(endpoint)) {
|
||||
throw httpError(403, "WMS endpoint is not allowed.");
|
||||
}
|
||||
|
||||
const upstream = new URL(endpoint);
|
||||
if (!["http:", "https:"].includes(upstream.protocol)) {
|
||||
throw httpError(400, "Unsupported WMS endpoint protocol.");
|
||||
}
|
||||
|
||||
return upstream;
|
||||
}
|
||||
|
||||
function normalizeEndpoint(rawUrl) {
|
||||
if (!rawUrl) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = new URL(rawUrl);
|
||||
endpoint.hash = "";
|
||||
endpoint.search = "";
|
||||
endpoint.pathname = endpoint.pathname.replace(/\/+$/, "");
|
||||
return endpoint.toString();
|
||||
} catch {
|
||||
throw httpError(400, "Invalid WMS endpoint URL.");
|
||||
}
|
||||
}
|
||||
|
||||
function fetchUpstream(url) {
|
||||
const client = url.protocol === "https:" ? https : http;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const upstreamReq = client.request(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
headers: GEOSERVER_AUTH_HEADER ? { Authorization: GEOSERVER_AUTH_HEADER } : {},
|
||||
},
|
||||
(upstreamRes) => {
|
||||
const chunks = [];
|
||||
upstreamRes.on("data", (chunk) => chunks.push(chunk));
|
||||
upstreamRes.on("end", () => {
|
||||
resolve({
|
||||
statusCode: upstreamRes.statusCode || 502,
|
||||
contentType: upstreamRes.headers["content-type"],
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
upstreamReq.on("error", reject);
|
||||
upstreamReq.end();
|
||||
});
|
||||
}
|
||||
|
||||
function buildGeoserverAuthHeader() {
|
||||
if (process.env.GEOSERVER_BASIC_AUTH) {
|
||||
return process.env.GEOSERVER_BASIC_AUTH.startsWith("Basic ")
|
||||
? process.env.GEOSERVER_BASIC_AUTH
|
||||
: `Basic ${process.env.GEOSERVER_BASIC_AUTH}`;
|
||||
}
|
||||
|
||||
if (!process.env.GEOSERVER_USERNAME || !process.env.GEOSERVER_PASSWORD) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const credentials = `${process.env.GEOSERVER_USERNAME}:${process.env.GEOSERVER_PASSWORD}`;
|
||||
return `Basic ${Buffer.from(credentials).toString("base64")}`;
|
||||
}
|
||||
|
||||
function serveStatic(requestPath, res) {
|
||||
const safePath = requestPath === "/" ? "/index.html" : requestPath;
|
||||
const filePath = path.resolve(ROOT, `.${safePath}`);
|
||||
const relativePath = path.relative(ROOT, filePath);
|
||||
|
||||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
json(res, 403, { error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
serveFile(filePath, res);
|
||||
}
|
||||
|
||||
function serveFile(filePath, res) {
|
||||
fs.readFile(filePath, (error, data) => {
|
||||
if (error) {
|
||||
json(res, 404, { error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, statusCode, payload) {
|
||||
res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user