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:
Maurice
2026-08-27 12:06:55 +02:00
committed by GitHub
parent 521e52c853
commit 1f66ef1d32
99 changed files with 1258 additions and 67 deletions
+117 -1
View File
@@ -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)
})
})
+34
View File
@@ -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)
}