From 4ce64c7839537d56ba61fd9cccd0814c1bf05cfb Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 10 Aug 2026 11:22:44 -0400 Subject: [PATCH] Fix CORS: add proxy support for browser-based API calls on git-pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add VITE_PROXY_URL env var to route requests through a CORS proxy when deployed as static site (CRDC API doesn't send Access-Control-Allow-Origin) - Update useApi.js, LoadingAnimation.jsx, and ChartPanel.jsx to respect proxy - Add nginx.conf with /api/v1/ reverse proxy + CORS headers for Docker deployment - Include proxy.php — simple PHP CORS proxy for git-pages hosts that support PHP - Document CORS workaround in README Without this fix, browser fetch() calls are blocked by same-origin policy and the app shows a blank white screen despite serving correct HTML. --- .gitea/workflows/pages.yml | 1 + Dockerfile | 11 ++++-- README.md | 15 +++++++- nginx.conf | 45 +++++++++++++++++++---- proxy.php | 57 +++++++++++++++++++++++++++++ src/components/ChartPanel.jsx | 16 ++++---- src/components/LoadingAnimation.jsx | 11 +++++- src/hooks/useApi.js | 29 +++++++++++---- 8 files changed, 155 insertions(+), 30 deletions(-) create mode 100644 proxy.php diff --git a/.gitea/workflows/pages.yml b/.gitea/workflows/pages.yml index 32a7621..4db32e1 100644 --- a/.gitea/workflows/pages.yml +++ b/.gitea/workflows/pages.yml @@ -32,6 +32,7 @@ jobs: run: | npm ci # VITE_API_BASE defaults to the production API; override if needed via secrets. + # For git-pages deployment, CORS is handled by setting up a proxy endpoint on the server. npm run build - name: Publish dist/ to pages branch diff --git a/Dockerfile b/Dockerfile index 7f6bac0..0b42691 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,15 +7,20 @@ COPY package*.json ./ RUN npm ci --prefer-offline COPY . . -# The VITE_API_BASE env var can be set at build time to point to a different API endpoint. ARG VITE_API_BASE=https://crdc-api.civilytics.org/api/v1 ENV VITE_API_BASE=${VITE_API_BASE} +ARG VITE_PROXY_URL="" +ENV VITE_PROXY_URL=${VITE_PROXY_URL} RUN npm run build -# Stage 2: Serve with nginx (tiny image, ~5MB) +# Stage 2: Serve with nginx (tiny image, ~5MB) + reverse proxy for API CORS bypass FROM nginx:alpine AS runtime + +# Copy static site files COPY --from=builder /app/dist/ /usr/share/nginx/html/ -# Custom nginx config for SPA routing + caching headers + +# Custom nginx config — serves static files AND proxies /api/v1/ to the CRDC API +# with CORS headers, so browser-based requests work without preflight issues. COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 diff --git a/README.md b/README.md index 66a6e55..8c5a765 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ git-pages webhook receiver. **URL**: `https://pages.civilytics.org/crdc-demo/` +> **CORS note**: The CRDC API (`crdc-api.civilytics.org`) does not send CORS headers. When deployed as a static site, +> browser-based fetch requests will be blocked by the same-origin policy. To fix this, deploy `proxy.php` to your git-pages host +> and set the `VITE_PROXY_URL` environment variable (e.g., `/crdc-demo/proxy.php`). The app automatically detects +> proxy availability — when unset, it attempts direct API calls (works via Docker/nginx or same-origin setups). + The workflow follows the same pattern as `Civilytics/sln-school-comparison`: 1. Triggers on push to `main` (only when source files change) 2. Builds with Node 22 + Vite (`npm ci && npm run build`) @@ -89,8 +94,14 @@ The workflow follows the same pattern as `Civilytics/sln-school-comparison`: > **Note**: Ensure `${{ secrets.GITHUB_TOKEN }}` is configured in repo settings on gitea.civilytics.org. -### Option B: Docker (self-hosted fleet) -Build and run the nginx container on your infrastructure (`efron`, `maxwell`, etc.): +### Option A.5: CORS Proxy for Git Pages +If you can't deploy PHP, create a simple reverse proxy in nginx or use an edge function that: +1. Accepts `?target=/api/v1/...` as a query parameter +2. Forwards the request to `https://crdc-api.civilytics.org/api/v1/... +3. Returns the response with `Access-Control-Allow-Origin: *` +4. Set `VITE_PROXY_URL` at build time to point at this endpoint. + +### Option B: Docker (self-hosted fleet) — includes CORS proxy nginx config: ```bash docker compose up -d # builds + serves on localhost:8080 diff --git a/nginx.conf b/nginx.conf index e56df66..db46074 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,24 +1,53 @@ -# nginx config for CRDC Arrests Demo — static SPA with proper caching. +# nginx config for CRDC Arrests Demo — static SPA + API reverse proxy with CORS. +# When served via Docker, this proxies /api/v1/ requests to the CRDC API server-side, +# adding Access-Control-Allow-Origin: * so browser-based fetch works without issues. + server { listen 80; server_name _; + # Proxy all /api/v1/ requests to the CRDC Arrest Rate API with CORS headers + location /api/v1/ { + proxy_pass https://crdc-api.civilytics.org/api/v1/; + proxy_set_header Host crdc-api.civilytics.org; + proxy_ssl_verify off; + + # Add CORS headers so browser-based requests work + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "*" always; + + if ($request_method = 'OPTIONS') { + return 204; + } + } + + # Also support a simple proxy endpoint for git-pages-style deployments + location /crdc-demo/proxy/ { + # Extract the target path from query string and forward to CRDC API + proxy_pass https://crdc-api.civilytics.org$arg_target; + proxy_set_header Host crdc-api.civilytics.org; + proxy_ssl_verify off; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + } + # Serve static files from the Vite build output - root /usr/share/nginx/html; + root /usr/share/nginx/html/crdc-demo/; # Adjust if base is different index index.html; - # Civilytics design tokens: immutable cache headers (data doesn't change between releases) + location / { + try_files $uri $uri/ /index.html; + } + + # Civilytics design tokens: immutable cache headers for static assets location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2|ttf)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } - # SPA fallback — all routes serve index.html (client-side routing) - location / { - try_files $uri $uri/ /index.html; - } - # Health check endpoint for container orchestration location /healthz { access_log off; diff --git a/proxy.php b/proxy.php new file mode 100644 index 0000000..c7cd3a8 --- /dev/null +++ b/proxy.php @@ -0,0 +1,57 @@ + 'Missing "target" parameter']); + exit; +} + +// Construct the full API URL — only allow requests to crdc-api.civilytics.org for security +$apiBase = 'https://crdc-api.civilytics.org'; +$url = $apiBase . $target; + +// Validate that we're not proxying arbitrary URLs (prevent SSRF) +if (!str_starts_with($url, $apiBase . '/api/v1/')) { + http_response_code(403); + echo json_encode(['error' => 'Invalid target — must be under /api/v1/']); + exit; +} + +// Fetch the API response and return it with CORS headers +$ch = curl_init($url); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => false, +]); + +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +// Forward the response with appropriate status code and CORS headers +http_response_code($httpCode); +header('Access-Control-Allow-Origin: *'); +echo $response; diff --git a/src/components/ChartPanel.jsx b/src/components/ChartPanel.jsx index b1d5d27..99cef7d 100644 --- a/src/components/ChartPanel.jsx +++ b/src/components/ChartPanel.jsx @@ -15,13 +15,19 @@ export default function ChartPanel({ district, state }) { const [nationalRates, setNationalRates] = useState(null) const [loading, setLoading] = useState(true) + // Proxy support for CORS bypass when deployed on pages.civilytics.org + const PROXY_URL = import.meta.env.VITE_PROXY_URL || '' + const apiFetchWithProxy = (path) => { + return PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(path)}` : `https://crdc-api.civilytics.org/api/v1${path}` + } + useEffect(() => { async function fetchData() { try { // National rates from static fixture (or cache from loading step) let nat = window.__NATIONAL_RATES__ if (!nat) { - const res = await fetch('/data/national_rates.json') + const res = await fetch('/crdc-demo/data/national_rates.json') nat = await res.json() } setNationalRates(nat) @@ -30,9 +36,7 @@ export default function ChartPanel({ district, state }) { const waves = ['21-22', '17-18', '15-16'] const waveData = {} for (const year of waves) { - const res = await fetch( - `https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=unified_m2_mod&year=${year}` - ) + const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`)) if (res.ok) waveData[year] = (await res.json()).data || [] } @@ -40,9 +44,7 @@ export default function ChartPanel({ district, state }) { const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod'] const quadData = {} for (const model of quadrants) { - const res = await fetch( - `https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=${model}&year=21-22` - ) + const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=${model}&year=21-22`)) if (res.ok) quadData[model] = (await res.json()).data || [] } diff --git a/src/components/LoadingAnimation.jsx b/src/components/LoadingAnimation.jsx index 71c5902..83d8b98 100644 --- a/src/components/LoadingAnimation.jsx +++ b/src/components/LoadingAnimation.jsx @@ -201,12 +201,19 @@ export default function LoadingAnimation({ district, state }) { ) } -// ——— Inline fetch helper (avoids circular import with useApi.js) ——— +// ——— Inline fetch helper with CORS proxy support ——— +const PROXY_URL = import.meta.env.VITE_PROXY_URL || '' + async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') { const params = new URLSearchParams({ leaid, year, model }) if (race) params.set('race', race) if (sex) params.set('sex', sex) - const res = await fetch(`https://crdc-api.civilytics.org/api/v1/estimates/${leaid}?${params}`) + + // Route through proxy to bypass CORS restrictions when deployed on pages.civilytics.org + const targetPath = `/api/v1/estimates/${leaid}?${params}` + const url = PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(targetPath)}` : `https://crdc-api.civilytics.org/api/v1/estimates/${leaid}?${params}` + + const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() } diff --git a/src/hooks/useApi.js b/src/hooks/useApi.js index 1016727..cb65975 100644 --- a/src/hooks/useApi.js +++ b/src/hooks/useApi.js @@ -4,7 +4,14 @@ * Base URL: https://crdc-api.civilytics.org/api/v1 */ +// The git-pages static host serves from pages.civilytics.org/crdc-demo/ but the CRDC API +// at crdc-api.civilytics.org does not send CORS headers. To work around this, we proxy +// requests through a simple serverless function deployed alongside the app. +// If no proxy is available (VITE_PROXY_URL unset), fall back to direct fetch — works when +// served from same origin or via Docker with nginx reverse proxy. + const BASE_URL = import.meta.env.VITE_API_BASE || 'https://crdc-api.civilytics.org/api/v1' +const PROXY_URL = import.meta.env.VITE_PROXY_URL // e.g., https://pages.civilytics.org/crdc-demo/proxy/ // Student group labels matching the API enum (race=AM|BL|HI|WH; sex=F|M) export const STUDENT_GROUPS = [ @@ -31,12 +38,12 @@ export const MODEL_IDS = [ 'stratified_m1_mod', 'stratified_m2_mod', 'stratified_m3_mod', 'stratified_m4_mod', 'stratified_m5_mod', ] -// The "four quadrants" for distribution charts (Chart 4–6) +// The "four quadrants" for distribution charts (Charts 4–6) export const MODEL_QUADRANTS = [ - { model: 'unified_m1_mod', label: 'One-year, no covariate', row: 0, col: 0 }, - { model: 'unified_m2_mod', label: 'One-year + referral rate', row: 1, col: 0 }, - { model: 'unified_m3_mod', label: 'Three-year, no covariate', row: 0, col: 1 }, - { model: 'unified_m4_mod', label: 'Three-year + referral rate', row: 1, col: 1 }, + { model: 'unified_m1_mod', label: 'One-year, no covariate' }, + { model: 'unified_m2_mod', label: 'One-year + referral rate' }, + { model: 'unified_m3_mod', label: 'Three-year, no covariate' }, + { model: 'unified_m4_mod', label: 'Three-year + referral rate' }, ] // Default/recommended model (from validate.R) @@ -44,7 +51,10 @@ export const DEFAULT_MODEL = 'unified_m2_mod' /** Exponential backoff fetch wrapper */ async function apiFetch(path, { retries = 3, delay = 500 } = {}) { - const url = `${BASE_URL}${path}` + // If a proxy URL is configured, route through it to bypass CORS restrictions. + // The proxy simply forwards the request and adds Access-Control-Allow-Origin: *. + const url = PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(path)}` : `${BASE_URL}${path}` + let lastError for (let attempt = 0; attempt <= retries; attempt++) { @@ -53,8 +63,11 @@ async function apiFetch(path, { retries = 3, delay = 500 } = {}) { if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`) /** @type {{status:string,data:any,error?:string,meta:object}} */ - const envelope = await res.json() - if (envelope.status === 'success') return envelope.data + const envelope = PROXY_URL ? await res.json() : (await res.json()) + // When using a proxy that returns the raw API response, unwrap it. + if (PROXY_URL && envelope.status) return envelope.data + if (!PROXY_URL && envelope.status === 'success') return envelope.data + throw new Error(envelope.error || 'Unknown API error') } catch (err) { lastError = err