diff --git a/server/src/nest/maps/maps-url.resolver.ts b/server/src/nest/maps/maps-url.resolver.ts index 09db4be8a..e241cf52c 100644 --- a/server/src/nest/maps/maps-url.resolver.ts +++ b/server/src/nest/maps/maps-url.resolver.ts @@ -34,12 +34,14 @@ 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=. +// them several ways: !3dlat!4dlng (map data param) · /@lat,lng,zoom · ?q=/?ll=. +// The data param is the place itself, while @ is only where the camera looks, +// often a hundred metres off on a place link, so the data param wins (#2635). 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 at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); + if (at) return { lat: Number.parseFloat(at[1]), lng: Number.parseFloat(at[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; diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 15056b44a..c9d0c1b7c 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -865,6 +865,89 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => { }); }); +// A Google place URL carries two points: @lat,lng is where the camera looks, +// !3d!4d is the place itself. Pinning the camera lands next to the place (#2635). +describe('resolveGoogleMapsUrl place pin before camera (#2635)', () => { + // What https://maps.app.goo.gl/CdynKSHT16bufnAp9 redirects to. + const EIFFEL_SHARE_TARGET = + 'https://www.google.com/maps/place/Tour+Eiffel/@48.8579431,2.292255,812m/data=!3m1!1e3!4m7!3m6!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813!15sChFtYXBzIGVpZmZlbCB0b3dlcloTIhFtYXBzIGVpZmZlbCB0b3dlcpIBE2hpc3RvcmljYWxfbGFuZG1hcmuaAURDaTlEUVVsUlFVTnZaRU5vZEhsalJqbHZUMnR3VGxkWGRFaFBWM2gyVFd3NWVtTnVTalJNVlU1RVUxWm9ORTlWUlJBQuABAPoBBAgAEDg!16zL20vMDJqODE?entry=tts&g_ep=EgoyMDI2MTAwNy4wIPu8ASoASAFQAw%3D%3D&skid=20b521f5-0f45-4a02-8475-b2a1c2bdcb0b'; + + const nominatimAt = (calls: string[]) => + vi.fn(async (u: string) => { + calls.push(u); + if (u.includes('goo.gl')) return { url: EIFFEL_SHARE_TARGET }; + return { ok: true, json: async () => ({ display_name: 'Avenue Gustave Eiffel, Paris', name: null, address: {} }) }; + }); + + it('MAPS-2635-001: a place URL with both @ and !3d!4d resolves to the place, not the camera', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', nominatimAt(calls)); + const result = await svc.resolveGoogleMapsUrl(EIFFEL_SHARE_TARGET); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + expect(result.name).toBe('Tour Eiffel'); + expect(result.google_ftid).toBe('0x47e66e2964e34e2d:0x8ddca9ee380ef7e0'); + // The address is looked up at the place too. + const reverse = calls.find((u) => u.includes('nominatim')); + expect(reverse).toContain('lat=48.8583701'); + expect(reverse).toContain('lon=2.2944813'); + }); + + it('MAPS-2635-002: the share short link resolves to the place pin', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', nominatimAt(calls)); + const result = await svc.resolveGoogleMapsUrl('https://maps.app.goo.gl/CdynKSHT16bufnAp9'); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); + + it('MAPS-2635-003: only !3d!4d still resolves', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl( + 'https://www.google.com/maps/place/Tour+Eiffel/data=!4m7!3m6!8m2!3d48.8583701!4d2.2944813', + ); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); + + it('MAPS-2635-004: only @ still resolves to the camera point', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8579431,2.292255,812m'); + expect(result.lat).toBe(48.8579431); + expect(result.lng).toBe(2.292255); + }); + + it('MAPS-2635-005: only ?q= still resolves', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8583701,2.2944813'); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); + + it('MAPS-2635-006: @ keeps its precedence over ?q= when no place pin is present', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.85,2.29,15z?q=40.6892,-74.0445'); + expect(result.lat).toBe(48.85); + expect(result.lng).toBe(2.29); + }); + + it('MAPS-2635-007: a page body with both @ and !3d!4d resolves to the place', async () => { + const fetchMock = vi.fn(async (u: string) => { + if (u.includes('nominatim')) { + return { ok: true, json: async () => ({ display_name: 'Paris', name: null, address: {} }) }; + } + return { + url: 'https://www.google.com/maps/place/Somewhere', + text: async () => 'x/@48.8579431,2.292255,812m/y!3d48.8583701!4d2.2944813z', + }; + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999'); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); +}); + // ── searchNominatim (fetch-dependent) ──────────────────────────────────────── describe('searchNominatim (fetch stubbed)', () => {