From 2aad8f465c4cd641a08165e99b3895ccedcad958 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 00:13:35 +0200 Subject: [PATCH] fix(maps): prevent server crash when legacy Google photo URLs are stored as placeIds Migration 107 only rewrote image_url rows matching /places/%/photos/%; URLs using the /place-photos/ or /places/ paths survived the upgrade and were passed verbatim to the Places API, producing a malformed request whose empty/HTML response body threw SyntaxError before detailsRes.ok was checked. The resulting rejection was leaked by placePhotoCache.setInFlight via an unhandled .finally() chain, triggering Node 22's default unhandledRejection=throw and terminating the process. - placePhotoCache: add .catch() after .finally() to prevent unhandled rejection crash - mapsService: reject URL-shaped placeIds early; read response as text before JSON.parse - migrations: add migration to rewrite remaining googleusercontent/places.googleapis URLs - MapView/MapViewGL: prefer stable proxy URL form of image_url before google_place_id Fixes #770 --- client/src/components/Map/MapView.tsx | 6 +++++- client/src/components/Map/MapViewGL.tsx | 6 +++++- server/src/db/migrations.ts | 21 +++++++++++++++++++++ server/src/services/mapsService.ts | 14 +++++++++++--- server/src/services/placePhotoCache.ts | 4 +++- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 2bb1827f..5af4bea3 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -477,7 +477,11 @@ export const MapView = memo(function MapView({ cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) if (!cached && !isLoading(cacheKey)) { - const photoId = place.image_url || place.google_place_id || place.osm_id + const photoId = + (place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null) + || place.google_place_id + || place.osm_id + || place.image_url if (photoId || (place.lat && place.lng)) { fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) } diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 72919b6c..557e2647 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -366,7 +366,11 @@ export function MapViewGL({ } cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) if (!cached && !isLoading(cacheKey)) { - const photoId = place.image_url || place.google_place_id || place.osm_id + const photoId = + (place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null) + || place.google_place_id + || place.osm_id + || place.image_url if (photoId || (place.lat && place.lng)) { fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 7c8ff903..85fe3490 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1906,6 +1906,27 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id); `); }, + // Migration: backfill remaining legacy Google photo URLs missed by Migration 107. + // Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use + // /place-photos/ or /places/ paths and were skipped. Rewrite any remaining + // google-hosted URL to the stable proxy form using the row's google_place_id. + () => { + db.exec(` + UPDATE places + SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', + updated_at = CURRENT_TIMESTAMP + WHERE google_place_id IS NOT NULL + AND image_url IS NOT NULL + AND image_url != '' + AND image_url NOT LIKE '/api/maps/place-photo/%' + AND ( + image_url LIKE 'http://%googleusercontent.com/%' + OR image_url LIKE 'https://%googleusercontent.com/%' + OR image_url LIKE 'http://%places.googleapis.com/%' + OR image_url LIKE 'https://%places.googleapis.com/%' + ) + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index ed484d0c..00151fe2 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -648,6 +648,12 @@ export async function getPlacePhoto( return null; } + // Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url + if (/^https?:\/\//i.test(placeId)) { + placePhotoCache.markError(placeId); + return null; + } + // Google Photos — fetch details to get photo name const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, { headers: { @@ -655,13 +661,15 @@ export async function getPlacePhoto( 'X-Goog-FieldMask': 'photos', }, }); - const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } }; - + const body = await detailsRes.text(); if (!detailsRes.ok) { - console.error('Google Places photo details error:', details.error?.message || detailsRes.status); + console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200)); placePhotoCache.markError(placeId); return null; } + let details: GooglePlaceDetails & { error?: { message?: string } }; + try { details = body ? JSON.parse(body) : { photos: [] }; } + catch { placePhotoCache.markError(placeId); return null; } if (!details.photos?.length) { placePhotoCache.markError(placeId); diff --git a/server/src/services/placePhotoCache.ts b/server/src/services/placePhotoCache.ts index 59522d8f..154c56db 100644 --- a/server/src/services/placePhotoCache.ts +++ b/server/src/services/placePhotoCache.ts @@ -96,7 +96,9 @@ export function getInFlight(placeId: string): Promise<{ filePath: string; attrib export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void { inFlight.set(placeId, promise); - promise.finally(() => inFlight.delete(placeId)); + promise + .finally(() => inFlight.delete(placeId)) + .catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ }); } export function serveFilePath(placeId: string): string | null {