From c0686d0c5a648c4f7af8c637bbce5f8125fa2650 Mon Sep 17 00:00:00 2001 From: Joao Figueiredo Date: Wed, 8 Jul 2026 10:19:59 +0100 Subject: [PATCH] Initial XPro map visualizer --- .dockerignore | 7 ++ .env.example | 5 + .gitignore | 3 + Dockerfile | 20 ++++ README.md | 64 +++++++++++++ app.js | 200 ++++++++++++++++++++++++++++++++++++++++ compose.yaml | 15 +++ index.html | 37 ++++++++ package-lock.json | 169 ++++++++++++++++++++++++++++++++++ package.json | 11 +++ server.js | 207 ++++++++++++++++++++++++++++++++++++++++++ styles.css | 227 ++++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 965 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.js create mode 100644 compose.yaml create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.js create mode 100644 styles.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f9bce0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.env +Dockerfile +README.md +npm-debug.log +node_modules diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..668b8cb --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +APP_PORT=4173 +WMS_ENDPOINT=https://xpro-viz.jfig.net/geoserver/xpro/wms +ALLOWED_WMS_ENDPOINTS=https://xpro-viz.jfig.net/geoserver/xpro/wms +GEOSERVER_USERNAME=admin +GEOSERVER_PASSWORD=change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c81b8d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..80e2be9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY server.js ./ +COPY index.html ./ +COPY app.js ./ +COPY styles.css ./ + +ENV HOST=0.0.0.0 +ENV PORT=4173 + +EXPOSE 4173 + +USER node + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e43f545 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# XPro Map Visualizer + +Tiny static WMS viewer for the GeoServer endpoint at `https://xpro-viz.jfig.net/geoserver/xpro/wms`. + +## Run locally + +Start the included Node server: + +```bash +npm install +npm start +``` + +Then open `http://localhost:4173`. + +## Run with Docker + +Using Docker Compose: + +```bash +cp .env.example .env +# Edit .env and set GEOSERVER_PASSWORD. +docker compose up --build +``` + +Then open `http://localhost:4173`. + +Build the image: + +```bash +docker build -t xprov-map-visualizer . +``` + +Run the container: + +```bash +docker run --rm -p 4173:4173 xprov-map-visualizer +``` + +Then open `http://localhost:4173`. + +The browser never receives the GeoServer password. Configure it in `.env` for +the backend proxy: + +```bash +GEOSERVER_USERNAME=admin +GEOSERVER_PASSWORD=change-me +``` + +To change the default WMS endpoint or allow additional WMS endpoints through +the proxy, edit `.env`: + +```bash +WMS_ENDPOINT=https://xpro-viz.jfig.net/geoserver/xpro/wms +ALLOWED_WMS_ENDPOINTS=https://xpro-viz.jfig.net/geoserver/xpro/wms,https://example.com/geoserver/wms +``` + +## Notes + +- The app does not store the password in the repo, local storage, or the browser. +- The included server proxies WMS requests so the browser does not run into cross-origin auth issues. +- The proxy only allows configured WMS endpoints. By default it allows the XPro GeoServer endpoint. +- It fetches WMS capabilities with backend HTTP Basic Auth and loads all discovered layers on top of OpenStreetMap. +- The currently published layers discovered during setup were `parcelas` and `predios`. diff --git a/app.js b/app.js new file mode 100644 index 0000000..3d537e9 --- /dev/null +++ b/app.js @@ -0,0 +1,200 @@ +const DEFAULT_CENTER = [-8.65, 39.55]; +const API_CAPABILITIES = "/api/capabilities"; +const API_WMS = "/api/wms"; + +const els = { + status: document.getElementById("status"), + layerList: document.getElementById("layer-list"), + layerCount: document.getElementById("layer-count"), +}; + +const osmLayer = new ol.layer.Tile({ + source: new ol.source.OSM(), +}); + +const wmsSource = new ol.source.TileWMS({ + url: API_WMS, + params: { + LAYERS: "", + TILED: true, + FORMAT: "image/png", + TRANSPARENT: true, + }, + crossOrigin: "anonymous", +}); + +const wmsLayer = new ol.layer.Tile({ + opacity: 0.8, + visible: false, + source: wmsSource, +}); + +const map = new ol.Map({ + target: "map", + layers: [osmLayer, wmsLayer], + view: new ol.View({ + center: ol.proj.fromLonLat(DEFAULT_CENTER), + zoom: 7, + }), +}); + +let availableLayers = []; +let selectedLayers = []; + +loadLayers(); + +async function loadLayers() { + updateStatus("Loading WMS layers..."); + + try { + const capabilities = await loadCapabilities(); + availableLayers = extractLayers(capabilities); + selectedLayers = availableLayers.map((layer) => layer.name); + + renderLayerList(); + syncSelectedLayers(); + fitToLayerExtent(availableLayers[0]); + + updateStatus(`Showing ${availableLayers.length} layer(s) from GeoServer.`, "success"); + } catch (error) { + console.error(error); + updateStatus(error.message || "Unable to load the WMS service.", "error"); + availableLayers = []; + selectedLayers = []; + renderLayerList(); + syncSelectedLayers(); + } +} + +async function loadCapabilities() { + const url = new URL(API_CAPABILITIES, window.location.origin); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`GeoServer connection failed with status ${response.status}.`); + } + + return response.text(); +} + +function extractLayers(xmlText) { + const parser = new DOMParser(); + const xml = parser.parseFromString(xmlText, "text/xml"); + const capabilityNode = firstChildByName(xml.documentElement, "Capability"); + const rootLayerNode = firstChildByName(capabilityNode, "Layer"); + const nodes = collectNamedLayers(rootLayerNode); + + return nodes + .map((layerNode) => { + const name = textFromChild(layerNode, "Name"); + const title = textFromChild(layerNode, "Title") || name; + const bbox = firstChildByName(layerNode, "EX_GeographicBoundingBox"); + const extent = bbox + ? [ + Number(textFromChild(bbox, "westBoundLongitude")), + Number(textFromChild(bbox, "southBoundLatitude")), + Number(textFromChild(bbox, "eastBoundLongitude")), + Number(textFromChild(bbox, "northBoundLatitude")), + ] + : null; + + return { name, title, extent }; + }) + .filter((layer) => layer.name); +} + +function renderLayerList() { + els.layerCount.textContent = String(availableLayers.length); + + if (!availableLayers.length) { + els.layerList.className = "layer-list empty"; + els.layerList.textContent = "No published layers were returned by the service."; + return; + } + + els.layerList.className = "layer-list"; + els.layerList.replaceChildren(); + + availableLayers.forEach((layer) => { + const label = document.createElement("label"); + label.className = "layer-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selectedLayers.includes(layer.name); + checkbox.addEventListener("change", () => { + selectedLayers = checkbox.checked + ? [...new Set([...selectedLayers, layer.name])] + : selectedLayers.filter((name) => name !== layer.name); + syncSelectedLayers(); + if (checkbox.checked) { + fitToLayerExtent(layer); + } + }); + + const meta = document.createElement("div"); + meta.className = "layer-meta"; + + const title = document.createElement("span"); + title.className = "layer-title"; + title.textContent = layer.title; + + const name = document.createElement("span"); + name.className = "layer-name"; + name.textContent = layer.name; + + meta.append(title, name); + label.append(checkbox, meta); + els.layerList.append(label); + }); +} + +function syncSelectedLayers() { + const orderedSelectedLayers = availableLayers + .map((layer) => layer.name) + .filter((name) => selectedLayers.includes(name)); + + wmsLayer.setVisible(selectedLayers.length > 0); + wmsSource.updateParams({ + LAYERS: [...orderedSelectedLayers].reverse().join(","), + _: Date.now(), + }); +} + +function fitToLayerExtent(layer) { + if (!layer?.extent || layer.extent.some((value) => Number.isNaN(value))) { + return; + } + + const projectedExtent = ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857"); + map.getView().fit(projectedExtent, { + padding: [40, 40, 40, 40], + duration: 500, + maxZoom: 18, + }); +} + +function childElementsByName(node, localName) { + return [...(node?.children || [])].filter((child) => child.localName === localName); +} + +function firstChildByName(node, localName) { + return childElementsByName(node, localName)[0] || null; +} + +function textFromChild(node, localName) { + return firstChildByName(node, localName)?.textContent?.trim() || ""; +} + +function collectNamedLayers(node) { + return childElementsByName(node, "Layer").flatMap((layerNode) => { + const children = collectNamedLayers(layerNode); + return textFromChild(layerNode, "Name") ? [layerNode, ...children] : children; + }); +} + +function updateStatus(message, tone) { + els.status.textContent = message; + els.status.className = tone ? `status ${tone}` : "status"; +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..de31743 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,15 @@ +services: + xprov-map-visualizer: + build: + context: . + image: xprov-map-visualizer:latest + container_name: xprov-map-visualizer + restart: unless-stopped + environment: + PORT: ${APP_PORT:-4173} + WMS_ENDPOINT: ${WMS_ENDPOINT:-https://xpro-viz.jfig.net/geoserver/xpro/wms} + ALLOWED_WMS_ENDPOINTS: ${ALLOWED_WMS_ENDPOINTS:-https://xpro-viz.jfig.net/geoserver/xpro/wms} + GEOSERVER_USERNAME: ${GEOSERVER_USERNAME:-admin} + GEOSERVER_PASSWORD: ${GEOSERVER_PASSWORD} + ports: + - "${APP_PORT:-4173}:${APP_PORT:-4173}" diff --git a/index.html b/index.html new file mode 100644 index 0000000..acf7d86 --- /dev/null +++ b/index.html @@ -0,0 +1,37 @@ + + + + + + XPro Map Visualizer + + + + +
+ + +
+
+
+
+ + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1496163 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,169 @@ +{ + "name": "xprov-map-visualizer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xprov-map-visualizer", + "version": "0.1.0", + "dependencies": { + "ol": "10.6.1" + } + }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", + "license": "MIT" + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/geotiff": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz", + "integrity": "sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.4.7", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.2.0", + "xml-utils": "^1.0.2", + "zstddec": "^0.1.0" + }, + "engines": { + "node": ">=10.19" + } + }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, + "node_modules/ol": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.6.1.tgz", + "integrity": "sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/rbush": "4.0.0", + "earcut": "^3.0.0", + "geotiff": "^2.1.3", + "pbf": "4.0.1", + "rbush": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/openlayers" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, + "node_modules/zstddec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", + "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==", + "license": "MIT AND BSD-3-Clause" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..43f0cd8 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "xprov-map-visualizer", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "ol": "10.6.1" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..38fd6dc --- /dev/null +++ b/server.js @@ -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; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..2094f2c --- /dev/null +++ b/styles.css @@ -0,0 +1,227 @@ +:root { + color-scheme: light; + --bg: #f3efe3; + --panel: rgba(253, 249, 240, 0.92); + --panel-border: rgba(92, 73, 49, 0.18); + --ink: #1f2a2e; + --muted: #5f6b6f; + --accent: #1f7a62; + --accent-strong: #125441; + --shadow: 0 24px 60px rgba(52, 43, 28, 0.12); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: "Aptos", "Segoe UI", sans-serif; + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.55), transparent 32%), + linear-gradient(135deg, #e9dec4 0%, #f7f4ec 52%, #d8eadf 100%); + color: var(--ink); +} + +body { + min-height: 100vh; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(320px, 380px) 1fr; + min-height: 100vh; + gap: 24px; + padding: 24px; +} + +.sidebar, +.map-wrap { + min-height: 0; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +.brand, +.panel { + background: var(--panel); + backdrop-filter: blur(16px); + border: 1px solid var(--panel-border); + border-radius: 24px; + box-shadow: var(--shadow); +} + +.brand { + padding: 24px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-family: "Aptos Display", "Segoe UI", sans-serif; + font-size: clamp(2rem, 4vw, 2.7rem); + line-height: 0.98; +} + +.panel { + padding: 20px; +} + +form.panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +label { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 0.95rem; +} + +label span { + color: var(--muted); + font-weight: 600; +} + +input { + width: 100%; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(73, 84, 79, 0.18); + background: rgba(255, 255, 255, 0.72); + color: var(--ink); + font: inherit; +} + +input:focus { + outline: 2px solid rgba(31, 122, 98, 0.18); + border-color: var(--accent); +} + +button { + border: 0; + border-radius: 14px; + padding: 13px 16px; + background: linear-gradient(135deg, var(--accent) 0%, #2b9a79 100%); + color: #fffefb; + font: inherit; + font-weight: 700; + cursor: pointer; + transition: transform 180ms ease, box-shadow 180ms ease, opacity 180ms ease; + box-shadow: 0 18px 36px rgba(31, 122, 98, 0.22); +} + +button:hover { + transform: translateY(-1px); +} + +button:disabled { + opacity: 0.72; + cursor: wait; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.badge { + min-width: 2rem; + padding: 5px 10px; + border-radius: 999px; + background: rgba(31, 122, 98, 0.12); + color: var(--accent-strong); + text-align: center; + font-weight: 700; +} + +.status { + margin-top: 12px; + color: var(--muted); +} + +.status.error { + color: #9d2e21; +} + +.status.success { + color: var(--accent-strong); +} + +.layer-list { + margin-top: 16px; + display: grid; + gap: 10px; +} + +.layer-list.empty { + color: var(--muted); + line-height: 1.5; +} + +.layer-item { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + padding: 12px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.66); + border: 1px solid rgba(73, 84, 79, 0.12); +} + +.layer-item input { + width: auto; + margin-top: 3px; +} + +.layer-meta { + display: grid; + gap: 3px; +} + +.layer-title { + font-weight: 700; +} + +.layer-name { + color: var(--muted); + font-size: 0.9rem; +} + +.map-wrap { + position: relative; + overflow: hidden; + border-radius: 32px; + box-shadow: var(--shadow); + min-height: 70vh; +} + +#map { + position: absolute; + inset: 0; +} + +@media (max-width: 920px) { + .app-shell { + grid-template-columns: 1fr; + } + + .map-wrap { + min-height: 62vh; + } +}