Files
2026-07-08 10:19:59 +01:00

208 lines
5.9 KiB
JavaScript

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;
}