mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06: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:
@@ -77,7 +77,7 @@ below.)
|
||||
|
||||
**Runtime-toggled** (read live on every access via `readEnv()` /
|
||||
`RuntimeEnvService`; tests mutate these mid-lifetime):
|
||||
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
|
||||
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
|
||||
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
|
||||
@@ -137,6 +137,8 @@ export function deriveMaps(raw: RawEnv) {
|
||||
placesApiKey: raw.PLACES_API_KEY || undefined,
|
||||
/** Public pk.* token shipped with a managed instance; reaches the browser by design. */
|
||||
mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined,
|
||||
/** CARTO basemap key; without one the tiles come back watermarked (#2054). Public too. */
|
||||
cartoKey: raw.CARTO_API_KEY || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ export const envSchema = z.object({
|
||||
PLACES_API_BASE: url,
|
||||
PLACES_API_KEY: anyString,
|
||||
MAPBOX_ACCESS_TOKEN: anyString,
|
||||
CARTO_API_KEY: anyString,
|
||||
DEMO_MODE: boolStr,
|
||||
DEMO_ADMIN_USER: anyString,
|
||||
DEMO_ADMIN_EMAIL: anyString,
|
||||
|
||||
@@ -38,15 +38,23 @@ export const SENSITIVE_KEYS = new Set([
|
||||
*/
|
||||
const SENSITIVE_SUFFIXES = ['_token', '_key', '_secret', '_password', '_pass', '_verifier'];
|
||||
|
||||
function isSensitiveName(name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
}
|
||||
|
||||
/** Deep-redacts every key in `SENSITIVE_KEYS` (case-insensitive) from a request-log value. */
|
||||
export function redact(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
||||
const entries = value as Record<string, unknown>;
|
||||
// PUT /api/settings names the setting instead of using it as the field:
|
||||
// {key: 'carto_api_key', value: '<secret>'}. Neither field name is sensitive
|
||||
// on its own, so without this the secret goes into the debug log in cleartext.
|
||||
const namedSecret = typeof entries.key === 'string' && isSensitiveName(entries.key);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
const lower = k.toLowerCase();
|
||||
const sensitive = SENSITIVE_KEYS.has(lower) || SENSITIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
out[k] = sensitive ? '[REDACTED]' : redact(v);
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
out[k] = isSensitiveName(k) || (namedSecret && k === 'value') ? '[REDACTED]' : redact(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -181,7 +189,10 @@ 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",
|
||||
// Both forms here too: CARTO documents the apex host on its key page,
|
||||
// so that is the template users paste in, and the {s} sharded form is
|
||||
// what TREK ships (#2054).
|
||||
"https://basemaps.cartocdn.com", "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
|
||||
|
||||
@@ -70,8 +70,14 @@ export function isManagedBlocked(env: RuntimeEnvService): boolean {
|
||||
* the one that reaches the browser: a managed instance ships with the operator's
|
||||
* public pk.* token, injected when the settings are read, and a per-user value
|
||||
* saved over it would only break the map for that user.
|
||||
*
|
||||
* carto_api_key is the same shape, injected on read and public in the browser,
|
||||
* and it is the operator's for one more reason: the key is registered to
|
||||
* whoever runs the instance, and CARTO's terms hold that account answerable for
|
||||
* the tiles it fetches.
|
||||
*/
|
||||
export const MANAGED_LOCKED_SETTING_KEYS = [
|
||||
'carto_api_key',
|
||||
'llm_api_key',
|
||||
'llm_base_url',
|
||||
'llm_model',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { JourneyDomainService } from './journey-domain.service';
|
||||
import { JournalRpc } from './journal.rpc';
|
||||
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
|
||||
import { JourneyShareService } from './journey-share.service';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
|
||||
/**
|
||||
* Leaf module holding the journey domain itself, without the controllers.
|
||||
@@ -16,9 +17,14 @@ import { JourneyShareService } from './journey-share.service';
|
||||
*
|
||||
* RealtimeModule is imported explicitly even though it is @Global, so a
|
||||
* single-domain e2e TestingModule can still resolve the broadcast.
|
||||
*
|
||||
* SettingsModule is the one addition to that shape: the public journey payload
|
||||
* carries the owner's CARTO tile key, and resolving that (per-user value, admin
|
||||
* instance default, managed-instance key, all decrypted) is SettingsService's
|
||||
* job rather than a second copy of its SQL here.
|
||||
*/
|
||||
@Module({
|
||||
imports: [RealtimeModule, TrekPhotosModule, PluginGuardsModule],
|
||||
imports: [RealtimeModule, TrekPhotosModule, PluginGuardsModule, SettingsModule],
|
||||
providers: [JourneyDomainService, JourneyShareService, JournalRpc],
|
||||
exports: [JourneyDomainService, JourneyShareService],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
interface JourneySharePermissions {
|
||||
share_timeline?: boolean;
|
||||
@@ -33,6 +34,7 @@ export class JourneyShareService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly journey: JourneyDomainService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
createOrUpdateJourneyShareLink(
|
||||
@@ -238,6 +240,16 @@ export class JourneyShareService {
|
||||
}));
|
||||
}
|
||||
|
||||
// Same reason as the trip share payload: CARTO watermarks every tile fetched
|
||||
// without a key (#2054) and the public journey map has no logged-in user to
|
||||
// read one from, so the owner's key travels with it. Only a valid share token
|
||||
// gets this far. getUserSettings composes the owner's own value, the admin
|
||||
// instance default and the managed-instance key in that order; carto_api_key is
|
||||
// encrypted at rest but deliberately unmasked, since it is useless until it
|
||||
// reaches a browser.
|
||||
const ownerCartoKey = this.settings.getUserSettings(journey.user_id)['carto_api_key'];
|
||||
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
|
||||
|
||||
return {
|
||||
journey: {
|
||||
title: journey.title,
|
||||
@@ -252,6 +264,7 @@ export class JourneyShareService {
|
||||
// would hand out places the map was deliberately turned off for.
|
||||
gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [],
|
||||
stats,
|
||||
cartoApiKey,
|
||||
permissions: {
|
||||
share_timeline: shareTimeline,
|
||||
share_gallery: shareGallery,
|
||||
|
||||
@@ -4,7 +4,13 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
|
||||
import { MASKED_SETTING_VALUE, normalizeAppearance } from '@trek/shared';
|
||||
import { readEnv } from '../../app-config';
|
||||
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key']);
|
||||
const ENCRYPTED_SETTING_KEYS = new Set([
|
||||
'webhook_url',
|
||||
'ntfy_token',
|
||||
'mapbox_access_token',
|
||||
'carto_api_key',
|
||||
'llm_api_key',
|
||||
]);
|
||||
// Encrypted keys that are masked (••••••••) when returned to the client.
|
||||
// Keys not in this set but in ENCRYPTED_SETTING_KEYS are decrypted and returned.
|
||||
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'llm_api_key']);
|
||||
@@ -19,6 +25,10 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'default_currency',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
// CARTO stamps an "API KEY REQUIRED" watermark into keyless tiles (#2054), and
|
||||
// the key is per-instance rather than per-person: defaultable so one admin
|
||||
// value clears the watermark for everybody at once.
|
||||
'carto_api_key',
|
||||
// Instance-wide GL map defaults: admins can set Mapbox token/style or
|
||||
// tokenless MapLibre/OpenFreeMap style defaults for new users (#920).
|
||||
'map_provider',
|
||||
@@ -216,6 +226,12 @@ export class SettingsService {
|
||||
merged.map_provider = 'mapbox-gl';
|
||||
}
|
||||
}
|
||||
// Injected the same way, but without touching map_provider: a CARTO key only
|
||||
// says whose basemap account the tiles are billed to, nothing about which
|
||||
// renderer the user wants.
|
||||
if (readEnv().managed.enabled && managedMaps.cartoKey) {
|
||||
merged.carto_api_key = managedMaps.cartoKey;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -280,15 +280,27 @@ export class ShareService {
|
||||
// honours per-user → admin-default; we then fall back to trip currency → EUR
|
||||
// (`||` on purpose: an empty-string trip currency also falls back).
|
||||
let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
|
||||
if (shareRow.created_by != null) {
|
||||
const ownerDefault = this.settings.getUserSettings(shareRow.created_by)['default_currency'];
|
||||
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
|
||||
baseCurrency = ownerDefault.trim();
|
||||
}
|
||||
const ownerSettings: Record<string, unknown> = shareRow.created_by != null
|
||||
? this.settings.getUserSettings(shareRow.created_by)
|
||||
: {};
|
||||
const ownerDefault = ownerSettings['default_currency'];
|
||||
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
|
||||
baseCurrency = ownerDefault.trim();
|
||||
}
|
||||
|
||||
// CARTO stamps an "API KEY REQUIRED" watermark into every tile fetched without
|
||||
// a key (#2054), and a public viewer has no settings of their own to hold one,
|
||||
// so the owner's key travels in this payload. Nothing without a valid share
|
||||
// token reaches it, and the key is public in the browser anyway. getUserSettings
|
||||
// is the right accessor here even though it is the client-facing one:
|
||||
// carto_api_key is encrypted at rest but deliberately unmasked (same as the
|
||||
// Mapbox token, both have to reach a browser), and it is the only accessor that
|
||||
// composes per-user value → admin instance default → managed-instance key.
|
||||
const ownerCartoKey = ownerSettings['carto_api_key'];
|
||||
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
|
||||
|
||||
return {
|
||||
trip, baseCurrency, categories, permissions,
|
||||
trip, baseCurrency, cartoApiKey, categories, permissions,
|
||||
days, assignments, dayNotes, places,
|
||||
reservations, accommodations,
|
||||
packing, budget,
|
||||
|
||||
Reference in New Issue
Block a user