Skip to content
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ CDP_UPLOADER_URL=http://localhost:7337
# When true this will generate and display the Swagger Open API documentation page at the route /docs
# We only tend to use this when developing locally.
USE_SWAGGER=false

# Ordnance Survey Vector Tile API key
# Get a key from https://osdatahub.os.uk/
# When set, the map page uses OS Vector Tile API base tiles instead of demo tiles
OS_API_KEY=
2 changes: 1 addition & 1 deletion src/client/javascripts/boundary-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function fitMapToBounds(mapInstance, geojson) {
[west, south],
[east, north]
],
{ padding: 40 }
{ padding: 40, maxZoom: 15 }
)
}
}
Expand Down
10 changes: 5 additions & 5 deletions src/client/javascripts/boundary-map.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ describe('boundary-map', () => {
[-1.5, 52.0],
[-1.4, 52.1]
],
{ padding: 40 }
{ padding: 40, maxZoom: 15 }
)
})

Expand Down Expand Up @@ -282,7 +282,7 @@ describe('boundary-map', () => {
[-1.5, 52.0],
[-1.5, 52.0]
],
{ padding: 40 }
{ padding: 40, maxZoom: 15 }
)
})

Expand Down Expand Up @@ -323,7 +323,7 @@ describe('boundary-map', () => {
[-2.0, 51.0],
[-1.0, 53.0]
],
{ padding: 40 }
{ padding: 40, maxZoom: 15 }
)
})

Expand Down Expand Up @@ -484,7 +484,7 @@ describe('boundary-map', () => {
[-3.0, 50.0],
[0.0, 53.0]
],
{ padding: 40 }
{ maxZoom: 15, padding: 40 }
)
})

Expand Down Expand Up @@ -514,7 +514,7 @@ describe('boundary-map', () => {
[-1.0, 51.0],
[-0.5, 51.5]
],
{ padding: 40 }
{ maxZoom: 15, padding: 40 }
)
})
})
8 changes: 4 additions & 4 deletions src/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,11 @@ export const config = convict({
env: 'USE_SWAGGER'
},
map: {
styleUrl: {
doc: 'URL to the map style.json (Mapbox Style Specification)',
osApiKey: {
doc: 'Ordnance Survey Vector Tile API key for the OS Vector Tile API base map.',
format: String,
default: 'https://demotiles.maplibre.org/style.json',
env: 'MAP_STYLE_URL'
default: '',
env: 'OS_API_KEY'
}
},
cdpUploader: {
Expand Down
3 changes: 2 additions & 1 deletion src/server/common/constants/status-codes.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export const statusCodes = {
forbidden: 403,
notFound: 404,
imATeapot: 418,
internalServerError: 500
internalServerError: 500,
badGateway: 502
}
15 changes: 4 additions & 11 deletions src/server/common/helpers/content-security-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,23 @@ import { config } from '../../../config/config.js'
* @satisfies {import('@hapi/hapi').Plugin}
*/
const cdpUploaderUrl = config.get('cdpUploader.url')
const mapStyleUrl = config.get('map.styleUrl')
const mapStyleOrigin = mapStyleUrl ? new URL(mapStyleUrl).origin : null

const contentSecurityPolicy = {
plugin: Blankie,
options: {
// Hash 'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw=' is to support a GOV.UK frontend script bundled within Nunjucks macros
// https://frontend.design-system.service.gov.uk/import-javascript/#if-our-inline-javascript-snippet-is-blocked-by-a-content-security-policy
defaultSrc: ['self'],
fontSrc: ['self', 'data:'],
connectSrc: [
'self',
'wss',
'data:',
...(mapStyleOrigin ? [mapStyleOrigin] : [])
],
fontSrc: ['self'],
connectSrc: ['self', 'wss'],
mediaSrc: ['self'],
styleSrc: ['self'],
scriptSrc: [
'self',
"'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's not in this PR's changeset, but what is this sha value needed for? The script tags in the templates seem to use nonce rather than this?

],
imgSrc: ['self', 'data:'],
frameSrc: ['self', 'data:'],
imgSrc: ['self'],
frameSrc: ['self'],
objectSrc: ['none'],
frameAncestors: ['none'],
formAction: ['self', ...(cdpUploaderUrl ? [cdpUploaderUrl] : [])],
Expand Down
35 changes: 2 additions & 33 deletions src/server/common/helpers/content-security-policy.unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,8 @@ describe('#contentSecurityPolicy config variations', () => {
vi.resetModules()
})

test('should include map style origin in connectSrc when configured', async () => {
mockConfigGet.mockImplementation((key) => {
if (key === 'map.styleUrl') {
return 'https://tiles.example.com/style.json'
}
if (key === 'cdpUploader.url') {
return null
}
return null
})

const { contentSecurityPolicy } =
await import('./content-security-policy.js')

expect(contentSecurityPolicy.options.connectSrc).toContain(
'https://tiles.example.com'
)
expect(contentSecurityPolicy.options.formAction).toEqual(['self'])
})

test('should include cdpUploader url in formAction when configured', async () => {
mockConfigGet.mockImplementation((key) => {
if (key === 'map.styleUrl') {
return null
}
if (key === 'cdpUploader.url') {
return 'https://uploader.example.com'
}
Expand All @@ -45,11 +22,7 @@ describe('#contentSecurityPolicy config variations', () => {
const { contentSecurityPolicy } =
await import('./content-security-policy.js')

expect(contentSecurityPolicy.options.connectSrc).toEqual([
'self',
'wss',
'data:'
])
expect(contentSecurityPolicy.options.connectSrc).toEqual(['self', 'wss'])
expect(contentSecurityPolicy.options.formAction).toContain(
'https://uploader.example.com'
)
Expand All @@ -61,11 +34,7 @@ describe('#contentSecurityPolicy config variations', () => {
const { contentSecurityPolicy } =
await import('./content-security-policy.js')

expect(contentSecurityPolicy.options.connectSrc).toEqual([
'self',
'wss',
'data:'
])
expect(contentSecurityPolicy.options.connectSrc).toEqual(['self', 'wss'])
expect(contentSecurityPolicy.options.formAction).toEqual(['self'])
})
})
10 changes: 10 additions & 0 deletions src/server/os-base-map/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import routes from './routes.js'

export const osBaseMap = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this naming really conveys that this is a proxy endpoint for map tiles

plugin: {
name: 'os-base-map',
register(server) {
server.route(routes)
}
}
}
109 changes: 109 additions & 0 deletions src/server/os-base-map/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import Wreck from '@hapi/wreck'
import { config } from '../../config/config.js'
import { createLogger } from '../common/helpers/logging/logger.js'
import { statusCodes } from '../common/constants/status-codes.js'

const logger = createLogger()

const ordnanceSurveyMapUrl = 'https://api.os.uk/maps/vector/v1/vts'

export const routePath = '/os-base-map'

function getOrdnanceSurveyMapUrl(path, query) {
const ordnanceSurveyApiKey = config.get('map.osApiKey')
const params = new URLSearchParams(query)
params.set('key', ordnanceSurveyApiKey)
params.set('srs', '3857')
const base = path ? `${ordnanceSurveyMapUrl}/${path}` : ordnanceSurveyMapUrl
return `${base}?${params.toString()}`
}

// Rewrites api.os.uk URLs in JSON responses to route through our proxy, stripping
// query strings so the API key isn't leaked to the client.
function rewriteOrdnanceSurveyMapUrls(body, host) {
const proxyBase = `${host}${routePath}`
const basePath = new URL(ordnanceSurveyMapUrl).pathname

try {
// Walk every value in the JSON using the parse reviver callback
const json = JSON.parse(body, (_key, value) => {
if (typeof value === 'string' && value.startsWith(ordnanceSurveyMapUrl)) {
// Extract the sub-path (e.g. /resources/styles) and discard the query string.
// decodeURIComponent restores MapLibre template tokens like {z}/{y}/{x}
// that new URL() percent-encodes.
const subPath = decodeURIComponent(
new URL(value).pathname.slice(basePath.length)
)
return proxyBase + subPath
}
return value
})

return JSON.stringify(json)
} catch {
return body
}
}

function isBinaryPath(path) {
return path.endsWith('.pbf') || path.endsWith('.png') || path.endsWith('.jpg')
}

const proxyHandler = {
method: 'GET',
path: `${routePath}/{path*}`,
options: {
auth: false
},
async handler(request, h) {
const path = request.params.path || ''
const ordnanceSurveyUrl = getOrdnanceSurveyMapUrl(path, request.query)

try {
// For binary resources (tiles, sprites), don't decompress — pass through raw
const binary = isBinaryPath(path)
const { res, payload } = await Wreck.get(ordnanceSurveyUrl, {
redirects: 3,
maxBytes: 10 * 1024 * 1024,
gunzip: !binary
})

const contentType = res.headers['content-type'] || ''
const cacheControl = res.headers['cache-control'] || 'no-cache'

if (binary) {
const response = h
.response(payload)
.type(contentType)
.header('cache-control', cacheControl)

if (res.headers['content-encoding']) {
response.header('content-encoding', res.headers['content-encoding'])
}

return response
}

// JSON responses — rewrite Ordnance Survey URLs to point to our proxy
const protocol =
request.headers['x-forwarded-proto'] || request.server.info.protocol
const host = `${protocol}://${request.info.host}`
const rewritten = rewriteOrdnanceSurveyMapUrls(payload.toString(), host)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering how this would scale with potentially 100's or 1000's of tile JSON responses being parsed simultaneously as JSON handling is blocking in node?

return h
.response(rewritten)
.type(contentType)
.header('cache-control', cacheControl)
} catch (err) {
// Wreck throws a Boom error on non-2xx responses (e.g. 403 for tiles outside UK coverage)
if (err.data?.isResponseError) {
const statusCode = err.data.res.statusCode
return h.response(err.data.payload).code(statusCode)
}

logger.error(`Map proxy error for ${path}: ${err.message}`)
return h.response('Map tile request failed').code(statusCodes.badGateway)
}
}
}

export default [proxyHandler]
Loading
Loading