SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%

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 12 days ago (Sep 12, 2026) parent 58394ea

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