Upgrade Express to v5 +Canvas v3 + code cleanup (#1429)

* first attempt to upgrade express to v5

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* try to fix https://github.com/maptiler/tileserver-gl/issues/1411

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup server.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup serve_font.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup sever_rendered.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup server_data.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup serve_style

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* Update serve_style.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* Move UV_THREADPOOL_SIZE  to main thred

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup utils.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* Use common app.get for images and static images

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* add allowedTileSizes and option

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup error responses

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* fix /style/id.json with next('route')

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* improve sprite path

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* add parseFloadts around zxy

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* simplify server_data

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* move tile fetch and add fix verbose logging

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* add Handling request to verbose logging

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* first attempt to upgrade express to v5

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* try to fix https://github.com/maptiler/tileserver-gl/issues/1411

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup server.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup serve_font.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup sever_rendered.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup server_data.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup serve_style

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* Update serve_style.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* Move UV_THREADPOOL_SIZE  to main thred

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup utils.js

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* Use common app.get for images and static images

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* add allowedTileSizes and option

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* cleanup error responses

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* fix /style/id.json with next('route')

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* improve sprite path

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* add parseFloadts around zxy

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* simplify server_data

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* move tile fetch and add fix verbose logging

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* add Handling request to verbose logging

Co-Authored-By: Andrew Calcutt <acalcutt@techidiots.net>

* merge elevation changes

* lint format

* add verbose logging, improve headers

* try to fix codeql

Information exposure through a stack trace

* test

* all tests passing

* cleanup unneeded changes

* cleanup

* try to fix codeql error

* font fixes

* fix tile size issue

* try to improve scale + codeql

* codeql for sprite logging

* codeql serve fonts

* codeql fixes

* fix failing test with multiple fonts

* Update serve_font.js

* codeql

* codeql

* codeql

* Update utils.js

* codeql

* codeql

* codeql

* codeql

* codeql sanitize

* Update serve_font.js

* Update serve_font.js

* remove useless assignment

* move isGzipped

* add if-modified-since and cache-control

* use consistent cache control

* reformat

* codeql

* codeql

* codeql

* codeql

* codeql

* codeql

* codeql

* Update serve_font.js

* Update serve_font.js

* Update serve_font.js

* Update serve_style.js

* Update serve_style.js

* Update serve_style.js

* Revert "Update serve_style.js"

This reverts commit e0574b1887.

* Revert "Update serve_style.js"

This reverts commit b1e1d72f25.

* Revert "Update serve_style.js"

This reverts commit 0f3629c752.

* Add readFile function

* use readFile, add path.normalize

* Update serve_rendered.js

* simplify input checking

* Update utils.js

* codeql

* Revert "codeql"

This reverts commit e18874fda0.

* Revert "Update utils.js"

This reverts commit 5de617dfe2.

* Revert "simplify input checking"

This reverts commit 62a3212629.

* move allowed functions to utils.js

* use xy[0],xy[1],

* uprade canvas per https://github.com/maptiler/tileserver-gl/issues/1433

* make font regex less restrictive

* fix regex error

* Add version and changelog

* Update CHANGELOG.md

* Update CHANGELOG.md
This commit is contained in:
Andrew Calcutt 2025-01-10 19:34:17 -05:00 committed by GitHub
parent 3abbb39633
commit 97be9db6b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2271 additions and 1198 deletions

View file

@ -1,5 +1,10 @@
# tileserver-gl changelog # tileserver-gl changelog
## 5.1.0-pre.0
* Upgrade Express to v5 + Canvas to v3 + code cleanup (https://github.com/maptiler/tileserver-gl/pull/1429) by @acalcutt
* Terrain Preview and simple Elevation Query (https://github.com/maptiler/tileserver-gl/pull/1425 and https://github.com/maptiler/tileserver-gl/pull/1432) by @okimiko
* add progressive rendering option for static jpeg images (#1397) by @samuel-git
## 5.0.0 ## 5.0.0
* Update Maplibre-Native to [v6.0.0](https://github.com/maplibre/maplibre-native/releases/tag/node-v6.0.0) release by @acalcutt in https://github.com/maptiler/tileserver-gl/pull/1376 and @dependabot in https://github.com/maptiler/tileserver-gl/pull/1381 * Update Maplibre-Native to [v6.0.0](https://github.com/maplibre/maplibre-native/releases/tag/node-v6.0.0) release by @acalcutt in https://github.com/maptiler/tileserver-gl/pull/1376 and @dependabot in https://github.com/maptiler/tileserver-gl/pull/1381
* This first release that use Metal for rendering instead of OpenGL (ES) for macOS. * This first release that use Metal for rendering instead of OpenGL (ES) for macOS.

854
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "tileserver-gl", "name": "tileserver-gl",
"version": "5.0.0", "version": "5.1.0-pre.0",
"description": "Map tile server for JSON GL styles - vector and server side generated raster tiles", "description": "Map tile server for JSON GL styles - vector and server side generated raster tiles",
"main": "src/main.js", "main": "src/main.js",
"bin": "src/main.js", "bin": "src/main.js",
@ -28,13 +28,13 @@
"@sindresorhus/fnv1a": "3.1.0", "@sindresorhus/fnv1a": "3.1.0",
"advanced-pool": "0.3.3", "advanced-pool": "0.3.3",
"axios": "^1.7.7", "axios": "^1.7.7",
"canvas": "2.11.2", "canvas": "3.0.1",
"chokidar": "3.6.0", "chokidar": "3.6.0",
"clone": "2.1.2", "clone": "2.1.2",
"color": "4.2.3", "color": "4.2.3",
"commander": "12.1.0", "commander": "12.1.0",
"cors": "2.8.5", "cors": "2.8.5",
"express": "4.19.2", "express": "5.0.1",
"handlebars": "4.7.8", "handlebars": "4.7.8",
"http-shutdown": "1.2.2", "http-shutdown": "1.2.2",
"morgan": "1.10.0", "morgan": "1.10.0",

View file

@ -1,6 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
'use strict'; 'use strict';
import os from 'os';
const envSize = parseInt(process.env.UV_THREADPOOL_SIZE, 10);
process.env.UV_THREADPOOL_SIZE = Math.ceil(
Math.max(4, isNaN(envSize) ? os.cpus().length * 1.5 : envSize),
);
import fs from 'node:fs'; import fs from 'node:fs';
import fsp from 'node:fs/promises'; import fsp from 'node:fs/promises';

View file

@ -11,350 +11,336 @@ import SphericalMercator from '@mapbox/sphericalmercator';
import { Image, createCanvas } from 'canvas'; import { Image, createCanvas } from 'canvas';
import sharp from 'sharp'; import sharp from 'sharp';
import { fixTileJSONCenter, getTileUrls, isValidHttpUrl } from './utils.js';
import { import {
getPMtilesInfo, fixTileJSONCenter,
getPMtilesTile, getTileUrls,
openPMtiles, isValidHttpUrl,
} from './pmtiles_adapter.js'; fetchTileData,
} from './utils.js';
import { getPMtilesInfo, openPMtiles } from './pmtiles_adapter.js';
import { gunzipP, gzipP } from './promises.js'; import { gunzipP, gzipP } from './promises.js';
import { openMbTilesWrapper } from './mbtiles_wrapper.js'; import { openMbTilesWrapper } from './mbtiles_wrapper.js';
export const serve_data = { export const serve_data = {
init: (options, repo) => { /**
* Initializes the serve_data module.
* @param {object} options Configuration options.
* @param {object} repo Repository object.
* @param {object} programOpts - An object containing the program options
* @returns {express.Application} The initialized Express application.
*/
init: function (options, repo, programOpts) {
const { verbose } = programOpts;
const app = express().disable('x-powered-by'); const app = express().disable('x-powered-by');
app.get( /**
'/:id/:z(\\d+)/:x(\\d+)/:y(\\d+).:format([\\w.]+)', * Handles requests for tile data, responding with the tile image.
async (req, res, next) => { * @param {object} req - Express request object.
const item = repo[req.params.id]; * @param {object} res - Express response object.
if (!item) { * @param {string} req.params.id - ID of the tile.
return res.sendStatus(404); * @param {string} req.params.z - Z coordinate of the tile.
* @param {string} req.params.x - X coordinate of the tile.
* @param {string} req.params.y - Y coordinate of the tile.
* @param {string} req.params.format - Format of the tile.
* @returns {Promise<void>}
*/
app.get('/:id/:z/:x/:y.:format', async (req, res) => {
if (verbose) {
console.log(
`Handling tile request for: /data/%s/%s/%s/%s.%s`,
String(req.params.id).replace(/\n|\r/g, ''),
String(req.params.z).replace(/\n|\r/g, ''),
String(req.params.x).replace(/\n|\r/g, ''),
String(req.params.y).replace(/\n|\r/g, ''),
String(req.params.format).replace(/\n|\r/g, ''),
);
}
const item = repo[req.params.id];
if (!item) {
return res.sendStatus(404);
}
const tileJSONFormat = item.tileJSON.format;
const z = parseInt(req.params.z, 10);
const x = parseInt(req.params.x, 10);
const y = parseInt(req.params.y, 10);
if (isNaN(z) || isNaN(x) || isNaN(y)) {
return res.status(404).send('Invalid Tile');
}
let format = req.params.format;
if (format === options.pbfAlias) {
format = 'pbf';
}
if (
format !== tileJSONFormat &&
!(format === 'geojson' && tileJSONFormat === 'pbf')
) {
return res.status(404).send('Invalid format');
}
if (
z < item.tileJSON.minzoom ||
x < 0 ||
y < 0 ||
z > item.tileJSON.maxzoom ||
x >= Math.pow(2, z) ||
y >= Math.pow(2, z)
) {
return res.status(404).send('Out of bounds');
}
const fetchTile = await fetchTileData(
item.source,
item.sourceType,
z,
x,
y,
);
if (fetchTile == null) return res.status(204).send();
let data = fetchTile.data;
let headers = fetchTile.headers;
let isGzipped = data.slice(0, 2).indexOf(Buffer.from([0x1f, 0x8b])) === 0;
if (tileJSONFormat === 'pbf') {
if (options.dataDecoratorFunc) {
if (isGzipped) {
data = await gunzipP(data);
isGzipped = false;
}
data = options.dataDecoratorFunc(
req.params.id,
'data',
data,
z,
x,
y,
);
} }
const tileJSONFormat = item.tileJSON.format; }
const z = req.params.z | 0;
const x = req.params.x | 0; if (format === 'pbf') {
const y = req.params.y | 0; headers['Content-Type'] = 'application/x-protobuf';
let format = req.params.format; } else if (format === 'geojson') {
if (format === options.pbfAlias) { headers['Content-Type'] = 'application/json';
format = 'pbf'; const tile = new VectorTile(new Pbf(data));
const geojson = {
type: 'FeatureCollection',
features: [],
};
for (const layerName in tile.layers) {
const layer = tile.layers[layerName];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const featureGeoJSON = feature.toGeoJSON(x, y, z);
featureGeoJSON.properties.layer = layerName;
geojson.features.push(featureGeoJSON);
}
} }
if ( data = JSON.stringify(geojson);
format !== tileJSONFormat && }
!(format === 'geojson' && tileJSONFormat === 'pbf') if (headers) {
) { delete headers['ETag'];
return res.status(404).send('Invalid format'); }
headers['Content-Encoding'] = 'gzip';
res.set(headers);
if (!isGzipped) {
data = await gzipP(data);
}
return res.status(200).send(data);
});
/**
* Handles requests for elevation data.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {string} req.params.id - ID of the elevation data.
* @param {string} req.params.z - Z coordinate of the tile.
* @param {string} req.params.x - X coordinate of the tile (either integer or float).
* @param {string} req.params.y - Y coordinate of the tile (either integer or float).
* @returns {Promise<void>}
*/
app.get('/:id/elevation/:z/:x/:y', async (req, res, next) => {
try {
if (verbose) {
console.log(
`Handling elevation request for: /data/%s/elevation/%s/%s/%s`,
String(req.params.id).replace(/\n|\r/g, ''),
String(req.params.z).replace(/\n|\r/g, ''),
String(req.params.x).replace(/\n|\r/g, ''),
String(req.params.y).replace(/\n|\r/g, ''),
);
} }
if ( const item = repo?.[req.params.id];
z < item.tileJSON.minzoom || if (!item) return res.sendStatus(404);
0 || if (!item.source) return res.status(404).send('Missing source');
x < 0 || if (!item.tileJSON) return res.status(404).send('Missing tileJSON');
y < 0 || if (!item.sourceType) return res.status(404).send('Missing sourceType');
z > item.tileJSON.maxzoom || const { source, tileJSON, sourceType } = item;
x >= Math.pow(2, z) || if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') {
y >= Math.pow(2, z)
) {
return res.status(404).send('Out of bounds');
}
if (item.sourceType === 'pmtiles') {
let tileinfo = await getPMtilesTile(item.source, z, x, y);
if (tileinfo == undefined || tileinfo.data == undefined) {
return res.status(404).send('Not found');
} else {
let data = tileinfo.data;
let headers = tileinfo.header;
if (tileJSONFormat === 'pbf') {
if (options.dataDecoratorFunc) {
data = options.dataDecoratorFunc(id, 'data', data, z, x, y);
}
}
if (format === 'pbf') {
headers['Content-Type'] = 'application/x-protobuf';
} else if (format === 'geojson') {
headers['Content-Type'] = 'application/json';
const tile = new VectorTile(new Pbf(data));
const geojson = {
type: 'FeatureCollection',
features: [],
};
for (const layerName in tile.layers) {
const layer = tile.layers[layerName];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const featureGeoJSON = feature.toGeoJSON(x, y, z);
featureGeoJSON.properties.layer = layerName;
geojson.features.push(featureGeoJSON);
}
}
data = JSON.stringify(geojson);
}
delete headers['ETag']; // do not trust the tile ETag -- regenerate
headers['Content-Encoding'] = 'gzip';
res.set(headers);
data = await gzipP(data);
return res.status(200).send(data);
}
} else if (item.sourceType === 'mbtiles') {
item.source.getTile(z, x, y, async (err, data, headers) => {
let isGzipped;
if (err) {
if (/does not exist/.test(err.message)) {
return res.status(204).send();
} else {
return res
.status(500)
.header('Content-Type', 'text/plain')
.send(err.message);
}
} else {
if (data == null) {
return res.status(404).send('Not found');
} else {
if (tileJSONFormat === 'pbf') {
isGzipped =
data.slice(0, 2).indexOf(Buffer.from([0x1f, 0x8b])) === 0;
if (options.dataDecoratorFunc) {
if (isGzipped) {
data = await gunzipP(data);
isGzipped = false;
}
data = options.dataDecoratorFunc(id, 'data', data, z, x, y);
}
}
if (format === 'pbf') {
headers['Content-Type'] = 'application/x-protobuf';
} else if (format === 'geojson') {
headers['Content-Type'] = 'application/json';
if (isGzipped) {
data = await gunzipP(data);
isGzipped = false;
}
const tile = new VectorTile(new Pbf(data));
const geojson = {
type: 'FeatureCollection',
features: [],
};
for (const layerName in tile.layers) {
const layer = tile.layers[layerName];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const featureGeoJSON = feature.toGeoJSON(x, y, z);
featureGeoJSON.properties.layer = layerName;
geojson.features.push(featureGeoJSON);
}
}
data = JSON.stringify(geojson);
}
delete headers['ETag']; // do not trust the tile ETag -- regenerate
headers['Content-Encoding'] = 'gzip';
res.set(headers);
if (!isGzipped) {
data = await gzipP(data);
}
return res.status(200).send(data);
}
}
});
}
},
);
app.get(
'^/:id/elevation/:z([0-9]+)/:x([-.0-9]+)/:y([-.0-9]+)',
async (req, res, next) => {
try {
const item = repo?.[req.params.id];
if (!item) return res.sendStatus(404);
if (!item.source) return res.status(404).send('Missing source');
if (!item.tileJSON) return res.status(404).send('Missing tileJSON');
if (!item.sourceType)
return res.status(404).send('Missing sourceType');
const { source, tileJSON, sourceType } = item;
if (sourceType !== 'pmtiles' && sourceType !== 'mbtiles') {
return res
.status(400)
.send('Invalid sourceType. Must be pmtiles or mbtiles.');
}
const encoding = tileJSON?.encoding;
if (encoding == null) {
return res.status(400).send('Missing tileJSON.encoding');
} else if (encoding !== 'terrarium' && encoding !== 'mapbox') {
return res
.status(400)
.send('Invalid encoding. Must be terrarium or mapbox.');
}
const format = tileJSON?.format;
if (format == null) {
return res.status(400).send('Missing tileJSON.format');
} else if (format !== 'webp' && format !== 'png') {
return res.status(400).send('Invalid format. Must be webp or png.');
}
const z = parseInt(req.params.z, 10);
const x = parseFloat(req.params.x);
const y = parseFloat(req.params.y);
if (tileJSON.minzoom == null || tileJSON.maxzoom == null) {
return res.status(404).send(JSON.stringify(tileJSON));
}
const TILE_SIZE = tileJSON.tileSize || 512;
let bbox;
let xy;
var zoom = z;
if (Number.isInteger(x) && Number.isInteger(y)) {
const intX = parseInt(req.params.x, 10);
const intY = parseInt(req.params.y, 10);
if (
zoom < tileJSON.minzoom ||
zoom > tileJSON.maxzoom ||
intX < 0 ||
intY < 0 ||
intX >= Math.pow(2, zoom) ||
intY >= Math.pow(2, zoom)
) {
return res.status(404).send('Out of bounds');
}
xy = [intX, intY];
bbox = new SphericalMercator().bbox(intX, intY, zoom);
} else {
//no zoom limit with coordinates
if (zoom < tileJSON.minzoom) {
zoom = tileJSON.minzoom;
}
if (zoom > tileJSON.maxzoom) {
zoom = tileJSON.maxzoom;
}
bbox = [x, y, x + 0.1, y + 0.1];
const { minX, minY } = new SphericalMercator().xyz(bbox, zoom);
xy = [minX, minY];
}
let data;
if (sourceType === 'pmtiles') {
const tileinfo = await getPMtilesTile(source, zoom, xy[0], xy[1]);
if (!tileinfo?.data) return res.status(204).send();
data = tileinfo.data;
} else {
data = await new Promise((resolve, reject) => {
source.getTile(zoom, xy[0], xy[1], (err, tileData) => {
if (err) {
return /does not exist/.test(err.message)
? resolve(null)
: reject(err);
}
resolve(tileData);
});
});
}
if (data == null) return res.status(204).send();
if (!data) return res.status(404).send('Not found');
const image = new Image();
await new Promise(async (resolve, reject) => {
image.onload = async () => {
const canvas = createCanvas(TILE_SIZE, TILE_SIZE);
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const long = bbox[0];
const lat = bbox[1];
// calculate pixel coordinate of tile,
// see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
let siny = Math.sin((lat * Math.PI) / 180);
// Truncating to 0.9999 effectively limits latitude to 89.189. This is
// about a third of a tile past the edge of the world tile.
siny = Math.min(Math.max(siny, -0.9999), 0.9999);
const xWorld = TILE_SIZE * (0.5 + long / 360);
const yWorld =
TILE_SIZE *
(0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
const scale = 1 << zoom;
const xTile = Math.floor((xWorld * scale) / TILE_SIZE);
const yTile = Math.floor((yWorld * scale) / TILE_SIZE);
const xPixel = Math.floor(xWorld * scale) - xTile * TILE_SIZE;
const yPixel = Math.floor(yWorld * scale) - yTile * TILE_SIZE;
if (
xPixel < 0 ||
yPixel < 0 ||
xPixel >= TILE_SIZE ||
yPixel >= TILE_SIZE
) {
return reject('Pixel is out of bounds');
}
const imgdata = context.getImageData(xPixel, yPixel, 1, 1);
const red = imgdata.data[0];
const green = imgdata.data[1];
const blue = imgdata.data[2];
let elevation;
if (encoding === 'mapbox') {
elevation =
-10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
} else if (encoding === 'terrarium') {
elevation = red * 256 + green + blue / 256 - 32768;
} else {
elevation = 'invalid encoding';
}
resolve(
res.status(200).send({
z: zoom,
x: xy[0],
y: xy[1],
red,
green,
blue,
latitude: lat,
longitude: long,
elevation,
}),
);
};
image.onerror = (err) => reject(err);
if (format === 'webp') {
try {
const img = await sharp(data).toFormat('png').toBuffer();
image.src = img;
} catch (err) {
reject(err);
}
} else {
image.src = data;
}
});
} catch (err) {
return res return res
.status(500) .status(400)
.header('Content-Type', 'text/plain') .send('Invalid sourceType. Must be pmtiles or mbtiles.');
.send(err.message);
} }
}, const encoding = tileJSON?.encoding;
); if (encoding == null) {
return res.status(400).send('Missing tileJSON.encoding');
} else if (encoding !== 'terrarium' && encoding !== 'mapbox') {
return res
.status(400)
.send('Invalid encoding. Must be terrarium or mapbox.');
}
const format = tileJSON?.format;
if (format == null) {
return res.status(400).send('Missing tileJSON.format');
} else if (format !== 'webp' && format !== 'png') {
return res.status(400).send('Invalid format. Must be webp or png.');
}
const z = parseInt(req.params.z, 10);
const x = parseFloat(req.params.x);
const y = parseFloat(req.params.y);
if (tileJSON.minzoom == null || tileJSON.maxzoom == null) {
return res.status(404).send(JSON.stringify(tileJSON));
}
const TILE_SIZE = tileJSON.tileSize || 512;
let bbox;
let xy;
var zoom = z;
app.get('/:id.json', (req, res, next) => { if (Number.isInteger(x) && Number.isInteger(y)) {
const intX = parseInt(req.params.x, 10);
const intY = parseInt(req.params.y, 10);
if (
zoom < tileJSON.minzoom ||
zoom > tileJSON.maxzoom ||
intX < 0 ||
intY < 0 ||
intX >= Math.pow(2, zoom) ||
intY >= Math.pow(2, zoom)
) {
return res.status(404).send('Out of bounds');
}
xy = [intX, intY];
bbox = new SphericalMercator().bbox(intX, intY, zoom);
} else {
//no zoom limit with coordinates
if (zoom < tileJSON.minzoom) {
zoom = tileJSON.minzoom;
}
if (zoom > tileJSON.maxzoom) {
zoom = tileJSON.maxzoom;
}
bbox = [x, y, x + 0.1, y + 0.1];
const { minX, minY } = new SphericalMercator().xyz(bbox, zoom);
xy = [minX, minY];
}
const fetchTile = await fetchTileData(
source,
sourceType,
zoom,
xy[0],
xy[1],
);
if (fetchTile == null) return res.status(204).send();
let data = fetchTile.data;
const image = new Image();
await new Promise(async (resolve, reject) => {
image.onload = async () => {
const canvas = createCanvas(TILE_SIZE, TILE_SIZE);
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const long = bbox[0];
const lat = bbox[1];
// calculate pixel coordinate of tile,
// see https://developers.google.com/maps/documentation/javascript/examples/map-coordinates
let siny = Math.sin((lat * Math.PI) / 180);
// Truncating to 0.9999 effectively limits latitude to 89.189. This is
// about a third of a tile past the edge of the world tile.
siny = Math.min(Math.max(siny, -0.9999), 0.9999);
const xWorld = TILE_SIZE * (0.5 + long / 360);
const yWorld =
TILE_SIZE *
(0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
const scale = 1 << zoom;
const xTile = Math.floor((xWorld * scale) / TILE_SIZE);
const yTile = Math.floor((yWorld * scale) / TILE_SIZE);
const xPixel = Math.floor(xWorld * scale) - xTile * TILE_SIZE;
const yPixel = Math.floor(yWorld * scale) - yTile * TILE_SIZE;
if (
xPixel < 0 ||
yPixel < 0 ||
xPixel >= TILE_SIZE ||
yPixel >= TILE_SIZE
) {
return reject('Out of bounds Pixel');
}
const imgdata = context.getImageData(xPixel, yPixel, 1, 1);
const red = imgdata.data[0];
const green = imgdata.data[1];
const blue = imgdata.data[2];
let elevation;
if (encoding === 'mapbox') {
elevation = -10000 + (red * 256 * 256 + green * 256 + blue) * 0.1;
} else if (encoding === 'terrarium') {
elevation = red * 256 + green + blue / 256 - 32768;
} else {
elevation = 'invalid encoding';
}
resolve(
res.status(200).send({
z: zoom,
x: xy[0],
y: xy[1],
red,
green,
blue,
latitude: lat,
longitude: long,
elevation,
}),
);
};
image.onerror = (err) => reject(err);
if (format === 'webp') {
try {
const img = await sharp(data).toFormat('png').toBuffer();
image.src = img;
} catch (err) {
reject(err);
}
} else {
image.src = data;
}
});
} catch (err) {
return res
.status(500)
.header('Content-Type', 'text/plain')
.send(err.message);
}
});
/**
* Handles requests for tilejson for the data tiles.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {string} req.params.id - ID of the data source.
* @returns {Promise<void>}
*/
app.get('/:id.json', (req, res) => {
if (verbose) {
console.log(
`Handling tilejson request for: /data/%s.json`,
String(req.params.id).replace(/\n|\r/g, ''),
);
}
const item = repo[req.params.id]; const item = repo[req.params.id];
if (!item) { if (!item) {
return res.sendStatus(404); return res.sendStatus(404);
@ -377,7 +363,20 @@ export const serve_data = {
return app; return app;
}, },
add: async (options, repo, params, id, publicUrl) => { /**
* Adds a new data source to the repository.
* @param {object} options Configuration options.
* @param {object} repo Repository object.
* @param {object} params Parameters object.
* @param {string} id ID of the data source.
* @param {object} programOpts - An object containing the program options
* @param {string} programOpts.publicUrl Public URL for the data.
* @param {boolean} programOpts.verbose Whether verbose logging should be used.
* @param {Function} dataResolver Function to resolve data.
* @returns {Promise<void>}
*/
add: async function (options, repo, params, id, programOpts) {
const { publicUrl } = programOpts;
let inputFile; let inputFile;
let inputType; let inputType;
if (params.pmtiles) { if (params.pmtiles) {

View file

@ -4,7 +4,15 @@ import express from 'express';
import { getFontsPbf, listFonts } from './utils.js'; import { getFontsPbf, listFonts } from './utils.js';
export const serve_font = async (options, allowedFonts) => { /**
* Initializes and returns an Express app that serves font files.
* @param {object} options - Configuration options for the server.
* @param {object} allowedFonts - An object containing allowed fonts.
* @param {object} programOpts - An object containing the program options.
* @returns {Promise<express.Application>} - A promise that resolves to the Express app.
*/
export async function serve_font(options, allowedFonts, programOpts) {
const { verbose } = programOpts;
const app = express().disable('x-powered-by'); const app = express().disable('x-powered-by');
const lastModified = new Date().toUTCString(); const lastModified = new Date().toUTCString();
@ -13,31 +21,74 @@ export const serve_font = async (options, allowedFonts) => {
const existingFonts = {}; const existingFonts = {};
app.get( /**
'/fonts/:fontstack/:range([\\d]+-[\\d]+).pbf', * Handles requests for a font file.
async (req, res, next) => { * @param {object} req - Express request object.
const fontstack = decodeURI(req.params.fontstack); * @param {object} res - Express response object.
const range = req.params.range; * @param {string} req.params.fontstack - Name of the font stack.
* @param {string} req.params.range - The range of the font (e.g. 0-255).
* @returns {Promise<void>}
*/
app.get('/fonts/:fontstack/:range.pbf', async (req, res) => {
const sRange = String(req.params.range).replace(/\n|\r/g, '');
const sFontStack = String(decodeURI(req.params.fontstack)).replace(
/\n|\r/g,
'',
);
try { if (verbose) {
const concatenated = await getFontsPbf( console.log(
options.serveAllFonts ? null : allowedFonts, `Handling font request for: /fonts/%s/%s.pbf`,
fontPath, sFontStack,
fontstack, sRange,
range, );
existingFonts, }
);
res.header('Content-type', 'application/x-protobuf'); const modifiedSince = req.get('if-modified-since');
res.header('Last-Modified', lastModified); const cc = req.get('cache-control');
return res.send(concatenated); if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) {
} catch (err) { if (
res.status(400).header('Content-Type', 'text/plain').send(err); new Date(lastModified).getTime() === new Date(modifiedSince).getTime()
) {
return res.sendStatus(304);
} }
}, }
);
app.get('/fonts.json', (req, res, next) => { try {
const concatenated = await getFontsPbf(
options.serveAllFonts ? null : allowedFonts,
fontPath,
sFontStack,
sRange,
existingFonts,
);
res.header('Content-type', 'application/x-protobuf');
res.header('Last-Modified', lastModified);
return res.send(concatenated);
} catch (err) {
console.error(
`Error serving font: %s/%s.pbf, Error: %s`,
sFontStack,
sRange,
String(err),
);
return res
.status(400)
.header('Content-Type', 'text/plain')
.send('Error serving font');
}
});
/**
* Handles requests for a list of all available fonts.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @returns {void}
*/
app.get('/fonts.json', (req, res) => {
if (verbose) {
console.log('Handling list font request for /fonts.json');
}
res.header('Content-type', 'application/json'); res.header('Content-type', 'application/json');
return res.send( return res.send(
Object.keys(options.serveAllFonts ? existingFonts : allowedFonts).sort(), Object.keys(options.serveAllFonts ? existingFonts : allowedFonts).sort(),
@ -47,4 +98,4 @@ export const serve_font = async (options, allowedFonts) => {
const fonts = await listFonts(options.paths.fonts); const fonts = await listFonts(options.paths.fonts);
Object.assign(existingFonts, fonts); Object.assign(existingFonts, fonts);
return app; return app;
}; }

View file

@ -3,7 +3,7 @@
'use strict'; 'use strict';
export const serve_rendered = { export const serve_rendered = {
init: (options, repo) => {}, init: (options, repo, programOpts) => {},
add: (options, repo, params, id, publicUrl, dataResolver) => {}, add: (options, repo, params, id, programOpts, dataResolver) => {},
remove: (repo, id) => {}, remove: (repo, id) => {},
}; };

File diff suppressed because it is too large Load diff

View file

@ -7,85 +7,209 @@ import clone from 'clone';
import express from 'express'; import express from 'express';
import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec'; import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec';
import { fixUrl, allowedOptions } from './utils.js'; import {
allowedSpriteScales,
allowedSpriteFormats,
fixUrl,
readFile,
} from './utils.js';
const httpTester = /^https?:\/\//i; const httpTester = /^https?:\/\//i;
const allowedSpriteScales = allowedOptions(['', '@2x', '@3x']);
const allowedSpriteFormats = allowedOptions(['png', 'json']);
export const serve_style = { export const serve_style = {
init: (options, repo) => { /**
* Initializes the serve_style module.
* @param {object} options Configuration options.
* @param {object} repo Repository object.
* @param {object} programOpts - An object containing the program options.
* @returns {express.Application} The initialized Express application.
*/
init: function (options, repo, programOpts) {
const { verbose } = programOpts;
const app = express().disable('x-powered-by'); const app = express().disable('x-powered-by');
/**
* Handles requests for style.json files.
* @param {express.Request} req - Express request object.
* @param {express.Response} res - Express response object.
* @param {express.NextFunction} next - Express next function.
* @param {string} req.params.id - ID of the style.
* @returns {Promise<void>}
*/
app.get('/:id/style.json', (req, res, next) => { app.get('/:id/style.json', (req, res, next) => {
const item = repo[req.params.id]; const { id } = req.params;
if (!item) { if (verbose) {
return res.sendStatus(404); console.log(
'Handling style request for: /styles/%s/style.json',
String(id).replace(/\n|\r/g, ''),
);
} }
const styleJSON_ = clone(item.styleJSON); try {
for (const name of Object.keys(styleJSON_.sources)) { const item = repo[id];
const source = styleJSON_.sources[name]; if (!item) {
source.url = fixUrl(req, source.url, item.publicUrl); return res.sendStatus(404);
if (typeof source.data == 'string') {
source.data = fixUrl(req, source.data, item.publicUrl);
} }
} const styleJSON_ = clone(item.styleJSON);
// mapbox-gl-js viewer cannot handle sprite urls with query for (const name of Object.keys(styleJSON_.sources)) {
if (styleJSON_.sprite) { const source = styleJSON_.sources[name];
if (Array.isArray(styleJSON_.sprite)) { source.url = fixUrl(req, source.url, item.publicUrl);
styleJSON_.sprite.forEach((spriteItem) => { if (typeof source.data == 'string') {
spriteItem.url = fixUrl(req, spriteItem.url, item.publicUrl); source.data = fixUrl(req, source.data, item.publicUrl);
}); }
} else {
styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl);
} }
} if (styleJSON_.sprite) {
if (styleJSON_.glyphs) { if (Array.isArray(styleJSON_.sprite)) {
styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl); styleJSON_.sprite.forEach((spriteItem) => {
} spriteItem.url = fixUrl(req, spriteItem.url, item.publicUrl);
return res.send(styleJSON_);
});
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 { } else {
return res.status(400).send('Bad Sprite ID or Scale'); styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl);
} }
} else { }
return res.status(400).send('Bad Sprite Format'); if (styleJSON_.glyphs) {
styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl);
}
return res.send(styleJSON_);
} catch (e) {
next(e);
}
});
/**
* Handles GET requests for sprite images and JSON files.
* @param {express.Request} req - Express request object.
* @param {express.Response} res - Express response object.
* @param {express.NextFunction} next - Express next function.
* @param {string} req.params.id - ID of the sprite.
* @param {string} [req.params.spriteID='default'] - ID of the specific sprite image, defaults to 'default'.
* @param {string} [req.params.scale] - Scale of the sprite image, defaults to ''.
* @param {string} req.params.format - Format of the sprite file, 'png' or 'json'.
* @returns {Promise<void>}
*/
app.get(
`/:id/sprite{/:spriteID}{@:scale}{.:format}`,
async (req, res, next) => {
const { spriteID = 'default', id, format, scale } = req.params;
const sanitizedId = String(id).replace(/\n|\r/g, '');
const sanitizedScale = scale ? String(scale).replace(/\n|\r/g, '') : '';
const sanitizedSpriteID = String(spriteID).replace(/\n|\r/g, '');
const sanitizedFormat = format
? '.' + String(format).replace(/\n|\r/g, '')
: '';
if (verbose) {
console.log(
`Handling sprite request for: /styles/%s/sprite/%s%s%s`,
sanitizedId,
sanitizedSpriteID,
sanitizedScale,
sanitizedFormat,
);
}
const item = repo[id];
const validatedFormat = allowedSpriteFormats(format);
if (!item || !validatedFormat) {
if (verbose)
console.error(
`Sprite item or format not found for: /styles/%s/sprite/%s%s%s`,
sanitizedId,
sanitizedSpriteID,
sanitizedScale,
sanitizedFormat,
);
return res.sendStatus(404);
}
const sprite = item.spritePaths.find(
(sprite) => sprite.id === spriteID,
);
const spriteScale = allowedSpriteScales(scale);
if (!sprite || spriteScale === null) {
if (verbose)
console.error(
`Bad Sprite ID or Scale for: /styles/%s/sprite/%s%s%s`,
sanitizedId,
sanitizedSpriteID,
sanitizedScale,
sanitizedFormat,
);
return res.status(400).send('Bad Sprite ID or Scale');
}
const modifiedSince = req.get('if-modified-since');
const cc = req.get('cache-control');
if (modifiedSince && (!cc || cc.indexOf('no-cache') === -1)) {
if (
new Date(item.lastModified).getTime() ===
new Date(modifiedSince).getTime()
) {
return res.sendStatus(304);
}
}
const sanitizedSpritePath = sprite.path.replace(/^(\.\.\/)+/, '');
const filename = `${sanitizedSpritePath}${spriteScale}.${validatedFormat}`;
if (verbose) console.log(`Loading sprite from: %s`, filename);
try {
const data = await readFile(filename);
if (validatedFormat === 'json') {
res.header('Content-type', 'application/json');
} else if (validatedFormat === 'png') {
res.header('Content-type', 'image/png');
}
if (verbose)
console.log(
`Responding with sprite data for /styles/%s/sprite/%s%s%s`,
sanitizedId,
sanitizedSpriteID,
sanitizedScale,
sanitizedFormat,
);
res.set({ 'Last-Modified': item.lastModified });
return res.send(data);
} catch (err) {
if (verbose) {
console.error(
'Sprite load error: %s, Error: %s',
filename,
String(err),
);
}
return res.sendStatus(404);
} }
}, },
); );
return app; return app;
}, },
remove: (repo, id) => { /**
* Removes an item from the repository.
* @param {object} repo Repository object.
* @param {string} id ID of the item to remove.
* @returns {void}
*/
remove: function (repo, id) {
delete repo[id]; delete repo[id];
}, },
add: (options, repo, params, id, publicUrl, reportTiles, reportFont) => { /**
* Adds a new style to the repository.
* @param {object} options Configuration options.
* @param {object} repo Repository object.
* @param {object} params Parameters object containing style path
* @param {string} id ID of the style.
* @param {object} programOpts - An object containing the program options
* @param {Function} reportTiles Function for reporting tile sources.
* @param {Function} reportFont Function for reporting font usage
* @returns {boolean} true if add is succesful
*/
add: function (
options,
repo,
params,
id,
programOpts,
reportTiles,
reportFont,
) {
const { publicUrl } = programOpts;
const styleFile = path.resolve(options.paths.styles, params.style); const styleFile = path.resolve(options.paths.styles, params.style);
let styleFileData; let styleFileData;
@ -199,6 +323,7 @@ export const serve_style = {
spritePaths, spritePaths,
publicUrl, publicUrl,
name: styleJSON.name, name: styleJSON.name,
lastModified: new Date().toUTCString(),
}; };
return true; return true;

View file

@ -1,9 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
'use strict'; 'use strict';
import os from 'os';
process.env.UV_THREADPOOL_SIZE = Math.ceil(Math.max(4, os.cpus().length * 1.5));
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'path'; import path from 'path';
import fnv1a from '@sindresorhus/fnv1a'; import fnv1a from '@sindresorhus/fnv1a';
@ -19,7 +16,12 @@ import morgan from 'morgan';
import { serve_data } from './serve_data.js'; import { serve_data } from './serve_data.js';
import { serve_style } from './serve_style.js'; import { serve_style } from './serve_style.js';
import { serve_font } from './serve_font.js'; import { serve_font } from './serve_font.js';
import { getTileUrls, getPublicUrl, isValidHttpUrl } from './utils.js'; import {
allowedTileSizes,
getTileUrls,
getPublicUrl,
isValidHttpUrl,
} from './utils.js';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -34,10 +36,11 @@ const serve_rendered = (
).serve_rendered; ).serve_rendered;
/** /**
* * Starts the server.
* @param opts * @param {object} opts - Configuration options for the server.
* @returns {Promise<object>} - A promise that resolves to the server object.
*/ */
function start(opts) { async function start(opts) {
console.log('Starting server'); console.log('Starting server');
const app = express().disable('x-powered-by'); const app = express().disable('x-powered-by');
@ -73,7 +76,7 @@ function start(opts) {
config = JSON.parse(fs.readFileSync(configPath, 'utf8')); config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) { } catch (e) {
console.log('ERROR: Config file not found or invalid!'); console.log('ERROR: Config file not found or invalid!');
console.log(' See README.md for instructions and sample data.'); console.log(' See README.md for instructions and sample data.');
process.exit(1); process.exit(1);
} }
} }
@ -116,8 +119,9 @@ function start(opts) {
* Recursively get all files within a directory. * Recursively get all files within a directory.
* Inspired by https://stackoverflow.com/a/45130990/10133863 * Inspired by https://stackoverflow.com/a/45130990/10133863
* @param {string} directory Absolute path to a directory to get files from. * @param {string} directory Absolute path to a directory to get files from.
* @returns {Promise<string[]>} - A promise that resolves to an array of file paths relative to the icon directory.
*/ */
const getFiles = async (directory) => { async function getFiles(directory) {
// Fetch all entries of the directory and attach type information // Fetch all entries of the directory and attach type information
const dirEntries = await fs.promises.readdir(directory, { const dirEntries = await fs.promises.readdir(directory, {
withFileTypes: true, withFileTypes: true,
@ -136,7 +140,7 @@ function start(opts) {
// Flatten the list of files to a single array // Flatten the list of files to a single array
return files.flat(); return files.flat();
}; }
// Load all available icons into a settings object // Load all available icons into a settings object
startupPromises.push( startupPromises.push(
@ -159,18 +163,25 @@ function start(opts) {
app.use(cors()); app.use(cors());
} }
app.use('/data/', serve_data.init(options, serving.data)); app.use('/data/', serve_data.init(options, serving.data, opts));
app.use('/files/', express.static(paths.files)); app.use('/files/', express.static(paths.files));
app.use('/styles/', serve_style.init(options, serving.styles)); app.use('/styles/', serve_style.init(options, serving.styles, opts));
if (!isLight) { if (!isLight) {
startupPromises.push( startupPromises.push(
serve_rendered.init(options, serving.rendered).then((sub) => { serve_rendered.init(options, serving.rendered, opts).then((sub) => {
app.use('/styles/', sub); app.use('/styles/', sub);
}), }),
); );
} }
/**
const addStyle = (id, item, allowMoreData, reportFonts) => { * Adds a style to the server.
* @param {string} id - The ID of the style.
* @param {object} item - The style configuration object.
* @param {boolean} allowMoreData - Whether to allow adding more data sources.
* @param {boolean} reportFonts - Whether to report fonts.
* @returns {void}
*/
function addStyle(id, item, allowMoreData, reportFonts) {
let success = true; let success = true;
if (item.serve_data !== false) { if (item.serve_data !== false) {
success = serve_style.add( success = serve_style.add(
@ -178,7 +189,7 @@ function start(opts) {
serving.styles, serving.styles,
item, item,
id, id,
opts.publicUrl, opts,
(styleSourceId, protocol) => { (styleSourceId, protocol) => {
let dataItemId; let dataItemId;
for (const id of Object.keys(data)) { for (const id of Object.keys(data)) {
@ -235,7 +246,7 @@ function start(opts) {
serving.rendered, serving.rendered,
item, item,
id, id,
opts.publicUrl, opts,
function dataResolver(styleSourceId) { function dataResolver(styleSourceId) {
let fileType; let fileType;
let inputFile; let inputFile;
@ -261,7 +272,7 @@ function start(opts) {
item.serve_rendered = false; item.serve_rendered = false;
} }
} }
}; }
for (const id of Object.keys(config.styles || {})) { for (const id of Object.keys(config.styles || {})) {
const item = config.styles[id]; const item = config.styles[id];
@ -272,13 +283,11 @@ function start(opts) {
addStyle(id, item, true, true); addStyle(id, item, true, true);
} }
startupPromises.push( startupPromises.push(
serve_font(options, serving.fonts).then((sub) => { serve_font(options, serving.fonts, opts).then((sub) => {
app.use('/', sub); app.use('/', sub);
}), }),
); );
for (const id of Object.keys(data)) { for (const id of Object.keys(data)) {
const item = data[id]; const item = data[id];
const fileType = Object.keys(data[id])[0]; const fileType = Object.keys(data[id])[0];
@ -288,12 +297,8 @@ function start(opts) {
); );
continue; continue;
} }
startupPromises.push(serve_data.add(options, serving.data, item, id, opts));
startupPromises.push(
serve_data.add(options, serving.data, item, id, opts.publicUrl),
);
} }
if (options.serveAllStyles) { if (options.serveAllStyles) {
fs.readdir(options.paths.styles, { withFileTypes: true }, (err, files) => { fs.readdir(options.paths.styles, { withFileTypes: true }, (err, files) => {
if (err) { if (err) {
@ -333,7 +338,13 @@ function start(opts) {
} }
}); });
} }
/**
* Handles requests for a list of available styles.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {string} [req.query.key] - Optional API key.
* @returns {void}
*/
app.get('/styles.json', (req, res, next) => { app.get('/styles.json', (req, res, next) => {
const result = []; const result = [];
const query = req.query.key const query = req.query.key
@ -354,7 +365,15 @@ function start(opts) {
res.send(result); res.send(result);
}); });
const addTileJSONs = (arr, req, type, tileSize) => { /**
* Adds TileJSON metadata to an array.
* @param {Array} arr - The array to add TileJSONs to
* @param {object} req - The express request object.
* @param {string} type - The type of resource
* @param {number} tileSize - The tile size.
* @returns {Array} - An array of TileJSON objects.
*/
function addTileJSONs(arr, req, type, tileSize) {
for (const id of Object.keys(serving[type])) { for (const id of Object.keys(serving[type])) {
const info = clone(serving[type][id].tileJSON); const info = clone(serving[type][id].tileJSON);
let path = ''; let path = '';
@ -377,20 +396,42 @@ function start(opts) {
arr.push(info); arr.push(info);
} }
return arr; return arr;
}; }
app.get('/(:tileSize(256|512)/)?rendered.json', (req, res, next) => { /**
const tileSize = parseInt(req.params.tileSize, 10) || undefined; * Handles requests for a rendered tilejson endpoint.
res.send(addTileJSONs([], req, 'rendered', tileSize)); * @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {string} req.params.tileSize - Optional tile size parameter.
* @returns {void}
*/
app.get('{/:tileSize}/rendered.json', (req, res, next) => {
const tileSize = allowedTileSizes(req.params['tileSize']);
res.send(addTileJSONs([], req, 'rendered', parseInt(tileSize, 10)));
}); });
app.get('/data.json', (req, res, next) => {
/**
* Handles requests for a data tilejson endpoint.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @returns {void}
*/
app.get('/data.json', (req, res) => {
res.send(addTileJSONs([], req, 'data', undefined)); res.send(addTileJSONs([], req, 'data', undefined));
}); });
app.get('/(:tileSize(256|512)/)?index.json', (req, res, next) => {
const tileSize = parseInt(req.params.tileSize, 10) || undefined; /**
* Handles requests for a combined rendered and data tilejson endpoint.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {string} req.params.tileSize - Optional tile size parameter.
* @returns {void}
*/
app.get('{/:tileSize}/index.json', (req, res, next) => {
const tileSize = allowedTileSizes(req.params['tileSize']);
res.send( res.send(
addTileJSONs( addTileJSONs(
addTileJSONs([], req, 'rendered', tileSize), addTileJSONs([], req, 'rendered', parseInt(tileSize, 10)),
req, req,
'data', 'data',
undefined, undefined,
@ -403,7 +444,15 @@ function start(opts) {
app.use('/', express.static(path.join(__dirname, '../public/resources'))); app.use('/', express.static(path.join(__dirname, '../public/resources')));
const templates = path.join(__dirname, '../public/templates'); const templates = path.join(__dirname, '../public/templates');
const serveTemplate = (urlPath, template, dataGetter) => {
/**
* Serves a Handlebars template.
* @param {string} urlPath - The URL path to serve the template at
* @param {string} template - The name of the template file
* @param {Function} dataGetter - A function to get data to be passed to the template.
* @returns {void}
*/
function serveTemplate(urlPath, template, dataGetter) {
let templateFile = `${templates}/${template}.tmpl`; let templateFile = `${templates}/${template}.tmpl`;
if (template === 'index') { if (template === 'index') {
if (options.frontPage === false) { if (options.frontPage === false) {
@ -415,24 +464,17 @@ function start(opts) {
templateFile = path.resolve(paths.root, options.frontPage); templateFile = path.resolve(paths.root, options.frontPage);
} }
} }
startupPromises.push( try {
new Promise((resolve, reject) => { const content = fs.readFileSync(templateFile, 'utf-8');
fs.readFile(templateFile, (err, content) => { const compiled = handlebars.compile(content.toString());
if (err) { app.get(urlPath, (req, res, next) => {
err = new Error(`Template not found: ${err.message}`); if (opts.verbose) {
reject(err); console.log(`Serving template at path: ${urlPath}`);
return; }
} let data = {};
const compiled = handlebars.compile(content.toString()); if (dataGetter) {
data = dataGetter(req);
app.use(urlPath, (req, res, next) => { if (data) {
let data = {};
if (dataGetter) {
data = dataGetter(req);
if (!data) {
return res.status(404).send('Not found');
}
}
data['server_version'] = data['server_version'] =
`${packageJson.name} v${packageJson.version}`; `${packageJson.name} v${packageJson.version}`;
data['public_url'] = opts.publicUrl || '/'; data['public_url'] = opts.publicUrl || '/';
@ -445,14 +487,27 @@ function start(opts) {
: ''; : '';
if (template === 'wmts') res.set('Content-Type', 'text/xml'); if (template === 'wmts') res.set('Content-Type', 'text/xml');
return res.status(200).send(compiled(data)); return res.status(200).send(compiled(data));
}); } else {
resolve(); if (opts.verbose) {
}); console.log(`Forwarding request for: ${urlPath} to next route`);
}), }
); next('route');
}; }
}
});
} catch (err) {
console.error(`Error reading template file: ${templateFile}`, err);
throw new Error(`Template not found: ${err.message}`); //throw an error so that the server doesnt start
}
}
serveTemplate('/$', 'index', (req) => { /**
* Handles requests for the index page, providing a list of available styles and data.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @returns {void}
*/
serveTemplate('/', 'index', (req) => {
let styles = {}; let styles = {};
for (const id of Object.keys(serving.styles || {})) { for (const id of Object.keys(serving.styles || {})) {
let style = { let style = {
@ -464,11 +519,15 @@ function start(opts) {
if (style.serving_rendered) { if (style.serving_rendered) {
const { center } = style.serving_rendered.tileJSON; const { center } = style.serving_rendered.tileJSON;
if (center) { if (center) {
style.viewer_hash = `#${center[2]}/${center[1].toFixed(5)}/${center[0].toFixed(5)}`; style.viewer_hash = `#${center[2]}/${center[1].toFixed(
5,
)}/${center[0].toFixed(5)}`;
const centerPx = mercator.px([center[0], center[1]], center[2]); const centerPx = mercator.px([center[0], center[1]], center[2]);
// Set thumbnail default size to be 256px x 256px // Set thumbnail default size to be 256px x 256px
style.thumbnail = `${Math.floor(center[2])}/${Math.floor(centerPx[0] / 256)}/${Math.floor(centerPx[1] / 256)}.png`; style.thumbnail = `${Math.floor(center[2])}/${Math.floor(
centerPx[0] / 256,
)}/${Math.floor(centerPx[1] / 256)}.png`;
} }
const tileSize = 512; const tileSize = 512;
@ -484,7 +543,6 @@ function start(opts) {
styles[id] = style; styles[id] = style;
} }
let datas = {}; let datas = {};
for (const id of Object.keys(serving.data || {})) { for (const id of Object.keys(serving.data || {})) {
let data = Object.assign({}, serving.data[id]); let data = Object.assign({}, serving.data[id]);
@ -525,7 +583,9 @@ function start(opts) {
} }
if (center) { if (center) {
const centerPx = mercator.px([center[0], center[1]], center[2]); const centerPx = mercator.px([center[0], center[1]], center[2]);
data.thumbnail = `${Math.floor(center[2])}/${Math.floor(centerPx[0] / 256)}/${Math.floor(centerPx[1] / 256)}.${tileJSON.format}`; data.thumbnail = `${Math.floor(center[2])}/${Math.floor(
centerPx[0] / 256,
)}/${Math.floor(centerPx[1] / 256)}.${tileJSON.format}`;
} }
} }
@ -542,24 +602,28 @@ function start(opts) {
} }
data.formatted_filesize = `${size.toFixed(2)} ${suffix}`; data.formatted_filesize = `${size.toFixed(2)} ${suffix}`;
} }
datas[id] = data; datas[id] = data;
} }
return { return {
styles: Object.keys(styles).length ? styles : null, styles: Object.keys(styles).length ? styles : null,
data: Object.keys(datas).length ? datas : null, data: Object.keys(datas).length ? datas : null,
}; };
}); });
serveTemplate('/styles/:id/$', 'viewer', (req) => { /**
* Handles requests for a map viewer template for a specific style.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {string} req.params.id - ID of the style.
* @returns {void}
*/
serveTemplate('/styles/:id/', 'viewer', (req) => {
const { id } = req.params; const { id } = req.params;
const style = clone(((serving.styles || {})[id] || {}).styleJSON); const style = clone(((serving.styles || {})[id] || {}).styleJSON);
if (!style) { if (!style) {
return null; return null;
} }
return { return {
...style, ...style,
id, id,
@ -569,11 +633,13 @@ function start(opts) {
}; };
}); });
/* /**
app.use('/rendered/:id/$', function(req, res, next) { * Handles requests for a Web Map Tile Service (WMTS) XML template.
return res.redirect(301, '/styles/' + req.params.id + '/'); * @param {object} req - Express request object.
}); * @param {object} res - Express response object.
*/ * @param {string} req.params.id - ID of the style.
* @returns {void}
*/
serveTemplate('/styles/:id/wmts.xml', 'wmts', (req) => { serveTemplate('/styles/:id/wmts.xml', 'wmts', (req) => {
const { id } = req.params; const { id } = req.params;
const wmts = clone((serving.styles || {})[id]); const wmts = clone((serving.styles || {})[id]);
@ -605,9 +671,16 @@ function start(opts) {
}; };
}); });
serveTemplate('^/data/(:preview(preview)/)?:id/$', 'data', (req) => { /**
const id = req.params.id; * Handles requests for a data view template for a specific data source.
const preview = req.params.preview || undefined; * @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {string} req.params.id - ID of the data source.
* @param {string} [req.params.view] - Optional view type.
* @returns {void}
*/
serveTemplate('/data{/:view}/:id/', 'data', (req) => {
const { id, view } = req.params;
const data = serving.data[id]; const data = serving.data[id];
if (!data) { if (!data) {
@ -616,7 +689,8 @@ function start(opts) {
const is_terrain = const is_terrain =
(data.tileJSON.encoding === 'terrarium' || (data.tileJSON.encoding === 'terrarium' ||
data.tileJSON.encoding === 'mapbox') && data.tileJSON.encoding === 'mapbox') &&
preview === 'preview'; view === 'preview';
return { return {
...data, ...data,
id, id,
@ -633,7 +707,13 @@ function start(opts) {
startupComplete = true; startupComplete = true;
}); });
app.get('/health', (req, res, next) => { /**
* Handles requests to see the health of the server.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @returns {void}
*/
app.get('/health', (req, res) => {
if (startupComplete) { if (startupComplete) {
return res.status(200).send('OK'); return res.status(200).send('OK');
} else { } else {
@ -662,10 +742,10 @@ function start(opts) {
startupPromise, startupPromise,
}; };
} }
/** /**
* Stop the server gracefully * Stop the server gracefully
* @param {string} signal Name of the received signal * @param {string} signal Name of the received signal
* @returns {void}
*/ */
function stopGracefully(signal) { function stopGracefully(signal) {
console.log(`Caught signal ${signal}, stopping gracefully`); console.log(`Caught signal ${signal}, stopping gracefully`);
@ -673,11 +753,12 @@ function stopGracefully(signal) {
} }
/** /**
* * Starts and manages the server
* @param opts * @param {object} opts - Configuration options for the server.
* @returns {Promise<object>} - A promise that resolves to the running server
*/ */
export function server(opts) { export async function server(opts) {
const running = start(opts); const running = await start(opts);
running.startupPromise.catch((err) => { running.startupPromise.catch((err) => {
console.error(err.message); console.error(err.message);
@ -697,6 +778,5 @@ export function server(opts) {
running.app = restarted.app; running.app = restarted.app;
}); });
}); });
return running; return running;
} }

View file

@ -6,12 +6,18 @@ import fs from 'node:fs';
import clone from 'clone'; import clone from 'clone';
import { combine } from '@jsse/pbfont'; import { combine } from '@jsse/pbfont';
import { existsP } from './promises.js'; import { existsP } from './promises.js';
import { getPMtilesTile } from './pmtiles_adapter.js';
export const allowedSpriteFormats = allowedOptions(['png', 'json']);
export const allowedTileSizes = allowedOptions(['256', '512']);
/** /**
* Restrict user input to an allowed set of options. * Restrict user input to an allowed set of options.
* @param opts * @param {string[]} opts - An array of allowed option strings.
* @param root0 * @param {object} [config] - Optional configuration object.
* @param root0.defaultValue * @param {string} [config.defaultValue] - The default value to return if input doesn't match.
* @returns {function(string): string} - A function that takes a value and returns it if valid or a default.
*/ */
export function allowedOptions(opts, { defaultValue } = {}) { export function allowedOptions(opts, { defaultValue } = {}) {
const values = Object.fromEntries(opts.map((key) => [key, key])); const values = Object.fromEntries(opts.map((key) => [key, key]));
@ -19,10 +25,52 @@ export function allowedOptions(opts, { defaultValue } = {}) {
} }
/** /**
* Replace local:// urls with public http(s):// urls * Parses a scale string to a number.
* @param req * @param {string} scale The scale string (e.g., '2x', '4x').
* @param url * @param {number} maxScale Maximum allowed scale digit.
* @param publicUrl * @returns {number|null} The parsed scale as a number or null if invalid.
*/
export function allowedScales(scale, maxScale = 9) {
if (scale === undefined) {
return 1;
}
// eslint-disable-next-line security/detect-non-literal-regexp
const regex = new RegExp(`^[2-${maxScale}]x$`);
if (!regex.test(scale)) {
return null;
}
return parseInt(scale.slice(0, -1), 10);
}
/**
* Checks if a string is a valid sprite scale and returns it if it is within the allowed range, and null if it does not conform.
* @param {string} scale - The scale string to validate (e.g., '2x', '3x').
* @param {number} [maxScale] - The maximum scale value. If no value is passed in, it defaults to a value of 3.
* @returns {string|null} - The valid scale string or null if invalid.
*/
export function allowedSpriteScales(scale, maxScale = 3) {
if (!scale) {
return '';
}
const match = scale?.match(/^([2-9]\d*)x$/);
if (!match) {
return null;
}
const parsedScale = parseInt(match[1], 10);
if (parsedScale <= maxScale) {
return `@${parsedScale}x`;
}
return null;
}
/**
* Replaces local:// URLs with public http(s):// URLs.
* @param {object} req - Express request object.
* @param {string} url - The URL string to fix.
* @param {string} publicUrl - The public URL prefix to use for replacements.
* @returns {string} - The fixed URL string.
*/ */
export function fixUrl(req, url, publicUrl) { export function fixUrl(req, url, publicUrl) {
if (!url || typeof url !== 'string' || url.indexOf('local://') !== 0) { if (!url || typeof url !== 'string' || url.indexOf('local://') !== 0) {
@ -40,12 +88,11 @@ export function fixUrl(req, url, publicUrl) {
} }
/** /**
* Generate new URL object * Generates a new URL object from the Express request.
* @param req * @param {object} req - Express request object.
* @params {object} req - Express request * @returns {URL} - URL object with correct host and optionally path.
* @returns {URL} object
*/ */
const getUrlObject = (req) => { function getUrlObject(req) {
const urlObject = new URL(`${req.protocol}://${req.headers.host}/`); const urlObject = new URL(`${req.protocol}://${req.headers.host}/`);
// support overriding hostname by sending X-Forwarded-Host http header // support overriding hostname by sending X-Forwarded-Host http header
urlObject.hostname = req.hostname; urlObject.hostname = req.hostname;
@ -62,16 +109,33 @@ const getUrlObject = (req) => {
urlObject.pathname = path.posix.join(xForwardedPath, urlObject.pathname); urlObject.pathname = path.posix.join(xForwardedPath, urlObject.pathname);
} }
return urlObject; return urlObject;
}; }
export const getPublicUrl = (publicUrl, req) => { /**
* Gets the public URL, either from a provided publicUrl or generated from the request.
* @param {string} publicUrl - The optional public URL to use.
* @param {object} req - The Express request object.
* @returns {string} - The final public URL string.
*/
export function getPublicUrl(publicUrl, req) {
if (publicUrl) { if (publicUrl) {
return publicUrl; return publicUrl;
} }
return getUrlObject(req).toString(); return getUrlObject(req).toString();
}; }
export const getTileUrls = ( /**
* Generates an array of tile URLs based on given parameters.
* @param {object} req - Express request object.
* @param {string | string[]} domains - Domain(s) to use for tile URLs.
* @param {string} path - The base path for the tiles.
* @param {number} [tileSize] - The size of the tile (optional).
* @param {string} format - The format of the tiles (e.g., 'png', 'jpg').
* @param {string} publicUrl - The public URL to use (if not using domains).
* @param {object} [aliases] - Aliases for format extensions.
* @returns {string[]} An array of tile URL strings.
*/
export function getTileUrls(
req, req,
domains, domains,
path, path,
@ -79,7 +143,7 @@ export const getTileUrls = (
format, format,
publicUrl, publicUrl,
aliases, aliases,
) => { ) {
const urlObject = getUrlObject(req); const urlObject = getUrlObject(req);
if (domains) { if (domains) {
if (domains.constructor === String && domains.length > 0) { if (domains.constructor === String && domains.length > 0) {
@ -144,9 +208,14 @@ export const getTileUrls = (
} }
return uris; return uris;
}; }
export const fixTileJSONCenter = (tileJSON) => { /**
* Fixes the center in the tileJSON if no center is available.
* @param {object} tileJSON - The tileJSON object to process.
* @returns {void}
*/
export function fixTileJSONCenter(tileJSON) {
if (tileJSON.bounds && !tileJSON.center) { if (tileJSON.bounds && !tileJSON.center) {
const fitWidth = 1024; const fitWidth = 1024;
const tiles = fitWidth / 256; const tiles = fitWidth / 256;
@ -159,59 +228,122 @@ export const fixTileJSONCenter = (tileJSON) => {
), ),
]; ];
} }
}; }
const getFontPbf = (allowedFonts, fontPath, name, range, fallbacks) => /**
new Promise((resolve, reject) => { * Reads a file and returns a Promise with the file data.
if (!allowedFonts || (allowedFonts[name] && fallbacks)) { * @param {string} filename - Path to the file to read.
const filename = path.join(fontPath, name, `${range}.pbf`); * @returns {Promise<Buffer>} - A Promise that resolves with the file data as a Buffer or rejects with an error.
if (!fallbacks) { */
fallbacks = clone(allowedFonts || {}); export function readFile(filename) {
return new Promise((resolve, reject) => {
const sanitizedFilename = path.normalize(filename); // Normalize path, remove ..
// eslint-disable-next-line security/detect-non-literal-fs-filename
fs.readFile(String(sanitizedFilename), (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
} }
delete fallbacks[name]; });
fs.readFile(filename, (err, data) => {
if (err) {
console.error(`ERROR: Font not found: ${name}`);
if (fallbacks && Object.keys(fallbacks).length) {
let fallbackName;
let fontStyle = name.split(' ').pop();
if (['Regular', 'Bold', 'Italic'].indexOf(fontStyle) < 0) {
fontStyle = 'Regular';
}
fallbackName = `Noto Sans ${fontStyle}`;
if (!fallbacks[fallbackName]) {
fallbackName = `Open Sans ${fontStyle}`;
if (!fallbacks[fallbackName]) {
fallbackName = Object.keys(fallbacks)[0];
}
}
console.error(`ERROR: Trying to use ${fallbackName} as a fallback`);
delete fallbacks[fallbackName];
getFontPbf(null, fontPath, fallbackName, range, fallbacks).then(
resolve,
reject,
);
} else {
reject(`Font load error: ${name}`);
}
} else {
resolve(data);
}
});
} else {
reject(`Font not allowed: ${name}`);
}
}); });
}
export const getFontsPbf = async ( /**
* Retrieves font data for a given font and range.
* @param {object} allowedFonts - An object of allowed fonts.
* @param {string} fontPath - The path to the font directory.
* @param {string} name - The name of the font.
* @param {string} range - The range (e.g., '0-255') of the font to load.
* @param {object} [fallbacks] - Optional fallback font list.
* @returns {Promise<Buffer>} A promise that resolves with the font data Buffer or rejects with an error.
*/
async function getFontPbf(allowedFonts, fontPath, name, range, fallbacks) {
if (!allowedFonts || (allowedFonts[name] && fallbacks)) {
const fontMatch = name?.match(/^[\p{L}\p{N} \-\.~!*'()@&=+,#$\[\]]+$/u);
const sanitizedName = fontMatch?.[0] || 'invalid';
if (!name || typeof name !== 'string' || name.trim() === '' || !fontMatch) {
console.error(
'ERROR: Invalid font name: %s',
sanitizedName.replace(/\n|\r/g, ''),
);
throw new Error('Invalid font name');
}
const rangeMatch = range?.match(/^[\d-]+$/);
const sanitizedRange = rangeMatch?.[0] || 'invalid';
if (!/^\d+-\d+$/.test(range)) {
console.error(
'ERROR: Invalid range: %s',
sanitizedRange.replace(/\n|\r/g, ''),
);
throw new Error('Invalid range');
}
const filename = path.join(
fontPath,
sanitizedName,
`${sanitizedRange}.pbf`,
);
if (!fallbacks) {
fallbacks = clone(allowedFonts || {});
}
delete fallbacks[name];
try {
const data = await readFile(filename);
return data;
} catch (err) {
console.error(
'ERROR: Font not found: %s, Error: %s',
filename.replace(/\n|\r/g, ''),
String(err),
);
if (fallbacks && Object.keys(fallbacks).length) {
let fallbackName;
let fontStyle = name.split(' ').pop();
if (['Regular', 'Bold', 'Italic'].indexOf(fontStyle) < 0) {
fontStyle = 'Regular';
}
fallbackName = `Noto Sans ${fontStyle}`;
if (!fallbacks[fallbackName]) {
fallbackName = `Open Sans ${fontStyle}`;
if (!fallbacks[fallbackName]) {
fallbackName = Object.keys(fallbacks)[0];
}
}
console.error(
`ERROR: Trying to use %s as a fallback for: %s`,
fallbackName,
sanitizedName,
);
delete fallbacks[fallbackName];
return getFontPbf(null, fontPath, fallbackName, range, fallbacks);
} else {
throw new Error('Font load error');
}
}
} else {
throw new Error('Font not allowed');
}
}
/**
* Combines multiple font pbf buffers into one.
* @param {object} allowedFonts - An object of allowed fonts.
* @param {string} fontPath - The path to the font directory.
* @param {string} names - Comma-separated font names.
* @param {string} range - The range of the font (e.g., '0-255').
* @param {object} [fallbacks] - Fallback font list.
* @returns {Promise<Buffer>} - A promise that resolves to the combined font data buffer.
*/
export async function getFontsPbf(
allowedFonts, allowedFonts,
fontPath, fontPath,
names, names,
range, range,
fallbacks, fallbacks,
) => { ) {
const fonts = names.split(','); const fonts = names.split(',');
const queue = []; const queue = [];
for (const font of fonts) { for (const font of fonts) {
@ -228,9 +360,14 @@ export const getFontsPbf = async (
const combined = combine(await Promise.all(queue), names); const combined = combine(await Promise.all(queue), names);
return Buffer.from(combined.buffer, 0, combined.buffer.length); return Buffer.from(combined.buffer, 0, combined.buffer.length);
}; }
export const listFonts = async (fontPath) => { /**
* Lists available fonts in a given font directory.
* @param {string} fontPath - The path to the font directory.
* @returns {Promise<object>} - Promise that resolves with an object where keys are the font names.
*/
export async function listFonts(fontPath) {
const existingFonts = {}; const existingFonts = {};
const files = await fsPromises.readdir(fontPath); const files = await fsPromises.readdir(fontPath);
@ -245,9 +382,14 @@ export const listFonts = async (fontPath) => {
} }
return existingFonts; return existingFonts;
}; }
export const isValidHttpUrl = (string) => { /**
* Checks if a string is a valid HTTP or HTTPS URL.
* @param {string} string - The string to validate.
* @returns {boolean} True if the string is a valid HTTP/HTTPS URL, false otherwise.
*/
export function isValidHttpUrl(string) {
let url; let url;
try { try {
@ -257,4 +399,32 @@ export const isValidHttpUrl = (string) => {
} }
return url.protocol === 'http:' || url.protocol === 'https:'; return url.protocol === 'http:' || url.protocol === 'https:';
}; }
/**
* Fetches tile data from either PMTiles or MBTiles source.
* @param {object} source - The source object, which may contain a mbtiles object, or pmtiles object.
* @param {string} sourceType - The source type, which should be `pmtiles` or `mbtiles`
* @param {number} z - The zoom level.
* @param {number} x - The x coordinate of the tile.
* @param {number} y - The y coordinate of the tile.
* @returns {Promise<object | null>} - A promise that resolves to an object with data and headers or null if no data is found.
*/
export async function fetchTileData(source, sourceType, z, x, y) {
if (sourceType === 'pmtiles') {
return await new Promise(async (resolve) => {
const tileinfo = await getPMtilesTile(source, z, x, y);
if (!tileinfo?.data) return resolve(null);
resolve({ data: tileinfo.data, headers: tileinfo.header });
});
} else if (sourceType === 'mbtiles') {
return await new Promise((resolve) => {
source.getTile(z, x, y, (err, tileData, tileHeader) => {
if (err) {
return resolve(null);
}
resolve({ data: tileData, headers: tileHeader });
});
});
}
}

View file

@ -7,10 +7,10 @@ import { server } from '../src/server.js';
global.expect = expect; global.expect = expect;
global.supertest = supertest; global.supertest = supertest;
before(function () { before(async function () {
console.log('global setup'); console.log('global setup');
process.chdir('test_data'); process.chdir('test_data');
const running = server({ const running = await server({
configPath: 'config.json', configPath: 'config.json',
port: 8888, port: 8888,
publicUrl: '/test/', publicUrl: '/test/',

View file

@ -78,7 +78,7 @@ describe('Static endpoints', function () {
testStatic(prefix, '0,0,0/256x256', 'png', 404, 1); testStatic(prefix, '0,0,0/256x256', 'png', 404, 1);
testStatic(prefix, '0,0,-1/256x256', 'png', 404); testStatic(prefix, '0,0,-1/256x256', 'png', 404);
testStatic(prefix, '0,0,0/256.5x256.5', 'png', 404); testStatic(prefix, '0,0,0/256.5x256.5', 'png', 400);
testStatic(prefix, '0,0,0,/256x256', 'png', 404); testStatic(prefix, '0,0,0,/256x256', 'png', 404);
testStatic(prefix, '0,0,0,0,/256x256', 'png', 404); testStatic(prefix, '0,0,0,0,/256x256', 'png', 404);
@ -135,7 +135,7 @@ describe('Static endpoints', function () {
testStatic(prefix, '0,0,1,1/1x1', 'gif', 400); testStatic(prefix, '0,0,1,1/1x1', 'gif', 400);
testStatic(prefix, '-180,-80,180,80/0.5x2.6', 'png', 404); testStatic(prefix, '-180,-80,180,80/0.5x2.6', 'png', 400);
}); });
}); });

View file

@ -60,16 +60,16 @@ describe('Raster tiles', function () {
describe('invalid requests return 4xx', function () { describe('invalid requests return 4xx', function () {
testTile('non_existent', 256, 0, 0, 0, 'png', 404); testTile('non_existent', 256, 0, 0, 0, 'png', 404);
testTile(prefix, 256, -1, 0, 0, 'png', 404); testTile(prefix, 256, -1, 0, 0, 'png', 400);
testTile(prefix, 256, 25, 0, 0, 'png', 404); testTile(prefix, 256, 25, 0, 0, 'png', 400);
testTile(prefix, 256, 0, 1, 0, 'png', 404); testTile(prefix, 256, 0, 1, 0, 'png', 400);
testTile(prefix, 256, 0, 0, 1, 'png', 404); testTile(prefix, 256, 0, 0, 1, 'png', 400);
testTile(prefix, 256, 0, 0, 0, 'gif', 400); testTile(prefix, 256, 0, 0, 0, 'gif', 400);
testTile(prefix, 256, 0, 0, 0, 'pbf', 400); testTile(prefix, 256, 0, 0, 0, 'pbf', 400);
testTile(prefix, 256, 0, 0, 0, 'png', 404, 1); testTile(prefix, 256, 0, 0, 0, 'png', 400, 1);
testTile(prefix, 256, 0, 0, 0, 'png', 404, 5); testTile(prefix, 256, 0, 0, 0, 'png', 400, 5);
testTile(prefix, 300, 0, 0, 0, 'png', 404); testTile(prefix, 300, 0, 0, 0, 'png', 400);
}); });
}); });