commit c0686d0c5a648c4f7af8c637bbce5f8125fa2650 Author: Joao Figueiredo Date: Wed Jul 8 10:19:59 2026 +0100 Initial XPro map visualizer 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; + } +}