diff --git a/.env.example b/.env.example index e02c4f8..fc868f0 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ AIS_API_KEY= # Windy Webcams global CCTV layer — free key from https://api.windy.com/webcams/docs # WINDY_API_KEY= +# CARTO basemap tiles (DEFAULT map) — free key from https://carto.com/basemaps/apikey +# Without it tiles render with an "API KEY REQUIRED" watermark. +# 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..38ec73e 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.). @@ -1130,6 +1130,7 @@ OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID # Optional (enhances data quality) AIS_API_KEY=your_aisstream_key # Maritime vessel tracking (aisstream.io) — ships layer empty without it LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras +CARTO_API_KEY=your_carto_key # CARTO basemap tiles — DEFAULT map shows an "API KEY REQUIRED" watermark without it (free: carto.com/basemaps/apikey) SHODAN_API_KEY=your_shodan_key # Shodan device search overlay SH_CLIENT_ID=your_sentinel_hub_id # Copernicus CDSE Sentinel Hub imagery SH_CLIENT_SECRET=your_sentinel_hub_secret # Paired with Sentinel Hub Client ID diff --git a/backend/main.py b/backend/main.py index e3b46a1..a099242 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9072,7 +9072,7 @@ async def api_sentinel_tile(request: Request): # --------------------------------------------------------------------------- # API Settings — key registry & management # --------------------------------------------------------------------------- -from services.api_settings import get_api_keys, get_env_path_info +from services.api_settings import get_api_keys, get_basemap_config, get_env_path_info from services.shodan_connector import ( ShodanConnectorError, count_shodan, @@ -9111,6 +9111,12 @@ async def api_get_keys_meta(request: Request): return get_env_path_info() +@app.get("/api/basemap-config") +@limiter.limit("60/minute") +async def api_basemap_config(request: Request): + return get_basemap_config() + + @app.get("/api/tools/shodan/status", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_shodan_status(request: Request): diff --git a/backend/services/api_settings.py b/backend/services/api_settings.py index e55e1bf..be65c7d 100644 --- a/backend/services/api_settings.py +++ b/backend/services/api_settings.py @@ -225,6 +225,15 @@ API_REGISTRY = [ "url": "https://dataspace.copernicus.eu/", "required": False, }, + { + "id": "carto_api_key", + "env_key": "CARTO_API_KEY", + "name": "CARTO Basemaps", + "description": "API key for the CARTO raster basemap behind the DEFAULT dark/light map. CARTO requires one; without it tiles still load but carry an \"API KEY REQUIRED\" watermark. Free at carto.com/basemaps/apikey (no CARTO account needed, 5M tiles/month). Unlike the other keys this one is sent to the browser (GET /api/basemap-config) because the browser passes it to CARTO on every tile request.", + "category": "Imagery", + "url": "https://carto.com/basemaps/apikey", + "required": False, + }, ] ALLOWED_ENV_KEYS = { @@ -391,6 +400,13 @@ def get_api_keys(): return result +def get_basemap_config() -> dict: + """Public config for the browser map: the CARTO key (or empty when unset).""" + load_persisted_api_keys_into_environ() + key = os.environ.get("CARTO_API_KEY", "").strip() + return {"carto": {"configured": bool(key), "key": key}} + + def save_api_keys(updates: dict[str, str]) -> dict: """Persist allowed API keys from a local operator request. diff --git a/backend/services/config.py b/backend/services/config.py index d22a910..fb24afe 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): OPENSKY_CLIENT_ID: str = "" OPENSKY_CLIENT_SECRET: str = "" LTA_ACCOUNT_KEY: str = "" + CARTO_API_KEY: str = "" # Basemap tiles; served to the browser via /api/basemap-config # Runtime CORS_ORIGINS: str = "" diff --git a/backend/services/env_check.py b/backend/services/env_check.py index 5eaad70..1cf3b89 100644 --- a/backend/services/env_check.py +++ b/backend/services/env_check.py @@ -49,6 +49,7 @@ _OPTIONAL = { "AISHUB_USERNAME": "AISHub REST backup when AISStream is silent (optional; free at aishub.net/api)", "GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)", "LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)", + "CARTO_API_KEY": "CARTO basemap tiles (DEFAULT map shows an API KEY REQUIRED watermark without it)", "PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)", } diff --git a/backend/tests/test_basemap_config.py b/backend/tests/test_basemap_config.py new file mode 100644 index 0000000..e03eddd --- /dev/null +++ b/backend/tests/test_basemap_config.py @@ -0,0 +1,55 @@ +"""GET /api/basemap-config serves the CARTO basemap key to the browser. + +The key is public by nature (the browser sends it to CARTO on every tile +request), so the endpoint needs no admin auth. It must read the key at +request time so Docker operators can set it without a rebuild, and it must +honor the persisted operator key file like every other registry key. +""" + +import pytest +from fastapi.testclient import TestClient + +from services import api_settings + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", tmp_path / "operator_api_keys.env") + monkeypatch.delenv("CARTO_API_KEY", raising=False) + import main + + return TestClient(main.app, raise_server_exceptions=False) + + +def test_unconfigured_when_env_unset(client): + r = client.get("/api/basemap-config") + assert r.status_code == 200 + assert r.json() == {"carto": {"configured": False, "key": ""}} + + +def test_returns_trimmed_key_without_admin_auth(client, monkeypatch): + monkeypatch.setenv("CARTO_API_KEY", " carto-test-key ") + r = client.get("/api/basemap-config") + assert r.status_code == 200 + assert r.json() == {"carto": {"configured": True, "key": "carto-test-key"}} + + +def test_reads_persisted_operator_key_file(client, tmp_path): + (tmp_path / "operator_api_keys.env").write_text("CARTO_API_KEY=persisted-key\n") + r = client.get("/api/basemap-config") + assert r.json()["carto"] == {"configured": True, "key": "persisted-key"} + + +def test_settings_model_exposes_carto_key(monkeypatch): + # env_check reads keys off Settings, so the field must exist there or the + # startup check would always report CARTO_API_KEY as unset. + from services.config import Settings + + monkeypatch.setenv("CARTO_API_KEY", "from-env") + assert Settings().CARTO_API_KEY == "from-env" + + +def test_carto_key_is_in_registry_and_saveable(): + assert "CARTO_API_KEY" in api_settings.ALLOWED_ENV_KEYS + entry = next(a for a in api_settings.API_REGISTRY if a["env_key"] == "CARTO_API_KEY") + assert entry["required"] is False diff --git a/docker-compose.yml b/docker-compose.yml index 306e28a..9ded9c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - GFW_EVENTS_LOOKBACK_DAYS=${GFW_EVENTS_LOOKBACK_DAYS:-7} - GFW_EVENTS_TIMEOUT_S=${GFW_EVENTS_TIMEOUT_S:-90} - WINDY_API_KEY=${WINDY_API_KEY:-} + - CARTO_API_KEY=${CARTO_API_KEY:-} - ADMIN_KEY=${ADMIN_KEY:-} - FINNHUB_API_KEY=${FINNHUB_API_KEY:-} - AIRFRAMES_API_KEY=${AIRFRAMES_API_KEY:-} diff --git a/docs/OUTBOUND_DATA.md b/docs/OUTBOUND_DATA.md index e07a8da..ab96f08 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` lives with the other backend keys (env or the API Keys panel) and is served to the browser by the public backend route `GET /api/basemap-config` through the normal same-origin `/api/*` path (Next.js proxy in web mode, companion server in packaged desktop). The browser then sends it to `*.basemaps.cartocdn.com` as a `?key=` query parameter on every tile request, so it is not treated as a secret. 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..5d47c8a --- /dev/null +++ b/frontend/src/__tests__/map/basemapConfig.test.ts @@ -0,0 +1,194 @@ +import fs from 'fs'; +import path from 'path'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + CARTO_ATTRIBUTION_HTML, + OSM_ATTRIBUTION_HTML, + buildBasemapStyle, + cartoTileUrls, + darkStyle, + lightStyle, +} from '@/components/map/styles/mapStyles'; +import { + BASEMAP_CONFIG_HARD_TIMEOUT_MS, + BASEMAP_CONFIG_SOFT_TIMEOUT_MS, + __resetBasemapConfigCache, + useBasemapConfig, +} from '@/hooks/useBasemapConfig'; + +const VIEWER_SRC = fs.readFileSync( + path.join(__dirname, '..', '..', 'components', 'MaplibreViewer.tsx'), + 'utf-8', +); + +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')); + }); + + it('declares OpenStreetMap and CARTO attribution on the raster source, keyed or not', () => { + for (const style of [buildBasemapStyle('dark'), buildBasemapStyle('light', 'k')]) { + const source = Object.values(style.sources)[0]; + expect(source.attribution).toContain('openstreetmap.org/copyright'); + expect(source.attribution).toContain('carto.com/attribution'); + } + }); +}); + +describe('MaplibreViewer attribution and basemap gating', () => { + it('still renders the explicit AttributionControl with the same OSM/CARTO markup', () => { + // attributionControl={false} only disables the default control; the + // explicit child below it is the visible attribution and must survive. + expect(VIEWER_SRC).toContain(' { + expect(VIEWER_SRC).toContain('{basemapConfigLoaded && ('); + expect(VIEWER_SRC).toMatch(/const \{ cartoApiKey, loaded: basemapConfigLoaded \} = useBasemapConfig\(\)/); + expect(BASEMAP_CONFIG_SOFT_TIMEOUT_MS).toBeLessThan(BASEMAP_CONFIG_HARD_TIMEOUT_MS); + }); +}); + +describe('useBasemapConfig', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + __resetBasemapConfigCache(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + function jsonResponse(body: unknown, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) } as Response); + } + + it('starts pending and resolves with the key', async () => { + fetchMock.mockReturnValue(jsonResponse({ carto: { configured: true, key: ' abc ' } })); + const { result } = renderHook(() => useBasemapConfig()); + expect(result.current).toEqual({ cartoApiKey: null, loaded: false }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual({ cartoApiKey: 'abc', loaded: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/api\/basemap-config$/); + }); + + it('fails open with the unkeyed config on a non-OK response', async () => { + fetchMock.mockReturnValue(jsonResponse({ detail: 'nope' }, false)); + const { result } = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + }); + + it('fails open on a network error', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + const { result } = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + }); + + it('releases the map after the soft timeout, then applies a late key', async () => { + let resolveFetch: (value: Response) => void = () => {}; + fetchMock.mockReturnValue(new Promise((resolve) => (resolveFetch = resolve))); + const { result } = renderHook(() => useBasemapConfig()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BASEMAP_CONFIG_SOFT_TIMEOUT_MS); + }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + + await act(async () => { + resolveFetch({ ok: true, json: () => Promise.resolve({ carto: { key: 'late' } }) } as Response); + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual({ cartoApiKey: 'late', loaded: true }); + }); + + it('aborts the request at the hard timeout and stays unkeyed', async () => { + fetchMock.mockImplementation( + (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }), + ); + const { result } = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(BASEMAP_CONFIG_HARD_TIMEOUT_MS + 1); + }); + expect(result.current).toEqual({ cartoApiKey: null, loaded: true }); + expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true); + }); + + it('shares one request across mounts and caches only successes', async () => { + fetchMock.mockReturnValue(jsonResponse({ carto: { key: 'shared' } })); + const a = renderHook(() => useBasemapConfig()); + const b = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(a.result.current.cartoApiKey).toBe('shared'); + expect(b.result.current.cartoApiKey).toBe('shared'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + cleanup(); + const c = renderHook(() => useBasemapConfig()); + expect(c.result.current).toEqual({ cartoApiKey: 'shared', loaded: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + __resetBasemapConfigCache(); + fetchMock.mockReturnValueOnce(jsonResponse({}, false)); + fetchMock.mockReturnValueOnce(jsonResponse({ carto: { key: 'second-try' } })); + cleanup(); + const d = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(d.result.current).toEqual({ cartoApiKey: null, loaded: true }); + cleanup(); + const e = renderHook(() => useBasemapConfig()); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(e.result.current).toEqual({ cartoApiKey: 'second-try', loaded: true }); + }); +}); diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index b4ecd04..211ff4b 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'; @@ -428,9 +429,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( @@ -1898,6 +1901,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. + Bounded: useBasemapConfig fails open to the unkeyed style after a short timeout. */} + {basemapConfigLoaded && ( + )} ); }; diff --git a/frontend/src/components/map/styles/mapStyles.ts b/frontend/src/components/map/styles/mapStyles.ts index e141d8f..2c0605b 100644 --- a/frontend/src/components/map/styles/mapStyles.ts +++ b/frontend/src/components/map/styles/mapStyles.ts @@ -1,41 +1,56 @@ -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 on CARTO raster tiles. CARTO requires an API key + * (unkeyed tiles are watermarked); MaplibreViewer passes one from + * useBasemapConfig() via buildBasemapStyle(). The key is served by the + * backend at GET /api/basemap-config. + */ -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'; + +// Declared on the raster source so MapLibre's AttributionControl shows it +// even without the custom list in MaplibreViewer. Same markup as that list so +// the control de-duplicates instead of showing both. +export const OSM_ATTRIBUTION_HTML = + '© OpenStreetMap contributors'; +export const CARTO_ATTRIBUTION_HTML = + 'CARTO'; + +/** 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, + attribution: `${OSM_ATTRIBUTION_HTML} ${CARTO_ATTRIBUTION_HTML}`, + }, + }, + layers: [ + { id: `${sourceId}-layer`, type: 'raster', source: sourceId, minzoom: 0, maxzoom: 22 }, + { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } }, + ], + }; +} + +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..f7aae73 --- /dev/null +++ b/frontend/src/hooks/useBasemapConfig.ts @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { API_BASE } from '@/lib/api'; + +export type BasemapConfig = { + /** CARTO basemap API key, or null when none is configured / not yet loaded. */ + cartoApiKey: string | null; + /** True once the map may render: config arrived, failed, or timed out. */ + loaded: boolean; +}; + +type BasemapConfigResponse = { carto?: { configured?: boolean; key?: string } }; + +/** Render the unkeyed map if the config has not arrived by then. */ +export const BASEMAP_CONFIG_SOFT_TIMEOUT_MS = 3000; +/** Abort the config request outright after this long. */ +export const BASEMAP_CONFIG_HARD_TIMEOUT_MS = 15000; + +const PENDING: BasemapConfig = { cartoApiKey: null, loaded: false }; +const UNCONFIGURED: BasemapConfig = { cartoApiKey: null, loaded: true }; + +// Successful responses are cached for the page lifetime and shared by every +// map instance. Failures are not cached so a later mount retries (the backend +// may still have been starting). +let cached: BasemapConfig | null = null; +let inflight: Promise | null = null; + +async function requestBasemapConfig(): Promise { + const controller = new AbortController(); + const hardTimer = setTimeout(() => controller.abort(), BASEMAP_CONFIG_HARD_TIMEOUT_MS); + try { + const res = await fetch(`${API_BASE}/api/basemap-config`, { + cache: 'no-store', + signal: controller.signal, + }); + if (!res.ok) return UNCONFIGURED; + const body = (await res.json()) as BasemapConfigResponse; + const key = String(body?.carto?.key || '').trim(); + cached = { cartoApiKey: key || null, loaded: true }; + return cached; + } catch { + return UNCONFIGURED; + } finally { + clearTimeout(hardTimer); + inflight = null; + } +} + +/** Reset module state (tests only). */ +export function __resetBasemapConfigCache(): void { + cached = null; + inflight = null; +} + +export function useBasemapConfig(): BasemapConfig { + const [config, setConfig] = useState(() => cached ?? PENDING); + + useEffect(() => { + if (cached) { + setConfig(cached); + return; + } + let cancelled = false; + if (!inflight) inflight = requestBasemapConfig(); + // Fail open: a slow or hung config request must not hold the whole map. + // If the key arrives later the style is rebuilt with it, same as a theme switch. + const softTimer = setTimeout(() => { + if (!cancelled) setConfig((current) => (current.loaded ? current : UNCONFIGURED)); + }, BASEMAP_CONFIG_SOFT_TIMEOUT_MS); + void inflight.then((resolved) => { + if (!cancelled) setConfig(resolved); + }); + return () => { + cancelled = true; + clearTimeout(softTimer); + }; + }, []); + + return config; +}