diff --git a/docs/config.rst b/docs/config.rst index 397725a..2164080 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -14,6 +14,7 @@ Example: "root": "", "fonts": "fonts", "sprites": "sprites", + "icons": "icons", "styles": "styles", "mbtiles": "" }, @@ -31,6 +32,7 @@ Example: "serveAllFonts": false, "serveAllStyles": false, "serveStaticMaps": true, + "allowRemoteMarkerIcons": true, "tileMargin": 0 }, "styles": { @@ -141,6 +143,13 @@ Optional string to be rendered into the raster tiles (and static maps) as waterm Can be used for hard-coding attributions etc. (can also be specified per-style). Not used by default. +``allowRemoteMarkerIcons`` +-------------- + +Allows the rendering of marker icons fetched via http(s) hyperlinks. +For security reasons only allow this if you can control the origins from where the markers are fetched! +Default is to disallow fetching of icons from remote sources. + ``styles`` ========== diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 57361fd..c4a0c7f 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -38,15 +38,41 @@ Static images * ``path`` - comma-separated ``lng,lat``, pipe-separated pairs * e.g. ``5.9,45.8|5.9,47.8|10.5,47.8|10.5,45.8|5.9,45.8`` + * can be provided multiple times - * ``latlng`` - indicates the ``path`` coordinates are in ``lat,lng`` order rather than the usual ``lng,lat`` + * ``latlng`` - indicates coordinates are in ``lat,lng`` order rather than the usual ``lng,lat`` * ``fill`` - color to use as the fill (e.g. ``red``, ``rgba(255,255,255,0.5)``, ``#0000ff``) * ``stroke`` - color of the path stroke * ``width`` - width of the stroke + * ``linecap`` - rendering style for the start and end points of the path + * ``linejoin`` - rendering style for overlapping segments of the path with differing directions + * ``border`` - color of the optional border path stroke + * ``borderwidth`` - width of the border stroke (default 10% of width) + * ``marker`` - Marker in format ``lng,lat|iconPath|option|option|...`` + + * Will be rendered with the bottom center at the provided location + * ``lng,lat`` and ``iconPath`` are mandatory and icons won't be rendered without them + * ``iconPath`` is either a link to an image served via http(s) or a path to a file relative to the configured icon path + * ``option`` must adhere to the format ``optionName:optionValue`` and supports the following names + + * ``scale`` - Factor to scale image by + + * e.g. ``0.5`` - Scales the image to half it's original size + + * ``offset`` - Image offset as positive or negative pixel value in format ``[offsetX],[offsetY]`` + + * scales with ``scale`` parameter since image placement is relative to it's size + * e.g. ``2,-4`` - Image will be moved 2 pixel to the right and 4 pixel in the upwards direction from the provided location + + * e.g. ``5.9,45.8|marker-start.svg|scale:0.5|offset:2,-4`` + * can be provided multiple times + * ``padding`` - "percentage" padding for fitted endpoints (area-based and path autofit) * value of ``0.1`` means "add 10% size to each side to make sure the area of interest is nicely visible" + * ``maxzoom`` - Maximum zoom level (only for auto endpoint where zoom level is calculated and not provided) + * You can also use (experimental) ``/styles/{id}/static/raw/...`` endpoints with raw spherical mercator coordinates (EPSG:3857) instead of WGS84. * The static images are not available in the ``tileserver-gl-light`` version. diff --git a/package.json b/package.json index 65c960f..2a977d3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "proj4": "2.8.0", "request": "2.88.2", "sharp": "0.31.0", - "tileserver-gl-styles": "2.0.0" + "tileserver-gl-styles": "2.0.0", + "sanitize-filename": "1.6.3" }, "devDependencies": { "chai": "4.3.6", diff --git a/src/serve_rendered.js b/src/serve_rendered.js index 39f15eb..316862b 100644 --- a/src/serve_rendered.js +++ b/src/serve_rendered.js @@ -11,6 +11,7 @@ import pkg from 'canvas'; import clone from 'clone'; import Color from 'color'; import express from 'express'; +import sanitize from "sanitize-filename"; import SphericalMercator from '@mapbox/sphericalmercator'; import mlgl from '@maplibre/maplibre-gl-native'; import MBTiles from '@mapbox/mbtiles'; @@ -21,7 +22,7 @@ import {getFontsPbf, getTileUrls, fixTileJSONCenter} from './utils.js'; const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+\.?\\d+)'; const httpTester = /^(http(s)?:)?\/\//; -const {createCanvas} = pkg; +const {createCanvas, Image} = pkg; const mercator = new SphericalMercator(); const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0; @@ -93,37 +94,385 @@ function createEmptyResponse(format, color, callback) { }); } -const extractPathFromQuery = (query, transformer) => { - const pathParts = (query.path || '').split('|'); - const path = []; - for (const pair of pathParts) { - const pairParts = pair.split(','); - if (pairParts.length === 2) { - let pair; - if (query.latlng === '1' || query.latlng === 'true') { - pair = [+(pairParts[1]), +(pairParts[0])]; - } else { - pair = [+(pairParts[0]), +(pairParts[1])]; - } - if (transformer) { - pair = transformer(pair); - } - path.push(pair); - } +/** + * Parses coordinate pair provided to pair of floats and ensures the resulting + * pair is a longitude/latitude combination depending on lnglat query parameter. + * @param {List} coordinatePair Coordinate pair. + * @param {Object} query Request query parameters. + */ +const parseCoordinatePair = (coordinates, query) => { + const firstCoordinate = parseFloat(coordinates[0]); + const secondCoordinate = parseFloat(coordinates[1]); + + // Ensure provided coordinates could be parsed and abort if not + if (isNaN(firstCoordinate) || isNaN(secondCoordinate)) { + return null; } - return path; + + // Check if coordinates have been provided as lat/lng pair instead of the + // ususal lng/lat pair and ensure resulting pair is lng/lat + if (query.latlng === '1' || query.latlng === 'true') { + return [secondCoordinate, firstCoordinate]; + } + + return [firstCoordinate, secondCoordinate]; }; -const renderOverlay = (z, x, y, bearing, pitch, w, h, scale, - path, query) => { +/** + * Parses a coordinate pair from query arguments and optionally transforms it. + * @param {List} coordinatePair Coordinate pair. + * @param {Object} query Request query parameters. + * @param {Function} transformer Optional transform function. + */ +const parseCoordinates = (coordinatePair, query, transformer) => { + const parsedCoordinates = parseCoordinatePair(coordinatePair, query); + + // Transform coordinates + if (transformer) { + return transformer(parsedCoordinates); + } + + return parsedCoordinates; +}; + + +/** + * Parses paths provided via query into a list of path objects. + * @param {Object} query Request query parameters. + * @param {Function} transformer Optional transform function. + */ +const extractPathsFromQuery = (query, transformer) => { + // Return an empty list if no paths have been provided + if (!query.path) { + return []; + } + + const paths = []; + + // Check if multiple paths have been provided and mimic a list if it's a + // single path. + const providedPaths = Array.isArray(query.path) ? query.path : [query.path]; + + // Iterate through paths, parse and validate them + for (const provided_path of providedPaths) { + const currentPath = []; + + // Extract coordinate-list from path + const pathParts = (provided_path || '').split('|'); + + // Iterate through coordinate-list, parse the coordinates and validate them + for (const pair of pathParts) { + // Extract coordinates from coordinate pair + const pairParts = pair.split(','); + + // Ensure we have two coordinates + if (pairParts.length === 2) { + const pair = parseCoordinates(pairParts, query, transformer); + + // Ensure coordinates could be parsed and skip them if not + if (pair === null) { + continue; + } + + // Add the coordinate-pair to the current path if they are valid + currentPath.push(pair); + } + } + + // Extend list of paths with current path if it contains coordinates + if (currentPath.length) { + paths.push(currentPath) + } + + } + return paths; +}; + +/** + * Parses marker options provided via query and sets corresponding attributes + * on marker object. + * Options adhere to the following format + * [optionName]:[optionValue] + * @param {List[String]} optionsList List of option strings. + * @param {Object} marker Marker object to configure. + */ +const parseMarkerOptions = (optionsList, marker) => { + for (const options of optionsList) { + const optionParts = options.split(':'); + // Ensure we got an option name and value + if (optionParts.length < 2) { + continue; + } + + switch (optionParts[0]) { + // Scale factor to up- or downscale icon + case 'scale': + // Scale factors must not be negative + marker.scale = Math.abs(parseFloat(optionParts[1])) + break; + // Icon offset as positive or negative pixel value in the following + // format [offsetX],[offsetY] where [offsetY] is optional + case 'offset': + const providedOffset = optionParts[1].split(','); + // Set X-axis offset + marker.offsetX = parseFloat(providedOffset[0]); + // Check if an offset has been provided for Y-axis + if (providedOffset.length > 1) { + marker.offsetY = parseFloat(providedOffset[1]); + } + break; + } + } +}; + +/** + * Parses markers provided via query into a list of marker objects. + * @param {Object} query Request query parameters. + * @param {Object} options Configuration options. + * @param {Function} transformer Optional transform function. + */ +const extractMarkersFromQuery = (query, options, transformer) => { + // Return an empty list if no markers have been provided + if (!query.marker) { + return []; + } + + const markers = []; + + // Check if multiple markers have been provided and mimic a list if it's a + // single maker. + const providedMarkers = Array.isArray(query.marker) ? + query.marker : [query.marker]; + + // Iterate through provided markers which can have one of the following + // formats + // [location]|[pathToFileTelativeToConfiguredIconPath] + // [location]|[pathToFile...]|[option]|[option]|... + for (const providedMarker of providedMarkers) { + const markerParts = providedMarker.split('|'); + // Ensure we got at least a location and an icon uri + if (markerParts.length < 2) { + continue; + } + + const locationParts = markerParts[0].split(','); + // Ensure the locationParts contains two items + if (locationParts.length !== 2) { + continue; + } + + let iconURI = markerParts[1]; + // Check if icon is served via http otherwise marker icons are expected to + // be provided as filepaths relative to configured icon path + if (!(iconURI.startsWith('http://') || iconURI.startsWith('https://'))) { + // Sanitize URI with sanitize-filename + // https://www.npmjs.com/package/sanitize-filename#details + iconURI = sanitize(iconURI) + + // If the selected icon is not part of available icons skip it + if (!options.paths.availableIcons.includes(iconURI)) { + continue; + } + + iconURI = path.resolve(options.paths.icons, iconURI); + + // When we encounter a remote icon check if the configuration explicitly allows them. + } else if (options.allowRemoteMarkerIcons !== true) { + continue; + } + + // Ensure marker location could be parsed + const location = parseCoordinates(locationParts, query, transformer); + if (location === null) { + continue; + } + + const marker = {}; + + marker.location = location; + marker.icon = iconURI; + + // Check if options have been provided + if (markerParts.length > 2) { + parseMarkerOptions(markerParts.slice(2), marker); + } + + // Add marker to list + markers.push(marker); + + } + return markers; +}; + +/** + * Transforms coordinates to pixels. + * @param {List[Number]} ll Longitude/Latitude coordinate pair. + * @param {Number} zoom Map zoom level. + */ +const precisePx = (ll, zoom) => { + const px = mercator.px(ll, 20); + const scale = Math.pow(2, zoom - 20); + return [px[0] * scale, px[1] * scale]; +}; + +/** + * Draws a marker in cavans context. + * @param {Object} ctx Canvas context object. + * @param {Object} marker Marker object parsed by extractMarkersFromQuery. + * @param {Number} z Map zoom level. + */ +const drawMarker = (ctx, marker, z) => { + return new Promise(resolve => { + const img = new Image(); + const pixelCoords = precisePx(marker.location, z); + + const getMarkerCoordinates = (imageWidth, imageHeight, scale) => { + // Images are placed with their top-left corner at the provided location + // within the canvas but we expect icons to be centered and above it. + + // Substract half of the images width from the x-coordinate to center + // the image in relation to the provided location + let xCoordinate = pixelCoords[0] - imageWidth / 2; + // Substract the images height from the y-coordinate to place it above + // the provided location + let yCoordinate = pixelCoords[1] - imageHeight; + + // Since image placement is dependent on the size offsets have to be + // scaled as well. Additionally offsets are provided as either positive or + // negative values so we always add them + if (marker.offsetX) { + xCoordinate = xCoordinate + (marker.offsetX * scale); + } + if (marker.offsetY) { + yCoordinate = yCoordinate + (marker.offsetY * scale); + } + + return { + 'x': xCoordinate, + 'y': yCoordinate + }; + }; + + const drawOnCanvas = () => { + // Check if the images should be resized before beeing drawn + const defaultScale = 1; + const scale = marker.scale ? marker.scale : defaultScale; + + // Calculate scaled image sizes + const imageWidth = img.width * scale; + const imageHeight = img.height * scale; + + // Pass the desired sizes to get correlating coordinates + const coords = getMarkerCoordinates(imageWidth, imageHeight, scale); + + // Draw the image on canvas + if (scale != defaultScale) { + ctx.drawImage(img, coords.x, coords.y, imageWidth, imageHeight); + } else { + ctx.drawImage(img, coords.x, coords.y); + } + // Resolve the promise when image has been drawn + resolve(); + }; + + img.onload = drawOnCanvas; + img.onerror = err => { throw err }; + img.src = marker.icon; + }); +} + +/** + * Draws a list of markers onto a canvas. + * Wraps drawing of markers into list of promises and awaits them. + * It's required because images are expected to load asynchronous in canvas js + * even when provided from a local disk. + * @param {Object} ctx Canvas context object. + * @param {List[Object]} markers Marker objects parsed by extractMarkersFromQuery. + * @param {Number} z Map zoom level. + */ +const drawMarkers = async (ctx, markers, z) => { + const markerPromises = []; + + for (const marker of markers) { + // Begin drawing marker + markerPromises.push(drawMarker(ctx, marker, z)); + } + + // Await marker drawings before continuing + await Promise.all(markerPromises); +} + +/** + * Draws a list of coordinates onto a canvas and styles the resulting path. + * @param {Object} ctx Canvas context object. + * @param {List[Number]} path List of coordinates. + * @param {Object} query Request query parameters. + * @param {Number} z Map zoom level. + */ +const drawPath = (ctx, path, query, z) => { if (!path || path.length < 2) { return null; } - const precisePx = (ll, zoom) => { - const px = mercator.px(ll, 20); - const scale = Math.pow(2, zoom - 20); - return [px[0] * scale, px[1] * scale]; - }; + + ctx.beginPath(); + + // Transform coordinates to pixel on canvas and draw lines between points + for (const pair of path) { + const px = precisePx(pair, z); + ctx.lineTo(px[0], px[1]); + } + + // Check if first coordinate matches last coordinate + if (path[0][0] === path[path.length - 1][0] && + path[0][1] === path[path.length - 1][1]) { + ctx.closePath(); + } + + // Optionally fill drawn shape with a rgba color from query + if (query.fill !== undefined) { + ctx.fillStyle = query.fill; + ctx.fill(); + } + + // Get line width from query and fall back to 1 if not provided + const lineWidth = query.width !== undefined ? + parseFloat(query.width) : 1; + + // Ensure line width is valid + if (lineWidth > 0) { + // Get border width from query and fall back to 10% of line width + const borderWidth = query.borderwidth !== undefined ? + parseFloat(query.borderwidth) : lineWidth * 0.1; + + // Set rendering style for the start and end points of the path + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap + ctx.lineCap = query.linecap || 'butt'; + + // Set rendering style for overlapping segments of the path with differing directions + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin + ctx.lineJoin = query.linejoin || 'miter'; + + // In order to simulate a border we draw the path two times with the first + // beeing the wider border part. + if (query.border !== undefined && borderWidth > 0) { + // We need to double the desired border width and add it to the line width + // in order to get the desired border on each side of the line. + ctx.lineWidth = lineWidth + (borderWidth * 2); + // Set border style as rgba + ctx.strokeStyle = query.border; + ctx.stroke(); + } + + ctx.lineWidth = lineWidth; + ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)'; + ctx.stroke(); + } +} + +const renderOverlay = async (z, x, y, bearing, pitch, w, h, scale, paths, markers, query) => { + if ((!paths || paths.length === 0) && (!markers || markers.length === 0)) { + return null; + } const center = precisePx([x, y], z); @@ -147,25 +496,15 @@ const renderOverlay = (z, x, y, bearing, pitch, w, h, scale, // optimized path ctx.translate(-center[0] + w / 2, -center[1] + h / 2); } - const lineWidth = query.width !== undefined ? - parseFloat(query.width) : 1; - ctx.lineWidth = lineWidth; - ctx.strokeStyle = query.stroke || 'rgba(0,64,255,0.7)'; - ctx.fillStyle = query.fill || 'rgba(255,255,255,0.4)'; - ctx.beginPath(); - for (const pair of path) { - const px = precisePx(pair, z); - ctx.lineTo(px[0], px[1]); - } - if (path[0][0] === path[path.length - 1][0] && - path[0][1] === path[path.length - 1][1]) { - ctx.closePath(); - } - ctx.fill(); - if (lineWidth > 0) { - ctx.stroke(); + + // Draw provided paths if any + for (const path of paths) { + drawPath(ctx, path, query, z); } + // Await drawing of markers before rendering the canvas + await drawMarkers(ctx, markers, z); + return canvas.toBuffer(); }; @@ -396,7 +735,7 @@ export const serve_rendered = { FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN, FLOAT_PATTERN); - app.get(util.format(staticPattern, centerPattern), (req, res, next) => { + app.get(util.format(staticPattern, centerPattern), async (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); @@ -425,13 +764,14 @@ export const serve_rendered = { y = ll[1]; } - const path = extractPathFromQuery(req.query, transformer); - const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query); + const paths = extractPathsFromQuery(req.query, transformer); + const markers = extractMarkersFromQuery(req.query, options, transformer); + const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query); return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static'); }); - const serveBounds = (req, res, next) => { + const serveBounds = async (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); @@ -464,9 +804,9 @@ export const serve_rendered = { const bearing = 0; const pitch = 0; - const path = extractPathFromQuery(req.query, transformer); - const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query); - + const paths = extractPathsFromQuery(req.query, transformer); + const markers = extractMarkersFromQuery(req.query, options, transformer); + const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query); return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static'); }; @@ -500,7 +840,7 @@ export const serve_rendered = { const autoPattern = 'auto'; - app.get(util.format(staticPattern, autoPattern), (req, res, next) => { + app.get(util.format(staticPattern, autoPattern), async (req, res, next) => { const item = repo[req.params.id]; if (!item) { return res.sendStatus(404); @@ -516,13 +856,25 @@ export const serve_rendered = { const transformer = raw ? mercator.inverse.bind(mercator) : item.dataProjWGStoInternalWGS; - const path = extractPathFromQuery(req.query, transformer); - if (path.length < 2) { - return res.status(400).send('Invalid path'); + const paths = extractPathsFromQuery(req.query, transformer); + const markers = extractMarkersFromQuery(req.query, options, transformer); + + // Extract coordinates from markers + const markerCoordinates = []; + for (const marker of markers) { + markerCoordinates.push(marker.location); + } + + // Create array with coordinates from markers and path + const coords = new Array().concat(paths.flat()).concat(markerCoordinates); + + // Check if we have at least one coordinate to calculate a bounding box + if (coords.length < 1) { + return res.status(400).send('No coordinates provided'); } const bbox = [Infinity, Infinity, -Infinity, -Infinity]; - for (const pair of path) { + for (const pair of coords) { bbox[0] = Math.min(bbox[0], pair[0]); bbox[1] = Math.min(bbox[1], pair[1]); bbox[2] = Math.max(bbox[2], pair[0]); @@ -534,11 +886,17 @@ export const serve_rendered = { [(bbox_[0] + bbox_[2]) / 2, (bbox_[1] + bbox_[3]) / 2] ); - const z = calcZForBBox(bbox, w, h, req.query); + // Calculate zoom level + const maxZoom = parseFloat(req.query.maxzoom); + let z = calcZForBBox(bbox, w, h, req.query); + if (maxZoom > 0) { + z = Math.min(z, maxZoom); + } + const x = center[0]; const y = center[1]; - const overlay = renderOverlay(z, x, y, bearing, pitch, w, h, scale, path, req.query); + const overlay = await renderOverlay(z, x, y, bearing, pitch, w, h, scale, paths, markers, req.query); return respondImage(item, z, x, y, bearing, pitch, w, h, scale, format, res, next, overlay, 'static'); }); diff --git a/src/server.js b/src/server.js index c005438..b472e9f 100644 --- a/src/server.js +++ b/src/server.js @@ -79,6 +79,7 @@ export function server(opts) { paths.fonts = path.resolve(paths.root, paths.fonts || ''); paths.sprites = path.resolve(paths.root, paths.sprites || ''); paths.mbtiles = path.resolve(paths.root, paths.mbtiles || ''); + paths.icons = path.resolve(paths.root, paths.icons || ''); const startupPromises = []; @@ -92,6 +93,36 @@ export function server(opts) { checkPath('fonts'); checkPath('sprites'); checkPath('mbtiles'); + checkPath('icons'); + + /** + * Recursively get all files within a directory. + * Inspired by https://stackoverflow.com/a/45130990/10133863 + * @param {String} directory Absolute path to a directory to get files from. + */ + const getFiles = async (directory) => { + // Fetch all entries of the directory and attach type information + const dirEntries = await fs.promises.readdir(directory, { withFileTypes: true }); + + // Iterate through entries and return the relative file-path to the icon directory if it is not a directory + // otherwise initiate a recursive call + const files = await Promise.all(dirEntries.map((dirEntry) => { + const entryPath = path.resolve(directory, dirEntry.name); + return dirEntry.isDirectory() ? + getFiles(entryPath) : entryPath.replace(paths.icons + path.sep, ""); + })); + + // Flatten the list of files to a single array + return files.flat(); + } + + // Load all available icons into a settings object + startupPromises.push(new Promise(resolve => { + getFiles(paths.icons).then((files) => { + paths.availableIcons = files; + resolve(); + }); + })); if (options.dataDecorator) { try { diff --git a/test/static.js b/test/static.js index 9499034..13e6ca6 100644 --- a/test/static.js +++ b/test/static.js @@ -95,7 +95,7 @@ describe('Static endpoints', function() { describe('invalid requests return 4xx', function() { testStatic(prefix, 'auto/256x256', 'png', 400); - testStatic(prefix, 'auto/256x256', 'png', 400, undefined, undefined, '?path=10,10'); + testStatic(prefix, 'auto/256x256', 'png', 400, undefined, undefined, '?path=invalid'); testStatic(prefix, 'auto/2560x2560', 'png', 400, undefined, undefined, '?path=10,10|20,20'); }); });