diff --git a/.env.example b/.env.example index 2fbd44c..d1235db 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js index 4ca6811..b8b137f 100644 --- a/src/client/javascripts/boundary-map.js +++ b/src/client/javascripts/boundary-map.js @@ -65,7 +65,7 @@ function fitMapToBounds(mapInstance, geojson) { [west, south], [east, north] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) } } diff --git a/src/client/javascripts/boundary-map.test.js b/src/client/javascripts/boundary-map.test.js index b08699a..c2e439d 100644 --- a/src/client/javascripts/boundary-map.test.js +++ b/src/client/javascripts/boundary-map.test.js @@ -220,7 +220,7 @@ describe('boundary-map', () => { [-1.5, 52.0], [-1.4, 52.1] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) }) @@ -282,7 +282,7 @@ describe('boundary-map', () => { [-1.5, 52.0], [-1.5, 52.0] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) }) @@ -323,7 +323,7 @@ describe('boundary-map', () => { [-2.0, 51.0], [-1.0, 53.0] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) }) @@ -484,7 +484,7 @@ describe('boundary-map', () => { [-3.0, 50.0], [0.0, 53.0] ], - { padding: 40 } + { maxZoom: 15, padding: 40 } ) }) @@ -514,7 +514,7 @@ describe('boundary-map', () => { [-1.0, 51.0], [-0.5, 51.5] ], - { padding: 40 } + { maxZoom: 15, padding: 40 } ) }) }) diff --git a/src/config/config.js b/src/config/config.js index 66d1d30..749b36b 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -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: { diff --git a/src/server/common/constants/status-codes.js b/src/server/common/constants/status-codes.js index 93e6b85..8592fca 100644 --- a/src/server/common/constants/status-codes.js +++ b/src/server/common/constants/status-codes.js @@ -7,5 +7,6 @@ export const statusCodes = { forbidden: 403, notFound: 404, imATeapot: 418, - internalServerError: 500 + internalServerError: 500, + badGateway: 502 } diff --git a/src/server/common/helpers/content-security-policy.js b/src/server/common/helpers/content-security-policy.js index e23614c..01e3b6a 100644 --- a/src/server/common/helpers/content-security-policy.js +++ b/src/server/common/helpers/content-security-policy.js @@ -6,8 +6,6 @@ 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, @@ -15,21 +13,16 @@ const contentSecurityPolicy = { // 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='" ], - imgSrc: ['self', 'data:'], - frameSrc: ['self', 'data:'], + imgSrc: ['self'], + frameSrc: ['self'], objectSrc: ['none'], frameAncestors: ['none'], formAction: ['self', ...(cdpUploaderUrl ? [cdpUploaderUrl] : [])], diff --git a/src/server/common/helpers/content-security-policy.unit.test.js b/src/server/common/helpers/content-security-policy.unit.test.js index 52db931..043f27c 100644 --- a/src/server/common/helpers/content-security-policy.unit.test.js +++ b/src/server/common/helpers/content-security-policy.unit.test.js @@ -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' } @@ -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' ) @@ -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']) }) }) diff --git a/src/server/os-base-map/index.js b/src/server/os-base-map/index.js new file mode 100644 index 0000000..d9ffbf4 --- /dev/null +++ b/src/server/os-base-map/index.js @@ -0,0 +1,10 @@ +import routes from './routes.js' + +export const osBaseMap = { + plugin: { + name: 'os-base-map', + register(server) { + server.route(routes) + } + } +} diff --git a/src/server/os-base-map/routes.js b/src/server/os-base-map/routes.js new file mode 100644 index 0000000..1ae1599 --- /dev/null +++ b/src/server/os-base-map/routes.js @@ -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) + 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] diff --git a/src/server/os-base-map/routes.test.js b/src/server/os-base-map/routes.test.js new file mode 100644 index 0000000..ac61e4c --- /dev/null +++ b/src/server/os-base-map/routes.test.js @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupMswServer } from '../../test-utils/setup-msw-server.js' + +vi.mock('../../config/config.js', () => ({ + config: { + get: vi.fn((key) => { + if (key === 'map.osApiKey') return 'test-api-key' + return null + }) + } +})) + +const mockLogger = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn() +})) + +vi.mock('../common/helpers/logging/logger.js', () => ({ + createLogger: () => mockLogger +})) + +const server = setupMswServer() + +const { default: routes } = await import('./routes.js') + +const handler = routes[0].handler + +function createMockRequest({ path = '', query = {} } = {}) { + return { + params: { path }, + query, + headers: {}, + server: { info: { protocol: 'http' } }, + info: { host: 'localhost:3000' } + } +} + +function createMockH() { + const response = { + type: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + code: vi.fn().mockReturnThis() + } + return { + response: vi.fn().mockReturnValue(response), + _response: response + } +} + +describe('os-base-map proxy routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('route config', () => { + it('should define a GET route at /os-base-map/{path*}', () => { + expect(routes[0].method).toBe('GET') + expect(routes[0].path).toBe('/os-base-map/{path*}') + }) + + it('should not require auth', () => { + expect(routes[0].options.auth).toBe(false) + }) + }) + + describe('JSON responses (map style, TileJSON)', () => { + it('should proxy map style requests and rewrite Ordnance Survey URLs', async () => { + const osMapStyleBody = { + sources: { + osVectorTiles: { + url: 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857' + } + }, + sprite: + 'https://api.os.uk/maps/vector/v1/vts/resources/sprites/sprite?key=test-api-key&srs=3857', + glyphs: + 'https://api.os.uk/maps/vector/v1/vts/resources/fonts/{fontstack}/{range}.pbf?key=test-api-key&srs=3857' + } + + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts/resources/styles', () => + HttpResponse.json(osMapStyleBody, { + headers: { + 'content-type': 'application/json', + 'cache-control': 'max-age=3600' + } + }) + ) + ) + + const request = createMockRequest({ path: 'resources/styles' }) + const h = createMockH() + + await handler(request, h) + + const responseBody = h.response.mock.calls[0][0] + expect(responseBody).toContain('http://localhost:3000/os-base-map') + expect(responseBody).not.toContain('api.os.uk') + expect(responseBody).not.toContain('test-api-key') + expect(h._response.type).toHaveBeenCalledWith('application/json') + expect(h._response.header).toHaveBeenCalledWith( + 'cache-control', + 'max-age=3600' + ) + }) + + it('should proxy TileJSON at the root path', async () => { + const tileJson = { + tiles: [ + 'https://api.os.uk/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf?key=test-api-key&srs=3857' + ] + } + + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts', () => + HttpResponse.json(tileJson, { + headers: { 'content-type': 'application/json' } + }) + ) + ) + + const request = createMockRequest() + const h = createMockH() + + await handler(request, h) + + const responseBody = h.response.mock.calls[0][0] + expect(responseBody).toContain( + 'http://localhost:3000/os-base-map/tile/{z}/{y}/{x}.pbf' + ) + }) + + it('should use x-forwarded-proto when present', async () => { + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts/resources/styles', () => + HttpResponse.json({ + url: 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857' + }) + ) + ) + + const request = createMockRequest({ path: 'resources/styles' }) + request.headers['x-forwarded-proto'] = 'https' + const h = createMockH() + + await handler(request, h) + + const responseBody = h.response.mock.calls[0][0] + expect(responseBody).toContain('https://localhost:3000/os-base-map') + }) + }) + + describe('binary responses (tiles, sprites)', () => { + it('should proxy .pbf tiles without decompression', async () => { + const tileData = new Uint8Array([0x1a, 0x02, 0x03]) + + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/tile/7/63/42.pbf', + () => + new HttpResponse(tileData, { + headers: { + 'content-type': 'application/octet-stream', + 'cache-control': 'max-age=86400', + 'content-encoding': 'gzip' + } + }) + ) + ) + + const request = createMockRequest({ path: 'tile/7/63/42.pbf' }) + const h = createMockH() + + await handler(request, h) + + expect(h.response).toHaveBeenCalled() + expect(h._response.type).toHaveBeenCalledWith('application/octet-stream') + expect(h._response.header).toHaveBeenCalledWith( + 'content-encoding', + 'gzip' + ) + expect(h._response.header).toHaveBeenCalledWith( + 'cache-control', + 'max-age=86400' + ) + }) + + it('should not set content-encoding if upstream does not send it', async () => { + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/resources/sprites/sprite.png', + () => + new HttpResponse(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + headers: { + 'content-type': 'image/png', + 'cache-control': 'no-cache' + } + }) + ) + ) + + const request = createMockRequest({ + path: 'resources/sprites/sprite.png' + }) + const h = createMockH() + + await handler(request, h) + + const headerCalls = h._response.header.mock.calls.map((c) => c[0]) + expect(headerCalls).not.toContain('content-encoding') + }) + }) + + describe('error handling', () => { + it('should pass through upstream error status codes', async () => { + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/tile/15/10706/16499.pbf', + () => new HttpResponse('Forbidden', { status: 403 }) + ) + ) + + const request = createMockRequest({ + path: 'tile/15/10706/16499.pbf' + }) + const h = createMockH() + + await handler(request, h) + + expect(h.response).toHaveBeenCalledWith(Buffer.from('Forbidden')) + expect(h._response.code).toHaveBeenCalledWith(403) + }) + + it('should return 502 for network errors', async () => { + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts/resources/styles', () => + HttpResponse.error() + ) + ) + + const request = createMockRequest({ path: 'resources/styles' }) + const h = createMockH() + + await handler(request, h) + + expect(h.response).toHaveBeenCalledWith('Map tile request failed') + expect(h._response.code).toHaveBeenCalledWith(502) + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe('URL construction', () => { + it('should append API key and srs=3857 to all requests', async () => { + let capturedUrl + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/resources/styles', + ({ request: req }) => { + capturedUrl = new URL(req.url) + return HttpResponse.json({}) + } + ) + ) + + const request = createMockRequest({ path: 'resources/styles' }) + const h = createMockH() + + await handler(request, h) + + expect(capturedUrl.searchParams.get('key')).toBe('test-api-key') + expect(capturedUrl.searchParams.get('srs')).toBe('3857') + }) + + it('should forward query parameters from the client', async () => { + let capturedUrl + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/resources/styles', + ({ request: req }) => { + capturedUrl = new URL(req.url) + return HttpResponse.json({}) + } + ) + ) + + const request = createMockRequest({ + path: 'resources/styles', + query: { f: 'json' } + }) + const h = createMockH() + + await handler(request, h) + + expect(capturedUrl.searchParams.get('f')).toBe('json') + }) + }) +}) diff --git a/src/server/quote/map/get-view-model.js b/src/server/quote/map/get-view-model.js index b8ad328..a760056 100644 --- a/src/server/quote/map/get-view-model.js +++ b/src/server/quote/map/get-view-model.js @@ -1,10 +1,13 @@ import { getPageTitle } from '../../common/helpers/page-title.js' import { routePath as uploadBoundaryPath } from '../upload-boundary/routes.js' import { routePath as boundaryTypePath } from '../boundary-type/routes.js' -import { config } from '../../../config/config.js' export const title = 'Boundary Map' +function getMapStyleUrl() { + return '/os-base-map/resources/styles' +} + export default function getViewModel(boundaryGeojson) { const geometry = boundaryGeojson?.geometry const intersectingEdps = boundaryGeojson?.intersecting_edps ?? [] @@ -22,6 +25,6 @@ export default function getViewModel(boundaryGeojson) { backLinkPath: uploadBoundaryPath, uploadBoundaryPath, cancelPath: boundaryTypePath, - mapStyleUrl: config.get('map.styleUrl') + mapStyleUrl: getMapStyleUrl() } } diff --git a/src/server/router.js b/src/server/router.js index 75daed8..24adceb 100644 --- a/src/server/router.js +++ b/src/server/router.js @@ -6,6 +6,7 @@ import { auth } from './auth/index.js' import { profile } from './profile/index.js' import { serveStaticFiles } from './common/helpers/serve-static-files.js' import { quote } from './quote/index.js' +import { osBaseMap } from './os-base-map/index.js' export const router = { plugin: { @@ -17,7 +18,7 @@ export const router = { await server.register([health]) // Application specific routes, add your own routes here - await server.register([about, auth, profile, quote]) + await server.register([about, auth, profile, quote, osBaseMap]) // Static assets await server.register([serveStaticFiles])