mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(maps): key selection, place details, marker photos and link resolving leave the maps orchestrator
This commit is contained in:
@@ -13,7 +13,6 @@
|
||||
"src/nest/doc-sync/doc-sync.service.ts": 1060,
|
||||
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
||||
"src/nest/journey/journey-domain.service.ts": 1799,
|
||||
"src/nest/maps/maps.service.ts": 3252,
|
||||
"src/nest/places/places.service.ts": 2020,
|
||||
"src/nest/plugins/plugin-runtime.service.ts": 1463,
|
||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1094,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The TREK Places index answers in its own shape; these turn its rows into the
|
||||
* shapes MapsService already answers with, so the client and the map renderer
|
||||
* need no branch per source. Pure: the calls themselves are trek-places.client's,
|
||||
* the decision to make them is MapsService's.
|
||||
*/
|
||||
import { normalizePlaceWebsite } from '@trek/shared';
|
||||
import type { MapsAutocompleteResult } from '@trek/shared';
|
||||
import { buildOsmDetails, parsePoiCategories } from './maps.helpers';
|
||||
import { POI_CATEGORY_TO_TREK, isOsmHit, osmPlaceId, toPlaceRecord, type TrekPlace, type TrekSearchHit } from './trek-places.client';
|
||||
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
|
||||
|
||||
type PoiBbox = { south: number; west: number; north: number; east: number };
|
||||
|
||||
/** What one POI request asks the index, and how its answer is labelled. */
|
||||
export interface IndexPoiPlan {
|
||||
wanted: string[];
|
||||
terms: string[];
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius: number;
|
||||
/** Half the viewport diagonal; a radius below it means the view was narrowed. */
|
||||
reach: number;
|
||||
cap: number;
|
||||
labelFor: (leaf: string | null, path: string | null) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index query for a POI request, or null when the index cannot answer it.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): IndexPoiPlan | null {
|
||||
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);
|
||||
if (!indexKnowsAll) return null;
|
||||
// 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 of categoryOfTerm.keys()) {
|
||||
if (!haystack.includes(term)) continue;
|
||||
if (best === undefined || term.length > best.length) best = term;
|
||||
}
|
||||
return best === undefined ? undefined : categoryOfTerm.get(best);
|
||||
};
|
||||
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);
|
||||
return { wanted, terms: [...categoryOfTerm.keys()], lat, lng, radius, reach, cap, labelFor };
|
||||
}
|
||||
|
||||
/**
|
||||
* The index's POI hits in the 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.
|
||||
*/
|
||||
export function indexPoiAnswer(
|
||||
found: (TrekPlace & { categoryPath?: string | null })[],
|
||||
plan: IndexPoiPlan,
|
||||
): PoiSearchResult {
|
||||
const { wanted, labelFor, cap, radius, reach } = plan;
|
||||
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: normalizePlaceWebsite(p.contact?.website),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Autocomplete rows out of the index and its OpenStreetMap layer, interleaved as they came. */
|
||||
export function indexSuggestions(found: TrekSearchHit[]): MapsAutocompleteResult['suggestions'] {
|
||||
return 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The details record of a place picked out of the index, with what
|
||||
* OpenStreetMap knows about the same building filled in.
|
||||
*
|
||||
* 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. `osmTags`
|
||||
* is null when no OSM match passed the identity gates.
|
||||
*/
|
||||
export function indexPlaceDetails(found: TrekPlace, osmTags: Record<string, string> | null, placeId: string): Record<string, unknown> {
|
||||
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 (!osmTags) {
|
||||
return fromSite?.opening_hours
|
||||
? {
|
||||
...record,
|
||||
opening_hours: fromSite.opening_hours,
|
||||
open_now: fromSite.open_now,
|
||||
opening_periods: fromSite.opening_periods,
|
||||
}
|
||||
: record;
|
||||
}
|
||||
|
||||
const osmDetails = buildOsmDetails(osmTags, '', '');
|
||||
const hoursFrom = osmDetails.opening_hours ? osmDetails : fromSite;
|
||||
return {
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* A pasted Google Maps or Amap link, turned into a coordinate.
|
||||
*
|
||||
* Short links are followed hop by hop through the SSRF guard, a Google page
|
||||
* without inline coordinates is read (capped) for the ones in its embedded map
|
||||
* data, and a Google point is reverse-geocoded through Nominatim for its
|
||||
* address. An Amap link is parsed here too, but handed back as a point for the
|
||||
* caller to reverse-geocode: inside China Amap answers that before Nominatim,
|
||||
* and choosing who answers is MapsService's job, not this resolver's.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
|
||||
import { discardBody, exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
|
||||
import { UA, googleFtidFromMapsUrl } from './maps.helpers';
|
||||
import { AMAP_SHORT_HOSTS, isAmapHost, parseAmapUrl } from './providers/amap.provider';
|
||||
import { OsmClient } from './providers/osm.client';
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
export interface ResolvedMapsUrl {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string | null;
|
||||
address: string | null;
|
||||
google_ftid: string | null;
|
||||
}
|
||||
|
||||
/** A Google link resolved in full, or an Amap point still to be named. */
|
||||
export type MapsLink =
|
||||
| { kind: 'resolved'; result: ResolvedMapsUrl }
|
||||
| { kind: 'amap'; lat: number; lng: number; name: string | null };
|
||||
|
||||
// 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=.
|
||||
function 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;
|
||||
}
|
||||
|
||||
async function followRedirects(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;
|
||||
}
|
||||
}
|
||||
|
||||
const notFound = () => Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
|
||||
|
||||
@Injectable()
|
||||
export class MapsUrlResolver {
|
||||
constructor(private readonly osm: OsmClient) {}
|
||||
|
||||
async resolve(url: string): Promise<MapsLink> {
|
||||
let resolvedUrl = url;
|
||||
|
||||
// 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 notFound();
|
||||
return { kind: 'amap', lat: amap.lat, lng: amap.lng, name: amap.name };
|
||||
}
|
||||
|
||||
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 notFound();
|
||||
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 nominatim = await this.osm.reverseRaw(lat, lng);
|
||||
|
||||
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
||||
const address = nominatim.display_name || null;
|
||||
|
||||
return { kind: 'resolved', result: { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) } };
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { MapsMcp } from './maps.mcp';
|
||||
import { GooglePlacesClient } from './providers/google-places.provider';
|
||||
import { OsmClient } from './providers/osm.client';
|
||||
import { WikimediaClient } from './providers/wikimedia.client';
|
||||
import { PlacesProviderSelector } from './places-provider.selector';
|
||||
import { PlacePhotoResolver } from './place-photo.resolver';
|
||||
import { MapsUrlResolver } from './maps-url.resolver';
|
||||
import { PlaceDetailsResolver } from './place-details.resolver';
|
||||
import { PlacePhotosModule } from '../place-photos/place-photos.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { GoogleQuotaModule } from '../google-quota/google-quota.module';
|
||||
@@ -15,23 +19,37 @@ import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
|
||||
/**
|
||||
* Maps / geo domain (L3 leaf module). Registered in AppModule. Exports
|
||||
* MapsService for the in-container consumers (BookingImportModule's Nominatim
|
||||
* geocoding, PlacesModule's search_place tool and list-import enrichment).
|
||||
* Nothing outside the container consumes this domain, so there is no bridge.
|
||||
* Maps / geo domain (L3 leaf module). Registered in AppModule.
|
||||
*
|
||||
* MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places]):
|
||||
* MapsService passes its own AppSettingsRepository/UsersRepository to
|
||||
* instance-api-keys.ts's resolveApiKey (Plan 3a Task 5). Plan 3h Task 4 (R8)
|
||||
* adds `PlaceDetailsCache` (MAP3-8 reuse `PlaceDetailsCacheRepository`, built
|
||||
* for `place-enrichment.service.ts` — only the entity class is imported
|
||||
* here, not that domain's own Nest module, so there is no new module edge/
|
||||
* cycle risk) and `Places` (MAP9's additive `setImageUrlIfUnset`).
|
||||
* MapsService is the orchestrator every consumer injects (BookingImportModule's
|
||||
* geocoding, PlacesModule's search_place tool and list-import enrichment, the
|
||||
* road trip search). Behind it sit one provider per outbound source
|
||||
* (GooglePlacesClient, OsmClient, WikimediaClient; Amap is built per request by
|
||||
* PlacesProviderSelector) and the resolvers it delegates to. The three clients
|
||||
* are exported too, so the enrichment column asks Google, OpenStreetMap and
|
||||
* Wikimedia directly rather than through a MapsService facade. Nothing outside
|
||||
* the container consumes this domain, so there is no bridge.
|
||||
*
|
||||
* MikroOrmModule.forFeature: AppSettings and Users for the key chain
|
||||
* (instance-api-keys.ts's resolveApiKey) and the kill switches,
|
||||
* PlaceDetailsCache for the details cache (the entity only, not
|
||||
* place-enrichment's module, so there is no new module edge) and Places for the
|
||||
* marker photo's `setImageUrlIfUnset`.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PlacePhotosModule, StorageModule, GoogleQuotaModule, MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places])],
|
||||
controllers: [MapsController],
|
||||
providers: [MapsService, MapsMcp, GooglePlacesClient, OsmClient, WikimediaClient],
|
||||
providers: [
|
||||
MapsService,
|
||||
MapsMcp,
|
||||
GooglePlacesClient,
|
||||
OsmClient,
|
||||
WikimediaClient,
|
||||
PlacesProviderSelector,
|
||||
PlacePhotoResolver,
|
||||
MapsUrlResolver,
|
||||
PlaceDetailsResolver,
|
||||
],
|
||||
exports: [MapsService, GooglePlacesClient, OsmClient, WikimediaClient],
|
||||
})
|
||||
export class MapsModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Place details by id, from whichever source the id belongs to: the TREK index
|
||||
* (`gers:`), Amap (`amap:`), OpenStreetMap (`node:` / `way:` / `relation:`) or
|
||||
* Google (everything else that is a Google place id), with the lean and the
|
||||
* expanded details cache in front of the keyed providers.
|
||||
*
|
||||
* MapsService.getPlaceDetails / getPlaceDetailsExpanded delegate here and hand
|
||||
* in whether the index is switched on, which is a deployment property they own.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { normalizePlaceWebsite } from '@trek/shared';
|
||||
import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
|
||||
import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDetailsCache.repository';
|
||||
import { GooglePlacesClient } from './providers/google-places.provider';
|
||||
import { OsmClient } from './providers/osm.client';
|
||||
import { isAmapPlaceId } from './providers/amap.provider';
|
||||
import { PlacesProviderSelector } from './places-provider.selector';
|
||||
import { trekPlacesById } from './trek-places.client';
|
||||
import { toApiLang, isGooglePlaceId, OSM_PLACE_ID } from './maps.helpers';
|
||||
import { indexPlaceDetails } from './maps-index.helpers';
|
||||
|
||||
/**
|
||||
* A details row as the cache holds it. A row written before #2483 still has a
|
||||
* source's website as it came, `www.hotel.cn` from Amap for one, and keeps for a
|
||||
* week (an expanded one until a refresh), so the website is normalized on the
|
||||
* way out of the cache as well as on the way in.
|
||||
*/
|
||||
function cachedDetails(payload: string): Record<string, unknown> | null {
|
||||
const place = JSON.parse(payload) as Record<string, unknown> | null;
|
||||
return place && 'website' in place ? { ...place, website: normalizePlaceWebsite(place.website) } : place;
|
||||
}
|
||||
|
||||
/** How long a lean details answer is served from the cache. */
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class PlaceDetailsResolver {
|
||||
constructor(
|
||||
@InjectRepository(PlaceDetailsCache) private readonly placeDetailsCache: PlaceDetailsCacheRepository,
|
||||
private readonly googlePlaces: GooglePlacesClient,
|
||||
private readonly osm: OsmClient,
|
||||
private readonly selector: PlacesProviderSelector,
|
||||
) {}
|
||||
|
||||
async lookup(
|
||||
userId: number,
|
||||
placeId: string,
|
||||
lang: string | undefined,
|
||||
sessionToken: string | undefined,
|
||||
indexEnabled: boolean,
|
||||
): 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 (!indexEnabled) return { place: null };
|
||||
const found = await trekPlacesById(placeId.slice('gers:'.length)).catch(() => null);
|
||||
if (!found) return { place: null };
|
||||
|
||||
// What the index lacks, OpenStreetMap often has for the same building
|
||||
// (see indexPlaceDetails). 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.osm.resolveOsmIdentity(found.name, found.lat, found.lng, {
|
||||
lang,
|
||||
maxDistanceM: 150,
|
||||
}).catch(() => null);
|
||||
|
||||
return { place: indexPlaceDetails(found, osm?.tags ?? null, placeId) };
|
||||
}
|
||||
|
||||
// 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(':')) return { place: await this.osm.elementDetails(placeId, lang) };
|
||||
|
||||
// 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.selector.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 };
|
||||
|
||||
// The details call closes the autocomplete session this lookup belongs to,
|
||||
// so Google bills the search once instead of per keystroke. A cache hit
|
||||
// never reaches it, which is billing-neutral: an unclosed session is
|
||||
// charged as a plain autocomplete session.
|
||||
const google = this.googlePlaces.provider({ key: apiKey, source: null, userId });
|
||||
return this.detailsThroughCache(placeId, langKey, () => google.placeDetails(placeId, lang, sessionToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* The lean details cache (expanded=0, seven days) in front of one provider
|
||||
* lookup. Keyed by place_id, and an Amap id carries its `amap:` prefix, so the
|
||||
* providers' rows cannot collide. A failed cache write costs the cache, not
|
||||
* the answer.
|
||||
*/
|
||||
private async detailsThroughCache(
|
||||
placeId: string,
|
||||
langKey: string,
|
||||
lookup: () => Promise<Record<string, unknown> | null>,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const cached = await this.placeDetailsCache.findEntry(placeId, langKey, 0);
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: cachedDetails(cached.payload_json) };
|
||||
|
||||
const place = await lookup();
|
||||
if (!place) return { place: null };
|
||||
|
||||
try {
|
||||
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 0, payload_json: JSON.stringify(place), fetched_at: 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.
|
||||
*
|
||||
* 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.selector.providerForPlaceId(userId, placeId);
|
||||
if (!provider) return { place: null };
|
||||
return this.detailsThroughCache(placeId, toApiLang(lang), () => provider.placeDetails(placeId, lang));
|
||||
}
|
||||
|
||||
async lookupExpanded(
|
||||
userId: number,
|
||||
placeId: string,
|
||||
lang: string | undefined,
|
||||
refresh: boolean,
|
||||
indexEnabled: boolean,
|
||||
): 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.lookup(userId, placeId, lang, undefined, indexEnabled)
|
||||
: { place: null };
|
||||
}
|
||||
|
||||
const langKey = toApiLang(lang); // 'en' default — see lookup
|
||||
const apiKey = await this.selector.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 = await this.placeDetailsCache.findEntry(placeId, langKey, 1);
|
||||
if (cached) return { place: cachedDetails(cached.payload_json) };
|
||||
}
|
||||
|
||||
const place = await this.googlePlaces.provider({ key: apiKey, source: null, userId }).placeDetailsExpanded(placeId, lang);
|
||||
|
||||
try {
|
||||
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 1, payload_json: JSON.stringify(place), fetched_at: Date.now() });
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache expanded place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The marker photo of a place: Google's first photo for a Google id, otherwise
|
||||
* (and as the fallback) a Wikipedia or Commons picture near its coordinates,
|
||||
* cached on disk and served through /api/maps/place-photo/:id/bytes.
|
||||
*
|
||||
* Everything the cache needs to stay cheap lives here: the disk hit, the
|
||||
* negative cache that tells "no photo anywhere" (a day) from "the provider
|
||||
* failed" (minutes), the in-flight dedupe of concurrent requests for one place
|
||||
* and the shared photo-fetch slots. MapsService.getPlacePhoto delegates to it.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { GooglePlacesClient } from './providers/google-places.provider';
|
||||
import { WikimediaClient } from './providers/wikimedia.client';
|
||||
import { PlacesProviderSelector } from './places-provider.selector';
|
||||
import { withPhotoFetchSlot } from './photo-fetch-slot';
|
||||
import { isGooglePlaceId } from './maps.helpers';
|
||||
|
||||
@Injectable()
|
||||
export class PlacePhotoResolver {
|
||||
constructor(
|
||||
private readonly photoCache: PlacePhotoCacheService,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
private readonly googlePlaces: GooglePlacesClient,
|
||||
private readonly wiki: WikimediaClient,
|
||||
private readonly selector: PlacesProviderSelector,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
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> => {
|
||||
return withPhotoFetchSlot(async () => {
|
||||
const apiKey = await this.selector.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> => {
|
||||
const outcome = await this.wiki.downloadPhoto(lat, lng, name);
|
||||
if (outcome.kind === 'failed') providerFailed = true;
|
||||
if (outcome.kind !== 'photo') return null;
|
||||
try {
|
||||
const cached = await this.photoCache.put(placeId, outcome.bytes, outcome.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;
|
||||
const outcome = await this.googlePlaces.firstPhoto(placeId, apiKey);
|
||||
if (outcome.kind === 'failed') providerFailed = true;
|
||||
if (outcome.kind !== 'photo') return null;
|
||||
|
||||
const cached = await this.photoCache.put(placeId, outcome.bytes, outcome.attribution);
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
await this.placesRepo.setImageUrlIfUnset(placeId, cached.photoUrl);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
return { attribution: outcome.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;
|
||||
});
|
||||
})();
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Who answers beside the index for one request: the keyed places provider and
|
||||
* the credential it spends.
|
||||
*
|
||||
* Search, autocomplete, nearby, details and the marker photo all start by
|
||||
* asking this: which provider holds the keyed slot (Google, Amap, or nobody so
|
||||
* the OpenStreetMap stack answers alone), whether the admin's "Google only"
|
||||
* switch applies, whether Amap answers first inside China, and which key a
|
||||
* Google or Amap call spends. MapsService orchestrates on top of the answer;
|
||||
* this class only resolves it, through the same app_settings and users rows
|
||||
* the key chain always read, in the same order.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { isOutsideChina } from '@trek/shared';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { GoogleQuotaService } from '../google-quota/google-quota.service';
|
||||
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
|
||||
import { AmapPlacesProvider, AmapTipStash, isAmapPlaceId } from './providers/amap.provider';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
/**
|
||||
* The admin switch that hands search and suggestions to Google alone. Off, the
|
||||
* index and OpenStreetMap answer first and Google is only asked when they find
|
||||
* nothing, which is what every install has had since 4.3.0.
|
||||
*/
|
||||
export const PLACES_GOOGLE_ONLY_SETTING = 'places_google_only';
|
||||
|
||||
/**
|
||||
* Whoever holds the keyed slot beside the index for one request: Google's
|
||||
* credential, an Amap provider, or nobody (the OpenStreetMap stack alone).
|
||||
*/
|
||||
export type KeyedProvider =
|
||||
| { id: 'google'; key: string; source: ApiKeySource | null }
|
||||
| { id: 'amap'; provider: AmapPlacesProvider };
|
||||
|
||||
@Injectable()
|
||||
export class PlacesProviderSelector {
|
||||
constructor(
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
private readonly googleQuota: GoogleQuotaService,
|
||||
) {}
|
||||
|
||||
/** Amap autocomplete tips for the details fallback. Here rather than on the provider:
|
||||
* a provider is built per request, and a pick is two requests. */
|
||||
private readonly amapTips = new AmapTipStash();
|
||||
|
||||
// ── 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.appSettings, this.usersRepo, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google key to spend, or null. Also null once today's calls reached the
|
||||
* admin's daily ceiling (#1582), which makes every caller fall back to what a
|
||||
* keyless install does instead of failing.
|
||||
*/
|
||||
async getMapsKey(userId: number): Promise<string | null> {
|
||||
if (await this.googleQuota.exhausted()) return 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.appSettings, this.usersRepo, '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.
|
||||
*/
|
||||
async placesProviderChoice(): Promise<PlacesProviderChoice> {
|
||||
const value = await this.appSettings.getValue(PLACES_PROVIDER_SETTING);
|
||||
return isPlacesProviderChoice(value) ? 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 = await this.placesProviderChoice();
|
||||
if (choice === 'openstreetmap') return null;
|
||||
|
||||
if (choice !== 'amap') {
|
||||
const google = await this.resolveMapsKey(userId);
|
||||
// Past the daily ceiling (#1582) the key is spent for today: answer as if
|
||||
// there were none, so `auto` moves on and OpenStreetMap fills in.
|
||||
if (google.key && !(await this.googleQuota.exhausted())) 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 }, this.amapTips) }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this search goes to Google and nowhere else.
|
||||
*
|
||||
* Two ways to ask for that, both born of the same moment: the index answered
|
||||
* a query with something that is not the place the traveller meant, and with
|
||||
* the index and OpenStreetMap answering first, Google was never consulted as
|
||||
* long as they found anything at all. The caller can send one search to
|
||||
* Google (`requested`, the "search Google instead" link under the results),
|
||||
* and the admin can make that the rule for every search and suggestion (the
|
||||
* switch beside the key). Either way it only holds when Google holds the key
|
||||
* slot: on an install without a Google key, or one that picked Amap or
|
||||
* OpenStreetMap, both change nothing, and the admin panel says so.
|
||||
*
|
||||
* Read after the keyed provider on purpose: that lookup already walked the
|
||||
* key chain, and a setting read ahead of it would shift the order of the
|
||||
* app_settings reads every test of the chain stubs by position.
|
||||
*/
|
||||
async googleOnly(keyed: KeyedProvider | null, requested = false): Promise<boolean> {
|
||||
if (keyed?.id !== 'google') return false;
|
||||
if (requested) return true;
|
||||
return (await this.appSettings.getValue(PLACES_GOOGLE_ONLY_SETTING)) === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Amap answers before the index and OpenStreetMap (#1636): only when
|
||||
* the admin picked Amap outright, not when it holds the slot by default, and
|
||||
* only for a search centred inside China. Outside it Amap still answers, with
|
||||
* the wrong place (the Eiffel Tower lands in Macau), so the gate is the point,
|
||||
* not the provider.
|
||||
*/
|
||||
async amapAnswersFirst(point?: { lat: number; lng: number }): Promise<boolean> {
|
||||
if (!point || !Number.isFinite(point.lat) || !Number.isFinite(point.lng)) return false;
|
||||
return (await this.placesProviderChoice()) === 'amap' && !isOutsideChina(point.lat, point.lng);
|
||||
}
|
||||
|
||||
/** 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 Google provider, whose key is
|
||||
* resolved 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.
|
||||
*/
|
||||
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 }, this.amapTips) : null;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
haversineMetres,
|
||||
namesOverlap,
|
||||
readChargingInfo,
|
||||
buildOsmDetails,
|
||||
OSM_PLACE_ID,
|
||||
type OverpassPoi,
|
||||
} from '../maps.helpers';
|
||||
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
|
||||
@@ -446,6 +448,46 @@ export class OsmClient {
|
||||
return res.ok ? ((await res.json()) as NominatimReverse) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The details record of an OpenStreetMap element id (`node:123`, `way:4`,
|
||||
* `relation:5`), or null for anything else with a colon in it.
|
||||
*/
|
||||
async elementDetails(placeId: string, lang?: string): Promise<Record<string, unknown> | null> {
|
||||
// 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 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 {
|
||||
...details,
|
||||
name: nominatim?.name || element?.tags?.name || '',
|
||||
address: nominatim?.address || '',
|
||||
lat: nominatim?.lat ?? null,
|
||||
lng: nominatim?.lng ?? null,
|
||||
osm_id: placeId,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Overpass API (OSM details) ─────────────────────────────────────────────
|
||||
|
||||
async fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { GooglePlacesClient } from '../../src/nest/maps/providers/google-places.provider';
|
||||
import { OsmClient } from '../../src/nest/maps/providers/osm.client';
|
||||
import { WikimediaClient } from '../../src/nest/maps/providers/wikimedia.client';
|
||||
import { PlacesProviderSelector } from '../../src/nest/maps/places-provider.selector';
|
||||
import { PlacePhotoResolver } from '../../src/nest/maps/place-photo.resolver';
|
||||
import { MapsUrlResolver } from '../../src/nest/maps/maps-url.resolver';
|
||||
import { PlaceDetailsResolver } from '../../src/nest/maps/place-details.resolver';
|
||||
import type { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../src/db/repositories/Users.repository';
|
||||
@@ -16,6 +20,7 @@ export interface MapsParts {
|
||||
google: GooglePlacesClient;
|
||||
osm: OsmClient;
|
||||
wiki: WikimediaClient;
|
||||
selector: PlacesProviderSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,8 +40,12 @@ export function buildMapsParts(
|
||||
const google = new GooglePlacesClient(googleQuota);
|
||||
const osm = new OsmClient();
|
||||
const wiki = new WikimediaClient();
|
||||
const svc = new MapsService(photoCache, appSettings, users, placeDetailsCache, places, googleQuota, google, osm, wiki);
|
||||
return { svc, google, osm, wiki };
|
||||
const selector = new PlacesProviderSelector(appSettings, users, googleQuota);
|
||||
const photos = new PlacePhotoResolver(photoCache, places, google, wiki, selector);
|
||||
const links = new MapsUrlResolver(osm);
|
||||
const details = new PlaceDetailsResolver(placeDetailsCache, google, osm, selector);
|
||||
const svc = new MapsService(photoCache, appSettings, google, osm, wiki, selector, photos, links, details);
|
||||
return { svc, google, osm, wiki, selector };
|
||||
}
|
||||
|
||||
export function buildMapsService(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* PlacePhotoResolver on its own: the outcomes of the two photo sources mapped
|
||||
* onto the negative cache. The full getPlacePhoto flow (cache hit, in-flight
|
||||
* dedupe, Google then Wikimedia) runs through MapsService in
|
||||
* maps.service.test.ts; this pins the cases a source answer alone decides.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PlacePhotoResolver } from '../../../src/nest/maps/place-photo.resolver';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider';
|
||||
import type { WikimediaClient } from '../../../src/nest/maps/providers/wikimedia.client';
|
||||
import type { PlacesProviderSelector } from '../../../src/nest/maps/places-provider.selector';
|
||||
|
||||
function cache(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
get: vi.fn(async () => null),
|
||||
getErrored: vi.fn(async () => false),
|
||||
getInFlight: vi.fn(() => undefined),
|
||||
setInFlight: vi.fn(),
|
||||
markError: vi.fn(async () => {}),
|
||||
put: vi.fn(async (id: string, _bytes: Buffer, attribution: string | null) => ({ photoUrl: `/p/${id}`, filePath: '/x', attribution })),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function resolver(c: ReturnType<typeof cache>, wikiOutcome: unknown, key: string | null = null, googleOutcome: unknown = { kind: 'none' }) {
|
||||
const places = { setImageUrlIfUnset: vi.fn(async () => 1) };
|
||||
const google = { firstPhoto: vi.fn(async () => googleOutcome) };
|
||||
const wiki = { downloadPhoto: vi.fn(async () => wikiOutcome) };
|
||||
const selector = { getMapsKey: vi.fn(async () => key) };
|
||||
const r = new PlacePhotoResolver(
|
||||
c as unknown as PlacePhotoCacheService,
|
||||
places as unknown as PlacesRepository,
|
||||
google as unknown as GooglePlacesClient,
|
||||
wiki as unknown as WikimediaClient,
|
||||
selector as unknown as PlacesProviderSelector,
|
||||
);
|
||||
return { r, places, google, wiki };
|
||||
}
|
||||
|
||||
describe('PlacePhotoResolver', () => {
|
||||
it('PHOTO-R-001: a Wikimedia picture the disk cache cannot store counts as a provider failure', async () => {
|
||||
const c = cache({ put: vi.fn(async () => { throw new Error('disk full'); }) });
|
||||
const { r } = resolver(c, { kind: 'photo', bytes: Buffer.from([1]), attribution: 'A' });
|
||||
expect(await r.resolve(1, 'coords:1,2', 1, 2, 'X')).toEqual({ photoUrl: null, attribution: null });
|
||||
expect(c.markError).toHaveBeenCalledWith('coords:1,2', 'provider-error');
|
||||
});
|
||||
|
||||
it('PHOTO-R-002: nothing anywhere is remembered as a plain miss', async () => {
|
||||
const c = cache();
|
||||
const { r } = resolver(c, { kind: 'none' });
|
||||
expect(await r.resolve(1, 'coords:1,2', 1, 2)).toEqual({ photoUrl: null, attribution: null });
|
||||
expect(c.markError).toHaveBeenCalledWith('coords:1,2', 'no-photo');
|
||||
});
|
||||
|
||||
it('PHOTO-R-003: a Google photo is stored, its proxy URL persisted, and Wikimedia never asked', async () => {
|
||||
const c = cache();
|
||||
const { r, places, wiki } = resolver(c, { kind: 'none' }, 'key', { kind: 'photo', bytes: Buffer.from([1]), attribution: 'G' });
|
||||
expect(await r.resolve(1, 'ChIJabc', 1, 2)).toEqual({ photoUrl: '/api/maps/place-photo/ChIJabc/bytes', attribution: 'G' });
|
||||
expect(places.setImageUrlIfUnset).toHaveBeenCalledWith('ChIJabc', '/p/ChIJabc');
|
||||
expect(wiki.downloadPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PHOTO-R-004: a Google failure falls back to Wikimedia and is remembered briefly when that has nothing', async () => {
|
||||
const c = cache();
|
||||
const { r, wiki } = resolver(c, { kind: 'none' }, 'key', { kind: 'failed' });
|
||||
expect(await r.resolve(1, 'ChIJabc', 1, 2)).toEqual({ photoUrl: null, attribution: null });
|
||||
expect(wiki.downloadPhoto).toHaveBeenCalledWith(1, 2, undefined);
|
||||
expect(c.markError).toHaveBeenCalledWith('ChIJabc', 'provider-error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user