diff --git a/changelog.d/842-voice-outlines.md b/changelog.d/842-voice-outlines.md index e0062d8e3..812ef7922 100644 --- a/changelog.d/842-voice-outlines.md +++ b/changelog.d/842-voice-outlines.md @@ -27,3 +27,6 @@ First sources whose licence is only listed as CC BY or CC0 "equivalent", including the Ottawa, Saskatoon, Gatineau and Madrid records (Manjunath, #842). +- Skip Nominatim place and outline lookups when the server reports that no + Nominatim endpoint is configured, so a default install sends no request it + would refuse (Manjunath, #842). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f9c1decd5..e8d0b76a0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5298,6 +5298,11 @@ Google first when configured, Photon/OpenStreetMap as fallback (including Google transport failures or declined requests), and the local `/api/geocode` route over Nominatim as a last resort when neither answers and an operator explicitly configures `NOMINATIM_URL`. Unset, empty and invalid values disable Nominatim. +`GET /api/geocode/status` answers `{configured}`; the browser asks once per +page, and when Nominatim is not configured neither the last-resort search nor +the voice outline client asks `/api/geocode`, so a default install sends no +lookup it would refuse. A missing or failed probe leaves the capability unknown +and lookups proceed as before. Explicit public-service configuration retains an identifying `User-Agent` and `Referer`, at least 1.1 seconds between public search and outline starts, a shared durable per-install daily allowance and pause state that fail closed when unusable. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 41517f7e0..06bc72de1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -396,6 +396,7 @@ "src/search/geospatial.js", "src/search/http.js", "src/search/defaults.js", + "src/sources/nominatimStatus.js", "src/search/alpsGeocoder.js", "src/search/coordinateGeocoder.js", "src/search/coordinateParser.js", @@ -2822,6 +2823,7 @@ "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", + "src/sources/nominatimStatus.js", "src/data/placeBoundaries.js", "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", @@ -3413,6 +3415,7 @@ "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", + "src/sources/nominatimStatus.js", "src/data/placeBoundaries.js", "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", @@ -3496,6 +3499,7 @@ "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", + "src/sources/nominatimStatus.js", "src/data/placeBoundaries.js", "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", diff --git a/server/providers/regional/place.js b/server/providers/regional/place.js index fcb118884..4479c1579 100644 --- a/server/providers/regional/place.js +++ b/server/providers/regional/place.js @@ -474,6 +474,12 @@ export function geocodeProxy({ function install(middlewares) { middlewares.use('/api/geocode', async (req, res, next) => { const url = new URL(req.url || '', 'http://localhost'); + // Capability probe, like /api/overpass/status: clients skip lookups a + // server without a Nominatim endpoint could only refuse. + if (url.pathname === '/status' && req.method === 'GET') + return sendJson(res, 200, { + configured: Boolean(currentGate().settings()?.endpoint), + }); if (url.pathname === '/outline') return handleOutline(req, res, url); if (url.pathname === '/' || url.pathname === '') return handleSearch(req, res, url); diff --git a/src/search/defaults.js b/src/search/defaults.js index f85c07193..0040ac638 100644 --- a/src/search/defaults.js +++ b/src/search/defaults.js @@ -2,6 +2,7 @@ import { createNominatimProvider } from './nominatim.js'; import { createGeospatialServices } from './geospatial.js'; import { createHttpGeospatialProvider } from './http.js'; import { createPlaceSearch } from './placeSearch.js'; +import { createNominatimStatus } from '../sources/nominatimStatus.js'; import { createGoogleGeocoder } from './google.js'; import { createPhotonGeocoder } from '../keylessGeocoder.js'; import { createCoordinateGeocoder } from './coordinateGeocoder.js'; @@ -28,6 +29,9 @@ export function createDefaultPlaceSearch({ } = {}) { if (geocoding && geocoding.provider !== 'nominatim') throw new TypeError('Unsupported geocoding provider'); + const nominatimConfigured = endpoints.nominatim + ? null + : createNominatimStatus({ fetchImpl }); const selected = geocoding ? createNominatimProvider({ ...geocoding, fetchImpl }) : null; @@ -74,7 +78,13 @@ export function createDefaultPlaceSearch({ // neither of the two above did. It speaks the same result shape, so it // rides the existing Google adapter rather than needing its own. createGoogleGeocoder({ - request(query, { bias, signal }) { + async request(query, { bias, signal }) { + // The app's own route says up front when Nominatim is off. + if ( + nominatimConfigured && + (await nominatimConfigured()) === false + ) + return null; const params = new URLSearchParams({ q: query }); if (bias) params.set('bounds', bias); return fetchImpl( diff --git a/src/search/photonSetting.test.mjs b/src/search/photonSetting.test.mjs index 24cd9eec5..9194ceb2e 100644 --- a/src/search/photonSetting.test.mjs +++ b/src/search/photonSetting.test.mjs @@ -30,3 +30,26 @@ test('place search follows the Photon setting', async () => { const off = await hosts({ photon: null }); assert.ok(!off.some((host) => host.includes('photon')), off.join(',')); }); + +test('the last-resort route is skipped when the server reports Nominatim unconfigured', async () => { + const run = async (configured) => { + const asked = []; + const search = createDefaultPlaceSearch({ + endpoints: { photon: null }, + fetchImpl: async (url) => { + const path = new URL(String(url), 'http://local.test').pathname; + asked.push(path); + if (path === '/api/geocode/status') + return Response.json({ configured }); + return Response.json({ status: 'ZERO_RESULTS', results: [] }); + }, + }); + await search.geocode(`nowhere-nominatim-${configured}`); + return asked; + }; + const off = await run(false); + assert.ok(off.includes('/api/geocode/status')); + assert.ok(!off.includes('/api/geocode'), off.join(',')); + const on = await run(true); + assert.ok(on.includes('/api/geocode'), on.join(',')); +}); diff --git a/src/sources/nominatimOutlines.js b/src/sources/nominatimOutlines.js index 168704bd1..35acb4125 100644 --- a/src/sources/nominatimOutlines.js +++ b/src/sources/nominatimOutlines.js @@ -1,4 +1,5 @@ import { readResponseJsonCapped } from './httpBody.js'; +import { createNominatimStatus } from './nominatimStatus.js'; /** * Browser client for the server's guarded outline route. The server owns the @@ -61,6 +62,11 @@ export function createNominatimOutlineClient({ fetchImpl = (...args) => globalThis.fetch(...args), endpoint = DEFAULT_ENDPOINT, maxRemembered = 64, + // The app's own route can say up front that Nominatim is not configured; + // another endpoint is simply asked. + configured = endpoint === DEFAULT_ENDPOINT + ? createNominatimStatus({ fetchImpl }) + : null, } = {}) { let unavailable = null; const remembered = new Map(); @@ -90,6 +96,15 @@ export function createNominatimOutlineClient({ if (remembered.has(key)) return remembered.get(key); // A cancelled ask spends nothing. signal?.throwIfAborted(); + if (configured && (await configured()) === false) { + signal?.throwIfAborted(); + unavailable = { + unavailable: true, + retryable: false, + code: 'NOMINATIM_DISABLED', + }; + return unavailable; + } const controller = new AbortController(); const abort = () => controller.abort(); diff --git a/src/sources/nominatimOutlines.test.mjs b/src/sources/nominatimOutlines.test.mjs index e7ecfe2f3..f5157274a 100644 --- a/src/sources/nominatimOutlines.test.mjs +++ b/src/sources/nominatimOutlines.test.mjs @@ -11,20 +11,30 @@ const ring = [ [2.3, 48.8], ]; -function client(respond) { +const reply = (status, body, headers = {}) => + new Response(JSON.stringify(body), { status, headers }); + +// `calls` counts outline requests only; the capability probe is answered by +// `status` and defaults to an older server without it (capability unknown). +function client( + respond, + { status = () => reply(404, { error: 'Not Found' }) } = {}, +) { const calls = []; + const probes = []; const lookup = createNominatimOutlineClient({ fetchImpl: async (url) => { + if (String(url) === '/api/geocode/status') { + probes.push(String(url)); + return status(probes.length); + } calls.push(String(url)); return respond(calls.length); }, }).lookup; - return { calls, lookup }; + return { calls, probes, lookup }; } -const reply = (status, body, headers = {}) => - new Response(JSON.stringify(body), { status, headers }); - test('an accepted outline comes back with its polygons and is remembered', async () => { const hole = ring.map(([lon, lat]) => [ 2.35 + (lon - 2.35) / 2, @@ -139,3 +149,33 @@ test('a cancelled ask sends nothing', async () => { ); assert.equal(calls.length, 0); }); + +test('a server that reports Nominatim unconfigured is never asked for outlines', async () => { + const { calls, probes, lookup } = client( + () => { + throw new Error('the outline route must not be asked'); + }, + { status: () => reply(200, { configured: false }) }, + ); + const first = await lookup({ query: 'Austin High School', kind: 'landmark' }); + assert.deepEqual(first, { + unavailable: true, + retryable: false, + code: 'NOMINATIM_DISABLED', + }); + const second = await lookup({ query: 'Paris', kind: 'city' }); + assert.equal(second.unavailable, true); + assert.equal(calls.length, 0, 'no outline request at all'); + assert.equal(probes.length, 1, 'one capability probe per session'); +}); + +test('a configured server is asked as usual after one probe', async () => { + const { calls, probes, lookup } = client( + () => reply(200, { status: 'ZERO_RESULTS', outline: null }), + { status: () => reply(200, { configured: true }) }, + ); + assert.equal(await lookup({ query: 'Nowhere', kind: 'landmark' }), null); + assert.equal(await lookup({ query: 'Elsewhere', kind: 'landmark' }), null); + assert.equal(calls.length, 2); + assert.equal(probes.length, 1); +}); diff --git a/src/sources/nominatimStatus.js b/src/sources/nominatimStatus.js new file mode 100644 index 000000000..421cdb927 --- /dev/null +++ b/src/sources/nominatimStatus.js @@ -0,0 +1,40 @@ +/** + * Whether the app's server has a Nominatim endpoint configured, asked once per + * page from `/api/geocode/status` (like `/api/overpass/status`). When it says + * no, callers skip lookups the server could only refuse, so a default install + * never asks and never logs a failed request. An older server without the + * probe, a failed or timed-out probe and a malformed answer leave the + * capability unknown (`null`): lookups then go to the server as before, and + * the probe is retried after a short backoff. + * + * @returns {() => Promise} + */ +export function createNominatimStatus({ + fetchImpl = (...args) => globalThis.fetch(...args), + url = '/api/geocode/status', + timeoutMs = 3000, + retryMs = 30_000, + now = Date.now, +} = {}) { + let decision = null; + let retryAt = 0; + return function configured() { + if (decision) return decision; + if (now() < retryAt) return Promise.resolve(null); + decision = (async () => { + try { + const response = await fetchImpl(url, { + signal: AbortSignal.timeout(timeoutMs), + }); + const body = response.ok ? await response.json() : null; + if (typeof body?.configured === 'boolean') return body.configured; + } catch { + // Unknown capability: ask the server as before. + } + decision = null; + retryAt = now() + retryMs; + return null; + })(); + return decision; + }; +} diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index ea47f9399..d14abb19c 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -1235,10 +1235,15 @@ test('the outline route reports disabled and capped as permanent, busy as retrya assert.equal(off.status, 503); assert.equal(off.body.code, 'NOMINATIM_DISABLED'); assert.equal(off.body.retryable, false); + // The capability probe answers 200, so a client can skip the refused lookup. + const probe = await disabled('/status'); + assert.equal(probe.status, 200); + assert.deepEqual(probe.body, { configured: false }); const capped = mount({ gate: publicGate(t, { settings: { ...PUBLIC, dailyCap: 0 } }), }); + assert.deepEqual((await capped('/status')).body, { configured: true }); const cap = await capped('/outline?q=Paris&kind=city'); assert.equal(cap.status, 429); assert.equal(cap.body.retryable, false);