208 lines
5.9 KiB
JavaScript
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;
|
|
}
|