Extended Static-Images Endpoint (#619)

* extended staticmap capabilities

* added allowRemoteMarkerIcons configuration option and restricted fetching of remote marker icons only when option is set to true;
asynchronously load all available icons in a settings object on server startup;
replaced fs.existsSync() call in serve_rendered when drawing marker icons with a check against available icons settings object;

* updated documentation for linecap parameter in staticmaps endpoint;
added linejoin parameter to staticmaps endpoint;

* added capability of staticmap endpoint to draw multiple paths
This commit is contained in:
benedikt-brandtner-bikemap 2022-10-28 04:55:46 +02:00 committed by GitHub
parent f8a0ab6d3c
commit f3f63498a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 485 additions and 60 deletions

View File

@ -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``
==========

View File

@ -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.

View File

@ -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",

View File

@ -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');
});

View File

@ -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 {

View File

@ -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');
});
});