From 8f169f1ecc24dd6fcef6c4347da5aa5261e139aa Mon Sep 17 00:00:00 2001 From: C3B2W23 <217007207+C3B2W23@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:43:59 -0700 Subject: [PATCH] Add runtime CARTO_API_KEY for basemap tiles CARTO now requires an API key for its basemap tiles; without one every tile in the DEFAULT dark/light map carries an "API KEY REQUIRED" watermark. The tile URLs were hardcoded in mapStyles.ts with no way to supply a key, and because the frontend ships as a prebuilt image a NEXT_PUBLIC_ variable would be baked in empty for every Docker user. - New frontend-local route GET /api/basemap-config reads CARTO_API_KEY from the frontend container's environment at request time (same pattern as BACKEND_URL), so no image rebuild is needed. - useBasemapConfig() fetches it once per page load; MaplibreViewer builds the MapLibre style from it via buildBasemapStyle(theme, key) and defers the map's first style load until the config has settled, avoiding a burst of unkeyed tile requests followed by a style swap. - Tile URLs move to CARTO's documented rastertiles/ path with ?key= appended when configured. Unkeyed URLs serve byte-identical tiles to the old path, so deployments without a key behave exactly as before. - CARTO_API_KEY wired through docker-compose.yml and documented in .env.example, README (data source table + frontend env table) and docs/OUTBOUND_DATA.md. - Tests cover the route (unset / set / trimmed) and the style builder. Co-Authored-By: Claude Fable 5.1 --- .env.example | 6 ++ README.md | 3 +- docker-compose.yml | 3 + docs/OUTBOUND_DATA.md | 1 + .../src/__tests__/map/basemapConfig.test.ts | 67 ++++++++++++++ frontend/src/app/api/basemap-config/route.ts | 41 +++++++++ frontend/src/components/MaplibreViewer.tsx | 13 ++- .../src/components/map/styles/mapStyles.ts | 91 +++++++++++-------- frontend/src/hooks/useBasemapConfig.ts | 52 +++++++++++ 9 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 frontend/src/__tests__/map/basemapConfig.test.ts create mode 100644 frontend/src/app/api/basemap-config/route.ts create mode 100644 frontend/src/hooks/useBasemapConfig.ts diff --git a/.env.example b/.env.example index e02c4f8..9f4cf28 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,12 @@ AIS_API_KEY= # Windy Webcams global CCTV layer — free key from https://api.windy.com/webcams/docs # WINDY_API_KEY= +# CARTO basemap tiles (DEFAULT dark/light map). CARTO now requires an API key; +# without one the map still loads but every tile carries an "API KEY REQUIRED" +# watermark. Free key (no CARTO account needed, 5M tiles/month fair use): +# https://carto.com/basemaps/apikey — used by the frontend container only. +# CARTO_API_KEY= + # Telegram OSINT map layer — scrapes public t.me/s channel previews (no bot token). # TELEGRAM_OSINT_ENABLED=true # TELEGRAM_OSINT_CHANNELS=osintdefender,insiderpaper,aljazeeraenglish,nexta_live,war_monitor diff --git a/README.md b/README.md index 05401b0..1217bce 100644 --- a/README.md +++ b/README.md @@ -666,7 +666,7 @@ ShadowBroker v0.9.7 is composed of three vertically-stacked planes — the **Ope | [Wikidata SPARQL](https://query.wikidata.org) | Head of state data | On-demand (cached 24h) | No | | [Wikipedia API](https://en.wikipedia.org/api) | Location summaries & aircraft images | On-demand (cached) | No | | [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No | -| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No | +| [CARTO Basemaps](https://carto.com) | Dark/light map tiles | Continuous | **Yes** (free, `CARTO_API_KEY`) | **Outbound privacy & audit (#348–#366):** Each self-hosted install uses its own backend IP and per-install User-Agent handle. See [docs/OUTBOUND_DATA.md](docs/OUTBOUND_DATA.md) for what contacts third parties, opt-in/env controls, and accepted tradeoffs (CCTV Referer, basemap CDN, LiveUAMap, etc.). @@ -1173,6 +1173,7 @@ Then confirm authenticated `GET /api/wormhole/status` or `GET /api/settings/worm |---|---|---| | `BACKEND_URL` | `environment` in `docker-compose.yml`, or shell env | URL the Next.js server uses to proxy API calls to the backend. Defaults to `http://backend:8000`. **Runtime variable — no rebuild needed.** | | `BACKEND_PORT` | repo-root `.env` or shell env before `docker compose up` | Host port used to expose the backend API for local diagnostics. Defaults to `8000`; set `BACKEND_PORT=8001` if port 8000 is already in use. Does not change Docker-internal `BACKEND_URL`. | +| `CARTO_API_KEY` | repo-root `.env` (passed to the frontend container by `docker-compose.yml`), or shell env | API key for the CARTO basemap tiles behind the DEFAULT dark/light map. CARTO now requires one; without it tiles still load but carry an "API KEY REQUIRED" watermark. Free at [carto.com/basemaps/apikey](https://carto.com/basemaps/apikey) (no account needed, 5M tiles/month). Served to the browser by the frontend-local `/api/basemap-config` route. **Runtime variable — no rebuild needed.** | **How it works:** The frontend proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. Browsers only talk to port 3000; the backend host port is only for local diagnostics. For local dev without Docker, `BACKEND_URL` defaults to `http://localhost:8000`. diff --git a/docker-compose.yml b/docker-compose.yml index 306e28a..093a609 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,9 @@ services: - BACKEND_URL=http://backend:8000 # Lets the server-side proxy authenticate protected local-node API calls. - ADMIN_KEY=${ADMIN_KEY:-} + # CARTO basemap tiles require an API key (free at https://carto.com/basemaps/apikey). + # Read at request time via /api/basemap-config, so no image rebuild is needed. + - CARTO_API_KEY=${CARTO_API_KEY:-} depends_on: backend: condition: service_healthy diff --git a/docs/OUTBOUND_DATA.md b/docs/OUTBOUND_DATA.md index e07a8da..afef72b 100644 --- a/docs/OUTBOUND_DATA.md +++ b/docs/OUTBOUND_DATA.md @@ -83,6 +83,7 @@ Shadowbroker is **self-hosted**: each install uses its own backend egress IP. Th - **Code:** `frontend/src/components/map/styles/mapStyles.ts`, `frontend/public/map-style.json` - **Hosts:** `*.basemaps.cartocdn.com`, `demotiles.maplibre.org` - **Exposure:** **Browser** loads tiles (client IP + pan/zoom), not the backend +- **API key:** CARTO requires a key for basemap tiles. `CARTO_API_KEY` is set on the frontend container and served to the browser by the frontend-local route `/api/basemap-config` (read at request time, never proxied to the backend). The browser then sends it to `*.basemaps.cartocdn.com` as a `?key=` query parameter on every tile request. Unset it to keep the previous unkeyed behavior (watermarked tiles). - **Mitigation:** Self-host raster tiles and point MapLibre `sources` at your tile server (operator choice; not required for core features) --- diff --git a/frontend/src/__tests__/map/basemapConfig.test.ts b/frontend/src/__tests__/map/basemapConfig.test.ts new file mode 100644 index 0000000..61e0558 --- /dev/null +++ b/frontend/src/__tests__/map/basemapConfig.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { GET as getBasemapConfig } from '@/app/api/basemap-config/route'; +import { + buildBasemapStyle, + cartoTileUrls, + darkStyle, + lightStyle, +} from '@/components/map/styles/mapStyles'; + +describe('CARTO basemap API key plumbing', () => { + const originalKey = process.env.CARTO_API_KEY; + + beforeEach(() => { + delete process.env.CARTO_API_KEY; + }); + + afterEach(() => { + if (originalKey === undefined) delete process.env.CARTO_API_KEY; + else process.env.CARTO_API_KEY = originalKey; + }); + + describe('GET /api/basemap-config', () => { + it('reports unconfigured when CARTO_API_KEY is unset', async () => { + const res = await getBasemapConfig(); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toContain('no-store'); + expect(await res.json()).toEqual({ carto: { configured: false, key: '' } }); + }); + + it('returns the trimmed key read at request time', async () => { + process.env.CARTO_API_KEY = ' abc123 '; + const res = await getBasemapConfig(); + expect(await res.json()).toEqual({ carto: { configured: true, key: 'abc123' } }); + }); + }); + + describe('buildBasemapStyle', () => { + it('produces unkeyed CARTO tile URLs when no key is given', () => { + const style = buildBasemapStyle('dark'); + const source = style.sources['carto-dark']; + expect(source.tiles).toHaveLength(4); + for (const url of source.tiles) { + expect(url).toMatch(/^https:\/\/[abcd]\.basemaps\.cartocdn\.com\/rastertiles\/dark_all\//); + expect(url).not.toContain('?'); + } + expect(style.layers[0]).toMatchObject({ id: 'carto-dark-layer', source: 'carto-dark' }); + }); + + it('appends ?key= to every tile URL when a key is given', () => { + const style = buildBasemapStyle('light', 'my key'); + for (const url of style.sources['carto-light'].tiles) { + expect(url).toMatch(/\/rastertiles\/light_all\/\{z\}\/\{x\}\/\{y\}@2x\.png\?key=my%20key$/); + } + }); + + it('treats blank keys as unconfigured', () => { + expect(cartoTileUrls('dark', ' ')).toEqual(cartoTileUrls('dark')); + expect(cartoTileUrls('dark', null)).toEqual(cartoTileUrls('dark')); + }); + + it('keeps the key-less default exports in sync with the builder', () => { + expect(darkStyle).toEqual(buildBasemapStyle('dark')); + expect(lightStyle).toEqual(buildBasemapStyle('light')); + }); + }); +}); diff --git a/frontend/src/app/api/basemap-config/route.ts b/frontend/src/app/api/basemap-config/route.ts new file mode 100644 index 0000000..ba657c2 --- /dev/null +++ b/frontend/src/app/api/basemap-config/route.ts @@ -0,0 +1,41 @@ +/** + * Runtime basemap configuration for the browser map. + * + * CARTO_API_KEY is a plain server-side env var on the frontend container + * (see docker-compose.yml). Like BACKEND_URL it is read at request time, so + * operators running the prebuilt GHCR image can set it in .env without a + * rebuild. A NEXT_PUBLIC_ var would be baked in at image build time and + * therefore always empty for them. + * + * The key is not a secret in the usual sense — the browser sends it to + * CARTO on every tile request — but it is only returned to same-origin + * callers of this Next.js server, never proxied to the backend. + */ + +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const NO_STORE_HEADERS = { + 'Cache-Control': 'no-store, max-age=0', + Pragma: 'no-cache', +}; + +export type BasemapConfigResponse = { + carto: { + configured: boolean; + key: string; + }; +}; + +export function readCartoApiKey(): string { + return String(process.env.CARTO_API_KEY || '').trim(); +} + +export async function GET() { + const key = readCartoApiKey(); + const body: BasemapConfigResponse = { + carto: { configured: key.length > 0, key }, + }; + return NextResponse.json(body, { headers: NO_STORE_HEADERS }); +} diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 47a8fba..bcd3ab9 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -15,7 +15,8 @@ import Map, { } from 'react-map-gl/maplibre'; import 'maplibre-gl/dist/maplibre-gl.css'; import { computeNightPolygon } from '@/utils/solarTerminator'; -import { darkStyle, lightStyle } from '@/components/map/styles/mapStyles'; +import { buildBasemapStyle } from '@/components/map/styles/mapStyles'; +import { useBasemapConfig } from '@/hooks/useBasemapConfig'; import maplibregl from 'maplibre-gl'; import { AlertTriangle, Radio, Activity, Play, Satellite, ExternalLink, Info } from 'lucide-react'; import WikiImage from '@/components/WikiImage'; @@ -427,9 +428,11 @@ const MaplibreViewer = ({ const mapInitRef = useRef(false); const [mapReady, setMapReady] = useState(false); const { theme } = useTheme(); + const { cartoApiKey, loaded: basemapConfigLoaded } = useBasemapConfig(); const mapThemeStyle = useMemo( - () => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification, - [theme], + () => + buildBasemapStyle(theme === 'light' ? 'light' : 'dark', cartoApiKey) as maplibregl.StyleSpecification, + [theme, cartoApiKey], ); const initialViewState = useMemo( @@ -1886,6 +1889,9 @@ const MaplibreViewer = ({ className={`relative h-full w-full z-0 isolate ${selectedEntity && ['region_dossier', 'gdelt', 'liveuamap', 'news', 'telegram_osint', 'gt_risk'].includes(selectedEntity.type) ? 'map-focus-active' : ''}`} style={pinPlacementMode || sarAoiDropMode ? { cursor: 'crosshair' } : undefined} > + {/* Wait for /api/basemap-config so the first style load already carries the CARTO key + (avoids a burst of unkeyed, watermarked tile requests followed by a style swap). */} + {basemapConfigLoaded && ( + )} ); }; diff --git a/frontend/src/components/map/styles/mapStyles.ts b/frontend/src/components/map/styles/mapStyles.ts index e141d8f..cfd27a2 100644 --- a/frontend/src/components/map/styles/mapStyles.ts +++ b/frontend/src/components/map/styles/mapStyles.ts @@ -1,41 +1,54 @@ -export const darkStyle = { - version: 8, - glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - 'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [ - { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }, - { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }, - ], -}; +/** + * MapLibre basemap styles backed by CARTO raster tiles. + * + * CARTO now requires an API key for its basemap tiles; unkeyed requests + * return tiles stamped with an "API KEY REQUIRED" watermark. The key is + * read at request time by /api/basemap-config (CARTO_API_KEY on the + * frontend container) and threaded in here via `buildBasemapStyle`, so + * prebuilt Docker images pick it up without a rebuild. + * + * With no key configured the styles are unchanged from before, so existing + * deployments keep working exactly as they did (watermark included). + */ -export const lightStyle = { - version: 8, - glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', - sources: { - 'carto-light': { - type: 'raster', - tiles: [ - 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - 'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png', - ], - tileSize: 256, - }, - }, - layers: [ - { id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 }, - { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }, - ], +export type BasemapTheme = 'dark' | 'light'; + +const CARTO_SUBDOMAINS = ['a', 'b', 'c', 'd'] as const; +const CARTO_RASTER_STYLE: Record = { + dark: 'dark_all', + light: 'light_all', }; +const GLYPHS_URL = 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'; + +/** Tile URL templates for a CARTO raster style, keyed when a key is supplied. */ +export function cartoTileUrls(theme: BasemapTheme, cartoApiKey?: string | null): string[] { + const style = CARTO_RASTER_STYLE[theme]; + const key = (cartoApiKey || '').trim(); + const query = key ? `?key=${encodeURIComponent(key)}` : ''; + return CARTO_SUBDOMAINS.map( + (s) => `https://${s}.basemaps.cartocdn.com/rastertiles/${style}/{z}/{x}/{y}@2x.png${query}`, + ); +} + +export function buildBasemapStyle(theme: BasemapTheme, cartoApiKey?: string | null) { + const sourceId = `carto-${theme}`; + return { + version: 8, + glyphs: GLYPHS_URL, + sources: { + [sourceId]: { + type: 'raster', + tiles: cartoTileUrls(theme, cartoApiKey), + tileSize: 256, + }, + }, + layers: [ + { id: `${sourceId}-layer`, type: 'raster', source: sourceId, minzoom: 0, maxzoom: 22 }, + { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }, + ], + }; +} + +// Key-less defaults, kept for callers that do not need a CARTO key. +export const darkStyle = buildBasemapStyle('dark'); +export const lightStyle = buildBasemapStyle('light'); diff --git a/frontend/src/hooks/useBasemapConfig.ts b/frontend/src/hooks/useBasemapConfig.ts new file mode 100644 index 0000000..6925146 --- /dev/null +++ b/frontend/src/hooks/useBasemapConfig.ts @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { API_BASE } from '@/lib/api'; +import type { BasemapConfigResponse } from '@/app/api/basemap-config/route'; + +export type BasemapConfig = { + /** CARTO basemap API key, or null when none is configured / not yet loaded. */ + cartoApiKey: string | null; + /** True once the config request has settled (success or failure). */ + loaded: boolean; +}; + +const UNCONFIGURED: BasemapConfig = { cartoApiKey: null, loaded: true }; + +// One request per page load, shared by every map instance. +let configPromise: Promise | null = null; + +async function fetchBasemapConfig(): Promise { + try { + const res = await fetch(`${API_BASE}/api/basemap-config`, { cache: 'no-store' }); + if (!res.ok) return UNCONFIGURED; + const body = (await res.json()) as Partial; + const key = String(body?.carto?.key || '').trim(); + return { cartoApiKey: key || null, loaded: true }; + } catch { + // Static/desktop exports have no API routes; fall back to unkeyed tiles. + return UNCONFIGURED; + } +} + +/** Reset the shared request cache (tests only). */ +export function __resetBasemapConfigCache(): void { + configPromise = null; +} + +export function useBasemapConfig(): BasemapConfig { + const [config, setConfig] = useState({ cartoApiKey: null, loaded: false }); + + useEffect(() => { + let cancelled = false; + if (!configPromise) configPromise = fetchBasemapConfig(); + void configPromise.then((resolved) => { + if (!cancelled) setConfig(resolved); + }); + return () => { + cancelled = true; + }; + }, []); + + return config; +}