SPB Git

spb/countryatlas

Public

Rate limiter exempts server-side renders (shared secret or loopback without proxy headers); lib/api retries once on 429; /compare/<country> opens the builder pre-selected; mobile control grids and tap-target fixes; histogram marker stacking; manifest build hook passes CA_ADMIN_TOKEN

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 58394ea Browse files

8 changed files +30 −9

modified apps/web/src/app/compare/[...slugs]/page.tsx +3 −1
@@ -1,5 +1,5 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2−import { notFound } from 'next/navigation'; 2+import { notFound, redirect } from 'next/navigation';
3 import { t } from '@/i18n'; 3 import { t } from '@/i18n';
4 import { api, isNotBuilt, safe } from '@/lib/api'; 4 import { api, isNotBuilt, safe } from '@/lib/api';
5 import { apiCompare } from '@/lib/api-compare'; 5 import { apiCompare } from '@/lib/api-compare';
@@ -78,6 +78,8 @@ export default async function CompareViewPage({ params, searchParams }: { params
78 const [{ slugs: segments }, sp] = await Promise.all([params, searchParams]); 78 const [{ slugs: segments }, sp] = await Promise.all([params, searchParams]);
79 const r = await resolve(segments); 79 const r = await resolve(segments);
80 if (r === 'not-built') return <NotBuiltState />; 80 if (r === 'not-built') return <NotBuiltState />;
81+ // A single country (e.g. the "Compare" action of a country page) opens the builder with it pre-selected.
82+ if (r.countries.length === 1) redirect(`${routes.compare()}?c=${r.countries[0]!.slug ?? r.countries[0]!.id.toLowerCase()}`);
81 if (r.countries.length < MIN_COMPARE_COUNTRIES) notFound(); 83 if (r.countries.length < MIN_COMPARE_COUNTRIES) notFound();
82 84
83 let state: CompareState = parseCompareState(sp); 85 let state: CompareState = parseCompareState(sp);
modified apps/web/src/app/indicators/[slug]/page.tsx +1 −1
@@ -348,7 +348,7 @@ export default async function IndicatorPage({ params, searchParams }: { params:
348 </span> 348 </span>
349 <div className="min-w-0 text-sm"> 349 <div className="min-w-0 text-sm">
350 <div className="flex flex-wrap items-baseline gap-x-2"> 350 <div className="flex flex-wrap items-baseline gap-x-2">
351− <Link href={routes.source(s.source_id)} className="link-quiet font-medium text-ink"> 351+ <Link href={routes.source(s.source_id)} className="link-quiet inline-flex min-h-[44px] items-center font-medium text-ink md:min-h-0">
352 {s.source_name ?? s.source_id} 352 {s.source_name ?? s.source_id}
353 </Link> 353 </Link>
354 <span className="text-ink-3">{s.dataset}</span> 354 <span className="text-ink-3">{s.dataset}</span>
modified apps/web/src/components/charts/histogram.tsx +2 −2
@@ -26,7 +26,7 @@ export interface HistogramMarker {
26 export function Histogram({ edges, counts, log = false, markers = [], spec, height = 220, title, subtitle, className, defaultWidth = 640, unitLabel }: { edges: number[]; counts: number[]; log?: boolean; markers?: HistogramMarker[]; spec: Spec; height?: number; title?: React.ReactNode; subtitle?: React.ReactNode; className?: string; defaultWidth?: number; unitLabel?: string }) { 26 export function Histogram({ edges, counts, log = false, markers = [], spec, height = 220, title, subtitle, className, defaultWidth = 640, unitLabel }: { edges: number[]; counts: number[]; log?: boolean; markers?: HistogramMarker[]; spec: Spec; height?: number; title?: React.ReactNode; subtitle?: React.ReactNode; className?: string; defaultWidth?: number; unitLabel?: string }) {
27 const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth); 27 const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);
28 const [hover, setHover] = useState<number | null>(null); 28 const [hover, setHover] = useState<number | null>(null);
29− const m = { ...DEFAULT_MARGIN, top: markers.length ? 34 : 12, bottom: 28, left: 36 }; 29+ const m = { ...DEFAULT_MARGIN, top: markers.length ? (markers.length > 2 ? 46 : 34) : 12, bottom: 28, left: 36 };
30 const model = useMemo(() => { 30 const model = useMemo(() => {
31 const innerW = Math.max(10, width - m.left - m.right); 31 const innerW = Math.max(10, width - m.left - m.right);
32 const innerH = Math.max(10, height - m.top - m.bottom); 32 const innerH = Math.max(10, height - m.top - m.bottom);
@@ -45,7 +45,7 @@ export function Histogram({ edges, counts, log = false, markers = [], spec, heig
45 const placed = markers 45 const placed = markers
46 .map((mk) => ({ ...mk, px: Math.min(innerW, Math.max(0, x(mk.value))) })) 46 .map((mk) => ({ ...mk, px: Math.min(innerW, Math.max(0, x(mk.value))) }))
47 .sort((a, b) => a.px - b.px) 47 .sort((a, b) => a.px - b.px)
48− .map((mk, i, arr) => ({ ...mk, row: i > 0 && mk.px - arr[i - 1]!.px < 90 ? (i % 2) : 0 })); 48+ .map((mk, i, arr) => ({ ...mk, row: Math.min(2, arr.slice(0, i).filter((o) => mk.px - o.px < 90).length) }));
49 return { innerW, innerH, x, y, bars, xTicks, yTicks: y.ticks(3), placed, dom: extent(edges) ?? [lo, hi] }; 49 return { innerW, innerH, x, y, bars, xTicks, yTicks: y.ticks(3), placed, dom: extent(edges) ?? [lo, hi] };
50 // eslint-disable-next-line react-hooks/exhaustive-deps 50 // eslint-disable-next-line react-hooks/exhaustive-deps
51 }, [edges, counts, log, markers, width, height]); 51 }, [edges, counts, log, markers, width, height]);
modified apps/web/src/components/explorer/scatter-view.tsx +1 −1
@@ -156,7 +156,7 @@ export function ScatterView({ indicators, groups, initial, initialRelated, initi
156 <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('scatter.title')}</h1> 156 <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('scatter.title')}</h1>
157 <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('scatter.lede')}</p> 157 <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('scatter.lede')}</p>
158 </header> 158 </header>
159− <div className="grid gap-2 border-y border-rule py-3 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_9rem_10rem]"> 159+ <div className="grid grid-cols-1 gap-2 border-y border-rule py-3 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_9rem_10rem]">
160 <IndicatorSelect options={indicators} value={x} onChange={(v) => set({ x: v === DEFAULT_TRAJ.x ? null : v, log: null }, 0)} label={t('traj.x')} size="sm" /> 160 <IndicatorSelect options={indicators} value={x} onChange={(v) => set({ x: v === DEFAULT_TRAJ.x ? null : v, log: null }, 0)} label={t('traj.x')} size="sm" />
161 <IndicatorSelect options={indicators} value={y} onChange={(v) => set({ y: v === DEFAULT_TRAJ.y ? null : v, log: null }, 0)} label={t('traj.y')} size="sm" /> 161 <IndicatorSelect options={indicators} value={y} onChange={(v) => set({ y: v === DEFAULT_TRAJ.y ? null : v, log: null }, 0)} label={t('traj.y')} size="sm" />
162 <IndicatorSelect options={sizeOptions} value={size} onChange={(v) => set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" /> 162 <IndicatorSelect options={sizeOptions} value={size} onChange={(v) => set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" />
modified apps/web/src/components/peers/peers-view.tsx +1 −1
@@ -37,7 +37,7 @@ export function PeersView({ data }: { data: PeersResponse }) {
37 const [x, y] = e.target.value.split('|'); 37 const [x, y] = e.target.value.split('|');
38 set({ x, y }, 0); 38 set({ x, y }, 0);
39 }} 39 }}
40− className="max-w-[18rem] truncate bg-transparent text-ink outline-none" 40+ className="min-w-0 max-w-[18rem] flex-1 truncate bg-transparent text-ink outline-none"
41 aria-label={t('peers.pair')} 41 aria-label={t('peers.pair')}
42 > 42 >
43 {pairs.map((p) => ( 43 {pairs.map((p) => (
modified apps/web/src/lib/api.ts +11 −1
@@ -29,6 +29,8 @@ import type {
29 29
30 export const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8291'; 30 export const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8291';
31 const BASE = `${API_URL.replace(/\/$/, '')}/api/v1`; 31 const BASE = `${API_URL.replace(/\/$/, '')}/api/v1`;
32+/** Shared secret (same env var on both processes) that exempts server-side renders from the per-IP rate limit. */
33+const INTERNAL_TOKEN = process.env.CA_ADMIN_TOKEN ?? '';
32 34
33 export class ApiError extends Error { 35 export class ApiError extends Error {
34 readonly status: number; 36 readonly status: number;
@@ -71,13 +73,21 @@ function qs(query?: Query): string {
71 73
72 export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> { 74 export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {
73 const url = `${BASE}${path}${qs(query)}`; 75 const url = `${BASE}${path}${qs(query)}`;
74− const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } }; 76+ const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = {
77+ headers: INTERNAL_TOKEN ? { accept: 'application/json', 'x-countryatlas-internal': INTERNAL_TOKEN } : { accept: 'application/json' },
78+ };
75 if (opts.revalidate === false) init.cache = 'no-store'; 79 if (opts.revalidate === false) init.cache = 'no-store';
76 else init.next = { revalidate: opts.revalidate ?? 900, tags: opts.tags }; 80 else init.next = { revalidate: opts.revalidate ?? 900, tags: opts.tags };
77 81
78 let res: Response; 82 let res: Response;
79 try { 83 try {
80 res = await fetch(url, init); 84 res = await fetch(url, init);
85+ if (res.status === 429) {
86+ // One polite retry after the advertised delay (capped at 2 s) before surfacing the error.
87+ const wait = Math.min(2000, Math.max(250, Number(res.headers.get('retry-after') ?? 1) * 1000));
88+ await new Promise((r) => setTimeout(r, wait));
89+ res = await fetch(url, init);
90+ }
81 } catch (e) { 91 } catch (e) {
82 throw new ApiError(0, path, null, `API unreachable at ${BASE} (${(e as Error).message})`); 92 throw new ApiError(0, path, null, `API unreachable at ${BASE} (${(e as Error).message})`);
83 } 93 }
modified deploy/countryatlas.mld.json +1 −1
@@ -91,7 +91,7 @@
91 "post_sync": [ 91 "post_sync": [
92 "export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' python deps ok'", 92 "export PATH=\"$HOME/.local/bin:/opt/homebrew/bin:$PATH\"; (test -x .venv/bin/python || uv venv --python 3.12 .venv) && uv pip install -q --python .venv/bin/python -e . && echo ' python deps ok'",
93 "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' web deps ok'", 93 "export PATH=\"/opt/homebrew/bin:$PATH\"; pnpm install --frozen-lockfile --silent && echo ' web deps ok'",
94− "export PATH=\"/opt/homebrew/bin:$PATH\"; cd apps/web && API_URL=http://127.0.0.1:8291 NEXT_PUBLIC_SITE_URL=https://www.countryatlas.co NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'", 94+ "export PATH=\"/opt/homebrew/bin:$PATH\"; cd apps/web && API_URL=http://127.0.0.1:8291 NEXT_PUBLIC_SITE_URL=https://www.countryatlas.co CA_ADMIN_TOKEN=c9ae6c67cdeb415c9df7db09d255c86d3537fb7670cf3c5a NEXT_TELEMETRY_DISABLED=1 pnpm build 2>&1 | tail -3 && echo ' web build ok'",
95 "mkdir -p $HOME/countryatlas-data/{raw,staging,build,snapshots,exports,logs} && echo ' data dirs ok'" 95 "mkdir -p $HOME/countryatlas-data/{raw,staging,build,snapshots,exports,logs} && echo ' data dirs ok'"
96 ], 96 ],
97 "post_start": [] 97 "post_start": []
modified src/countryatlas/api/main.py +10 −1
@@ -109,7 +109,16 @@ def create_app(db_path: str | Path | None = None, *, rate_limit_per_minute: int
109 async def atlas_middleware(request: Request, call_next): # type: ignore[no-untyped-def] 109 async def atlas_middleware(request: Request, call_next): # type: ignore[no-untyped-def]
110 path = request.url.path 110 path = request.url.path
111 public = path.startswith(API_PREFIX) and not path.startswith(f"{API_PREFIX}/admin") and path != f"{API_PREFIX}/health" 111 public = path.startswith(API_PREFIX) and not path.startswith(f"{API_PREFIX}/admin") and path != f"{API_PREFIX}/health"
112− if limiter is not None and public: 112+ # Server-side renders of the web app run on the same host and would exhaust the per-IP budget on their own:
113+ # requests carrying the shared secret (CA_ADMIN_TOKEN, known to both processes) bypass the limiter.
114+ # Next.js adds X-Forwarded-For to every proxied browser request (base-server), so a loopback connection WITHOUT any
115+ # forwarding header can only come from a local process (SSR, build prerender, pipeline) — also exempt.
116+ client_host = request.client.host if request.client else None
117+ no_proxy_headers = not request.headers.get("x-forwarded-for") and not request.headers.get("x-real-ip")
118+ internal = (bool(settings.admin_token) and request.headers.get("x-countryatlas-internal") == settings.admin_token) or (
119+ client_host in ("127.0.0.1", "::1") and no_proxy_headers
120+ )
121+ if limiter is not None and public and not internal:
113 ok, retry = limiter.allow(client_ip(request.headers, request.client.host if request.client else None)) 122 ok, retry = limiter.allow(client_ip(request.headers, request.client.host if request.client else None))
114 if not ok: 123 if not ok:
115 return _problem(request, 429, "Too many requests", f"Rate limit is {rpm} requests per minute per IP.", 124 return _problem(request, 429, "Too many requests", f"Rate limit is {rpm} requests per minute per IP.",
116 125
Menu

Theme