diff --git a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx index f61fc4e03..8a1342c9e 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx @@ -18,7 +18,7 @@ const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard'; const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11'; const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1'; const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty'; -const TILE_PLACEHOLDER = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; +const TILE_PLACEHOLDER = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; /** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */ function stubDefaults(initial: Record = {}) { diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index d7bd03b2b..70b4f3d3f 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -8,6 +8,7 @@ import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants' import type { DistanceUnit, Place } from '../../types' +import { normalizeTileUrl } from '../../utils/tileUrl' import { MAPBOX_DEFAULT_STYLE, defaultStyleForProvider, @@ -19,7 +20,7 @@ import { } from '../Map/glProviders' const MAP_PRESETS = [ - { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, @@ -114,7 +115,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { adminApi.getDefaultUserSettings().then((data: Defaults) => { const provider = normalizeProvider(data.map_provider) setDefaults(data) - setMapTileUrl(data.map_tile_url || '') + setMapTileUrl(normalizeTileUrl(data.map_tile_url || '')) setMapboxToken(data.mapbox_access_token || '') setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)) setLoaded(true) @@ -328,7 +329,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { value={mapTileUrl} onChange={(e: React.ChangeEvent) => setMapTileUrl(e.target.value)} onBlur={() => save({ map_tile_url: mapTileUrl })} - placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{t('settings.mapDefaultHint')}

diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 995014770..ae4c30612 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -24,7 +24,7 @@ interface MapPreset { } const MAP_PRESETS: MapPreset[] = [ - { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, @@ -307,7 +307,7 @@ export default function MapSettingsTab(): React.ReactElement { type="text" value={mapTileUrl} onChange={(e: React.ChangeEvent) => setMapTileUrl(e.target.value)} - placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{t('settings.mapDefaultHint')}

diff --git a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx index 36773d6d0..51846fb88 100644 --- a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx +++ b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx @@ -6,6 +6,7 @@ import { useToast } from '../../../components/shared/Toast' import { MapView } from '../../../components/Map/MapView' import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.constants' import { getApiErrorMessage, type DistanceUnit, type Place } from '../../../types' +import { normalizeTileUrl } from '../../../utils/tileUrl' import { MAPBOX_DEFAULT_STYLE, defaultStyleForProvider, @@ -22,7 +23,7 @@ import { MSetSelectRow } from '../settings/MSettingsUi' import MSetPickerSheet from '../settings/MSetPickerSheet' const MAP_PRESETS = [ - { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, @@ -76,7 +77,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement { adminApi.getDefaultUserSettings().then((data: Defaults) => { const provider = normalizeProvider(data.map_provider) setDefaults(data) - setMapTileUrl(data.map_tile_url || '') + setMapTileUrl(normalizeTileUrl(data.map_tile_url || '')) setMapboxToken(data.mapbox_access_token || '') setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)) setLoaded(true) @@ -282,7 +283,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement { value={mapTileUrl} onChange={(e: React.ChangeEvent) => setMapTileUrl(e.target.value)} onBlur={() => save({ map_tile_url: mapTileUrl })} - placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> diff --git a/client/src/mobile/screens/settings/MSettingsMap.tsx b/client/src/mobile/screens/settings/MSettingsMap.tsx index e29586d1c..df5714b12 100644 --- a/client/src/mobile/screens/settings/MSettingsMap.tsx +++ b/client/src/mobile/screens/settings/MSettingsMap.tsx @@ -24,7 +24,7 @@ interface MapPreset { } const MAP_PRESETS: MapPreset[] = [ - { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, @@ -185,7 +185,7 @@ export default function MSettingsMap() { className="mt-[6px]" value={mapTileUrl} onChange={(e: React.ChangeEvent) => setMapTileUrl(e.target.value)} - placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> {t('settings.mapDefaultHint')} diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index 085a9ae00..175d09634 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -4,6 +4,7 @@ import type { Settings } from '../types' import { DEFAULT_APPEARANCE } from '@trek/shared' import { getApiErrorMessage } from '../types' import { SUPPORTED_LANGUAGE_CODES } from '../i18n/supportedLanguages' +import { normalizeTileUrl } from '../utils/tileUrl' interface SettingsState { settings: Settings @@ -56,6 +57,16 @@ export const DEFAULT_SETTINGS: Settings = { // periodic) can all fire a retry at once — collapse them into one in-flight GET. let _loadInFlight: Promise | null = null +// Every tile consumer (planner map, journey map, tile prefetcher, the settings +// preview) reads the template from this store, so the retired +// {s}.tile.openstreetmap.org host is rewritten here — on the way in from the +// server and on the way out to it. Rewriting only on read would let a template +// typed by hand survive in the database until the next load put it back (#1733). +function withNormalizedTileUrl>(patch: T): T { + if (typeof patch.map_tile_url !== 'string') return patch + return { ...patch, map_tile_url: normalizeTileUrl(patch.map_tile_url) } +} + export const useSettingsStore = create((set, get) => ({ settings: { ...DEFAULT_SETTINGS }, isLoaded: false, @@ -65,8 +76,11 @@ export const useSettingsStore = create((set, get) => ({ _loadInFlight = (async () => { try { const data = await settingsApi.get() + // Rewritten here so the Map settings input already shows the host that + // still resolves, and persists it on the next save. + const incoming = withNormalizedTileUrl({ ...data.settings } as Partial) set((state) => ({ - settings: { ...state.settings, ...data.settings }, + settings: { ...state.settings, ...incoming }, isLoaded: true, })) } catch (err: unknown) { @@ -83,12 +97,14 @@ export const useSettingsStore = create((set, get) => ({ }, updateSetting: async (key: keyof Settings, value: Settings[keyof Settings]) => { + const next = + key === 'map_tile_url' && typeof value === 'string' ? normalizeTileUrl(value) : value set((state) => ({ - settings: { ...state.settings, [key]: value }, + settings: { ...state.settings, [key]: next }, })) - if (key === 'language') localStorage.setItem('app_language', value as string) + if (key === 'language') localStorage.setItem('app_language', next as string) try { - await settingsApi.set(key, value) + await settingsApi.set(key, next) } catch (err: unknown) { console.error('Failed to save setting:', err) throw new Error(getApiErrorMessage(err, 'Error saving setting')) @@ -109,11 +125,12 @@ export const useSettingsStore = create((set, get) => ({ }, updateSettings: async (settingsObj: Partial) => { + const patch = withNormalizedTileUrl(settingsObj) set((state) => ({ - settings: { ...state.settings, ...settingsObj }, + settings: { ...state.settings, ...patch }, })) try { - await settingsApi.setBulk(settingsObj) + await settingsApi.setBulk(patch) } catch (err: unknown) { console.error('Failed to save settings:', err) throw new Error(getApiErrorMessage(err, 'Error saving settings')) diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index a33247fdd..a6d676c3d 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -21,6 +21,7 @@ import type { Place } from '../types' import { offlineDb, upsertSyncMeta } from '../db/offlineDb' import { isAuthed } from './authGate' +import { normalizeTileUrl } from '../utils/tileUrl' // ── Constants ───────────────────────────────────────────────────────────────── @@ -52,14 +53,21 @@ const TILE_CACHE = 'map-tiles' const DEFAULT_TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' -const SUBDOMAINS = ['a', 'b', 'c', 'd'] +/** + * Must stay identical to Leaflet's `subdomains` default ('abc'), because the + * index is taken modulo the list length: a fourth entry here shifts the host + * for most tiles away from the one the TileLayer will ask for, so the prefetch + * fills the cache under URLs the map never requests. It also produced the dead + * d.tile.openstreetmap.org lookups in #1733. + */ +const SUBDOMAINS = ['a', 'b', 'c'] /** * Pick the subdomain from the tile coordinates, the way Leaflet does. * * It has to be a pure function of x/y: a rotating counter hands the same tile a * different host on every run, which both defeats the cache lookup below and - * stores the tile up to four times under four URLs. + * stores the tile once per host. */ function subdomainFor(x: number, y: number): string { return SUBDOMAINS[Math.abs(x + y) % SUBDOMAINS.length] @@ -145,10 +153,14 @@ export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): nu /** * Build the concrete tile URL for given z/x/y from a Leaflet template. - * Rotates through subdomains (a–d). + * Rotates through subdomains (a–c). + * + * The template is normalized first: this function is also reached with a raw + * admin default rather than the value from the settings store, so the OSM + * sharding rewrite has to happen here too. */ export function buildTileUrl(template: string, z: number, x: number, y: number): string { - return template + return normalizeTileUrl(template) .replace('{z}', String(z)) .replace('{x}', String(x)) .replace('{y}', String(y)) diff --git a/client/src/utils/tileUrl.test.ts b/client/src/utils/tileUrl.test.ts new file mode 100644 index 000000000..108a3a402 --- /dev/null +++ b/client/src/utils/tileUrl.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { normalizeTileUrl } from './tileUrl' + +describe('normalizeTileUrl', () => { + it('drops the {s} placeholder from an OSM template', () => { + expect(normalizeTileUrl('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')) + .toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png') + }) + + it('rewrites a hard-coded shard host', () => { + for (const shard of ['a', 'b', 'c', 'd']) { + expect(normalizeTileUrl(`https://${shard}.tile.openstreetmap.org/{z}/{x}/{y}.png`)) + .toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png') + } + }) + + it('rewrites a protocol-relative template', () => { + // Older templates were often stored without a scheme so they'd follow the + // page. Those reach the network just like the https ones, so the shard + // rewrite has to see them too. + expect(normalizeTileUrl('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')) + .toBe('//tile.openstreetmap.org/{z}/{x}/{y}.png') + expect(normalizeTileUrl('//d.tile.openstreetmap.org/{z}/{x}/{y}.png')) + .toBe('//tile.openstreetmap.org/{z}/{x}/{y}.png') + }) + + it('keeps http and an explicit port', () => { + expect(normalizeTileUrl('http://{s}.tile.openstreetmap.org:8080/{z}/{x}/{y}.png')) + .toBe('http://tile.openstreetmap.org:8080/{z}/{x}/{y}.png') + }) + + it('leaves the already-correct host alone', () => { + const url = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + expect(normalizeTileUrl(url)).toBe(url) + }) + + it('leaves other providers untouched', () => { + const carto = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' + expect(normalizeTileUrl(carto)).toBe(carto) + + const osmDe = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' + expect(normalizeTileUrl(osmDe)).toBe(osmDe) + }) + + it('does not touch a look-alike host', () => { + // Only the {s} placeholder and single shard letters are rewritten — a + // self-hosted mirror keeps its own hostname. + const mirror = 'https://mirror.tile.openstreetmap.org.example.com/{z}/{x}/{y}.png' + expect(normalizeTileUrl(mirror)).toBe(mirror) + + const proxy = 'https://tiles.example.com/https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + expect(normalizeTileUrl(proxy)).toBe(proxy) + }) + + it('passes an empty template through', () => { + expect(normalizeTileUrl('')).toBe('') + }) +}) diff --git a/client/src/utils/tileUrl.ts b/client/src/utils/tileUrl.ts new file mode 100644 index 000000000..c5576734a --- /dev/null +++ b/client/src/utils/tileUrl.ts @@ -0,0 +1,24 @@ +/** + * OpenStreetMap has not needed the a/b/c.tile.openstreetmap.org subdomains + * since 2022 — tile.openstreetmap.org serves the whole grid on its own — and + * d.tile.openstreetmap.org has meanwhile lost its DNS record entirely. A + * template that still carries the `{s}` placeholder therefore depends on which + * letters the renderer substitutes: everything that lands on `d` fails with + * ERR_NAME_NOT_RESOLVED, which is console noise plus holes in the offline tile + * cache (#1733). + * + * The presets ship the single-host form now, but a template the user (or an + * admin default) saved earlier is still in the database. Rewriting it on read + * fixes those instances without a migration; the settings store rewrites it on + * save as well, so a legacy URL typed by hand converges on the same host + * instead of coming back on the next load. Other providers are left untouched. + */ + +/** `{s}.` or a bare shard letter in front of tile.openstreetmap.org. */ +const OSM_SHARD = /^((?:https?:)?\/\/)(?:\{s\}|[a-d])\.tile\.openstreetmap\.org(?=[/:]|$)/i + +/** Collapse a sharded OSM tile template onto the single supported host. */ +export function normalizeTileUrl(url: string): string { + if (!url) return url + return url.replace(OSM_SHARD, '$1tile.openstreetmap.org') +} diff --git a/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx index ab41355d7..84bb1c2a1 100644 --- a/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx @@ -187,7 +187,7 @@ describe('MAdminDefaultUserSettings', () => { await screen.findByText('Default User Settings'); // Typed through fireEvent: the {z}/{x}/{y} placeholders collide with userEvent's key syntax - const input = screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'); + const input = screen.getByPlaceholderText('https://tile.openstreetmap.org/{z}/{x}/{y}.png'); fireEvent.change(input, { target: { value: 'https://tiles.example.org/{z}/{x}/{y}.png' } }); fireEvent.blur(input); @@ -207,7 +207,7 @@ describe('MAdminDefaultUserSettings', () => { const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }])); - expect(screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue(url); + expect(screen.getByPlaceholderText('https://tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue(url); await waitFor(() => expect(screen.getByRole('button', { name: 'CartoDB Dark' })).toBeInTheDocument()); }); @@ -221,7 +221,7 @@ describe('MAdminDefaultUserSettings', () => { await user.click(resetLink('Map Template')); await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }])); - await waitFor(() => expect(screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue('')); + await waitFor(() => expect(screen.getByPlaceholderText('https://tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue('')); }); it('FE-MOB-MDUS-014: leaflet hides the GL-only token and style fields', async () => { diff --git a/client/tests/unit/mobile/settings/MSettingsMap.test.tsx b/client/tests/unit/mobile/settings/MSettingsMap.test.tsx index ab90c62e3..2abbcb9af 100644 --- a/client/tests/unit/mobile/settings/MSettingsMap.test.tsx +++ b/client/tests/unit/mobile/settings/MSettingsMap.test.tsx @@ -29,7 +29,7 @@ vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({ ), })); -const OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; +const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; function seedMap(over: Partial = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) { seedStore(useSettingsStore, { diff --git a/client/tests/unit/stores/settingsStore.test.ts b/client/tests/unit/stores/settingsStore.test.ts index 8f88f02da..795dcf87b 100644 --- a/client/tests/unit/stores/settingsStore.test.ts +++ b/client/tests/unit/stores/settingsStore.test.ts @@ -241,4 +241,91 @@ describe('settingsStore', () => { expect(useSettingsStore.getState().settings.default_currency).toBe('EUR'); }); }); + + describe('FE-STORE-SETTINGS-018: loadSettings normalizes a legacy OSM tile template (#1733)', () => { + it('rewrites the retired {s}.tile.openstreetmap.org host on read', async () => { + const settings = buildSettings({ + map_tile_url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + }); + server.use(http.get('/api/settings', () => HttpResponse.json({ settings }))); + + await useSettingsStore.getState().loadSettings(); + + // d.tile.openstreetmap.org no longer resolves, so a template stored before + // OSM dropped sharding must not reach the map or the tile prefetcher. + expect(useSettingsStore.getState().settings.map_tile_url).toBe( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + ); + }); + + it('leaves a custom template from another provider untouched', async () => { + const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + server.use( + http.get('/api/settings', () => + HttpResponse.json({ settings: buildSettings({ map_tile_url: url }) }) + ) + ); + + await useSettingsStore.getState().loadSettings(); + + expect(useSettingsStore.getState().settings.map_tile_url).toBe(url); + }); + }); + + describe('FE-STORE-SETTINGS-019: saving normalizes the tile template too (#1733)', () => { + it('rewrites a hand-typed legacy host in updateSetting and persists the rewrite', async () => { + const bodies: unknown[] = []; + server.use( + http.put('/api/settings', async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ success: true }); + }) + ); + + await useSettingsStore + .getState() + .updateSetting('map_tile_url', 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'); + + // Normalizing only on read would leave the dead host in the database and + // hand it straight back on the next load. + expect(useSettingsStore.getState().settings.map_tile_url).toBe( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + ); + expect(bodies).toEqual([ + { key: 'map_tile_url', value: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, + ]); + }); + + it('rewrites the template inside a bulk save and persists the rewrite', async () => { + const bodies: Array<{ settings: Record }> = []; + server.use( + http.post('/api/settings/bulk', async ({ request }) => { + bodies.push((await request.json()) as { settings: Record }); + return HttpResponse.json({ success: true }); + }) + ); + + await useSettingsStore.getState().updateSettings({ + map_tile_url: 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png', + map_provider: 'leaflet', + }); + + expect(useSettingsStore.getState().settings.map_tile_url).toBe( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + ); + expect(bodies[0].settings).toEqual({ + map_tile_url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + map_provider: 'leaflet', + }); + }); + + it('leaves other settings and other providers alone', async () => { + await useSettingsStore.getState().updateSetting('default_currency', 'CHF'); + expect(useSettingsStore.getState().settings.default_currency).toBe('CHF'); + + const carto = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + await useSettingsStore.getState().updateSettings({ map_tile_url: carto }); + expect(useSettingsStore.getState().settings.map_tile_url).toBe(carto); + }); + }); }); diff --git a/client/tests/unit/sync/tilePrefetcher.test.ts b/client/tests/unit/sync/tilePrefetcher.test.ts index 2f051ad28..99c6af3d3 100644 --- a/client/tests/unit/sync/tilePrefetcher.test.ts +++ b/client/tests/unit/sync/tilePrefetcher.test.ts @@ -4,6 +4,8 @@ * Covers: bbox computation, tile math, URL building, size guard, * offline/no-SW guard, syncMeta update. */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import 'fake-indexeddb/auto'; import { @@ -123,13 +125,13 @@ describe('buildTileUrl', () => { it('replaces {s} with a subdomain character', () => { const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; const url = buildTileUrl(tmpl, 10, 0, 0); - expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/); + expect(url).toMatch(/^https:\/\/[abc]\.tiles\.example\.com\/10\/0\/0\.png$/); }); it('picks the subdomain deterministically so a tile keeps one URL', () => { const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; // A rotating counter would hand out a different host on each call, which - // both breaks the cache lookup and stores the tile four times over. + // both breaks the cache lookup and stores the tile once per host. const first = buildTileUrl(tmpl, 10, 479, 329); buildTileUrl(tmpl, 10, 480, 330); expect(buildTileUrl(tmpl, 10, 479, 329)).toBe(first); @@ -138,9 +140,30 @@ describe('buildTileUrl', () => { it('spreads neighbouring tiles across subdomains', () => { const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; const hosts = new Set( - [0, 1, 2, 3].map(dy => buildTileUrl(tmpl, 10, 479, 329 + dy).slice(8, 9)), + [0, 1, 2].map(dy => buildTileUrl(tmpl, 10, 479, 329 + dy).slice(8, 9)), ); - expect(hosts.size).toBe(4); + expect(hosts.size).toBe(3); + }); + + it('uses the same subdomain rotation as Leaflet', () => { + // Leaflet's TileLayer defaults to subdomains 'abc' and indexes it with + // Math.abs(x + y) % length. A different list length here would cache each + // tile under a host the map never asks for. + const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png'; + const leaflet = 'abc'; + for (const [x, y] of [[479, 329], [480, 330], [12, 7], [0, 0], [5, 4]]) { + const expected = leaflet[Math.abs(x + y) % leaflet.length]; + expect(buildTileUrl(tmpl, 10, x, y)).toBe( + `https://${expected}.tiles.example.com/10/${x}/${y}.png`, + ); + } + }); + + it('collapses the retired OSM subdomain sharding onto the single host', () => { + // d.tile.openstreetmap.org stopped resolving after OSM dropped sharding, so + // a template saved before that must not reach the network as-is (#1733). + const url = buildTileUrl('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 13, 4091, 2722); + expect(url).toBe('https://tile.openstreetmap.org/13/4091/2722.png'); }); it('removes {r} (retina placeholder)', () => { @@ -420,3 +443,37 @@ describe('MAX_TILES budget', () => { expect(MAX_TILES).toBe(12288); }); }); + +// ── service worker rule coherence ─────────────────────────────────────────────── + +describe('the map-tiles rule for OpenStreetMap (#1733)', () => { + // Vitest runs with the client package as its root, so cwd is stable here. + const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8'); + + // Pull the OSM runtimeCaching pattern back out of the config and exercise it, + // so this stays a statement about matching behaviour rather than about the + // exact characters someone typed. + const osmRule = /urlPattern:\s*(\/\^https[^\n]*openstreetmap[^\n]*\/i),/.exec(config); + if (!osmRule) throw new Error('no OpenStreetMap runtimeCaching rule found in vite.config.js'); + const literal = osmRule[1]; + const pattern = new RegExp(literal.slice(1, literal.lastIndexOf('/')), 'i'); + + it('caches the single-host URLs the prefetcher now builds', () => { + // Without this the prefetch writes nothing to map-tiles, cache.match() never + // hits, and the offline tile store stays empty. + const url = buildTileUrl('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 13, 4091, 2722); + expect(url).toBe('https://tile.openstreetmap.org/13/4091/2722.png'); + expect(pattern.test(url)).toBe(true); + }); + + it('still matches the sharded URLs already sitting in existing caches', () => { + for (const shard of ['a', 'b', 'c']) { + expect(pattern.test(`https://${shard}.tile.openstreetmap.org/13/4091/2722.png`)).toBe(true); + } + }); + + it('does not swallow a look-alike host', () => { + expect(pattern.test('https://tile.openstreetmap.org.example.com/13/4091/2722.png')).toBe(false); + expect(pattern.test('https://tile.openstreetmap.de/13/4091/2722.png')).toBe(false); + }); +}); diff --git a/client/vite.config.js b/client/vite.config.js index 62da74a48..bc8c067da 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -46,7 +46,10 @@ export default defineConfig({ // OpenStreetMap tiles (fallback / alternative) // Shares the 'map-tiles' cache; keep maxEntries equal to the Carto // rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288). - urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, + // Both spellings have to stay in the pattern: templates are rewritten + // onto the apex host (src/utils/tileUrl.ts), but caches filled before + // that still hold a/b/c URLs and must keep serving offline. + urlPattern: /^https:\/\/(?:[a-c]\.)?tile\.openstreetmap\.org\/.*/i, handler: 'CacheFirst', options: { cacheName: 'map-tiles', diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index ff2abe3b1..5c3264c9e 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -128,7 +128,12 @@ export function applyGlobalMiddleware( "https://nominatim.openstreetmap.org", "https://overpass-api.de", "https://places.googleapis.com", "https://api.openweathermap.org", "https://en.wikipedia.org", "https://commons.wikimedia.org", - "https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org", + "https://*.basemaps.cartocdn.com", + // Both forms: a CSP wildcard host never matches the apex, and OSM + // serves everything from the bare tile.openstreetmap.org since it + // retired the a/b/c/d shards (#1733). The sharded hosts stay listed + // for tile templates users saved before that. + "https://tile.openstreetmap.org", "https://*.tile.openstreetmap.org", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts new file mode 100644 index 000000000..effaa754c --- /dev/null +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { applyGlobalMiddleware } from '../../../src/middleware/globalMiddleware'; + +async function connectSrcSources(): Promise { + const app = express(); + applyGlobalMiddleware(app, { bodyParser: false }); + app.get('/probe', (_req, res) => res.json({ ok: true })); + + const res = await request(app).get('/probe'); + const csp = String(res.headers['content-security-policy'] || ''); + const directive = csp + .split(';') + .map(d => d.trim()) + .find(d => d.startsWith('connect-src')); + + return directive ? directive.split(/\s+/).slice(1) : []; +} + +describe('global CSP: OpenStreetMap tile hosts (#1733)', () => { + it('allows the bare tile.openstreetmap.org host', async () => { + // A CSP wildcard host never matches the apex, so `*.tile.openstreetmap.org` + // alone would block the tile prefetcher's fetch() against the single host + // OSM has served from since it retired a/b/c/d sharding. + expect(await connectSrcSources()).toContain('https://tile.openstreetmap.org'); + }); + + it('still allows the sharded hosts for templates saved earlier', async () => { + expect(await connectSrcSources()).toContain('https://*.tile.openstreetmap.org'); + }); +}); diff --git a/wiki/Map-Settings.md b/wiki/Map-Settings.md index 5bccdcf6e..c5456c184 100644 --- a/wiki/Map-Settings.md +++ b/wiki/Map-Settings.md @@ -30,7 +30,7 @@ When Leaflet is selected, pick a preset or enter a custom tile URL. | Name | URL | |------|-----| -| OpenStreetMap | `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png` | +| OpenStreetMap | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | | OpenStreetMap DE | `https://tile.openstreetmap.de/{z}/{x}/{y}.png` | | CartoDB Light | `https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png` | | CartoDB Dark | `https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` | diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index ae6484583..032dce41f 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -295,7 +295,7 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default). ## Place photos not loading / place thumbnail shows default map pin (Google Maps API key configured) -**Cause:** When a Google Maps API key is set, TREK fetches photo references and image bytes from the Google Places API on the server side. If the server-side call is rejected or returns no photos, the `/place-photo/:id` endpoint returns 404 and the place falls back to the default map-pin thumbnail. The most common causes are: +**Cause:** When a Google Maps API key is set, TREK fetches photo references and image bytes from the Google Places API on the server side. If the server-side call is rejected or returns no photos, the `/place-photo/:id` endpoint answers `200 { "photoUrl": null }` and the place falls back to the default map-pin thumbnail. The image proxy behind it, `/place-photo/:id/bytes`, answers `204 No Content` when it has nothing cached — neither endpoint returns 404, so a trip full of photo-less places cannot trip a 404 rate limit in a reverse proxy or IPS. The most common causes are: 1. **HTTP referrer restriction on the API key.** Google Cloud Console lets you restrict a key to specific HTTP referrers. Because TREK calls Google from the server (not the browser), it sends a `Referer` header derived from `APP_URL`. If `APP_URL` is not set, the fallback is `http://localhost:`, which will not match any domain whitelist in GCP.