mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-17 06:35:29 +02:00
Merge pull request #558 from C3B2W23/fix/carto-api-key
fix(basemap): add runtime CARTO_API_KEY for CARTO tiles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-1
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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('<AttributionControl');
|
||||
expect(VIEWER_SRC).toContain(OSM_ATTRIBUTION_HTML);
|
||||
expect(VIEWER_SRC).toContain(CARTO_ATTRIBUTION_HTML);
|
||||
});
|
||||
|
||||
it('gates the map on the bounded basemap config, not on an open-ended request', () => {
|
||||
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<Response>((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<Response>((_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 });
|
||||
});
|
||||
});
|
||||
@@ -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<maplibregl.StyleSpecification>(
|
||||
() => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification,
|
||||
[theme],
|
||||
() =>
|
||||
buildBasemapStyle(theme === 'light' ? 'light' : 'dark', cartoApiKey) as maplibregl.StyleSpecification,
|
||||
[theme, cartoApiKey],
|
||||
);
|
||||
|
||||
const initialViewState = useMemo<ViewState>(
|
||||
@@ -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 && (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
reuseMaps
|
||||
@@ -6670,6 +6676,7 @@ const MaplibreViewer = ({
|
||||
|
||||
<MeasurementLayers measurePoints={measurePoints} />
|
||||
</Map>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<BasemapTheme, string> = {
|
||||
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 =
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a>';
|
||||
export const CARTO_ATTRIBUTION_HTML =
|
||||
'<a href="https://carto.com/attribution" target="_blank" rel="noopener">CARTO</a>';
|
||||
|
||||
/** 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');
|
||||
|
||||
@@ -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<BasemapConfig> | null = null;
|
||||
|
||||
async function requestBasemapConfig(): Promise<BasemapConfig> {
|
||||
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<BasemapConfig>(() => 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;
|
||||
}
|
||||
Reference in New Issue
Block a user