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 | 1 | import type { Metadata } from 'next'; |
| 2 | −import { notFound } from 'next/navigation'; | |
| 2 | +import { notFound, redirect } from 'next/navigation'; | |
| 3 | 3 | import { t } from '@/i18n'; |
| 4 | 4 | import { api, isNotBuilt, safe } from '@/lib/api'; |
| 5 | 5 | import { apiCompare } from '@/lib/api-compare'; |
@@ -78,6 +78,8 @@ export default async function CompareViewPage({ params, searchParams }: { params | ||
| 78 | 78 | const [{ slugs: segments }, sp] = await Promise.all([params, searchParams]); |
| 79 | 79 | const r = await resolve(segments); |
| 80 | 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 | 83 | if (r.countries.length < MIN_COMPARE_COUNTRIES) notFound(); |
| 82 | 84 | |
| 83 | 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 | 348 | </span> |
| 349 | 349 | <div className="min-w-0 text-sm"> |
| 350 | 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 | 352 | {s.source_name ?? s.source_id} |
| 353 | 353 | </Link> |
| 354 | 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 | 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 | 27 | const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth); |
| 28 | 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 | 30 | const model = useMemo(() => { |
| 31 | 31 | const innerW = Math.max(10, width - m.left - m.right); |
| 32 | 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 | 45 | const placed = markers |
| 46 | 46 | .map((mk) => ({ ...mk, px: Math.min(innerW, Math.max(0, x(mk.value))) })) |
| 47 | 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 | 49 | return { innerW, innerH, x, y, bars, xTicks, yTicks: y.ticks(3), placed, dom: extent(edges) ?? [lo, hi] }; |
| 50 | 50 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 51 | 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 | 156 | <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('scatter.title')}</h1> |
| 157 | 157 | <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('scatter.lede')}</p> |
| 158 | 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 | 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 | 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 | 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 | 37 | const [x, y] = e.target.value.split('|'); |
| 38 | 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 | 41 | aria-label={t('peers.pair')} |
| 42 | 42 | > |
| 43 | 43 | {pairs.map((p) => ( |
modified
apps/web/src/lib/api.ts
+11 −1
@@ -29,6 +29,8 @@ import type { | ||
| 29 | 29 | |
| 30 | 30 | export const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8291'; |
| 31 | 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 | 35 | export class ApiError extends Error { |
| 34 | 36 | readonly status: number; |
@@ -71,13 +73,21 @@ function qs(query?: Query): string { | ||
| 71 | 73 | |
| 72 | 74 | export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> { |
| 73 | 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 | 79 | if (opts.revalidate === false) init.cache = 'no-store'; |
| 76 | 80 | else init.next = { revalidate: opts.revalidate ?? 900, tags: opts.tags }; |
| 77 | 81 | |
| 78 | 82 | let res: Response; |
| 79 | 83 | try { |
| 80 | 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 | 91 | } catch (e) { |
| 82 | 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 | 91 | "post_sync": [ |
| 92 | 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 | 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 | 95 | "mkdir -p $HOME/countryatlas-data/{raw,staging,build,snapshots,exports,logs} && echo ' data dirs ok'" |
| 96 | 96 | ], |
| 97 | 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 | 109 | async def atlas_middleware(request: Request, call_next): # type: ignore[no-untyped-def] |
| 110 | 110 | path = request.url.path |
| 111 | 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 | 122 | ok, retry = limiter.allow(client_ip(request.headers, request.client.host if request.client else None)) |
| 114 | 123 | if not ok: |
| 115 | 124 | return _problem(request, 429, "Too many requests", f"Rate limit is {rpm} requests per minute per IP.", |
| 116 | 125 | |