diff --git a/docs/endpoints.rst b/docs/endpoints.rst index 093b6d857..af7c7b414 100644 --- a/docs/endpoints.rst +++ b/docs/endpoints.rst @@ -8,7 +8,7 @@ Styles ====== * Styles are served at ``/styles/{id}/style.json`` (+ array at ``/styles.json``) - * Sprites at ``/styles/{id}/sprite[@2x].{format}`` + * Sprites at ``/styles/{id}/sprite[/spriteID][@2x].{format}`` * Fonts at ``/fonts/{fontstack}/{start}-{end}.pbf`` Rendered tiles diff --git a/src/serve_rendered.js b/src/serve_rendered.js index 82e9c598a..c6953e7ff 100644 --- a/src/serve_rendered.js +++ b/src/serve_rendered.js @@ -46,7 +46,7 @@ import { renderOverlay, renderWatermark, renderAttribution } from './render.js'; const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)'; const PATH_PATTERN = /^((fill|stroke|width)\:[^\|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/; -const httpTester = /^(http(s)?:)?\/\//; +const httpTester = /^https?:\/\//i; const mercator = new SphericalMercator(); const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0; @@ -1045,16 +1045,27 @@ export const serve_rendered = { return false; } - if (styleJSON.sprite && !httpTester.test(styleJSON.sprite)) { - styleJSON.sprite = - 'sprites://' + - styleJSON.sprite - .replace('{style}', path.basename(styleFile, '.json')) - .replace( - '{styleJsonFolder}', - path.relative(options.paths.sprites, path.dirname(styleJSONPath)), - ); + if (styleJSON.sprite) { + if (!Array.isArray(styleJSON.sprite)) { + styleJSON.sprite = [{ id: 'default', url: styleJSON.sprite }]; + } + styleJSON.sprite.forEach((spriteItem) => { + if (!httpTester.test(spriteItem.url)) { + spriteItem.url = + 'sprites://' + + spriteItem.url + .replace('{style}', path.basename(styleFile, '.json')) + .replace( + '{styleJsonFolder}', + path.relative( + options.paths.sprites, + path.dirname(styleJSONPath), + ), + ); + } + }); } + if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) { styleJSON.glyphs = `fonts://${styleJSON.glyphs}`; } diff --git a/src/serve_style.js b/src/serve_style.js index cb0dfcf80..3c0c75a19 100644 --- a/src/serve_style.js +++ b/src/serve_style.js @@ -7,24 +7,11 @@ import clone from 'clone'; import express from 'express'; import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec'; -import { getPublicUrl } from './utils.js'; - -const httpTester = /^(http(s)?:)?\/\//; - -const fixUrl = (req, url, publicUrl) => { - if (!url || typeof url !== 'string' || url.indexOf('local://') !== 0) { - return url; - } - const queryParams = []; - if (req.query.key) { - queryParams.unshift(`key=${encodeURIComponent(req.query.key)}`); - } - let query = ''; - if (queryParams.length) { - query = `?${queryParams.join('&')}`; - } - return url.replace('local://', getPublicUrl(publicUrl, req)) + query; -}; +import { fixUrl, allowedOptions } from './utils.js'; + +const httpTester = /^https?:\/\//i; +const allowedSpriteScales = allowedOptions(['', '@2x', '@3x']); +const allowedSpriteFormats = allowedOptions(['png', 'json']); export const serve_style = { init: (options, repo) => { @@ -42,7 +29,13 @@ export const serve_style = { } // mapbox-gl-js viewer cannot handle sprite urls with query if (styleJSON_.sprite) { - styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl); + if (Array.isArray(styleJSON_.sprite)) { + styleJSON_.sprite.forEach((spriteItem) => { + spriteItem.url = fixUrl(req, spriteItem.url, item.publicUrl); + }); + } else { + styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl); + } } if (styleJSON_.glyphs) { styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl); @@ -50,25 +43,39 @@ export const serve_style = { return res.send(styleJSON_); }); - app.get('/:id/sprite:scale(@[23]x)?.:format([\\w]+)', (req, res, next) => { - const item = repo[req.params.id]; - if (!item || !item.spritePath) { - return res.sendStatus(404); - } - const scale = req.params.scale; - const format = req.params.format; - const filename = `${item.spritePath + (scale || '')}.${format}`; - return fs.readFile(filename, (err, data) => { - if (err) { - console.log('Sprite load error:', filename); - return res.sendStatus(404); + app.get( + '/:id/sprite(/:spriteID)?:scale(@[23]x)?.:format([\\w]+)', + (req, res, next) => { + const { spriteID = 'default', id } = req.params; + const scale = allowedSpriteScales(req.params.scale) || ''; + const format = allowedSpriteFormats(req.params.format); + + if (format) { + const item = repo[id]; + const sprite = item.spritePaths.find( + (sprite) => sprite.id === spriteID, + ); + if (sprite) { + const filename = `${sprite.path + scale}.${format}`; + return fs.readFile(filename, (err, data) => { + if (err) { + console.log('Sprite load error:', filename); + return res.sendStatus(404); + } else { + if (format === 'json') + res.header('Content-type', 'application/json'); + if (format === 'png') res.header('Content-type', 'image/png'); + return res.send(data); + } + }); + } else { + return res.status(400).send('Bad Sprite ID or Scale'); + } } else { - if (format === 'json') res.header('Content-type', 'application/json'); - if (format === 'png') res.header('Content-type', 'image/png'); - return res.send(data); + return res.status(400).send('Bad Sprite Format'); } - }); - }); + }, + ); return app; }, @@ -135,27 +142,48 @@ export const serve_style = { } } - let spritePath; - - if (styleJSON.sprite && !httpTester.test(styleJSON.sprite)) { - spritePath = path.join( - options.paths.sprites, - styleJSON.sprite - .replace('{style}', path.basename(styleFile, '.json')) - .replace( - '{styleJsonFolder}', - path.relative(options.paths.sprites, path.dirname(styleFile)), - ), - ); - styleJSON.sprite = `local://styles/${id}/sprite`; + let spritePaths = []; + if (styleJSON.sprite) { + if (!Array.isArray(styleJSON.sprite)) { + if (!httpTester.test(styleJSON.sprite)) { + let spritePath = path.join( + options.paths.sprites, + styleJSON.sprite + .replace('{style}', path.basename(styleFile, '.json')) + .replace( + '{styleJsonFolder}', + path.relative(options.paths.sprites, path.dirname(styleFile)), + ), + ); + styleJSON.sprite = `local://styles/${id}/sprite`; + spritePaths.push({ id: 'default', path: spritePath }); + } + } else { + for (let spriteItem of styleJSON.sprite) { + if (!httpTester.test(spriteItem.url)) { + let spritePath = path.join( + options.paths.sprites, + spriteItem.url + .replace('{style}', path.basename(styleFile, '.json')) + .replace( + '{styleJsonFolder}', + path.relative(options.paths.sprites, path.dirname(styleFile)), + ), + ); + spriteItem.url = `local://styles/${id}/sprite/` + spriteItem.id; + spritePaths.push({ id: spriteItem.id, path: spritePath }); + } + } + } } + if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) { styleJSON.glyphs = 'local://fonts/{fontstack}/{range}.pbf'; } repo[id] = { styleJSON, - spritePath, + spritePaths, publicUrl, name: styleJSON.name, }; diff --git a/src/utils.js b/src/utils.js index 2a516bccd..b5b737b99 100644 --- a/src/utils.js +++ b/src/utils.js @@ -6,6 +6,38 @@ import fs, { existsSync } from 'node:fs'; import clone from 'clone'; import glyphCompose from '@mapbox/glyph-pbf-composite'; +/** + * Restrict user input to an allowed set of options. + * @param opts + * @param root0 + * @param root0.defaultValue + */ +export function allowedOptions(opts, { defaultValue } = {}) { + const values = Object.fromEntries(opts.map((key) => [key, key])); + return (value) => values[value] || defaultValue; +} + +/** + * Replace local:// urls with public http(s):// urls + * @param req + * @param url + * @param publicUrl + */ +export function fixUrl(req, url, publicUrl) { + if (!url || typeof url !== 'string' || url.indexOf('local://') !== 0) { + return url; + } + const queryParams = []; + if (req.query.key) { + queryParams.unshift(`key=${encodeURIComponent(req.query.key)}`); + } + let query = ''; + if (queryParams.length) { + query = `?${queryParams.join('&')}`; + } + return url.replace('local://', getPublicUrl(publicUrl, req)) + query; +} + /** * Generate new URL object * @param req diff --git a/test/style.js b/test/style.js index 46607ec2b..469eab7c1 100644 --- a/test/style.js +++ b/test/style.js @@ -41,6 +41,16 @@ describe('Styles', function () { testIs('/styles/' + prefix + '/sprite.png', /image\/png/); testIs('/styles/' + prefix + '/sprite@2x.png', /image\/png/); }); + + describe('/styles/' + prefix + '/sprite/default[@2x].{format}', function () { + testIs('/styles/' + prefix + '/sprite/default.json', /application\/json/); + testIs( + '/styles/' + prefix + '/sprite/default@2x.json', + /application\/json/, + ); + testIs('/styles/' + prefix + '/sprite/default.png', /image\/png/); + testIs('/styles/' + prefix + '/sprite/default@2x.png', /image\/png/); + }); }); describe('Fonts', function () {