mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(map): drop the retired OpenStreetMap tile subdomains (#1733)
The a/b/c shards stopped being necessary in 2022 and d has since lost its DNS record. The map never asked for d — Leaflet defaults to abc — but the offline prefetcher rotated over a to d, which is where the failing requests came from, and it cached most tiles under a host the map never requests. Presets and the prefetcher use the apex host now. A URL already saved in user_settings is rewritten where it is read and written back, so existing instances heal without a migration touching what someone typed themselves. The CSP only allowed https://*.tile.openstreetmap.org, and a wildcard host never matches the apex domain, so the prefetch fetch would have been blocked. The service worker rule had the same gap and would have stopped filling the tile cache.
This commit is contained in:
@@ -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<string, unknown> = {}) {
|
||||
|
||||
@@ -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<HTMLInputElement>) => 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"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
|
||||
@@ -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<HTMLInputElement>) => 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"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
|
||||
@@ -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<HTMLInputElement>) => 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"
|
||||
/>
|
||||
</MAdminField>
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MSetHint>{t('settings.mapDefaultHint')}</MSetHint>
|
||||
</>
|
||||
|
||||
@@ -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<void> | 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<T extends Partial<Settings>>(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<SettingsState>((set, get) => ({
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
isLoaded: false,
|
||||
@@ -65,8 +76,11 @@ export const useSettingsStore = create<SettingsState>((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<Settings>)
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...data.settings },
|
||||
settings: { ...state.settings, ...incoming },
|
||||
isLoaded: true,
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
@@ -83,12 +97,14 @@ export const useSettingsStore = create<SettingsState>((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<SettingsState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateSettings: async (settingsObj: Partial<Settings>) => {
|
||||
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'))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
|
||||
seedStore(useSettingsStore, {
|
||||
|
||||
@@ -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<string, unknown> }> = [];
|
||||
server.use(
|
||||
http.post('/api/settings/bulk', async ({ request }) => {
|
||||
bodies.push((await request.json()) as { settings: Record<string, unknown> });
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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<string[]> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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` |
|
||||
|
||||
@@ -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:<PORT>`, which will not match any domain whitelist in GCP.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user