mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-18 15:12:20 +02:00
fix(basemap): serve CARTO key from backend, bound the map gate, add source attribution
Review follow-up: - Drop the Next.js route. CARTO_API_KEY is now a regular backend registry key (env, .env, or the API Keys panel) served by public GET /api/basemap-config. Every frontend mode already proxies /api/* to the backend (Next.js proxy in web mode, companion server in packaged desktop), so this covers web and desktop with one mechanism and leaves the static export untouched. Also removes the invalid non-handler export from the route module by removing the module. - useBasemapConfig: fail open to the unkeyed style after 3 s, abort the request at 15 s, apply a late key when it arrives, cache successes per page and retry failures on the next mount. - Declare OSM/CARTO attribution on the raster source (same markup as the viewer's existing AttributionControl so MapLibre de-duplicates it). - Tests: backend endpoint (unset / set+trimmed / persisted operator key / registry), hook behaviour (success, non-OK, network error, soft timeout then late key, hard abort, shared request and retry), attribution and gating source checks. - CARTO_API_KEY moves to the backend service in docker-compose.yml; docs updated accordingly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
71550b4adf
commit
667f51cb7a
@@ -1,67 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
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 { GET as getBasemapConfig } from '@/app/api/basemap-config/route';
|
||||
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';
|
||||
|
||||
describe('CARTO basemap API key plumbing', () => {
|
||||
const originalKey = process.env.CARTO_API_KEY;
|
||||
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(() => {
|
||||
delete process.env.CARTO_API_KEY;
|
||||
vi.useFakeTimers();
|
||||
__resetBasemapConfigCache();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKey === undefined) delete process.env.CARTO_API_KEY;
|
||||
else process.env.CARTO_API_KEY = originalKey;
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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: '' } });
|
||||
});
|
||||
function jsonResponse(body: unknown, ok = true) {
|
||||
return Promise.resolve({ ok, json: () => Promise.resolve(body) } as Response);
|
||||
}
|
||||
|
||||
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' } });
|
||||
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$/);
|
||||
});
|
||||
|
||||
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('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('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('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('treats blank keys as unconfigured', () => {
|
||||
expect(cartoTileUrls('dark', ' ')).toEqual(cartoTileUrls('dark'));
|
||||
expect(cartoTileUrls('dark', null)).toEqual(cartoTileUrls('dark'));
|
||||
});
|
||||
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());
|
||||
|
||||
it('keeps the key-less default exports in sync with the builder', () => {
|
||||
expect(darkStyle).toEqual(buildBasemapStyle('dark'));
|
||||
expect(lightStyle).toEqual(buildBasemapStyle('light'));
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Serves CARTO_API_KEY to the browser map. Read from the frontend container's
|
||||
* environment at request time (like BACKEND_URL) so the prebuilt image needs
|
||||
* no rebuild. Consumed by useBasemapConfig().
|
||||
*/
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -1889,8 +1889,8 @@ 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). */}
|
||||
{/* 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}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* MapLibre basemap styles on CARTO raster tiles. CARTO requires an API key
|
||||
* (unkeyed tiles are watermarked); MaplibreViewer passes one from
|
||||
* useBasemapConfig() via buildBasemapStyle().
|
||||
* useBasemapConfig() via buildBasemapStyle(). The key is served by the
|
||||
* backend at GET /api/basemap-config.
|
||||
*/
|
||||
|
||||
export type BasemapTheme = 'dark' | 'light';
|
||||
@@ -13,6 +14,14 @@ const CARTO_RASTER_STYLE: Record<BasemapTheme, string> = {
|
||||
};
|
||||
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];
|
||||
@@ -33,6 +42,7 @@ export function buildBasemapStyle(theme: BasemapTheme, cartoApiKey?: string | nu
|
||||
type: 'raster',
|
||||
tiles: cartoTileUrls(theme, cartoApiKey),
|
||||
tileSize: 256,
|
||||
attribution: `${OSM_ATTRIBUTION_HTML} ${CARTO_ATTRIBUTION_HTML}`,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
|
||||
@@ -2,49 +2,78 @@
|
||||
|
||||
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). */
|
||||
/** 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 };
|
||||
|
||||
// One request per page load, shared by every map instance.
|
||||
let configPromise: Promise<BasemapConfig> | null = null;
|
||||
// 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 fetchBasemapConfig(): Promise<BasemapConfig> {
|
||||
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' });
|
||||
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 Partial<BasemapConfigResponse>;
|
||||
const body = (await res.json()) as BasemapConfigResponse;
|
||||
const key = String(body?.carto?.key || '').trim();
|
||||
return { cartoApiKey: key || null, loaded: true };
|
||||
cached = { cartoApiKey: key || null, loaded: true };
|
||||
return cached;
|
||||
} catch {
|
||||
// Static/desktop exports have no API routes; fall back to unkeyed tiles.
|
||||
return UNCONFIGURED;
|
||||
} finally {
|
||||
clearTimeout(hardTimer);
|
||||
inflight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the shared request cache (tests only). */
|
||||
/** Reset module state (tests only). */
|
||||
export function __resetBasemapConfigCache(): void {
|
||||
configPromise = null;
|
||||
cached = null;
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
export function useBasemapConfig(): BasemapConfig {
|
||||
const [config, setConfig] = useState<BasemapConfig>({ cartoApiKey: null, loaded: false });
|
||||
const [config, setConfig] = useState<BasemapConfig>(() => cached ?? PENDING);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) {
|
||||
setConfig(cached);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
if (!configPromise) configPromise = fetchBasemapConfig();
|
||||
void configPromise.then((resolved) => {
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user