Fix CORS: add proxy support for browser-based API calls on git-pages
Deploy to git-pages / deploy (push) Successful in 20s
Deploy to git-pages / deploy (push) Successful in 20s
- 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.
This commit is contained in:
@@ -32,6 +32,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
# VITE_API_BASE defaults to the production API; override if needed via secrets.
|
# 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
|
npm run build
|
||||||
|
|
||||||
- name: Publish dist/ to pages branch
|
- name: Publish dist/ to pages branch
|
||||||
|
|||||||
+8
-3
@@ -7,15 +7,20 @@ COPY package*.json ./
|
|||||||
RUN npm ci --prefer-offline
|
RUN npm ci --prefer-offline
|
||||||
|
|
||||||
COPY . .
|
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
|
ARG VITE_API_BASE=https://crdc-api.civilytics.org/api/v1
|
||||||
ENV VITE_API_BASE=${VITE_API_BASE}
|
ENV VITE_API_BASE=${VITE_API_BASE}
|
||||||
|
ARG VITE_PROXY_URL=""
|
||||||
|
ENV VITE_PROXY_URL=${VITE_PROXY_URL}
|
||||||
RUN npm run build
|
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
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
|
# Copy static site files
|
||||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
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
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ git-pages webhook receiver.
|
|||||||
|
|
||||||
**URL**: `https://pages.civilytics.org/crdc-demo/`
|
**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`:
|
The workflow follows the same pattern as `Civilytics/sln-school-comparison`:
|
||||||
1. Triggers on push to `main` (only when source files change)
|
1. Triggers on push to `main` (only when source files change)
|
||||||
2. Builds with Node 22 + Vite (`npm ci && npm run build`)
|
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.
|
> **Note**: Ensure `${{ secrets.GITHUB_TOKEN }}` is configured in repo settings on gitea.civilytics.org.
|
||||||
|
|
||||||
### Option B: Docker (self-hosted fleet)
|
### Option A.5: CORS Proxy for Git Pages
|
||||||
Build and run the nginx container on your infrastructure (`efron`, `maxwell`, etc.):
|
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
|
```bash
|
||||||
docker compose up -d # builds + serves on localhost:8080
|
docker compose up -d # builds + serves on localhost:8080
|
||||||
|
|||||||
+37
-8
@@ -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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
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
|
# 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;
|
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)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2|ttf)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
try_files $uri =404;
|
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
|
# Health check endpoint for container orchestration
|
||||||
location /healthz {
|
location /healthz {
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Simple CORS proxy for CRDC Arrest Rate API.
|
||||||
|
*
|
||||||
|
* Deploy this file to your git-pages host alongside the static site (e.g., at /crdc-demo/proxy.php).
|
||||||
|
* Then set VITE_PROXY_URL=/crdc-demo/proxy.php in your build environment.
|
||||||
|
*
|
||||||
|
* Usage: GET /proxy.php?target=/api/v1/districts?q=Denver&state=CO
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Allow cross-origin requests from any origin
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: *');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate the target path from query string
|
||||||
|
$target = isset($_GET['target']) ? $_GET['target'] : '';
|
||||||
|
if (empty($target)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => '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;
|
||||||
@@ -15,13 +15,19 @@ export default function ChartPanel({ district, state }) {
|
|||||||
const [nationalRates, setNationalRates] = useState(null)
|
const [nationalRates, setNationalRates] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
// National rates from static fixture (or cache from loading step)
|
// National rates from static fixture (or cache from loading step)
|
||||||
let nat = window.__NATIONAL_RATES__
|
let nat = window.__NATIONAL_RATES__
|
||||||
if (!nat) {
|
if (!nat) {
|
||||||
const res = await fetch('/data/national_rates.json')
|
const res = await fetch('/crdc-demo/data/national_rates.json')
|
||||||
nat = await res.json()
|
nat = await res.json()
|
||||||
}
|
}
|
||||||
setNationalRates(nat)
|
setNationalRates(nat)
|
||||||
@@ -30,9 +36,7 @@ export default function ChartPanel({ district, state }) {
|
|||||||
const waves = ['21-22', '17-18', '15-16']
|
const waves = ['21-22', '17-18', '15-16']
|
||||||
const waveData = {}
|
const waveData = {}
|
||||||
for (const year of waves) {
|
for (const year of waves) {
|
||||||
const res = await fetch(
|
const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`))
|
||||||
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`
|
|
||||||
)
|
|
||||||
if (res.ok) waveData[year] = (await res.json()).data || []
|
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 quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
|
||||||
const quadData = {}
|
const quadData = {}
|
||||||
for (const model of quadrants) {
|
for (const model of quadrants) {
|
||||||
const res = await fetch(
|
const res = await fetch(apiFetchWithProxy(`/estimates/${district.leaid}?model=${model}&year=21-22`))
|
||||||
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=${model}&year=21-22`
|
|
||||||
)
|
|
||||||
if (res.ok) quadData[model] = (await res.json()).data || []
|
if (res.ok) quadData[model] = (await res.json()).data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') {
|
||||||
const params = new URLSearchParams({ leaid, year, model })
|
const params = new URLSearchParams({ leaid, year, model })
|
||||||
if (race) params.set('race', race)
|
if (race) params.set('race', race)
|
||||||
if (sex) params.set('sex', sex)
|
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}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-8
@@ -4,7 +4,14 @@
|
|||||||
* Base URL: https://crdc-api.civilytics.org/api/v1
|
* 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 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)
|
// Student group labels matching the API enum (race=AM|BL|HI|WH; sex=F|M)
|
||||||
export const STUDENT_GROUPS = [
|
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',
|
'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 = [
|
export const MODEL_QUADRANTS = [
|
||||||
{ model: 'unified_m1_mod', label: 'One-year, no covariate', row: 0, col: 0 },
|
{ model: 'unified_m1_mod', label: 'One-year, no covariate' },
|
||||||
{ model: 'unified_m2_mod', label: 'One-year + referral rate', row: 1, col: 0 },
|
{ model: 'unified_m2_mod', label: 'One-year + referral rate' },
|
||||||
{ model: 'unified_m3_mod', label: 'Three-year, no covariate', row: 0, col: 1 },
|
{ model: 'unified_m3_mod', label: 'Three-year, no covariate' },
|
||||||
{ model: 'unified_m4_mod', label: 'Three-year + referral rate', row: 1, col: 1 },
|
{ model: 'unified_m4_mod', label: 'Three-year + referral rate' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Default/recommended model (from validate.R)
|
// Default/recommended model (from validate.R)
|
||||||
@@ -44,7 +51,10 @@ export const DEFAULT_MODEL = 'unified_m2_mod'
|
|||||||
|
|
||||||
/** Exponential backoff fetch wrapper */
|
/** Exponential backoff fetch wrapper */
|
||||||
async function apiFetch(path, { retries = 3, delay = 500 } = {}) {
|
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
|
let lastError
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
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}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||||
|
|
||||||
/** @type {{status:string,data:any,error?:string,meta:object}} */
|
/** @type {{status:string,data:any,error?:string,meta:object}} */
|
||||||
const envelope = await res.json()
|
const envelope = PROXY_URL ? await res.json() : (await res.json())
|
||||||
if (envelope.status === 'success') return envelope.data
|
// 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')
|
throw new Error(envelope.error || 'Unknown API error')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err
|
lastError = err
|
||||||
|
|||||||
Reference in New Issue
Block a user