UNPKG

@wikimedia/kartotherian-snapshot

Version:
264 lines (227 loc) 10.1 kB
'use strict'; let util = require('util'), Err = require('@wikimedia/err'), Promise = require('bluebird'), abaculus = Promise.promisify(require('@kartotherian/abaculus'), {multiArgs: true}), Overlay = require('@kartotherian/tilelive-overlay'), _ = require('underscore'), checkType = require('@wikimedia/kartotherian-input-validator'), makeDomainValidator = require('domain-validator'), autoPosition = require('./autoPosition'); let core, mapdataLoader, parseProtocol, mapnik; module.exports = function snapshot(cor, router) { core = cor; mapnik = core.mapnik; let allowedDomains = core.getConfiguration().allowedDomains, httpsDomains = makeDomainValidator(allowedDomains ? allowedDomains.https : undefined, true), httpDomains = makeDomainValidator(allowedDomains ? allowedDomains.http : undefined, true); parseProtocol = domain => { if (httpsDomains.test(domain)) { return 'https'; } else if (httpDomains.test(domain)) { return 'http'; } else { throw new Err('Domain is not allowed').metrics('err.req.domain'); } }; if (allowedDomains) { mapdataLoader = require('./mapdataLoader'); } // get static image router.get('/img/:src(' + core.Sources.sourceIdReStr + '),:zoom(a|\\d+),:lat(a|[-\\d\\.]+),:lon(a|[-\\d\\.]+),:w(\\d+)x:h(\\d+):scale(@[\\.\\d]+x)?.:format([\\w]+)?', requestHandler); }; /** * Create a parameters object for Abaculus * @param params * @param tileSource * @return {{zoom: number, scale: number, center: {y: number, x: number, w: number, h: number}, format: string, getTile: function}} */ function makeParams(params, tileSource) { return { zoom: params.zoom, scale: params.scale, center: { y: Math.min(85, Math.max(-85, params.lat)), x: Math.min(180, Math.max(-180, params.lon)), w: params.w, h: params.h }, format: params.format, getTile: function(z, x, y, cb) { if ( typeof tileSource.getAsync === 'function' ) { const opts = { type: 'tile', z: z, x: x, y: y, scale: params.scale, lang: params.lang }; return tileSource.getAsync(opts).then( data => cb(undefined, data.data, data.headers) ).catch(err => cb(err)); } else { // source is old school and can't receive lang param return tileSource.getTile(z, x, y, cb); } } }; } /** * Magical float regex found in http://stackoverflow.com/a/21664614/177275 * @type {RegExp} */ let floatRe = /^-?\d+(?:[.,]\d*?)?$/; /** * Converts value to float if possible, or returns the original */ function strToFloat(value) { if (typeof value === 'string' && floatRe.test(value)) { return parseFloat(value); } return value; }; /** * Web server (express) route handler to get a snapshot image * @param req request object * @param res response object * @param next will be called if request is not handled */ function requestHandler(req, res, next) { let source, protocol, params = req && req.params, qparams = req && req.query, start = Date.now(); return Promise.try(() => { source = core.getPublicSource(params.src); if (qparams.lang) { params.lang = qparams.lang; } if (params.scale !== undefined) { // From "@2x", remove first and last characters params.scale = params.scale.substring(1, params.scale.length - 1); } params.scale = core.validateScale(params.scale, source); // Overlays only support 2x scaling, so if scale is less than <1.5x, drop to 1x, otherwise - 2x params.scale = (!params.scale || params.scale < 1.5) ? 1 : 2; if (!source.static) { throw new Err('Static snapshot images are not enabled for this source').metrics('err.req.static'); } if (params.format !== 'png' && params.format !== 'jpeg' || !_.contains(source.formats, params.format)) { throw new Err('Format %s is not allowed for static images', params.format).metrics('err.req.stformat'); } params.w = checkType.strToInt(params.w); params.h = checkType.strToInt(params.h); if (!Number.isInteger(params.w) || !Number.isInteger(params.h)) { throw new Err('The width and height params must be integers for static images').metrics('err.req.stsize'); } if (params.w > source.maxwidth || params.h > source.maxheight) { throw new Err('Requested image is too big').metrics('err.req.stsizebig'); } let noOverlay = !qparams.domain && !qparams.title, useAutoCentering = params.lat === 'a' || params.lon === 'a', useAutoZooming = params.zoom === 'a', useAutoPositioning = useAutoCentering || useAutoZooming; if (useAutoCentering && params.lat !== params.lon) { // `lat` and `lon` should be set to `a` throw new Err('Both latitude and longitude must be numbers, or they must both be set to the letter "a" for auto positioning').metrics('err.req.stauto'); } if (noOverlay) { if (useAutoPositioning) { throw new Err('Auto zoom or positioning is only allowed when both domain and title are present').metrics('err.req.stauto'); } // For now returns JPEG without overlays params.lat = strToFloat(params.lat); params.lon = strToFloat(params.lon); params.zoom = core.validateZoom(params.zoom, source); if (typeof params.lat !== 'number' || typeof params.lon !== 'number') { throw new Err('The lat and lon coordinates must be numeric for static images').metrics('err.req.stcoords'); } return abaculus(makeParams(params, source.getHandler())); } if (!mapdataLoader) { throw new Err('Snapshot overlays are disabled, conf.allowedDomains is not set').metrics('err.req.stdisabled'); } if (!qparams.domain || !qparams.title) { throw new Err('Both domain and title params are required').metrics('err.req.stboth'); } if (qparams.groups) { qparams.groups = qparams.groups.split(','); } else { throw new Err('A comma-separated list of groups is required').metrics('err.req.stgroups'); } if (params.format !== 'png') { throw new Err('Only png format is allowed for images with overlays').metrics('err.req.stnonpng'); } if (qparams.title.indexOf('|') !== -1) { throw new Err('title param may not contain pipe "|" symbol').metrics('err.req.stpipe'); } if (qparams.revid && qparams.revid.indexOf('|') !== -1) { throw new Err('revid param may not contain pipe "|" symbol').metrics('err.req.stpipe'); } protocol = parseProtocol(qparams.domain); let baseMapHdrs = {}; let isVersioned = core.getConfiguration().versioned_maps !== false; return mapdataLoader( req, protocol, qparams.domain, qparams.title, isVersioned && qparams.revid, qparams.groups ).then(geojson => { let mapPosition; if (useAutoPositioning) { mapPosition = autoPosition(params, geojson); params.lon = mapPosition.longitude; params.lat = mapPosition.latitude; params.zoom = mapPosition.zoom; } else { params.lat = strToFloat(params.lat); params.lon = strToFloat(params.lon); } params.zoom = core.validateZoom(params.zoom, source); let renderBaseMap = abaculus(makeParams(params, source.getHandler())).spread((data, headers) => { baseMapHdrs = headers; return mapnik.Image.fromBytesAsync(data); }).then( image => image.premultiplyAsync() ); // This is far from ideal - we should be using geojson-mapnikify directly let renderOverlayMap = Promise.try(() => new Promise((accept, reject) => { // Render overlay layer let url = 'overlaydata://' + (params.scale === 2 ? '2x:' : '') + JSON.stringify( geojson ); new Overlay( url, (err, overlay) => { if ( err ) reject( err ); accept( overlay ); }) })).then( overlay => abaculus(makeParams(params, overlay)) ).then( overlayBuf => mapnik.Image.fromBytesAsync(overlayBuf[0]) ).then( image => image.premultiplyAsync() ); return Promise.join( renderBaseMap, renderOverlayMap, (baseImage, overlayImage) => { return baseImage.compositeAsync(overlayImage); // }).then(image => { // // Not sure if this step is needed - result appears identical // return image.demultiplyAsync(); } ); }) .then( image => image.encodeAsync('png8:m=h:z=9') ).then( image => [image, baseMapHdrs] ); }).spread((data, dataHeaders) => { core.setResponseHeaders(res, source, dataHeaders); res.send(data); let mx = util.format('req.%s.%s.%s.static', params.src, params.zoom, params.format); if (params.scale) { // replace '.' with ',' -- otherwise grafana treats it as a divider mx += '.' + (params.scale.toString().replace('.', ',')); } core.metrics.endTiming(mx, start); }).catch( err => core.reportRequestError(err, res) ).catch(next); }