mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-17 14:42:20 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a5fb1c392e
commit
8f169f1ecc
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<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>(
|
||||
@@ -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 && (
|
||||
<Map
|
||||
ref={mapRef}
|
||||
reuseMaps
|
||||
@@ -6658,6 +6664,7 @@ const MaplibreViewer = ({
|
||||
|
||||
<MeasurementLayers measurePoints={measurePoints} />
|
||||
</Map>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<BasemapTheme, string> = {
|
||||
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');
|
||||
|
||||
@@ -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<BasemapConfig> | null = null;
|
||||
|
||||
async function fetchBasemapConfig(): Promise<BasemapConfig> {
|
||||
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<BasemapConfigResponse>;
|
||||
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<BasemapConfig>({ 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;
|
||||
}
|
||||
Reference in New Issue
Block a user