mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile fetched without a key since 26.08.2026, and both CartoDB presets plus the built-in fallback are CARTO. The key is free and needs no account, so add it as a setting rather than dropping the provider. carto_api_key sits next to mapbox_access_token: per user in Settings/Map, instance-wide under Admin/User Defaults, encrypted at rest, injected from CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the map provider, since a raster key says nothing about the renderer. The key is appended as ?key= at render time instead of being stored in the template, so a saved URL stays portable and survives a rotation. That is also what reaches the four maps that pinned CARTO and ignored the user's tile setting: atlas, collections, the public trip share and the public journey. The two share pages read it from their token-gated payload, the same way baseCurrency already travels. Also lifts the release notice above page chrome. Its overlay sat at z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither on top nor modal. Adds a six-step layer scale to index.css and moves the three notice shells onto it. Closes #2054 Closes #2052
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { normalizeTileUrl } from './tileUrl'
|
||||
import { normalizeTileUrl, withTileApiKey, stripTileApiKey, resolveTileUrl } from './tileUrl'
|
||||
|
||||
describe('normalizeTileUrl', () => {
|
||||
it('drops the {s} placeholder from an OSM template', () => {
|
||||
@@ -56,3 +56,119 @@ describe('normalizeTileUrl', () => {
|
||||
expect(normalizeTileUrl('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
const CARTO = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const CARTO_APEX = 'https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||
const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const SATELLITE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||
const SELF_HOSTED = 'https://tiles.example.com/basemaps/{z}/{x}/{y}.png'
|
||||
|
||||
describe('withTileApiKey', () => {
|
||||
it('appends the key to a sharded CARTO template', () => {
|
||||
expect(withTileApiKey(CARTO, 'abc123')).toBe(`${CARTO}?key=abc123`)
|
||||
})
|
||||
|
||||
it('appends the key to the apex form and to a fixed shard', () => {
|
||||
expect(withTileApiKey(CARTO_APEX, 'abc123')).toBe(`${CARTO_APEX}?key=abc123`)
|
||||
expect(withTileApiKey('https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', 'abc123'))
|
||||
.toBe('https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png?key=abc123')
|
||||
})
|
||||
|
||||
it('leaves other providers untouched', () => {
|
||||
for (const url of [OSM, SATELLITE, SELF_HOSTED]) {
|
||||
expect(withTileApiKey(url, 'abc123')).toBe(url)
|
||||
}
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
const once = withTileApiKey(CARTO, 'abc123')
|
||||
expect(withTileApiKey(once, 'abc123')).toBe(once)
|
||||
// A different key does not stack a second parameter either: the one already
|
||||
// in the template wins, so a double-wrapped URL stays valid.
|
||||
expect(withTileApiKey(once, 'other')).toBe(once)
|
||||
})
|
||||
|
||||
it('passes the template through without a usable key', () => {
|
||||
expect(withTileApiKey(CARTO)).toBe(CARTO)
|
||||
expect(withTileApiKey(CARTO, '')).toBe(CARTO)
|
||||
expect(withTileApiKey(CARTO, null)).toBe(CARTO)
|
||||
expect(withTileApiKey('', 'abc123')).toBe('')
|
||||
})
|
||||
|
||||
it('joins onto an existing query string with &', () => {
|
||||
const withQuery = 'https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png?attribution=none'
|
||||
expect(withTileApiKey(withQuery, 'abc123')).toBe(`${withQuery}&key=abc123`)
|
||||
})
|
||||
|
||||
it('encodes the key', () => {
|
||||
expect(withTileApiKey(CARTO, 'a b&c=d')).toBe(`${CARTO}?key=a%20b%26c%3Dd`)
|
||||
})
|
||||
|
||||
it('does not hand the key to a host that merely contains cartocdn', () => {
|
||||
// The security promise of the function: a template pointing somewhere else
|
||||
// must never turn into an outbound copy of the key.
|
||||
const lookalike = 'https://basemaps.cartocdn.com.example.org/light_all/{z}/{x}/{y}.png'
|
||||
expect(withTileApiKey(lookalike, 'abc123')).toBe(lookalike)
|
||||
|
||||
const proxy = 'https://tiles.example.com/basemaps.cartocdn.com/{z}/{x}/{y}.png'
|
||||
expect(withTileApiKey(proxy, 'abc123')).toBe(proxy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripTileApiKey', () => {
|
||||
it('removes the key and keeps the other parameters', () => {
|
||||
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?key=abc&lang=de'))
|
||||
.toBe('https://basemaps.cartocdn.com/x.png?lang=de')
|
||||
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?lang=de&key=abc'))
|
||||
.toBe('https://basemaps.cartocdn.com/x.png?lang=de')
|
||||
})
|
||||
|
||||
it('keeps a parameter that only ends in key', () => {
|
||||
const url = 'https://tiles.example.com/x.png?monkey=1'
|
||||
expect(stripTileApiKey(url)).toBe(url)
|
||||
})
|
||||
|
||||
it('passes a URL without a key through', () => {
|
||||
expect(stripTileApiKey(CARTO)).toBe(CARTO)
|
||||
expect(stripTileApiKey('')).toBe('')
|
||||
})
|
||||
|
||||
it('leaves no dangling ? or &', () => {
|
||||
expect(stripTileApiKey(`${CARTO}?key=abc`)).toBe(CARTO)
|
||||
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?key=abc'))
|
||||
.toBe('https://basemaps.cartocdn.com/x.png')
|
||||
expect(stripTileApiKey('https://basemaps.cartocdn.com/x.png?key=abc&a=1'))
|
||||
.not.toContain('?&')
|
||||
})
|
||||
|
||||
it('undoes withTileApiKey', () => {
|
||||
expect(stripTileApiKey(withTileApiKey(CARTO, 'abc123'))).toBe(CARTO)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTileUrl', () => {
|
||||
it('falls back on an empty or blank template', () => {
|
||||
expect(resolveTileUrl('', OSM)).toBe(OSM)
|
||||
expect(resolveTileUrl(' ', OSM)).toBe(OSM)
|
||||
expect(resolveTileUrl(null, OSM)).toBe(OSM)
|
||||
expect(resolveTileUrl(undefined, OSM)).toBe(OSM)
|
||||
})
|
||||
|
||||
it('prefers a configured template over the fallback', () => {
|
||||
expect(resolveTileUrl(SELF_HOSTED, OSM)).toBe(SELF_HOSTED)
|
||||
expect(resolveTileUrl(` ${SELF_HOSTED} `, OSM)).toBe(SELF_HOSTED)
|
||||
})
|
||||
|
||||
it('still normalizes the retired OSM shard host', () => {
|
||||
expect(resolveTileUrl('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', CARTO)).toBe(OSM)
|
||||
expect(resolveTileUrl('', 'https://d.tile.openstreetmap.org/{z}/{x}/{y}.png')).toBe(OSM)
|
||||
})
|
||||
|
||||
it('appends the key when the result is a CARTO host', () => {
|
||||
expect(resolveTileUrl('', CARTO, 'abc123')).toBe(`${CARTO}?key=abc123`)
|
||||
expect(resolveTileUrl(CARTO_APEX, OSM, 'abc123')).toBe(`${CARTO_APEX}?key=abc123`)
|
||||
// A self-hosted template reaches the network without the key, even when the
|
||||
// fallback would have been CARTO.
|
||||
expect(resolveTileUrl(SELF_HOSTED, CARTO, 'abc123')).toBe(SELF_HOSTED)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,3 +22,37 @@ export function normalizeTileUrl(url: string): string {
|
||||
if (!url) return url
|
||||
return url.replace(OSM_SHARD, '$1tile.openstreetmap.org')
|
||||
}
|
||||
|
||||
/**
|
||||
* CARTO started watermarking keyless basemap tiles on 26.08.2026 (#2054). The
|
||||
* key rides along as a `?key=` query parameter, which every template engine in
|
||||
* the client passes through untouched, so it is appended here rather than baked
|
||||
* into the stored template: a saved URL stays portable and survives a key
|
||||
* change. Only CARTO hosts are touched; OSM and self-hosted templates are not.
|
||||
*/
|
||||
const CARTO_HOST = /^(?:\{s\}|[a-d])?\.?basemaps\.cartocdn\.com$/i
|
||||
|
||||
function templateHost(url: string): string {
|
||||
return url.replace(/^\w*:?\/\//, '').split(/[/?#]/)[0]
|
||||
}
|
||||
|
||||
export function withTileApiKey(url: string, key?: string | null): string {
|
||||
if (!url || !key) return url
|
||||
if (!CARTO_HOST.test(templateHost(url))) return url
|
||||
if (/[?&]key=/.test(url)) return url
|
||||
return `${url}${url.includes('?') ? '&' : '?'}key=${encodeURIComponent(key)}`
|
||||
}
|
||||
|
||||
/** Keeps the key out of anything we persist: stored templates, book documents. */
|
||||
export function stripTileApiKey(url: string): string {
|
||||
if (!url || !/[?&]key=/.test(url)) return url
|
||||
return url.replace(/([?&])key=[^&]*&?/, '$1').replace(/[?&]$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank template means "not configured", not "no tiles": the settings
|
||||
* previews save an empty string and would otherwise render grey.
|
||||
*/
|
||||
export function resolveTileUrl(template: string | null | undefined, fallback: string, cartoKey?: string | null): string {
|
||||
return withTileApiKey(normalizeTileUrl(template?.trim() || fallback), cartoKey)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user