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>
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 | ||||