Files
TREK/server/src/nest/maps/maps.service.ts
T

3047 lines
130 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { isOutsideChina } from '@trek/shared';
import type {
MapsSearchResult,
MapsAutocompleteResult,
MapsPlaceDetailsResult,
MapsPlacePhotoResult,
MapsReverseResult,
MapsResolveUrlResult,
} from '@trek/shared';
import { Jimp } from 'jimp';
import { readEnv, getAppUrl } from '../../app-config';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { discardBody, exceedsDeclaredLength, readCapped, readCappedText } from '../../utils/cappedFetch';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
import {
AMAP_SHORT_HOSTS,
AmapPlacesProvider,
isAmapHost,
isAmapPlaceId,
parseAmapUrl,
} from './providers/amap.provider';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { DatabaseService } from '../database/database.service';
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
import {
trekPlacesSearch,
indexHitsOnly,
isOsmHit,
osmPlaceId,
trekPlacesById,
trekPlacesArea,
trekPlacesNearby,
toPlaceRecord,
POI_CATEGORY_TO_TREK,
type TrekPlace,
} from './trek-places.client';
import {
UA,
SEARCH_TEXT_FIELD_MASK,
toApiLang,
googleFtidFromMapsUrl,
buildOsmDetails,
normalizeOpeningPeriods,
normalizeSpecialDays,
isGooglePlaceId,
OSM_PLACE_ID,
CATEGORY_OSM_FILTERS,
parsePoiCategories,
resolveOverpassEndpoints,
resolveOverpassTimeoutMs,
OVERPASS_QUERY_TIMEOUT_S,
stripWikiMarkup,
parseWikipediaTag,
toWikiLang,
haversineMetres,
namesOverlap,
mergeSearchResults,
readChargingInfo,
type GoogleOpeningHours,
type OverpassPoi,
} from './maps.helpers';
// ── Google API call counter ───────────────────────────────────────────────────
let googleApiCallCount = 0;
/** The upstream every Places call is written against. */
const PLACES_UPSTREAM = 'https://places.googleapis.com';
/**
* Sends the call somewhere else when PLACES_API_BASE is set.
*
* The nine Places endpoints below all spell out the upstream host, so an install
* that wants these calls to leave through something of its own — an egress proxy,
* a cache, a gateway holding the key — has no way to say so today. One variable,
* substituted at the one place every call funnels through.
*
* Path and query are untouched, so the replacement has to speak the same API.
* Unset, which is every install today, the string is returned as it came in.
*/
function placesEndpoint(endpoint: string): string {
const base = readEnv().maps.placesApiBase;
if (!base || !endpoint.startsWith(PLACES_UPSTREAM)) return endpoint;
// The character before the run is matched and written straight back. A bare
// /\/+$/ restarts at every slash of a base that does not end in one, reading
// the rest of the run again from each of them.
return base.replace(/([^/]|^)\/+$/, '$1') + endpoint.slice(PLACES_UPSTREAM.length);
}
/**
* Says which of the three credentials Google rejected, never which value.
*
* The response body Google sends ("The caller does not have permission") is
* identical whichever key was used, so without this line a report of "works for
* the admin, fails for everyone else" cannot be told apart from a genuinely
* broken key.
*/
function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void {
console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`);
}
/** Ceiling for one Google Places call. Generous — the photo download is the slow one. */
const GOOGLE_FETCH_TIMEOUT_MS = 20000;
function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise<Response> {
const endpoint = placesEndpoint(rawEndpoint);
googleApiCallCount++;
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
const referer = readEnv().app.appUrl ? getAppUrl() : undefined;
return fetch(endpoint, {
...init,
// A default ceiling here rather than at each of the nine call sites, none of
// which passed one: a hung upstream held the request handler open for as
// long as it liked. A caller that needs longer still wins, it only has to
// say so.
signal: init?.signal ?? AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS),
headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record<string, string>) ?? {}) },
});
}
// ── Interfaces ───────────────────────────────────────────────────────────────
interface NominatimResult {
osm_type: string;
osm_id: string;
name?: string;
display_name?: string;
lat: string;
lon: string;
extratags?: Record<string, string> | null;
}
/**
* The keys that say which encyclopaedia entry, Wikidata item and Commons
* category describe a place. Everything the enrichment column shows beyond
* coordinates hangs off one of these.
*
* Bare keys only. OSM also carries `brand:wikidata` / `brand:wikipedia`, and
* following those means a branch of a chain gets the chain's article and the
* chain's logo — for "L'Osteria Rostock" you would confidently describe
* L'Osteria the company. That is the exact failure the tag-only rule was
* written to avoid, so do not "improve" this by falling back to brand:*.
*/
const WIKI_IDENTITY_TAGS = ['wikipedia', 'wikidata', 'wikimedia_commons'] as const;
export interface WikiIdentity {
wikipedia: string | null;
wikidata: string | null;
wikimedia_commons: string | null;
}
interface WikidataSnak {
mainsnak?: { datavalue?: { value?: string } };
rank?: 'preferred' | 'normal' | 'deprecated';
}
type WikidataClaims = Record<string, WikidataSnak[] | undefined>;
/**
* Wikidata properties that name a picture of a place, in the order we want them.
*
* P18 is the representative image. The rest exist because a station or a
* monument is not one view: asking for the interior, the night shot, the
* panorama and the aerial gives a picker four genuinely different pictures
* instead of four frames of the same façade.
*/
const WIKIDATA_IMAGE_PROPERTIES = [
'P18', // image
'P5775', // interior view
'P3451', // night view
'P8592', // aerial view
'P4291', // panoramic view
'P5252', // winter view
'P948', // Wikivoyage banner
] as const;
/** First non-empty string value of a claim list. */
function claimValue(snaks: WikidataSnak[] | undefined): string | null {
for (const snak of snaks ?? []) {
const value = snak.mainsnak?.datavalue?.value;
if (typeof value === 'string' && value.trim()) return value.trim();
}
return null;
}
/**
* File names from an item's picture properties, best first.
*
* Within P18 the statement rank decides: an item with several images marks one
* `preferred`, and that is the one an editor considers representative. The API
* returns statements in edit order, not rank order, so taking `[0]` picks
* whichever was added first — for the Brandenburg Gate that is a coin toss
* between the morning shot and a wide overview.
*/
function wikidataImageClaims(claims: WikidataClaims, limit: number): string[] {
const rankOrder = { preferred: 0, normal: 1, deprecated: 2 } as const;
const names: string[] = [];
const seen = new Set<string>();
for (const property of WIKIDATA_IMAGE_PROPERTIES) {
const snaks = [...(claims[property] ?? [])]
.filter((snak) => snak.rank !== 'deprecated')
.sort((a, b) => (rankOrder[a.rank ?? 'normal'] ?? 1) - (rankOrder[b.rank ?? 'normal'] ?? 1));
for (const snak of snaks) {
const value = snak.mainsnak?.datavalue?.value;
if (typeof value !== 'string' || !value.trim()) continue;
const key = normalizeFileTitle(value);
if (seen.has(key)) continue;
seen.add(key);
names.push(value.trim());
if (names.length >= limit) return names;
}
}
return names;
}
/** `File:` prefix off, underscores and case normalised — Commons treats these as one title. */
function normalizeFileTitle(title: string): string {
return title.replace(/^File:/i, '').replaceAll('_', ' ').trim().toLowerCase();
}
/**
* A bare Commons category name out of whatever an OSM `wikimedia_commons` tag
* holds.
*
* The tag is free text and mappers put three different things in it: a bare
* name, a prefixed `Category:…`, or — against the wiki's own advice — a single
* `File:…`. Prefixing blindly turned the last one into `Category:File:X.jpg`,
* which matches nothing and fell through to the coordinate search without a
* word. Localised prefixes (`Kategorie:`) appear too.
*/
function normalizeCategoryName(raw: string): string | null {
const value = raw.trim();
if (!value) return null;
// A file name is not a category, and there is nothing sensible to derive.
if (/^(file|datei|image|bild)\s*:/i.test(value)) return null;
return value.replace(/^(category|kategorie|categorie|categoría|categoria)\s*:/i, '').trim() || null;
}
/**
* The chain a place belongs to, when it belongs to one.
*
* Read separately from `readWikiIdentity` and never mixed into it. Following
* `brand:wikidata` as if it described the place is how "L'Osteria Rostock"
* ends up illustrated with the company logo and described as a franchise
* operator — which is why the picture ladder never sees these. For a
* description they are still worth something: a branch of a chain has no
* article of its own and never will, and "L'Osteria is a German restaurant
* chain serving pizza and pasta" beats an empty column, as long as the reader
* is told that is what they are looking at.
*/
export function readBrandIdentity(extratags: Record<string, string> | null | undefined): {
wikidata: string | null;
wikipedia: string | null;
} {
const read = (key: string): string | null => {
const value = extratags?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
};
return { wikidata: read('brand:wikidata'), wikipedia: read('brand:wikipedia') };
}
/** Picks the three identity tags out of a Nominatim `extratags` blob. */
export function readWikiIdentity(extratags: Record<string, string> | null | undefined): WikiIdentity {
const out: WikiIdentity = { wikipedia: null, wikidata: null, wikimedia_commons: null };
if (!extratags) return out;
for (const tag of WIKI_IDENTITY_TAGS) {
const value = extratags[tag];
if (typeof value === 'string' && value.trim()) out[tag] = value.trim();
}
return out;
}
interface OverpassElement {
tags?: Record<string, string>;
}
interface WikiCommonsPage {
pageid?: number;
title?: string;
imageinfo?: {
url?: string;
thumburl?: string;
mime?: string;
width?: number;
height?: number;
/** The file description page — where the full licence terms live. */
descriptionurl?: string;
extmetadata?: {
Artist?: { value?: string };
LicenseShortName?: { value?: string };
LicenseUrl?: { value?: string };
UsageTerms?: { value?: string };
/** Used to spot survey imagery and diagrams, which are not pictures of a place. */
Categories?: { value?: string };
ObjectName?: { value?: string };
ImageDescription?: { value?: string };
};
}[];
}
/**
* One Commons image with everything needed to credit it. Commons is mostly
* CC BY / CC BY-SA, so a candidate that cannot be attributed is not usable in a
* picker — the fields are nullable because Commons metadata is user-maintained
* and genuinely incomplete on some files, not because they are optional to show.
*/
export interface CommonsCandidate {
photoUrl: string;
attribution: string | null;
license: string | null;
licenseUrl: string | null;
sourceUrl: string | null;
/**
* Commons page id — the only stable identity a file has across the four ways
* we reach it. The thumbnail URL is not: the same file comes back from
* commons.wikimedia.org and from a language Wikipedia with different query
* strings, so deduplicating on the URL silently lets the same picture through
* twice. It is also what keys the cached bytes, so it must survive.
*/
pageId: number | null;
/** File page title, e.g. `File:Brandenburger Tor morgens.jpg`. */
title: string | null;
width: number | null;
height: number | null;
/** Free text used to reject survey imagery, floor plans and logos. */
descriptors: string | null;
}
interface GooglePlaceResult {
id: string;
displayName?: { text: string };
/** OPERATIONAL | CLOSED_TEMPORARILY | CLOSED_PERMANENTLY. Absent on non-business results. */
businessStatus?: string;
formattedAddress?: string;
location?: { latitude: number; longitude: number };
rating?: number;
websiteUri?: string;
nationalPhoneNumber?: string;
types?: string[];
googleMapsUri?: string;
}
interface GoogleAutocompleteSuggestion {
placePrediction?: {
placeId: string;
structuredFormat?: {
mainText?: { text: string };
secondaryText?: { text: string };
};
};
}
interface GooglePlaceDetails extends GooglePlaceResult {
userRatingCount?: number;
regularOpeningHours?: GoogleOpeningHours;
editorialSummary?: { text: string };
reviews?: {
authorAttribution?: { displayName?: string; photoUri?: string };
rating?: number;
text?: { text?: string };
relativePublishTimeDescription?: string;
}[];
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
}
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
// of places cannot monopolise the event loop or trigger external API rate limits.
// Module-scoped ON PURPOSE (permissions-cache precedent): the bridge instance and
// the DI singleton must share one limiter, one POI cache and one call counter.
// Wikimedia is normally well under a second, but a cold TLS handshake from a
// fresh container has been seen at eight. Enrichment answers a live dialog, so
// a slow provider is dropped rather than waited out.
// A Google Maps place page is a few hundred KB; the coordinates sit in the
// embedded map data near the top, so two megabytes is plenty and keeps an
// unbounded body out of memory.
const MAX_MAPS_PAGE_BYTES = 2_000_000;
export const GOOGLE_SHORT_HOSTS = ['goo.gl', 'maps.app.goo.gl'];
/**
* Google Maps lives on every country domain — google.de, maps.google.co.uk,
* google.com.au — so the host is matched by shape. A fixed list of .com hosts
* would quietly stop resolving the ccTLD links people actually paste. The TLD
* labels stay short (2-3 letters, optionally two of them) so that
* `google.evil.com` is not a Google host.
*/
export function isGoogleMapsHost(hostname: string): boolean {
return GOOGLE_SHORT_HOSTS.includes(hostname)
|| /^(www\.|maps\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(hostname);
}
const WIKI_TIMEOUT_MS = 6000;
// ── Brand logos ──────────────────────────────────────────────────────────────
//
// A road trip corridor is mostly chains — Shell, Aral, JET — and the brand is the
// fastest thing to recognise on a map. OSM carries `brand:wikidata` on most of them,
// Wikidata carries the logo (P154), and Commons serves it.
//
// The bytes are proxied rather than linked so the browser never talks to Wikimedia:
// one self-hosted instance asking for a handful of logos is a very different egress
// profile from every visitor's browser announcing which petrol stations they are
// looking at. The cache is in memory on purpose — a GET must not write to the DB, and
// there are only so many fuel brands.
const BRAND_LOGO_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const BRAND_LOGO_CACHE_MAX = 300;
/** Well past any logo; a Commons original can be a multi-megabyte SVG or print-res PNG. */
const BRAND_LOGO_MAX_BYTES = 512 * 1024;
/** A place photo is a full-size image rather than a 128px mark, so its own ceiling. */
const WIKIMEDIA_PHOTO_MAX_BYTES = 8 * 1024 * 1024;
const BRAND_LOGO_WIDTH = 128;
/** The square the logo is centred in, and the breathing room around it. */
const BRAND_LOGO_CANVAS = 96;
const BRAND_LOGO_PADDING = 8;
const WIKIDATA_ID_RE = /^Q[1-9][0-9]{0,11}$/;
export interface BrandLogo {
bytes: Buffer;
contentType: string;
}
/**
* Puts a logo on a background it can actually be seen against.
*
* Half the fuel brands ship a white wordmark with a transparent background —
* TotalEnergies, Esso and JET among them — and on the white pill the marker used to
* draw they were invisible. Which background is right is a property of the image, not
* of the brand, so it is measured: the mean brightness of the pixels that are actually
* opaque decides between a white and a near-black backdrop.
*
* Returns the flattened PNG, or null when the bytes cannot be read at all — the pin
* then keeps its category icon, which is a fine outcome.
*/
async function flattenBrandLogo(bytes: Buffer): Promise<BrandLogo | null> {
try {
const image = await Jimp.read(bytes);
const width = image.bitmap.width;
const height = image.bitmap.height;
if (!width || !height) return null;
let sum = 0;
let opaque = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const alpha = image.bitmap.data[idx + 3];
// Half-transparent pixels are anti-aliasing, not the mark itself.
if (alpha < 128) continue;
const r = image.bitmap.data[idx];
const g = image.bitmap.data[idx + 1];
const b = image.bitmap.data[idx + 2];
sum += 0.2126 * r + 0.7152 * g + 0.0722 * b;
opaque++;
}
}
// A logo with nothing opaque in it is not a logo.
if (opaque === 0) return null;
// Square, with the logo scaled to fit inside it. Most brand marks are wide wordmarks
// — TotalEnergies is three times as wide as it is tall — and a round pin showing the
// middle of one is unreadable. Fitting it into a square means the pin can crop to a
// circle without ever cutting the mark itself.
const scale = Math.min(
(BRAND_LOGO_CANVAS - 2 * BRAND_LOGO_PADDING) / width,
(BRAND_LOGO_CANVAS - 2 * BRAND_LOGO_PADDING) / height,
);
// Never upscale: a 16-pixel favicon blown up to 96 looks worse than a small one.
if (scale < 1) image.scale(scale);
const light = sum / opaque > 150;
const canvas = new Jimp({
width: BRAND_LOGO_CANVAS,
height: BRAND_LOGO_CANVAS,
color: light ? 0x111827ff : 0xffffffff,
});
canvas.composite(
image,
Math.round((BRAND_LOGO_CANVAS - image.bitmap.width) / 2),
Math.round((BRAND_LOGO_CANVAS - image.bitmap.height) / 2),
);
const out = await canvas.getBuffer('image/png');
return { bytes: Buffer.from(out), contentType: 'image/png' };
} catch {
return null;
}
}
// Tighter than the wiki calls, because this one sits at the FRONT of a chain:
// identity, then sitelinks, then the extract. Nominatim answers a bounded
// search in 0.2-0.7s in practice, so anything past a couple of seconds is a bad
// day at the provider rather than a slow answer worth waiting for.
const IDENTITY_TIMEOUT_MS = 2500;
// The explicit search asks Nominatim alongside the index, and the pair costs
// the slower one. The index gives up after 3.5 s and the browser after 8 s, so a
// Nominatim that accepts the connection and then sits on it (the public service
// under load, a self-hosted one that hung) has to give up in between: without a
// deadline of its own the request rides undici's 300 s default, and the index's
// answer is thrown away with the request that timed out waiting for it. The
// deadline starts after the throttle wait, so it measures the answer alone:
// generous for a slow one, still inside the browser's budget with the wait
// added on.
const SEARCH_TIMEOUT_MS = 6000;
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
let photoFetchActive = 0;
const photoFetchQueue: Array<() => void> = [];
function acquirePhotoFetchSlot(): Promise<void> {
if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) {
photoFetchActive++;
return Promise.resolve();
}
return new Promise((resolve) => photoFetchQueue.push(resolve));
}
function releasePhotoFetchSlot(): void {
const next = photoFetchQueue.shift();
if (next) {
next();
} else {
photoFetchActive--;
}
}
/**
* Runs an outbound photo fetch under the shared slot limit. Exported so the
* enrichment module queues behind the same five slots — a picker that grabs
* three images per selected place would otherwise sail past a cap the rest of
* the app respects.
*/
export async function withPhotoFetchSlot<T>(fn: () => Promise<T>): Promise<T> {
await acquirePhotoFetchSlot();
try {
return await fn();
} finally {
releasePhotoFetchSlot();
}
}
// ── Overpass POI search state ────────────────────────────────────────────────
interface OverpassPoiElement {
type: string;
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
interface PoiSearchResult {
pois: OverpassPoi[];
/**
* Which index answered. Not decoration: this branch calls naming the sources
* a licence obligation rather than a courtesy, and Overture carries more than
* OpenStreetMap under more than one licence, so stamping its rows as OSM is a
* wrong attribution rather than a rounding of one.
*/
source: 'openstreetmap' | 'trek-places';
truncated: boolean;
// True when the requested viewport was too large and got shrunk to a centred
// window before querying — the results then cover the middle of the view only.
clamped: boolean;
}
// Frozen at module load, same timing as the legacy service ("frozen on purpose",
// see src/app-config/README.md).
const OVERPASS_MIRRORS = resolveOverpassEndpoints();
const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs();
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes
// Overpass scan millions of elements and time out; clamping to a centred window
// keeps the query cheap so the explore pill returns fast at ANY zoom level.
const MAX_BBOX_SPAN_DEG = 0.5;
// Short-lived cache so panning back over / re-toggling the same area doesn't
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
// Cap the number of cached areas so panning across the globe can't grow the map
// without bound (entries are evicted oldest-first once the cap is reached).
const POI_CACHE_MAX = 500;
/**
* Hard ceiling on one POI answer, however many categories it carries. A corridor search
* asks for four kinds at once; without a ceiling a dense city box would return a payload
* nobody reads and every mirror pays for.
*/
const POI_RESULT_CAP = 240;
// POST the query to all mirrors at once and return the first one that answers with
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
// sum of every dead mirror's timeout.
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
const body = `data=${encodeURIComponent(query)}`;
const controllers: AbortController[] = [];
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
const ctrl = new AbortController();
controllers.push(ctrl);
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
body,
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
const data = (await res.json()) as { elements?: OverpassPoiElement[]; remark?: string };
// Overpass signals an internal timeout / runtime error via `remark` while
// still answering HTTP 200 — often fast, with an empty or partial element
// set. Treat that as a failed attempt so a healthy mirror wins the race
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
// still surfaces a real error to the client instead of a silent "no places".
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
return data.elements;
} finally {
clearTimeout(timer);
}
};
try {
// Promise.any resolves with the first mirror to return valid JSON, and only
// rejects (AggregateError) once every mirror has failed.
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
} catch (err) {
// Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM
// body, …) so an operator can tell blocked egress / a firewall from a transiently
// overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309).
const reasons =
err instanceof AggregateError
? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).join(' | ')
: err instanceof Error
? err.message
: String(err);
console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`);
throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 });
} finally {
// Cancel the slower/losing requests — we already have (or have given up on) a result.
controllers.forEach((c) => {
try {
c.abort();
} catch {
/* noop */
}
});
}
}
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
/**
* The app_settings row that names the keyed places provider.
*
* A setting rather than "whichever key is configured": an install can hold both
* credentials (a team split between China and elsewhere), and then only an
* admin can say which one should answer. Absent, which is every install that
* predates Amap, means `auto`, which keeps Google.
*/
export const PLACES_PROVIDER_SETTING = 'places_provider';
/**
* Whoever holds the keyed slot beside the index for one request: Google's
* credential, an Amap provider, or nobody (the OpenStreetMap stack alone).
*/
type KeyedProvider =
| { id: 'google'; key: string; source: ApiKeySource | null }
| { id: 'amap'; provider: AmapPlacesProvider };
/**
* /api/maps domain service — geocoding, the provider fan-out
* (Nominatim/Overpass/Google), the place-details/photo caches and the SSRF
* guard on every outbound URL. DI-native since the maps fold: the legacy
* services/mapsService.ts functions live here as methods over the injected
* DatabaseService (byte-identical SQL and behaviour). Every consumer injects
* this class; pure helpers live in maps.helpers.ts.
*
* The per-endpoint kill-switches are settings reads the legacy route does
* inline; they're encapsulated here as `*Disabled()` helpers over the same
* `app_settings` rows.
*/
@Injectable()
export class MapsService {
constructor(
private readonly database: DatabaseService,
private readonly photoCache: PlacePhotoCacheService,
) {}
/** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the
* oldest entry is the one evicted when it fills up. */
private readonly brandLogoCache = new Map<string, { at: number; logo: BrandLogo | null }>();
private isSettingDisabled(key: string): boolean {
const row = this.database.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?',
key,
);
return row?.value === 'false';
}
/**
* Whether the index answers on this instance.
*
* On unless TREK_PLACES_ENABLED says otherwise, because the index is the path
* we want people on and an upgrade must not quietly drop back to Nominatim,
* whose usage policy forbids what TREK was doing with it.
*
* An environment variable rather than an admin switch on purpose. This decides
* whether a search leaves the instance at all, which is a property of the
* deployment: an operator pins it in their compose file, and it cannot be
* turned off from a browser by whoever holds an admin account that day.
*/
trekPlacesEnabled(): boolean {
return readEnv().maps.trekPlacesEnabled;
}
/**
* The places in a trip's area, for the offline cache.
*
* The one call TREK makes that is not driven by something a user just typed.
* It runs when a trip is prepared for offline use, alongside the map tiles,
* and it is the reason searching a trip works on a plane: without it the
* offline instance has the trip's own places and nothing else, so "find a
* pharmacy near the hotel" has nothing to answer from.
*
* Returns null rather than throwing when the index is switched off or the
* service is unreachable, because this is a nice-to-have running in the
* background of a sync — a failure here must not fail the sync.
*/
async placesInArea(
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
limit?: number,
): Promise<{ results: Record<string, unknown>[]; truncated: boolean } | null> {
if (!this.trekPlacesEnabled()) return null;
try {
const area = await trekPlacesArea(bbox, limit);
return { results: area.results.map(toPlaceRecord), truncated: area.truncated };
} catch (err) {
console.warn('TREK Places area lookup failed:', (err as Error).message);
return null;
}
}
autocompleteDisabled(): boolean {
return this.isSettingDisabled('places_autocomplete_enabled');
}
detailsDisabled(): boolean {
return this.isSettingDisabled('places_details_enabled');
}
photosDisabled(): boolean {
return this.isSettingDisabled('places_photos_enabled');
}
// ── Controller-facing surface (unchanged signatures) ───────────────────────
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> {
return this.searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
}
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias, sessionToken?: string): Promise<MapsAutocompleteResult> {
return this.autocompletePlaces(userId, input, lang, locationBias, sessionToken) as Promise<MapsAutocompleteResult>;
}
details(userId: number, placeId: string, lang?: string, sessionToken?: string): Promise<MapsPlaceDetailsResult> {
return this.getPlaceDetails(userId, placeId, lang, sessionToken) as Promise<MapsPlaceDetailsResult>;
}
detailsExpanded(userId: number, placeId: string, lang: string | undefined, refresh: boolean): Promise<MapsPlaceDetailsResult> {
return this.getPlaceDetailsExpanded(userId, placeId, lang, refresh) as Promise<MapsPlaceDetailsResult>;
}
photo(userId: number, placeId: string, lat: number, lng: number, name?: string): Promise<MapsPlacePhotoResult> {
return this.getPlacePhoto(userId, placeId, lat, lng, name) as Promise<MapsPlacePhotoResult>;
}
photoBytesKey(placeId: string): Promise<string | null> {
return this.photoCache.serveKey(placeId);
}
reverse(lat: string, lng: string, lang?: string): Promise<MapsReverseResult> {
return this.reverseGeocode(lat, lng, lang) as Promise<MapsReverseResult>;
}
resolveUrl(url: string): Promise<MapsResolveUrlResult> {
return this.resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
}
/**
* The logo of a brand, by its Wikidata id, as bytes.
*
* Two hops: Wikidata says which Commons file is the logo (property P154), Commons
* serves a thumbnail of it. Both answers are cached, including "this brand has no
* logo" — otherwise every map pan would ask Wikidata about the same supermarket
* chain again. Returns null whenever anything is missing or unreadable; a marker
* without a logo falls back to its category icon, which is a fine outcome.
*/
async brandLogo(wikidataId: string): Promise<BrandLogo | null> {
if (!WIKIDATA_ID_RE.test(wikidataId)) return null;
const cached = this.brandLogoCache.get(wikidataId);
if (cached && Date.now() - cached.at < BRAND_LOGO_CACHE_TTL_MS) return cached.logo;
const remember = (logo: BrandLogo | null): BrandLogo | null => {
if (this.brandLogoCache.size >= BRAND_LOGO_CACHE_MAX) {
const oldest = this.brandLogoCache.keys().next().value;
if (oldest !== undefined) this.brandLogoCache.delete(oldest);
}
this.brandLogoCache.set(wikidataId, { at: Date.now(), logo });
return logo;
};
try {
const params = new URLSearchParams({
action: 'wbgetclaims',
entity: wikidataId,
property: 'P154',
format: 'json',
});
const claimRes = await fetch(`https://www.wikidata.org/w/api.php?${params}`, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
if (!claimRes.ok) return remember(null);
const claims = (await claimRes.json()) as {
claims?: { P154?: { mainsnak?: { datavalue?: { value?: unknown } } }[] };
};
const file = claims.claims?.P154?.[0]?.mainsnak?.datavalue?.value;
if (typeof file !== 'string' || !file.trim()) return remember(null);
// Special:FilePath renders a thumbnail at the width asked for and redirects to
// the CDN, so each hop is re-checked by the guard rather than trusted.
const url = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=${BRAND_LOGO_WIDTH}`;
// The same six seconds the Wikidata hop above allows. Without a deadline
// this waited on undici's five-minute default, holding a request context
// and a socket per stalled logo while the pin sat on its fallback icon.
const imgRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, { bypassInternalIpAllowed: true });
if (!imgRes.ok) return remember(null);
if (exceedsDeclaredLength(imgRes, BRAND_LOGO_MAX_BYTES)) {
discardBody(imgRes);
return remember(null);
}
// Streamed rather than buffered whole: a chunked answer declares no length,
// so the post-check only ever ran after the bytes were already in memory.
const { bytes, truncated } = await readCapped(imgRes, BRAND_LOGO_MAX_BYTES);
if (truncated || bytes.byteLength === 0) return remember(null);
const contentType = imgRes.headers.get('content-type') ?? '';
if (!contentType.startsWith('image/')) return remember(null);
return remember(await flattenBrandLogo(bytes));
} catch (err) {
if (err instanceof SsrfBlockedError) return remember(null);
return remember(null);
}
}
// POI search by category within a viewport. Never calls Google.
//
// The index answers first: it holds the same places, indexed in an R-Tree, and
// the public Overpass mirrors this used to depend on are regularly overloaded.
// A miss or a failure drops through to Overpass exactly as before.
async pois(
category: string,
bbox: { south: number; west: number; north: number; east: number },
lang?: string,
limit = 60,
): Promise<PoiSearchResult> {
// One or several categories, the way searchOverpassPois reads them. The
// corridor search sends a comma-separated list, and looking the whole string
// up as one key missed every time — so every corridor query fell through to
// Overpass, on the one path where that hurts most: a single search fans out
// over sixteen boxes, and each of those races four public mirrors.
const wanted = parsePoiCategories(category);
// Which category each Overture term belongs to, so a hit can be labelled
// with the category that actually produced it rather than with the whole
// list. The client colours and groups its markers by that field.
const categoryOfTerm = new Map<string, string>();
for (const key of wanted) {
for (const term of POI_CATEGORY_TO_TREK[key] ?? []) categoryOfTerm.set(term, key);
}
// All or nothing: a category the index has no terms for has to be answered
// by Overpass, and a mixed answer would silently drop it.
const indexKnowsAll = wanted.length > 0 && wanted.every(key => POI_CATEGORY_TO_TREK[key]?.length);
const terms = [...categoryOfTerm.keys()];
// The index matches a term as a SUBSTRING of `category` and `category_path`
// (see POI_CATEGORY_TO_TREK), so an exact lookup misses every leaf that is
// not literally a term: `italian_restaurant` is a hit for `restaurant` and
// finds nothing here. Falling through to wanted[0] then labelled it with
// whichever pill the user happened to tap first, and the corridor panel
// groups and colours on exactly that field, so a trattoria came back as a
// petrol station. Longest match wins, because `fast_food` must not lose to
// `food` when both are terms of different categories.
const labelFor = (leaf: string | null, path: string | null): string | undefined => {
const haystack = `${leaf ?? ''} ${path ?? ''}`;
let best: string | undefined;
for (const [term, key] of categoryOfTerm) {
if (!haystack.includes(term)) continue;
if (best === undefined || term.length > best.length) best = term;
}
return best === undefined ? undefined : categoryOfTerm.get(best);
};
if (this.trekPlacesEnabled() && indexKnowsAll) {
try {
const lat = (bbox.south + bbox.north) / 2;
const lng = (bbox.west + bbox.east) / 2;
// Half the diagonal, so the circle covers the viewport corners rather
// than leaving the edges of the map empty.
const reach = Math.round(
Math.hypot(
(bbox.north - bbox.south) * 111_320,
(bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180),
) / 2,
);
const radius = Math.min(20000, Math.max(300, reach));
// The same budget the Overpass path spends: per category, capped, so a
// mixed search does not spend the whole allowance on whichever kind
// happens to be densest.
const cap = Math.min(limit * wanted.length, POI_RESULT_CAP);
const found = await trekPlacesNearby(lat, lng, {
radius,
limit: cap,
category: terms.join(','),
});
if (found.length > 0) {
// Same shape the Overpass path produces, so the client and the map
// renderer need no branch — but named as what it is. Overture is not
// OpenStreetMap: it carries OSM among other sources under other
// licences, and this branch argues elsewhere that naming a source is
// a licence obligation. The wire contract keeps `source` an open
// string, so widening it costs nothing.
//
// One thing the index cannot do is localise. The Overpass path picks
// `name:<lang>` and falls back to `int_name`; the service has no
// language parameter at all, so a German user exploring Tokyo gets
// the Japanese primary names here. Named rather than hidden: whoever
// adds localisation upstream should find this comment.
return {
pois: found.map(p => ({
osm_id: `gers:${p.gers}`,
name: p.name,
lat: p.lat,
lng: p.lng,
// The category that produced the hit, not the list that was
// asked for: a mixed search must not label a petrol station as
// "fuel,charging,restaurant".
category: labelFor(p.category ?? null, p.categoryPath ?? null) ?? wanted[0],
poi_type: p.category ?? wanted[0],
address: p.address?.freeform ?? null,
website: p.contact?.website ?? null,
phone: p.contact?.phone ?? null,
opening_hours: p.hours?.osm ?? null,
// The index carries the chain and its Wikidata item, which is what
// the logo on the pin is looked up from — so a branch of a chain
// gets its own mark here exactly as it does on the Overpass path.
brand: p.brand?.name ?? null,
brand_wikidata: p.brand?.wikidata ?? null,
// Sockets are an OSM thing; the index has no charging fields, so
// a station answered from here reports "not stated" rather than
// claiming it offers nothing.
charging: null,
// The index has no cuisine field, so this null is the truth
// rather than a field being dropped on the way through.
cuisine: null,
source: 'trek-places' as const,
})),
source: 'trek-places' as const,
truncated: found.length >= cap,
// A wide viewport is narrowed here too, and the caller is told so
// for the same reason the Overpass path tells it.
clamped: radius < reach,
};
}
} catch (err: unknown) {
console.warn('TREK Places nearby failed, falling back:', (err as Error).message);
}
}
return this.searchOverpassPois(category, bbox, lang, limit);
}
// ── API key retrieval ──────────────────────────────────────────────────────
/**
* The Places credential for this request, and where it came from.
*
* Operator env first: a per-user key would route around whatever the
* operator's endpoint counts, and unset, that branch never runs. Then the
* instance-wide value the admin panel writes, then the caller's own row.
*
* What is deliberately gone is the old third step, "any admin's key" (#1939):
* it read a stranger's credential, which server/CLAUDE.md forbids, and made
* the answer depend on who was asking — the saving admin got their own key,
* everybody else got the lowest-id admin's and a 403 from Google. The source
* is returned so a provider error can say which of the three was used.
*/
async resolveMapsKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
}
async getMapsKey(userId: number): Promise<string | null> {
return (await this.resolveMapsKey(userId)).key;
}
/** The Amap credential, resolved through the identical three-step chain. */
async resolveAmapKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
return resolveApiKey(this.database, 'amap_api_key', userId, readEnv().maps.amapApiKey);
}
// ── Keyed provider selection ───────────────────────────────────────────────
/**
* Which keyed provider the admin picked, or `auto`.
*
* An unrecognised stored value degrades to `auto` rather than throwing: this
* is read on the hot path of every search, and a hand-edited settings row must
* not take place search down.
*/
placesProviderChoice(): PlacesProviderChoice {
const row = this.database.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?',
PLACES_PROVIDER_SETTING,
);
return isPlacesProviderChoice(row?.value) ? row.value : 'auto';
}
/**
* Who holds the keyed slot for this request, or null for the OpenStreetMap
* stack alone. The index and OpenStreetMap are asked either way; this only
* decides what answers once they have nothing.
*
* `auto`, the default and what every install that predates Amap has, prefers
* Google. That is deliberately the incumbent rather than "the newest provider
* wins": an existing install must not silently start querying somewhere
* else, with a different bill and different results, because a release added
* a provider. An admin who wants Amap says so.
*
* Key resolution is ordered to match: under `auto` the Amap chain is only
* walked when there is no Google key, so an install on Google issues exactly
* the database reads it always did.
*/
async keyedProvider(userId: number): Promise<KeyedProvider | null> {
const choice = this.placesProviderChoice();
if (choice === 'openstreetmap') return null;
if (choice !== 'amap') {
const google = await this.resolveMapsKey(userId);
if (google.key) return { id: 'google', key: google.key, source: google.source };
// An explicit 'google' choice with no key is not a reason to query Amap
// instead: this install is on Google and is misconfigured. OSM answers,
// the way a keyless install has always been answered.
if (choice === 'google') return null;
}
const amap = await this.resolveAmapKey(userId);
return amap.key
? { id: 'amap', provider: new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) }
: null;
}
/** The Amap provider, when Amap holds the keyed slot; null otherwise. */
async resolvePlacesProvider(userId: number): Promise<AmapPlacesProvider | null> {
const keyed = await this.keyedProvider(userId);
return keyed?.id === 'amap' ? keyed.provider : null;
}
/**
* The Amap provider for an `amap:` id, regardless of which provider is
* currently selected.
*
* Places outlive the setting. An install that ran on Amap for a year and then
* switches to Google still holds its `amap:` places, and every one of those
* keeps opening against the Amap key that is still configured. Google ids do
* not come through here at all: they take the inline Google path, which
* resolves its own key the same way.
*
* Null means nobody can resolve it: a Google id, or an Amap place on an
* install that has since dropped its Amap key. Callers treat that as a miss,
* not an error.
*/
private async providerForPlaceId(userId: number, placeId: string): Promise<AmapPlacesProvider | null> {
if (!isAmapPlaceId(placeId)) return null;
const amap = await this.resolveAmapKey(userId);
return amap.key ? new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) : null;
}
/**
* A coordinate for a name that came out of an import, from whichever source
* has it.
*
* Booking imports geocode every venue and every uncoordinated endpoint in one
* request loop, up to thirty sequential lookups. Every one of those used to be
* a Nominatim call on the background lane, waiting out its throttle. The index
* answers most of them without leaving our own infrastructure and without a
* throttle at all; a street address it does not know still falls through to
* Nominatim, which is the source that resolves addresses.
*
* Never throws: an import that cannot place a hotel still imports the hotel.
*/
async geocodeQuery(query: string): Promise<{ lat: number; lng: number } | null> {
if (this.trekPlacesEnabled()) {
try {
const found = await trekPlacesSearch(query, { limit: 1 });
const hit = found[0];
if (hit && Number.isFinite(hit.lat) && Number.isFinite(hit.lng)) {
return { lat: hit.lat, lng: hit.lng };
}
} catch (err: unknown) {
console.warn('TREK Places geocode failed, falling back:', (err as Error).message);
}
}
const hit = (await this.searchNominatim(query, undefined, 'background'))[0];
return hit?.lat != null && hit?.lng != null ? { lat: hit.lat, lng: hit.lng } : null;
}
// ── Nominatim search ───────────────────────────────────────────────────────
/**
* `lane` defaults to interactive because most callers are a keystroke.
*
* Bulk callers must pass 'background': booking-import geocodes every venue and
* every uncoordinated endpoint of an import in one request loop, which is up
* to thirty sequential calls. On the interactive lane those thirty take the
* next slot each time, so somebody typing in the place search waits behind the
* whole import. Yielding does not make the import faster, it stops it from
* being the only thing the process will do for half a minute.
*/
async searchNominatim(
query: string,
lang?: string,
lane: GeoLane = 'interactive',
bias?: { lat: number; lng: number },
) {
const params = new URLSearchParams({
q: query,
format: 'json',
addressdetails: '1',
// Free, same request: this is where a place's wikidata/wikipedia/commons
// tags live. Without them the enrichment column can only fall back to
// "photos taken within 300m", which around a city centre is passers-by
// and the neighbouring building.
extratags: '1',
limit: '10',
'accept-language': toApiLang(lang),
});
// Prefer the area the caller is looking at, never restrict to it
// (bounded=0). Without this, "Hase-dera" returns the temple of that name in
// Nara rather than the one in Kamakura the user is standing next to; with
// bounded=1 a search for somewhere genuinely far away would return nothing.
if (bias) {
const d = 0.5;
params.set('viewbox', [bias.lng - d, bias.lat - d, bias.lng + d, bias.lat + d].join(','));
params.set('bounded', '0');
}
// Through the shared client: one throttle for the whole process.
const response = await nominatimFetch('search', params, { lane, timeoutMs: SEARCH_TIMEOUT_MS });
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`,
);
}
const data = (await response.json()) as NominatimResult[];
return data.map((item) => {
// Number.isFinite, not `|| null`: a place on the equator or prime
// meridian has a legitimate 0 coordinate.
const lat = Number.parseFloat(item.lat);
const lng = Number.parseFloat(item.lon);
return {
google_place_id: null,
google_ftid: null,
osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '',
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
rating: null,
website: null,
phone: null,
source: 'openstreetmap',
...readWikiIdentity(item.extratags),
};
});
}
/**
* Finds the OpenStreetMap record for a place we only know by name and
* coordinate, and hands back its tags.
*
* This is what gives a Google place a free identity. Google's payload has no
* `wikidata`, no `wikipedia` and no `wikimedia_commons` — it never had — so
* without this the entire free half of the enrichment column is unreachable
* for anyone who configured a Google key, which is the opposite of how this
* feature is meant to work. OSM knows these places perfectly well; nobody was
* asking it.
*
* Two gates keep it from describing the wrong building, because a confident
* description of somewhere else is worse than none:
* - the match has to be within `maxDistanceM` of where we are looking, and
* - it has to share a substantial word with the name we are looking for.
* Among what survives, Nominatim's own `importance` decides — that is what
* separates the Brandenburg Gate from the underground station named after it.
*/
async resolveOsmIdentity(
name: string,
lat: number,
lng: number,
opts: { lang?: string; maxDistanceM?: number; signal?: AbortSignal } = {},
): Promise<{ tags: Record<string, string>; osmUrl: string | null; matchedName: string } | null> {
const query = (name || '').trim();
if (!query || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const maxDistanceM = opts.maxDistanceM ?? 2000;
// ~2km box around the point, so Nominatim ranks locally instead of handing
// back the most famous place on earth with this name.
const d = 0.02;
const params = new URLSearchParams({
q: query,
format: 'jsonv2',
extratags: '1',
limit: '5',
bounded: '1',
viewbox: `${lng - d},${lat - d},${lng + d},${lat + d}`,
'accept-language': toApiLang(opts.lang),
});
try {
// The caller's deadline is handed in rather than built here, so the
// throttle wait cannot eat it before the request starts.
const res = await nominatimFetch('search', params, {
signal: opts.signal,
timeoutMs: IDENTITY_TIMEOUT_MS,
});
if (!res.ok) return null;
// Nominatim answers rate limiting in plain text, not JSON.
const data = (await res.json()) as (NominatimResult & { importance?: number })[];
if (!Array.isArray(data)) return null;
const best = data
.map((item) => ({
item,
lat: Number.parseFloat(item.lat),
lng: Number.parseFloat(item.lon),
}))
.filter(({ item, lat: hitLat, lng: hitLng }) => {
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLng)) return false;
if (haversineMetres(lat, lng, hitLat, hitLng) > maxDistanceM) return false;
const label = item.name || item.display_name?.split(',')[0] || '';
return namesOverlap(query, label);
})
.sort((a, b) => {
const byImportance = (b.item.importance ?? 0) - (a.item.importance ?? 0);
if (byImportance !== 0) return byImportance;
return (
haversineMetres(lat, lng, a.lat, a.lng) - haversineMetres(lat, lng, b.lat, b.lng)
);
})[0];
if (!best) return null;
return {
tags: best.item.extratags ?? {},
osmUrl:
best.item.osm_type && best.item.osm_id
? `https://www.openstreetmap.org/${best.item.osm_type}/${best.item.osm_id}`
: null,
matchedName: best.item.name || best.item.display_name?.split(',')[0] || query,
};
} catch {
return null;
}
}
// ── Nominatim lookup (by OSM ID) ───────────────────────────────────────────
async lookupNominatim(
osmType: string,
osmId: string,
lang?: string,
): Promise<{
name: string;
address: string;
lat: number | null;
lng: number | null;
extratags: Record<string, string> | null;
} | null> {
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
const params = new URLSearchParams({
osm_ids: `${typePrefix}${osmId}`,
format: 'json',
// Overpass is the richer source but it is also the one that times out;
// whatever Nominatim already knows costs nothing extra here.
extratags: '1',
'accept-language': toApiLang(lang),
});
try {
const res = await nominatimFetch('lookup', params);
if (!res.ok) return null;
const data = (await res.json()) as NominatimResult[];
const item = data[0];
if (!item) return null;
const lat = Number.parseFloat(item.lat);
const lng = Number.parseFloat(item.lon);
return {
name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '',
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
extratags: item.extratags ?? null,
};
} catch {
return null;
}
}
// ── Overpass API (OSM details) ─────────────────────────────────────────────
async fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
const oType = typeMap[osmType];
if (!oType) return null;
// The id is the one thing written into the query, and the query is a
// language. An OSM element id is a number and nothing else; anything with
// more in it is a statement of its own, sent under TREK's shared user agent.
if (!/^\d+$/.test(osmId)) return null;
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
try {
const res = await fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
if (!res.ok) return null;
const data = (await res.json()) as { elements?: OverpassElement[] };
return data.elements?.[0] || null;
} catch {
return null;
}
}
// ── Overpass POI search (by category within a viewport bbox) ───────────────
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
// calls Google, even when a Google key is configured.
async searchOverpassPois(
category: string,
bbox: { south: number; west: number; north: number; east: number },
lang?: string,
limit = 60,
): Promise<PoiSearchResult> {
// One or several categories in one query. Each OSM selector belongs to exactly one
// category, so a hit can still be labelled with the category it answered.
const categories = parsePoiCategories(category);
if (!categories.length) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
const categoryOfFilter = new Map<string, string>();
for (const key of categories) {
const own = CATEGORY_OSM_FILTERS[key];
if (!own) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
for (const f of own) categoryOfFilter.set(f, key);
}
const filters = [...categoryOfFilter.keys()];
// Each category gets its own share of the cap, or a mixed search would spend the
// whole budget on whichever kind happens to be densest.
const cap = Math.min(limit * categories.length, POI_RESULT_CAP);
// Clamp an oversized viewport to a centred window so the query stays cheap and
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
let { south, west, north, east } = bbox;
let clamped = false;
if (north - south > MAX_BBOX_SPAN_DEG) {
const c = (north + south) / 2;
south = c - MAX_BBOX_SPAN_DEG / 2;
north = c + MAX_BBOX_SPAN_DEG / 2;
clamped = true;
}
if (east - west > MAX_BBOX_SPAN_DEG) {
const c = (east + west) / 2;
west = c - MAX_BBOX_SPAN_DEG / 2;
east = c + MAX_BBOX_SPAN_DEG / 2;
clamped = true;
}
// OSM `name:*` tags are keyed by language subtag: prefer the user's language
// (the same localization the search/autocomplete path asks the geocoder for)
// over the native `name`. `int_name` is OSM's international/romanized name — a
// sensible fallback before the native one. Part of the cache key so a cached
// area isn't served with another language's titles.
const osmLang = toApiLang(lang).split('-')[0].toLowerCase();
// Serve repeat pans/toggles of the same area straight from the cache.
const cacheKey = `${[...categories].sort((a, b) => a.localeCompare(b)).join('+')}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${cap}`;
const cached = POI_CACHE.get(cacheKey);
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
const box = `(${south},${west},${north},${east})`;
const selectors = filters
.map((f) => {
const [k, v] = f.split('=');
return ` nwr["${k}"="${v}"]${box};`;
})
.join('\n');
// `out center tags <n>` returns ways/relations with a computed center and caps
// the result count in one round-trip.
const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`;
const elements = await overpassFetch(query);
const pois: OverpassPoi[] = [];
for (const el of elements) {
const tags = el.tags || {};
// `operator` comes last but matters for the road categories: petrol stations,
// charging points and service areas are routinely mapped with an operator and no
// name, and dropping those would empty the road trip corridor over long stretches.
const name =
tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
if (!name) continue; // unnamed POIs aren't useful to add to a plan
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
// the selectors above — but plenty keep their original tag and gain a marker
// instead, and those do come back. `opening_hours=closed`/`off` is the same
// statement in the hours field.
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
const lat = el.lat ?? el.center?.lat;
const lng = el.lon ?? el.center?.lon;
if (lat == null || lng == null) continue;
const matched =
filters.find((f) => {
const [k, v] = f.split('=');
return tags[k] === v;
}) || filters[0];
const addr =
[tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
.filter(Boolean)
.join(' ') || null;
pois.push({
osm_id: `${el.type}:${el.id}`,
name,
lat,
lng,
category: categoryOfFilter.get(matched) ?? categories[0],
poi_type: matched,
address: addr,
website: tags.website || tags['contact:website'] || null,
phone: tags.phone || tags['contact:phone'] || null,
opening_hours: tags.opening_hours || null,
cuisine: tags.cuisine || null,
brand: tags.brand || tags.operator || null,
// Only the plain Q-id form is passed on; anything else would be a lookup we
// would have to guess at.
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
// Only where it means something. Every POI carries `capacity` and `fee` for its
// own reasons — a restaurant's capacity is seats — so reading them as charging
// data anywhere else would be wrong on most of the map.
charging: categoryOfFilter.get(matched) === 'charging' ? readChargingInfo(tags) : null,
source: 'openstreetmap',
});
}
const truncated = pois.length > cap;
const value: PoiSearchResult = { pois: pois.slice(0, cap), source: 'openstreetmap', truncated, clamped };
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
POI_CACHE.set(cacheKey, { at: Date.now(), value });
return value;
}
// ── Wikimedia Commons photo lookup ─────────────────────────────────────────
async fetchWikimediaPhoto(
lat: number,
lng: number,
name?: string,
): Promise<{ photoUrl: string; attribution: string | null } | null> {
// Strategy 1: Search Wikipedia for the place name -> get the article image
if (name) {
try {
const searchParams = new URLSearchParams({
action: 'query',
format: 'json',
titles: name,
prop: 'pageimages',
piprop: 'thumbnail',
pithumbsize: '400',
pilimit: '1',
redirects: '1',
});
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
if (res.ok) {
const data = (await res.json()) as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
const pages = data.query?.pages;
if (pages) {
for (const page of Object.values(pages)) {
if (page.thumbnail?.source) {
return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' };
}
}
}
}
} catch {
/* fall through to geosearch */
}
}
// Strategy 2: Wikimedia Commons geosearch by coordinates
const candidates = await this.fetchCommonsCandidates(lat, lng, 5);
const first = candidates[0];
return first ? { photoUrl: first.photoUrl, attribution: first.attribution } : null;
}
/**
* Commons images near a coordinate, licence metadata included.
*
* geosearch already returns up to `limit` files in a single request, so asking
* for a whole strip costs the same as asking for one picture. Callers that only
* want a single image (fetchWikimediaPhoto, and through it getPlacePhoto) take
* the first entry and get exactly the file they got before this existed.
*/
/**
* Anything photographed near a coordinate. The bottom rung of the picture
* ladder, and the only one with no claim on the subject at all.
*
* 60 metres rather than the 300 it used to be. Three hundred metres in a city
* centre is a whole block: the town hall, the church and the underground
* entrance are all inside it, and every one of them outranks the doner shop
* we were actually asked about. Sixty is roughly "the same building and its
* neighbours", which is the widest a picture can be taken and still plausibly
* show the place. It does not fix the real problem, which is that nobody
* photographed the shop, but it stops the wrong answer from being confident.
*/
async fetchCommonsCandidates(lat: number, lng: number, limit = 5): Promise<CommonsCandidate[]> {
const params = new URLSearchParams({
action: 'query',
format: 'json',
generator: 'geosearch',
ggsprimary: 'all',
ggsnamespace: '6',
ggsradius: '60',
ggscoord: `${lat}|${lng}`,
// Deliberately more than the caller asked for. Around anything worth
// visiting the first few hits are survey tiles, passers-by and the
// building next door; the ranker needs a pool to reject from, and
// geosearch charges the same for one result as for twenty.
ggslimit: String(Math.max(1, Math.min(Math.max(limit * 4, 8), 20))),
prop: 'imageinfo',
iiprop: 'url|extmetadata|mime|size',
iiurlwidth: '400',
});
try {
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, {
headers: { 'User-Agent': UA },
// A hanging provider must not hold the whole enrichment request open;
// no pictures is a fine answer, a request that never returns is not.
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
if (!res.ok) return [];
const data = (await res.json()) as { query?: { pages?: Record<string, WikiCommonsPage> } };
// Hand back the whole pool. fetchWikimediaPhoto still takes [0] and gets
// what it always got; the enrichment column ranks before it cuts.
return this.toCommonsCandidates(data.query?.pages, Number(params.get('ggslimit')));
} catch {
return [];
}
}
/** Shared shaping for every Commons query (coordinate, category, Wikidata, batch). */
private toCommonsCandidates(
pages: Record<string, WikiCommonsPage> | undefined,
limit: number,
): CommonsCandidate[] {
if (!pages) return [];
const out: CommonsCandidate[] = [];
// entries(), not values(): the map key is the page id, and for the queries
// that reach a file by title it is the only place the id appears.
for (const [key, page] of Object.entries(pages)) {
const info = page.imageinfo?.[0];
// Only use actual photos (JPEG/PNG), skip SVGs and PDFs
const mime = info?.mime || '';
if (!info?.url || !(mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) continue;
const meta = info.extmetadata;
const pageId = page.pageid ?? (Number.isInteger(Number(key)) ? Number(key) : null);
out.push({
// iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it —
// info.url is the full-resolution original (multi-megapixel camera exports).
photoUrl: info.thumburl ?? info.url,
attribution: stripWikiMarkup(meta?.Artist?.value),
license: stripWikiMarkup(meta?.LicenseShortName?.value) ?? stripWikiMarkup(meta?.UsageTerms?.value),
licenseUrl: meta?.LicenseUrl?.value?.trim() || null,
sourceUrl: info.descriptionurl || null,
pageId: pageId && pageId > 0 ? pageId : null,
title: page.title ?? null,
width: info.width ?? null,
height: info.height ?? null,
descriptors: [
stripWikiMarkup(meta?.ObjectName?.value),
stripWikiMarkup(meta?.ImageDescription?.value),
stripWikiMarkup(meta?.Categories?.value),
]
.filter(Boolean)
.join(' | ') || null,
});
if (out.length >= limit) break;
}
return out;
}
/**
* Lead paragraph of a wiki article, from Wikivoyage first and Wikipedia after.
*
* Wikivoyage is the travel sibling: same MediaWiki API, same CC BY-SA, but it
* describes a place for someone about to go there, where Wikipedia opens with
* area in square kilometres and pronunciation. Both are resolved from the OSM
* `wikipedia` tag — guessing the article from the place name lands on the
* wrong one for every ambiguous name, so no tag means no description rather
* than a confident description of somewhere else.
*/
async fetchWikiExtract(
wikipediaTag: string | null | undefined,
): Promise<{ text: string; sourceUrl: string; source: 'wikivoyage' | 'wikipedia' } | null> {
const parsed = parseWikipediaTag(wikipediaTag);
if (!parsed) return null;
// Two sentences, not three: this sits next to a form, and a fourth line of
// prose pushes the pictures out of view.
const params = new URLSearchParams({
action: 'query',
format: 'json',
titles: parsed.title,
prop: 'extracts',
exintro: '1',
explaintext: '1',
exsentences: '2',
redirects: '1',
});
for (const host of ['wikivoyage', 'wikipedia'] as const) {
const hit = await this.fetchWikiExtractFor(host, parsed.lang, parsed.title);
if (hit) return hit;
}
return null;
}
/**
* The lead paragraph of one named article on one named wiki.
*
* Split out from `fetchWikiExtract` because the article is not always found
* through an OSM tag: a place can carry a Wikidata id and no `wikipedia` tag
* at all (Berlin Hauptbahnhof is exactly that), and then the title comes from
* the item's sitelinks instead.
*/
async fetchWikiExtractFor(
host: 'wikivoyage' | 'wikipedia',
lang: string,
title: string,
signal?: AbortSignal,
): Promise<{ text: string; sourceUrl: string; source: 'wikivoyage' | 'wikipedia' } | null> {
if (!lang || !title) return null;
// Two sentences, not three: this sits next to a form, and a fourth line of
// prose pushes the pictures out of view.
const params = new URLSearchParams({
action: 'query',
format: 'json',
titles: title,
prop: 'extracts',
exintro: '1',
explaintext: '1',
exsentences: '2',
redirects: '1',
});
try {
const res = await fetch(`https://${lang}.${host}.org/w/api.php?${params}`, {
headers: { 'User-Agent': UA },
signal: signal ?? AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = (await res.json()) as {
query?: { pages?: Record<string, { title?: string; extract?: string }> };
};
for (const page of Object.values(data.query?.pages ?? {})) {
const text = page.extract?.trim();
// A missing article comes back as a page with no extract, not a 404.
if (!text) continue;
const resolved = page.title ?? title;
return {
text,
sourceUrl: `https://${lang}.${host}.org/wiki/${encodeURIComponent(resolved)}`,
source: host,
};
}
return null;
} catch {
return null;
}
}
/**
* Which articles a Wikidata item is linked to, for the wikis we care about.
*
* The way to an article when a place has a Wikidata id but no `wikipedia`
* tag — which is most of them, because mappers add one or the other. One
* request, a few hundred bytes.
*/
async fetchWikidataSitelinks(
wikidataId: string,
sites: string[],
signal?: AbortSignal,
): Promise<Record<string, string>> {
const qid = wikidataId.trim();
if (!/^Q\d+$/.test(qid) || sites.length === 0) return {};
const params = new URLSearchParams({
action: 'wbgetentities',
props: 'sitelinks',
ids: qid,
sitefilter: sites.join('|'),
format: 'json',
});
try {
const res = await fetch(`https://www.wikidata.org/w/api.php?${params}`, {
headers: { 'User-Agent': UA },
signal: signal ?? AbortSignal.timeout(IDENTITY_TIMEOUT_MS),
});
if (!res.ok) return {};
const data = (await res.json()) as {
entities?: Record<string, { sitelinks?: Record<string, { title?: string }> }>;
};
const out: Record<string, string> = {};
for (const [site, link] of Object.entries(data.entities?.[qid]?.sitelinks ?? {})) {
if (link?.title) out[site] = link.title;
}
return out;
} catch {
return {};
}
}
/**
* The pictures Wikidata records for a place, best first.
*
* By far the most accurate source there is: a person chose each of these to
* represent this exact object, where a coordinate search only knows what was
* photographed nearby. Wikidata also keeps them apart by what they show, so
* asking for more than P18 buys genuine variety rather than another frame of
* the same burst — Berlin Hauptbahnhof has an exterior, two interiors, a
* night shot, a panorama and a winter view, all curated.
*
* Two calls total whatever the item holds: one for the claims, one batch for
* the file metadata.
*/
async fetchWikidataCandidates(
wikidataId: string,
limit = 5,
): Promise<{ candidates: CommonsCandidate[]; commonsCategory: string | null }> {
const empty = { candidates: [], commonsCategory: null };
const qid = wikidataId.trim();
if (!/^Q\d+$/.test(qid)) return empty;
try {
const res = await fetch(
`https://www.wikidata.org/w/api.php?action=wbgetentities&props=claims&ids=${qid}&format=json`,
{ headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) },
);
if (!res.ok) return empty;
const data = (await res.json()) as { entities?: Record<string, { claims?: WikidataClaims }> };
const claims = data.entities?.[qid]?.claims;
if (!claims) return empty;
const fileNames = wikidataImageClaims(claims, limit);
const commonsCategory = claimValue(claims.P373) ?? null;
if (fileNames.length === 0) return { candidates: [], commonsCategory };
const byTitle = await this.fetchCommonsFilesByName(fileNames);
// Back into the order Wikidata implied, which the batch response loses.
const candidates = fileNames.map((name) => byTitle.get(normalizeFileTitle(name))).filter((c): c is CommonsCandidate => !!c);
return { candidates, commonsCategory };
} catch {
return empty;
}
}
/**
* Metadata for a list of Commons files, in one request.
*
* `redirects=1` matters more than it looks: a Wikidata claim or a Wikipedia
* lead image often names a file that has since been renamed, and without it
* the API answers with a `missing` page and the picture disappears silently.
* Keyed by normalised title so callers can restore their own ordering.
*/
async fetchCommonsFilesByName(fileNames: string[]): Promise<Map<string, CommonsCandidate>> {
const out = new Map<string, CommonsCandidate>();
const titles = fileNames.map((name) => (/^File:/i.test(name) ? name : `File:${name}`));
if (titles.length === 0) return out;
const params = new URLSearchParams({
action: 'query',
format: 'json',
titles: titles.join('|'),
redirects: '1',
prop: 'imageinfo',
iiprop: 'url|extmetadata|mime|size',
iiurlwidth: '400',
});
try {
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
if (!res.ok) return out;
const data = (await res.json()) as {
query?: {
pages?: Record<string, WikiCommonsPage>;
normalized?: { from: string; to: string }[];
redirects?: { from: string; to: string }[];
};
};
// The API renames titles twice on the way in (normalisation, then
// redirects), so walk the chain back to what the caller asked for.
const aliases = new Map<string, string>();
for (const hop of [...(data.query?.normalized ?? []), ...(data.query?.redirects ?? [])]) {
aliases.set(normalizeFileTitle(hop.to), normalizeFileTitle(hop.from));
}
const resolveOriginal = (title: string): string => {
let key = normalizeFileTitle(title);
for (let hop = 0; hop < 4; hop++) {
const previous = aliases.get(key);
if (!previous || previous === key) break;
key = previous;
}
return key;
};
for (const candidate of this.toCommonsCandidates(data.query?.pages, titles.length)) {
if (!candidate.title) continue;
out.set(resolveOriginal(candidate.title), candidate);
// Also reachable under its own name, for callers that already resolved.
out.set(normalizeFileTitle(candidate.title), candidate);
}
return out;
} catch {
return out;
}
}
/**
* The lead image a wiki article picked for a place.
*
* Only the file NAME is taken from here; the bytes and the licence come from
* the same Commons batch as everything else. The thumbnail URL the API offers
* alongside it carries no attribution, and a picture we cannot credit is a
* picture we cannot show.
*/
async fetchWikiLeadImageName(wikipediaTag: string | null | undefined): Promise<string | null> {
const parsed = parseWikipediaTag(wikipediaTag);
if (!parsed) return null;
const params = new URLSearchParams({
action: 'query',
format: 'json',
titles: parsed.title,
prop: 'pageimages',
piprop: 'name',
redirects: '1',
});
for (const host of ['wikivoyage', 'wikipedia'] as const) {
try {
const res = await fetch(`https://${parsed.lang}.${host}.org/w/api.php?${params}`, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
if (!res.ok) continue;
const data = (await res.json()) as {
query?: { pages?: Record<string, { pageimage?: string }> };
};
for (const page of Object.values(data.query?.pages ?? {})) {
if (page.pageimage) return page.pageimage;
}
} catch {
/* try the next wiki */
}
}
return null;
}
/**
* Commons images from a category, which is the set of pictures OF a place.
*
* Preferred over the coordinate search wherever a place carries a
* `wikimedia_commons` tag: geosearch around a city centre returns statues and
* passers-by, while the category of a restaurant returns the restaurant.
*/
async fetchCommonsCategoryCandidates(category: string, limit = 5): Promise<CommonsCandidate[]> {
const name = normalizeCategoryName(category);
if (!name) return [];
// Overfetch: the ranker throws away survey imagery, diagrams and repeats,
// and it can only do that from a pool bigger than the strip.
const poolSize = String(Math.max(1, Math.min(limit * 3, 20)));
// `generator=search` first. `categorymembers` orders by sort key, i.e.
// alphabetically by file name, which is not a quality signal in any
// direction: "Category:Brandenburg Gate" opens with an .ogg pronunciation,
// a marathon photo and six near-identical press shots, and
// "Category:Hamburg Airport" with a noise map and a terminal layout. The
// search index at least ranks by how well a file matches its category.
const search = new URLSearchParams({
action: 'query',
format: 'json',
generator: 'search',
gsrsearch: `incategory:"${name}" filetype:bitmap`,
gsrnamespace: '6',
gsrlimit: poolSize,
prop: 'imageinfo',
iiprop: 'url|extmetadata|mime|size',
iiurlwidth: '400',
});
const members = new URLSearchParams({
action: 'query',
format: 'json',
generator: 'categorymembers',
gcmtitle: `Category:${name}`,
gcmtype: 'file',
gcmlimit: poolSize,
prop: 'imageinfo',
iiprop: 'url|extmetadata|mime|size',
iiurlwidth: '400',
});
for (const params of [search, members]) {
try {
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
});
if (!res.ok) continue;
const data = (await res.json()) as { query?: { pages?: Record<string, WikiCommonsPage> } };
const hits = this.toCommonsCandidates(data.query?.pages, Number(poolSize));
if (hits.length) return hits;
} catch {
/* fall through to the second strategy */
}
}
return [];
}
/**
* Photo references for a Google place, capped by the caller.
*
* Split out from the bytes download on purpose: this is one billed Details
* call for the whole strip, while every reference turned into an image is a
* separate billed /media call. Callers fetch bytes only for what they show.
*/
async fetchGooglePhotoRefs(
placeId: string,
apiKey: string,
cap: number,
): Promise<{ name: string; attribution: string | null }[]> {
if (!isGooglePlaceId(placeId) || cap < 1) return [];
try {
const res = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}`,
`fetchGooglePhotoRefs(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' } },
);
if (!res.ok) return [];
const data = (await res.json()) as GooglePlaceDetails;
return (data.photos ?? []).slice(0, cap).map((photo) => ({
name: photo.name,
attribution: photo.authorAttributions?.[0]?.displayName || null,
}));
} catch {
return [];
}
}
/** Image bytes for one photo reference. Null on any miss; the caller skips it. */
async fetchGooglePhotoBytes(photoName: string, apiKey: string, maxHeightPx = 400): Promise<Buffer | null> {
try {
const res = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=${maxHeightPx}`,
`fetchGooglePhotoBytes(${photoName})`,
{ headers: { 'X-Goog-Api-Key': apiKey } },
);
if (!res.ok) return null;
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length ? bytes : null;
} catch {
return null;
}
}
/**
* Google's editorial summary, on its own.
*
* getPlaceDetailsExpanded would also return this, but its field mask includes
* `reviews`, which moves the call into the Enterprise SKU. Enrichment only
* wants the sentence, so it asks for the sentence.
*/
async fetchEditorialSummary(placeId: string, apiKey: string, lang?: string): Promise<string | null> {
if (!isGooglePlaceId(placeId)) return null;
try {
const res = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}?languageCode=${toApiLang(lang)}`,
`fetchEditorialSummary(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'editorialSummary' } },
);
if (!res.ok) return null;
const data = (await res.json()) as GooglePlaceDetails;
return data.editorialSummary?.text?.trim() || null;
} catch {
return null;
}
}
// ── Search places (Google or Nominatim fallback) ───────────────────────────
async searchPlaces(
userId: number,
query: string,
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
opts: { googleIdentityOnly?: boolean } = {},
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const keyed = await this.keyedProvider(userId);
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
// The TREK index answers first, whether or not a Google key exists. It is
// the only source here that may be stored, works offline as a country
// package, and costs the caller nothing; a key buys ratings and photos on
// top of it, not a better search.
//
// It never throws upward. A search that used to work must keep working when
// the service is slow or down, so a failure drops through to exactly what
// this method did before.
//
// `googleIdentityOnly` skips it. One caller does not want the best answer,
// it wants a Google id: list-import enrichment (#886) exists to attach a
// `google_place_id` to a bare imported pin, and `pickEnrichmentMatch`
// discards every candidate that has none. Index and OpenStreetMap records
// both carry `google_place_id: null`, so answering that caller from them
// returns matches it must throw away, and the import silently stays
// unenriched on an instance that pays for a key. It changes nothing for an
// instance without one: enrichment could never resolve anything there
// either, before this branch existed or after.
// Set once the OpenStreetMap half below has run, so the fallback does not ask
// the same question twice. `null` means it never ran.
let osmAnswer: Record<string, unknown>[] | null = null;
if (this.trekPlacesEnabled() && !(opts.googleIdentityOnly && apiKey)) {
// Both at once. The index is a dataset of businesses and is very good
// at those; OpenStreetMap is where the temples, bridges, riverside
// walks and viewpoints are, and a travel search asks for those
// constantly. Concurrently, so the pair costs the slower one rather
// than the sum. This is the explicit search, not the keystroke path
// Nominatim's policy rules out.
//
// Each side catches its own failure. A rejection reaching Promise.all
// would throw away the answer the other side had already produced — and
// the index refusing a query is ordinary traffic, not an outage: a common
// single word without coordinates is turned down upstream as too
// expensive. That used to discard ten good OpenStreetMap results and ask
// Nominatim the same question a second time, behind its own 1.1 s
// process-wide throttle.
const [found, osm] = await Promise.all([
trekPlacesSearch(query, {
lat: locationBias?.lat,
lng: locationBias?.lng,
limit: 10,
}).then(indexHitsOnly).catch((err: unknown) => {
console.warn('TREK Places search failed, falling back:', (err as Error).message);
return [] as TrekPlace[];
}),
this.searchNominatim(query, lang, 'interactive', locationBias).catch((err: unknown) => {
console.warn('OpenStreetMap search failed, index only:', (err as Error).message);
return [] as Record<string, unknown>[];
}),
]);
osmAnswer = osm;
const places = mergeSearchResults(found.map(toPlaceRecord), osm);
if (places.length > 0) {
// Names the sources that actually contributed, not the ones that were
// asked. Either side can come back empty — the index turns down a common
// single word without coordinates, and OpenStreetMap can be down — and
// the search log writes this into the corpus a candidate index is later
// scored against, so a list that is entirely OpenStreetMap must not be
// recorded as though the index had a hand in it.
const source = found.length > 0
? (osm.length > 0 ? 'trek-places+openstreetmap' : 'trek-places')
: 'openstreetmap';
return { places, source };
}
}
// Amap in the slot Google otherwise holds: asked only once the index and
// OpenStreetMap came back empty, exactly like the Google call below.
if (keyed?.id === 'amap') {
const places = await keyed.provider.searchText(query, lang, locationBias);
return { places, source: 'amap' };
}
if (!apiKey) {
// Reuse what OpenStreetMap already said rather than asking again. The
// first call carried a viewbox with bounded=0, which orders results
// without changing which ones exist, so a second call can only return the
// same empty list a throttle-interval later.
const places = osmAnswer ?? (await this.searchNominatim(query, lang));
return { places, source: 'openstreetmap' };
}
const searchBody: Record<string, unknown> = { textQuery: query, languageCode: toApiLang(lang) };
// Bias results toward the caller's area when supplied — without it Google Text
// Search falls back to the API key's billing region, which skews foreign-region queries.
//
// Clamped, like the nearby path above: Google caps the circle at 50 km and
// answers a wider one with a 400 that this method throws, so a trip spread
// across a hundred kilometres would turn an ordinary search into an error
// toast. The client keeps its own ceiling; this one is here because the
// radius arrives over the wire and the schema cannot know Google's limit.
if (locationBias) {
searchBody.locationBias = {
circle: {
center: { latitude: locationBias.lat, longitude: locationBias.lng },
radius: Math.min(50000, Math.max(1, locationBias.radius ?? 50000)),
},
};
}
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify(searchBody),
});
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
logKeyFailure('searchText', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
}
// A place that has shut down for good is never the answer to "where should we
// go" (#1341). Temporarily closed stays: a restaurant on holiday next month is
// still worth planning around. Anything without the field is a non-business
// result (a park, a viewpoint) and is kept.
const places = (data.places || [])
.filter((p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY')
.map((p: GooglePlaceResult) => ({
google_place_id: p.id,
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
name: p.displayName?.text || '',
address: p.formattedAddress || '',
// `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian).
lat: p.location?.latitude ?? null,
lng: p.location?.longitude ?? null,
rating: p.rating || null,
website: p.websiteUri || null,
phone: p.nationalPhoneNumber || null,
types: p.types || [],
source: 'google',
}));
return { places, source: 'google' };
}
// ── Autocomplete (Google or Nominatim fallback) ────────────────────────────
async autocompletePlaces(
userId: number,
input: string,
lang?: string,
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
sessionToken?: string,
): Promise<MapsAutocompleteResult> {
const keyed = await this.keyedProvider(userId);
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
// This is the path that mattered most. Nominatim's usage policy names
// autocomplete as unacceptable use in its own words, regardless of rate,
// and the whole TREK fleet shares one User-Agent there: one abusive install
// could get every instance blocked at once. The index removes that.
//
// Same contract as search: never throws upward, falls through to what this
// method did before.
if (this.trekPlacesEnabled()) {
try {
const centre = locationBias
? {
lat: (locationBias.low.lat + locationBias.high.lat) / 2,
lng: (locationBias.low.lng + locationBias.high.lng) / 2,
}
: undefined;
// Both layers here, unlike the explicit search above. That path asks
// Nominatim in parallel and would get the same OpenStreetMap places
// twice; this one asks nobody else, because Nominatim's usage policy
// names autocomplete as unacceptable use. So the layer is not a second
// opinion here, it is the only place the missing names live.
//
// Measured on the case that surfaced it: "Tokio station" typed from
// Tokyo returned a weigh station in Ritzville and a station in Mexico
// from the index alone, and "Tokio Hauptbahnhof" in second place with
// the layer. The index holds businesses; stations, temples and bridges
// are in OpenStreetMap, and so is every exonym a traveller types.
const found = await trekPlacesSearch(input, {
lat: centre?.lat,
lng: centre?.lng,
limit: 8,
sources: 'index,osm',
});
if (found.length > 0) {
return {
suggestions: found.map(p =>
isOsmHit(p)
? {
// The service's own id form, translated into the one this
// file already resolves. Leaving it as `osm:node/123` would
// hand the client an id getPlaceDetails does not know, and
// the failure would land after the user had picked it.
placeId: osmPlaceId(p),
mainText: p.name,
// The layer carries no address. The local name is what the
// place is called on the spot, which is more use under a
// translated label than an empty line.
secondaryText: p.local_name && p.local_name !== p.name ? p.local_name : '',
// Per row, because this list is two indexes interleaved.
// The name above the list says `trek-places`, which is true
// of the call and false of half the rows in it — the layer
// is OpenStreetMap, and a reader deciding whether to trust
// a suggestion is asking exactly that.
source: 'openstreetmap',
// Both indexes hand these over with the row. Carried rather
// than dropped so picking a suggestion has something to fall
// back on when the details lookup cannot answer.
lat: p.lat,
lng: p.lng,
}
: {
placeId: `gers:${p.gers}`,
mainText: p.name,
secondaryText: [p.address?.locality, p.address?.country].filter(Boolean).join(', '),
source: 'trek-places',
lat: p.lat,
lng: p.lng,
},
),
source: 'trek-places',
};
}
} catch (err: unknown) {
console.warn('TREK Places autocomplete failed, falling back:', (err as Error).message);
}
}
if (keyed?.id === 'amap') {
const suggestions = await keyed.provider.autocomplete(input, lang, locationBias);
return { suggestions, source: 'amap' };
}
if (!apiKey) {
return this.autocompleteNominatim(input, lang);
}
const body: Record<string, unknown> = {
input,
languageCode: toApiLang(lang),
};
// With a session token Google bills the whole search as one autocomplete
// session instead of charging each keystroke; the details call that closes
// the session carries the same token.
if (sessionToken) body.sessionToken = sessionToken;
if (locationBias) {
body.locationBias = {
rectangle: {
low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng },
high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng },
},
};
}
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
},
body: JSON.stringify(body),
});
const data = (await response.json()) as {
suggestions?: GoogleAutocompleteSuggestion[];
error?: { message?: string };
};
if (!response.ok) {
logKeyFailure('autocomplete', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
err.status = response.status;
throw err;
}
const suggestions = (data.suggestions || [])
.filter((s) => s.placePrediction)
.slice(0, 5)
.map((s) => ({
placeId: s.placePrediction!.placeId,
mainText: s.placePrediction!.structuredFormat?.mainText?.text || '',
secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '',
}));
return { suggestions, source: 'google' };
}
private async autocompleteNominatim(
input: string,
lang?: string,
): Promise<MapsAutocompleteResult> {
try {
const places = await this.searchNominatim(input, lang);
const suggestions = places
.filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '')
.slice(0, 5)
.map((p) => {
const parts = (p.address || '').split(',').map((s) => s.trim());
return {
placeId: p.osm_id,
mainText: p.name || parts[0] || '',
secondaryText: parts.slice(1).join(', '),
};
});
return { suggestions, source: 'nominatim' };
} catch (err) {
console.error('Nominatim autocomplete failed:', err);
return { suggestions: [], source: 'nominatim' };
}
}
// ── Place details (Google or OSM) ──────────────────────────────────────────
async getPlaceDetails(
userId: number,
placeId: string,
lang?: string,
sessionToken?: string,
): Promise<{ place: Record<string, unknown> | null }> {
// A place picked out of the TREK index. Checked BEFORE the generic
// colon branch below, which would otherwise read "gers" as an OSM type
// and ask Overpass for an element that does not exist.
if (placeId.startsWith('gers:')) {
// The switch is a deployment property, and off means nothing leaves for
// the index: a place saved while it was on is still opened from what the
// trip holds, not looked up again.
if (!this.trekPlacesEnabled()) return { place: null };
const found = await trekPlacesById(placeId.slice('gers:'.length)).catch(() => null);
if (!found) return { place: null };
// What the index does not have, the free sources still do: cuisine,
// wheelchair access, a menu link. OpenStreetMap has all of them for the
// same building, and without this the details a user saw while adding the
// place disappeared from its card afterwards, which reads like data loss.
//
// Matched by name and coordinate, with the same two gates
// resolveOsmIdentity applies everywhere: within range, and sharing a
// substantial word of the name. A confident description of the building
// next door is worse than none.
const osm = await this.resolveOsmIdentity(found.name, found.lat, found.lng, {
lang,
maxDistanceM: 150,
}).catch(() => null);
const record = toPlaceRecord(found);
// Hours the index read off the operator's own site, run through the same
// expansion OSM's go through — the client reads a list of weekday lines,
// not the raw syntax, and handing it two shapes for one field would be a
// bug on every card that shows it.
//
// Measured across seven countries, OpenStreetMap has hours for 27.5
// percent of gastronomy; this covers part of the rest. It is the
// fallback, not the first choice: an OSM entry describes this exact
// object and gets corrected by people who walked past, where a chain's
// website often carries one set of hours for every branch.
const fromSite =
typeof found.hours?.osm === 'string' ? buildOsmDetails({ opening_hours: found.hours.osm }, '', '') : null;
if (!osm) {
return {
place: fromSite?.opening_hours
? {
...record,
opening_hours: fromSite.opening_hours,
open_now: fromSite.open_now,
opening_periods: fromSite.opening_periods,
}
: record,
};
}
const osmDetails = buildOsmDetails(osm.tags, '', '');
const hoursFrom = osmDetails.opening_hours ? osmDetails : fromSite;
return {
place: {
// Index first: its name, coordinate and contact details are the ones
// the user picked. OSM only fills what is still missing.
...osmDetails,
...record,
opening_hours: hoursFrom?.opening_hours ?? null,
open_now: hoursFrom?.open_now ?? null,
opening_periods: hoursFrom?.opening_periods ?? null,
website: record.website ?? osmDetails.website ?? null,
phone: record.phone ?? osmDetails.phone ?? null,
osm_id: placeId,
source: 'trek-places',
},
};
}
// An Amap id is `amap:<poiid>` and so carries a colon too. Before the OSM
// branch, which would otherwise send "amap" to Overpass as an element type
// and answer every Chinese place with an empty record.
if (isAmapPlaceId(placeId)) return this.amapDetails(userId, placeId, lang);
// OSM details: placeId is "node:123456" or "way:123456" etc.
if (placeId.includes(':')) {
// Only an element type with a numeric id is looked up. The id is written
// into an Overpass query and a Nominatim lookup as it came in, and nothing
// else with a colon in it (a legacy image URL, a coordinate pseudo-id) has
// a details source: answering those with an empty record cost two
// requests that could not succeed, and let anything after the colon be
// sent as a query of its own.
if (!OSM_PLACE_ID.test(placeId)) return { place: null };
const [osmType, osmId] = placeId.split(':');
// buildOsmDetails never yields name/address/coordinates — Nominatim is
// always the source for those (Overpass contributes the tag-derived rest).
const [element, nominatim] = await Promise.all([
this.fetchOverpassDetails(osmType, osmId),
this.lookupNominatim(osmType, osmId, lang),
]);
// Overpass has the fuller tag set and wins where both answer, but it is
// also the one that goes down — overpass-api.de is regularly overloaded.
// Nominatim's extratags carry the wikidata/wikipedia/commons tags too, so
// a place keeps its pictures and its description when Overpass times out
// instead of falling back to "photographed within 300m".
const details = buildOsmDetails(
{ ...(nominatim?.extratags ?? {}), ...(element?.tags ?? {}) },
osmType,
osmId,
);
return {
place: {
...details,
name: nominatim?.name || element?.tags?.name || '',
address: nominatim?.address || '',
lat: nominatim?.lat ?? null,
lng: nominatim?.lng ?? null,
osm_id: placeId,
},
};
}
// Google details
// 'en' default, aligned with search/autocomplete and the MCP tools' ?? 'en'
// (the 'de' the legacy service defaulted to was a development leftover;
// cache rows keyed 'de' for lang-less callers go cold once — 7-day TTL).
const langKey = toApiLang(lang);
const apiKey = await this.getMapsKey(userId);
// No key means no way to resolve a Google id: they have no OpenStreetMap
// equivalent to fall back to. That is an empty result, not a client error.
// Search and autocomplete already answer their keyless case with the OSM
// stack; this used to be the one place that threw instead, which turned an
// instance without a key into a stream of 400s whenever an older Google
// place was opened. Callers already treat a null place as a miss.
if (!apiKey) return { place: null };
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
const cached = this.database.get<{ payload_json: string; fetched_at: number }>(
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0',
placeId,
langKey,
);
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
// Closes the autocomplete session this lookup belongs to, so Google bills
// the search once instead of per keystroke. A cache hit above never reaches
// here, which is billing-neutral: an unclosed session is charged as a plain
// autocomplete session.
const sessionParam = sessionToken ? `&sessionToken=${encodeURIComponent(sessionToken)}` : '';
const response = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}${sessionParam}`,
`getPlaceDetails(${placeId})`,
{
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
},
},
);
const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
}
const place = {
google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '',
address: data.formattedAddress || '',
// `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian).
lat: data.location?.latitude ?? null,
lng: data.location?.longitude ?? null,
rating: data.rating || null,
rating_count: data.userRatingCount || null,
website: data.websiteUri || null,
phone: data.nationalPhoneNumber || null,
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
open_now: data.regularOpeningHours?.openNow ?? null,
// open_now is a snapshot Google took when this payload was fetched and it is cached
// for days; the periods let the client recompute the state in the place's own
// timezone, which the localised weekday lines above cannot do. Issue #1680.
opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods),
opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays),
google_maps_url: data.googleMapsUri || null,
summary: null,
reviews: [],
source: 'google' as const,
cached_at: Date.now(),
};
try {
this.database.run(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)',
placeId,
langKey,
JSON.stringify(place),
Date.now(),
);
} catch (dbErr) {
console.error('Failed to cache place details:', dbErr);
}
return { place };
}
/**
* The Amap half of getPlaceDetails, behind the same cache the Google half
* uses. Keyed by place_id, and an Amap id carries its `amap:` prefix, so the
* two providers' rows cannot collide.
*
* No key for the id is an empty result, not a client error, for the same
* reason the Google half answers its keyless case that way: an Amap place
* opened on an install that has since dropped its Amap key is a miss.
*/
private async amapDetails(
userId: number,
placeId: string,
lang?: string,
): Promise<{ place: Record<string, unknown> | null }> {
const provider = await this.providerForPlaceId(userId, placeId);
if (!provider) return { place: null };
const langKey = toApiLang(lang);
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
const cached = this.database.get<{ payload_json: string; fetched_at: number }>(
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0',
placeId,
langKey,
);
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
const place = await provider.placeDetails(placeId, lang);
if (!place) return { place: null };
try {
this.database.run(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)',
placeId,
langKey,
JSON.stringify(place),
Date.now(),
);
} catch (dbErr) {
console.error('Failed to cache place details:', dbErr);
}
return { place };
}
async getPlaceDetailsExpanded(
userId: number,
placeId: string,
lang?: string,
refresh = false,
): Promise<{ place: Record<string, unknown> | null }> {
// Reviews and the editorial summary only exist at Google, but the id does not
// have to be a Google one — the client sends whatever the place carries. OSM ids
// keep the details they do have (Overpass, via the plain lookup); coordinate
// pseudo-ids and legacy image URLs have no details source at all. Neither may be
// forwarded to Google, which bills the 400 INVALID_ARGUMENT it answers with.
//
// Index ids degrade the same way, for the same reason: the plain lookup has a
// whole record for them — name, address, contact, hours — and only the reviews
// and the editorial summary are Google's to add. Answering `expand=1` with a
// null while `expand=0` answers in full would make the richer request the
// poorer one. An Amap id has no richer tier either, so it takes the plain
// lookup as well.
if (!isGooglePlaceId(placeId)) {
return OSM_PLACE_ID.test(placeId) || placeId.startsWith('gers:') || isAmapPlaceId(placeId)
? this.getPlaceDetails(userId, placeId, lang)
: { place: null };
}
const langKey = toApiLang(lang); // 'en' default — see getPlaceDetails
const apiKey = await this.getMapsKey(userId);
// Same as the lean lookup above: an empty result, not a client error.
if (!apiKey) return { place: null };
// Check DB cache for expanded result
if (!refresh) {
const cached = this.database.get<{ payload_json: string }>(
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1',
placeId,
langKey,
);
if (cached) return { place: JSON.parse(cached.payload_json) };
}
const response = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`,
`getPlaceDetailsExpanded(${placeId})`,
{
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
},
},
);
const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
}
const place = {
google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '',
address: data.formattedAddress || '',
// `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian).
lat: data.location?.latitude ?? null,
lng: data.location?.longitude ?? null,
rating: data.rating || null,
rating_count: data.userRatingCount || null,
website: data.websiteUri || null,
phone: data.nationalPhoneNumber || null,
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
open_now: data.regularOpeningHours?.openNow ?? null,
opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods),
opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays),
google_maps_url: data.googleMapsUri || null,
summary: data.editorialSummary?.text || null,
reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable<GooglePlaceDetails['reviews']>[number]) => ({
author: r.authorAttribution?.displayName || null,
rating: r.rating || null,
text: r.text?.text || null,
time: r.relativePublishTimeDescription || null,
photo: r.authorAttribution?.photoUri || null,
})),
source: 'google' as const,
cached_at: Date.now(),
};
try {
this.database.run(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)',
placeId,
langKey,
JSON.stringify(place),
Date.now(),
);
} catch (dbErr) {
console.error('Failed to cache expanded place details:', dbErr);
}
return { place };
}
// ── Place photo (Google or Wikimedia, disk-cached) ─────────────────────────
async getPlacePhoto(
userId: number,
placeId: string,
lat: number,
lng: number,
name?: string,
): Promise<{ photoUrl: string | null; attribution: string | null }> {
// Disk cache hit — serve immediately, no Google call
const diskHit = await this.photoCache.get(placeId);
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
// "No photo for this place" is an empty result, not a missing resource: a trip
// view asks for one photo per place, so answering each miss with a 404 makes a
// normal itinerary render look like a 404 scan to fail2ban/CrowdSec and gets
// the user's IP banned. Every miss below returns photoUrl: null instead — the
// same shape the photos kill-switch already returns.
const noPhoto = { photoUrl: null, attribution: null };
// Recent miss — don't hammer the API
if (await this.photoCache.getErrored(placeId)) return noPhoto;
// Deduplicate concurrent requests for the same placeId
const existing = this.photoCache.getInFlight(placeId);
if (existing !== undefined) {
const result = await existing;
if (!result) return noPhoto;
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
// Tells the two empty outcomes apart for the negative cache below: a place that
// has no photo anywhere is worth remembering for a day, a provider that refused
// or timed out only for a few minutes.
let providerFailed = false;
const fetchPromise = (async (): Promise<{ attribution: string | null } | null> => {
await acquirePhotoFetchSlot();
try {
const apiKey = await this.getMapsKey(userId);
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
// (right-click) places and as a fallback when a Google place yields no photo,
// so a place added via search still gets a marker image when Google returns
// nothing. Returns null (without marking an error) so the caller decides.
const fetchWikimediaFallback = async (): Promise<{ attribution: string | null } | null> => {
if (Number.isNaN(lat) || Number.isNaN(lng)) return null;
try {
const wiki = await this.fetchWikimediaPhoto(lat, lng, name);
if (!wiki) return null;
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
// host) is re-validated against the SSRF guard, not just the first URL.
const imgRes = await safeFetchFollow(
wiki.photoUrl, { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, { bypassInternalIpAllowed: true },
);
if (!imgRes.ok) {
providerFailed = true;
return null;
}
if (exceedsDeclaredLength(imgRes, WIKIMEDIA_PHOTO_MAX_BYTES)) {
discardBody(imgRes);
providerFailed = true;
return null;
}
const { bytes, truncated } = await readCapped(imgRes, WIKIMEDIA_PHOTO_MAX_BYTES);
if (truncated || bytes.byteLength === 0) {
providerFailed = true;
return null;
}
const cached = await this.photoCache.put(placeId, bytes, wiki.attribution);
return { attribution: cached.attribution };
} catch {
providerFailed = true;
return null;
}
};
// Google Places photo for a Google place_id. Returns null on any miss — no
// key, request rejected, no photos, or a failed media download — so the
// caller can fall back to Wikimedia; the misses that were Google's fault
// flag providerFailed on the way out.
const fetchGooglePhoto = async (): Promise<{ attribution: string | null } | null> => {
if (!apiKey) return null;
// Fetch details to get the photo name
const detailsRes = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}`,
`getPlacePhoto/details(${placeId})`,
{
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
},
);
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
providerFailed = true;
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try {
details = body ? JSON.parse(body) : { photos: [] };
} catch {
providerFailed = true;
return null;
}
if (!details.photos?.length) return null;
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } },
);
// The place does have a photo — only the download for it went wrong.
if (!mediaRes.ok) {
providerFailed = true;
return null;
}
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) {
providerFailed = true;
return null;
}
const cached = await this.photoCache.put(placeId, bytes, attribution);
// Persist stable proxy URL to database
try {
this.database.run(
"UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')",
cached.photoUrl,
placeId,
);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { attribution };
};
// Prefer the Google photo (higher quality); if Google yields nothing, fall
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
// places use. Ids Google cannot resolve skip it entirely.
if (isGooglePlaceId(placeId)) {
const googlePhoto = await fetchGooglePhoto();
if (googlePhoto) return googlePhoto;
}
const fallback = await fetchWikimediaFallback();
if (fallback) return fallback;
await this.photoCache.markError(placeId, providerFailed ? 'provider-error' : 'no-photo');
return null;
} finally {
releasePhotoFetchSlot();
}
})();
this.photoCache.setInFlight(placeId, fetchPromise);
const result = await fetchPromise;
if (!result) return noPhoto;
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
// ── Reverse geocoding ──────────────────────────────────────────────────────
async reverseGeocode(
lat: string,
lng: string,
lang?: string,
opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean },
): Promise<{ name: string | null; address: string | null }> {
// Amap answers first when it holds the keyed slot, and only for a point it
// can possibly know: outside its box the call would cost a round trip to
// come back empty before Nominatim is asked anyway. Resolved at userId 0,
// because most callers here have no person behind them (a booking import,
// an Atlas tile, a right-click on a shared map): the chain stops at the
// operator env var and the instance-wide row, and nobody's personal key is
// read on somebody else's behalf (#1939). Nominatim stays the fallback, so
// an Amap outage does not take a right-click down with it.
const amap = await this.resolvePlacesProvider(0);
if (amap) {
const latNum = Number.parseFloat(lat);
const lngNum = Number.parseFloat(lng);
if (Number.isFinite(latNum) && Number.isFinite(lngNum) && !isOutsideChina(latNum, lngNum)) {
try {
const answer = await amap.reverse(latNum, lngNum, lang);
if (answer) return answer;
} catch (err) {
console.error('[Maps] amap reverse geocode failed, falling back to Nominatim:', (err as Error).message);
}
}
}
const params = new URLSearchParams({
lat,
lon: lng,
format: 'json',
addressdetails: '1',
zoom: opts?.locality ? '10' : '18',
'accept-language': toApiLang(lang),
});
const response = await nominatimFetch('reverse', params, opts);
if (!response.ok) return { name: null, address: null };
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
const addr = data.address || {};
const name = opts?.locality
? addr.city || addr.town || addr.village || addr.municipality || data.name || null
: data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
return { name, address: data.display_name || null };
}
// ── Resolve Google Maps URL ────────────────────────────────────────────────
async resolveGoogleMapsUrl(
url: string,
): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
let resolvedUrl = url;
// Extract coordinates from a string (URL or page body). Google Maps encodes
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
const extractCoords = (s: string): { lat: number; lng: number } | null => {
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
if (at) return { lat: Number.parseFloat(at[1]), lng: Number.parseFloat(at[2]) };
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
if (data) return { lat: Number.parseFloat(data[1]), lng: Number.parseFloat(data[2]) };
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
if (q) return { lat: Number.parseFloat(q[1]), lng: Number.parseFloat(q[2]) };
return null;
};
const followRedirects = async (target: string, init?: RequestInit): Promise<Response> => {
try {
return await safeFetchFollow(
target,
{ signal: AbortSignal.timeout(10000), ...init },
{ bypassInternalIpAllowed: true },
);
} catch (err) {
if (err instanceof SsrfBlockedError) {
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
}
throw err;
}
};
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps
// URLs that carry no inline coordinates — e.g. ?cid= links (the format
// get_place_details returns) and "Share"-button links. The redirect target
// usually carries the !3d!4d data param we can then parse. Redirects are
// followed manually so every hop is SSRF-re-checked.
const parsed = new URL(url);
const isShort = GOOGLE_SHORT_HOSTS.includes(parsed.hostname) || AMAP_SHORT_HOSTS.includes(parsed.hostname);
const isGoogleMaps = isGoogleMapsHost(parsed.hostname);
if (isShort || (isGoogleMaps && !extractCoords(url))) {
resolvedUrl = (await followRedirects(url)).url || resolvedUrl;
}
let resolvedHost = '';
try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, both host branches are skipped */ }
// Amap links first, and on their own: they spell the coordinate `lng,lat`
// in GCJ-02, which the Google patterns below would read as a WGS-84
// `lat,lng` and put a Shanghai restaurant in the East China Sea.
// parseAmapUrl owns both the ordering and the datum conversion.
if (isAmapHost(resolvedHost)) {
const amap = parseAmapUrl(resolvedUrl);
// A POI page without a coordinate would need a keyed detail lookup, and
// this method has no user to resolve a key for: the same answer a Google
// page without coordinates gets.
if (!amap || !Number.isFinite(amap.lat) || !Number.isFinite(amap.lng)) {
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
}
const reverse = await this.reverseGeocode(String(amap.lat), String(amap.lng), undefined, { timeoutMs: 8000 });
return { lat: amap.lat, lng: amap.lng, name: amap.name || reverse.name, address: reverse.address, google_ftid: null };
}
let coords = extractCoords(resolvedUrl);
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
// page body once and parse the coordinates out of the embedded map data.
// Only Google's own pages get read; the resolved host is what counts, so a
// short link that lands on maps.google.com still qualifies.
if (!coords && isGoogleMapsHost(resolvedHost)) {
try {
const pageRes = await followRedirects(resolvedUrl, {
headers: { 'User-Agent': UA },
});
if (exceedsDeclaredLength(pageRes, MAX_MAPS_PAGE_BYTES)) {
// Nothing here will read it, and an unread body keeps its socket.
discardBody(pageRes);
} else {
// The map data sits near the top of the document, so a truncated read
// still finds the coordinates; an oversized page degrades to the same
// 400 an unparseable one already produced.
const { text } = await readCappedText(pageRes, MAX_MAPS_PAGE_BYTES);
coords = extractCoords(text);
}
} catch (err) {
if ((err as { status?: number })?.status === 403) throw err; // SSRF block, surface it
// Otherwise fall through to the not-found error below.
}
}
// Extract place name from URL path: /place/Place+Name/@...
let placeName: string | null = null;
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
if (placeMatch) {
placeName = decodeURIComponent(placeMatch[1].replaceAll(/\+/g, ' '));
}
if (!coords || Number.isNaN(coords.lat) || Number.isNaN(coords.lng)) {
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
}
const { lat, lng } = coords;
// Reverse geocode to get address. A non-ok answer (Nominatim 5xx/429) must
// not fail the whole resolution — the coordinates are already extracted, so
// fall back to the URL-derived name and a null address.
const nominatimRes = await nominatimFetch(
'reverse',
new URLSearchParams({ lat: String(lat), lon: String(lng), format: 'json', addressdetails: '1' }),
{ timeoutMs: 8000 },
);
const nominatim: { display_name?: string; name?: string; address?: Record<string, string> } = nominatimRes.ok
? await nominatimRes.json()
: {};
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) };
}
}