Skip to content

Commit 152de3b

Browse files
committed
Use /lat,lon[/name][?z=zoom] format instead of /lat,lon,zoom[/name]
Signed-off-by: Alexander Borsuk <me@alex.bio>
1 parent 28a5c14 commit 152de3b

2 files changed

Lines changed: 24 additions & 20 deletions

File tree

src/ge0.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ export function normalizeNameAndTitle(name: string | undefined): [string, string
4343
return [name, title];
4444
}
4545

46-
export function normalizeZoom(zoom: string | undefined): number {
46+
export function normalizeZoom(zoom: string | null | undefined): number {
4747
const DEFAULT_ZOOM = 14;
48-
// Upper bound mirrors the app's kMaxZoom (organicmaps libs/ge0/geo_url_parser.cpp, GeoURLInfo::SetZoom).
48+
// Clamp a shared zoom to a sane range; out-of-range or non-numeric values fall back to the default.
4949
const MAX_ZOOM = 20;
5050
if (!zoom || !/^\d+$/.test(zoom)) return DEFAULT_ZOOM;
5151
const z = Number(zoom);
@@ -106,12 +106,13 @@ function renderTemplate(template: string, llz: LatLonZoom, name: string, title:
106106
return new Response(template, { headers: { 'content-type': 'text/html' } });
107107
}
108108

109-
// Clear decimal coordinates: /lat,lon[,zoom][/name]. An optional ?z=<zoom> query param takes
110-
// precedence over the path zoom (see onGe0Decode). Kept in sync with the app's clear-coordinate
111-
// parser (organicmaps libs/ge0/geo_url_parser.cpp) — same grammar, same ?z= precedence and zoom cap.
112-
// Coordinates and zoom are validated separately.
109+
// Clear decimal coordinates: /lat,lon[/name], with the zoom in an optional ?z= query param.
110+
// Grammar-identical to the app's clear-coordinate parser (organicmaps libs/ge0/parser.cpp,
111+
// Ge0Parser::ParseClearCoordinates; zoom read in libs/map/mwm_url.cpp) so a shared link resolves the
112+
// same here and in the app. There is no in-path zoom, so a trailing number stays part of the name
113+
// (e.g. "7-Eleven"). Coordinates are validated below.
113114
export const CLEAR_COORDINATES_REGEX =
114-
/(?<lat>-?\d+\.\d+)[^.](?<lon>-?\d+\.\d+)(?:[^.](?<zoom>\d{1,2}))?(?:[^\d.](?<name>.+))?/;
115+
/(?<lat>-?\d+\.\d+)[^.](?<lon>-?\d+\.\d+)(?:[^\d.](?<name>.+))?/;
115116

116117
// Throws on decode error.
117118
export async function onGe0Decode(template: string, url: string): Promise<Response> {
@@ -120,8 +121,8 @@ export async function onGe0Decode(template: string, url: string): Promise<Respon
120121

121122
const m = pathname.match(CLEAR_COORDINATES_REGEX);
122123
if (m && m.groups) {
123-
// Zoom precedence: ?z= query param, then a legacy /lat,lon,zoom path segment, then the default.
124-
const zoom = normalizeZoom(new URLSearchParams(search).get('z') ?? m.groups.zoom);
124+
// Zoom comes from the ?z= query param (or the default when absent).
125+
const zoom = normalizeZoom(new URLSearchParams(search).get('z'));
125126
const llz = { lat: Number(m.groups.lat), lon: Number(m.groups.lon), zoom };
126127
if (llz.lat <= -90.0 || llz.lat >= 90.0 || llz.lon <= -180.0 || llz.lon >= 180.0)
127128
throw new Error(`Invalid coordinates ${m.groups.lat} and ${m.groups.lon}`);

test/api.test.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,20 @@ describe('normalizeNameAndTitle', () => {
5858
});
5959

6060
describe('CLEAR_COORDINATES_REGEX', () => {
61-
test('captures lat/lon plus optional zoom and name from a clear-text path', () => {
61+
test('captures lat/lon and an optional /name from a clear-text path', () => {
6262
expect('/53.9,27.56'.match(CLEAR_COORDINATES_REGEX)?.groups).toMatchObject({ lat: '53.9', lon: '27.56' });
63-
expect('/53.9,27.56,15,Minsk'.match(CLEAR_COORDINATES_REGEX)?.groups).toMatchObject({
63+
expect('/53.9,27.56/Minsk'.match(CLEAR_COORDINATES_REGEX)?.groups).toMatchObject({
6464
lat: '53.9',
6565
lon: '27.56',
66-
zoom: '15',
6766
name: 'Minsk',
6867
});
6968
});
7069

70+
test('keeps a digit-initial name intact (there is no in-path zoom to swallow it)', () => {
71+
expect('/48.85,2.29/7-Eleven'.match(CLEAR_COORDINATES_REGEX)?.groups?.name).toBe('7-Eleven');
72+
expect('/48.85,2.29/24-hour-shop'.match(CLEAR_COORDINATES_REGEX)?.groups?.name).toBe('24-hour-shop');
73+
});
74+
7175
test('does not match an encoded ge0 payload, so it falls through to the binary decoder', () => {
7276
expect('/B4srhdHVVt'.match(CLEAR_COORDINATES_REGEX)).toBeNull();
7377
});
@@ -98,7 +102,7 @@ describe('onGe0Decode — end to end', () => {
98102
});
99103

100104
test('renders clear-text coordinates', async () => {
101-
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56,15,Minsk')).text();
105+
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56/Minsk?z=15')).text();
102106
expect(html).toContain('<coords>53.9,27.56@15</coords>');
103107
expect(html).toContain('<name>Minsk</name>');
104108
});
@@ -109,7 +113,7 @@ describe('onGe0Decode — end to end', () => {
109113
});
110114

111115
test('HTML-escapes the pin name of a clear-text link to prevent XSS', async () => {
112-
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/12.3,45.6,15,<script>alert(1)</script>')).text();
116+
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/12.3,45.6/<script>alert(1)</script>')).text();
113117
expect(html).toContain('<name>&lt;script&gt;alert(1)&lt;/script&gt;</name>');
114118
expect(html).not.toContain('<script>alert(1)</script>');
115119
});
@@ -139,14 +143,13 @@ describe('onGe0Decode — end to end', () => {
139143
expect(html).toContain('<coords>53.9,27.56@16</coords>');
140144
});
141145

142-
test('?z= takes precedence over a legacy path zoom', async () => {
143-
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56,15?z=16')).text();
144-
expect(html).toContain('<coords>53.9,27.56@16</coords>');
146+
test('a digit-initial name is not mistaken for a zoom', async () => {
147+
const html = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/48.85,2.29/7-Eleven?z=16')).text();
148+
expect(html).toContain('<coords>48.85,2.29@16</coords>');
149+
expect(html).toContain('<name>7-Eleven</name>');
145150
});
146151

147-
test('?z= is optional — falls back to the path zoom, then the default', async () => {
148-
const path = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56,15')).text();
149-
expect(path).toContain('<coords>53.9,27.56@15</coords>');
152+
test('?z= is optional — a bare link falls back to the default zoom', async () => {
150153
const bare = await (await onGe0Decode(TEMPLATE, 'https://omaps.app/53.9,27.56')).text();
151154
expect(bare).toContain('<coords>53.9,27.56@14</coords>');
152155
});

0 commit comments

Comments
 (0)