From 393cc5f90183e5dfe64f17b785790bc6860efeeb Mon Sep 17 00:00:00 2001 From: kew <108450560+kewonit@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:05:00 +0530 Subject: [PATCH] fix: call OpenSky API directly from browser to bypass Vercel IP blocks (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: switch flights API to edge runtime, lower timeouts - Switch to Edge Runtime (30s timeout on ALL Vercel plans, near-zero cold starts) - Lower FETCH_TIMEOUT_MS from 20s to 8s (prevents Vercel killing the function) - Lower TOKEN_TIMEOUT_MS from 5s to 3s - Replace Buffer.from() with btoa() for edge compatibility - Remove maxDuration (not needed for edge functions) Fixes 502 Bad Gateway on Vercel deployments caused by Hobby plan's 10s function timeout being exceeded by the 20s fetch timeout. * fix: revert edge runtime, keep reduced timeouts Edge runtime caused 500 on Vercel. Reverted to Node.js serverless with reduced timeouts only: - FETCH_TIMEOUT_MS: 20s -> 8s - TOKEN_TIMEOUT_MS: 5s -> 3s - Restored Buffer.from() for basic auth * fix: handle DOMException unavailability in Vercel runtime - Fix TypeError: 'Right-hand side of instanceof is not an object' caused by DOMException not existing in Vercel's server runtime - Use safe abort detection: check err.name === 'AbortError' on Error first, then conditionally check DOMException if available - Restore TOKEN_TIMEOUT_MS to 5s (3s was too aggressive from Vercel) - Keep FETCH_TIMEOUT_MS at 8s (down from original 20s) * fix: deploy to Frankfurt, increase timeouts, add retry logic - Set preferredRegion to 'fra1' (Frankfurt) — closest to OpenSky EU servers - Increase FETCH_TIMEOUT_MS to 15s and TOKEN_TIMEOUT_MS to 8s (OpenSky is slow from cloud IPs, needs more time) - Add retry logic: 1 retry with 500ms delay before giving up - Keep safe DOMException handling for Vercel runtime compat * feat: add Railway proxy for OpenSky API, proxy mode for Vercel route OpenSky blocks/throttles requests from Vercel's IP ranges. This adds: 1. **Railway proxy** (`proxy/`) — standalone Node.js server that handles OpenSky API calls with auth, caching, and CORS. Zero dependencies. 2. **Proxy mode in Vercel route** — when OPENSKY_PROXY_URL env var is set, the Vercel /api/flights route forwards requests to the Railway proxy instead of calling OpenSky directly. Setup: - Deploy proxy/ to Railway with OPENSKY credentials - Set OPENSKY_PROXY_URL in Vercel to the Railway URL - Remove OPENSKY credentials from Vercel (only needed on Railway) * refactor: call OpenSky directly from browser, remove server-side proxy OpenSky supports CORS (Access-Control-Allow-Origin: *), so we can call the API directly from the user's browser. This bypasses Vercel's cloud-provider IPs that OpenSky blocks. Changes: - opensky.ts: fetch from opensky-network.org/api directly (was /api/flights) - use-flights.ts: fix DOMException abort detection - Remove src/app/api/flights/route.ts (server-side proxy) - Remove proxy/ directory (Railway proxy) The app is now fully static — no server-side API routes needed. * chore: remove server-side API route (now browser-side) * chore: remove Railway proxy (no longer needed) * chore: remove proxy package.json * chore: remove proxy README * polish: production-grade cleanup, security hardening, remove redundant comments - Remove redundant JSDoc blocks in opensky.ts, keep only @see link - Add bounds-clamping for lat/lng parameters (defense-in-depth) - Fix memory leak: map.on("movestart") listener now cleaned up on unmount - Validate GA_ID format before interpolating into script tag (XSS defense) - Remove duplicate canonical tag (already set via metadata.alternates) - Sanitize JSON-LD output with \u003c escaping to prevent injection - Use useRef instead of useState for mutable TrailStore class instance - Fix unused useState import in use-trail-history - Add Map.displayName for React DevTools - Fix Tailwind lint: px-[2px] → px-0.5 - Remove unused OPENSKY credentials from .env.example * chore: push remaining polished files (flight-tracker, use-flights, map) - flight-tracker: movestart listener cleanup on unmount - use-flights: clean up redundant comments, fix abort detection - map: add displayName, remove redundant comment prefix * chore: polish control-panel (clean comments, Tailwind lint fix) --- .env.example | 19 +- src/app/api/flights/route.ts | 307 ---------------------------- src/app/layout.tsx | 3 +- src/app/page.tsx | 4 +- src/components/flight-tracker.tsx | 6 +- src/components/map/map.tsx | 3 +- src/components/ui/control-panel.tsx | 12 +- src/hooks/use-flights.ts | 18 +- src/hooks/use-trail-history.ts | 9 +- src/lib/opensky.ts | 81 +++++--- 10 files changed, 88 insertions(+), 374 deletions(-) delete mode 100644 src/app/api/flights/route.ts diff --git a/.env.example b/.env.example index 6c0be13..a895ad4 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,9 @@ # Environment Variables # Copy this file to .env.local and fill in your values. -# ─── OpenSky Network API ────────────────────────────────────────────────────── -# -# OPTION 1 (Recommended): OAuth2 Client Credentials -# For accounts created since mid-March 2025. -# Go to https://opensky-network.org → Account → Create API Client -OPENSKY_CLIENT_ID= -OPENSKY_CLIENT_SECRET= +# --- Analytics (optional) --- +# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX -# OPTION 2: Basic Auth (Legacy accounts only) -# Deprecated — will be removed. Only works for accounts created before March 2025. -# OPENSKY_USERNAME= -# OPENSKY_PASSWORD= - -# ─── Analytics (optional) ───────────────────────────────────────────────────── -# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX \ No newline at end of file +# --- Notes --- +# OpenSky API calls are made directly from the browser (CORS supported). +# No server-side credentials are needed. diff --git a/src/app/api/flights/route.ts b/src/app/api/flights/route.ts deleted file mode 100644 index af92896..0000000 --- a/src/app/api/flights/route.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export const maxDuration = 30; - -const OPENSKY_BASE = "https://opensky-network.org/api"; -const OPENSKY_TOKEN_URL = - "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"; -const TOKEN_TIMEOUT_MS = 5_000; -const FETCH_TIMEOUT_MS = 20_000; -const CACHE_TTL_MS = 25_000; -const MAX_REQUESTS_PER_MINUTE = 20; -const MAX_BBOX_SPAN = 20; -const CACHE_GRID_STEP = 0.5; - -let cachedToken: string | null = null; -let tokenExpiresAt = 0; - -async function getAccessToken(): Promise { - const clientId = process.env.OPENSKY_CLIENT_ID; - const clientSecret = process.env.OPENSKY_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; - - if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken; - - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS); - const res = await fetch(OPENSKY_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "client_credentials", - client_id: clientId, - client_secret: clientSecret, - }), - cache: "no-store", - signal: controller.signal, - }).finally(() => clearTimeout(timer)); - - if (!res.ok) { - console.error(`[aeris] Token request failed: ${res.status}`); - cachedToken = null; - return null; - } - - const data = await res.json(); - cachedToken = data.access_token; - tokenExpiresAt = Date.now() + (data.expires_in ?? 1800) * 1000; - return cachedToken; - } catch (err) { - console.error( - "[aeris] Token error:", - err instanceof Error ? err.message : err, - ); - cachedToken = null; - return null; - } -} - -type AuthMode = "oauth2" | "basic" | "anonymous"; -let authDisabled = false; -let authLoggedOnce = false; - -function detectAuthMode(): AuthMode { - if (authDisabled) return "anonymous"; - if (process.env.OPENSKY_CLIENT_ID && process.env.OPENSKY_CLIENT_SECRET) - return "oauth2"; - if (process.env.OPENSKY_USERNAME && process.env.OPENSKY_PASSWORD) - return "basic"; - return "anonymous"; -} - -async function buildAuthHeaders(): Promise { - const mode = detectAuthMode(); - - if (mode === "oauth2") { - const token = await getAccessToken(); - if (token) return { Authorization: `Bearer ${token}` }; - return {}; - } - - if (mode === "basic") { - const user = process.env.OPENSKY_USERNAME!; - const pass = process.env.OPENSKY_PASSWORD!; - return { - Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`, - }; - } - - return {}; -} - -function logAuthOnce() { - if (authLoggedOnce) return; - authLoggedOnce = true; - console.info(`[aeris] Auth mode: ${detectAuthMode()}`); -} - -const requestLog = new Map(); - -function isRateLimited(ip: string): boolean { - const now = Date.now(); - const window = 60_000; - const timestamps = requestLog.get(ip) ?? []; - const recent = timestamps.filter((t) => now - t < window); - recent.push(now); - requestLog.set(ip, recent); - - if (requestLog.size > 500) { - for (const [key, val] of requestLog) { - if (val.every((t) => now - t > window)) requestLog.delete(key); - } - } - - return recent.length > MAX_REQUESTS_PER_MINUTE; -} - -let responseCache: { - key: string; - data: unknown; - expiresAt: number; -} | null = null; - -function getCached(key: string): unknown | null { - if ( - responseCache && - responseCache.key === key && - Date.now() < responseCache.expiresAt - ) { - return responseCache.data; - } - return null; -} - -function setCache(key: string, data: unknown): void { - responseCache = { key, data, expiresAt: Date.now() + CACHE_TTL_MS }; -} - -async function fetchOpenSky( - url: string, - useAuth: boolean, -): Promise { - const headers = useAuth ? await buildAuthHeaders() : {}; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - return await fetch(url, { - headers, - cache: "no-store", - signal: controller.signal, - }); - } finally { - clearTimeout(timer); - } -} - -function clamp(val: number, min: number, max: number) { - return Math.max(min, Math.min(max, val)); -} - -function json( - body: unknown, - status: number, - extra?: Record, -) { - return NextResponse.json(body, { - status, - headers: { "Cache-Control": "no-store", ...extra }, - }); -} - -export async function GET(request: NextRequest) { - const ip = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? - request.headers.get("x-real-ip") ?? - "unknown"; - - if (isRateLimited(ip)) { - return json({ time: 0, states: null, rateLimited: true }, 200); - } - - const { searchParams } = request.nextUrl; - const lamin = searchParams.get("lamin"); - const lamax = searchParams.get("lamax"); - const lomin = searchParams.get("lomin"); - const lomax = searchParams.get("lomax"); - - if (!lamin || !lamax || !lomin || !lomax) { - return json({ error: "Missing required bbox parameters" }, 400); - } - - const raw = { lamin: +lamin, lamax: +lamax, lomin: +lomin, lomax: +lomax }; - for (const [key, val] of Object.entries(raw)) { - if (Number.isNaN(val)) { - return json({ error: `Invalid parameter: ${key}` }, 400); - } - } - - const coords = { - lamin: clamp(raw.lamin, -90, 90), - lamax: clamp(raw.lamax, -90, 90), - lomin: clamp(raw.lomin, -180, 180), - lomax: clamp(raw.lomax, -180, 180), - }; - - if ( - Math.abs(coords.lamax - coords.lamin) > MAX_BBOX_SPAN || - Math.abs(coords.lomax - coords.lomin) > MAX_BBOX_SPAN - ) { - return json( - { error: `Bounding box too large (max ${MAX_BBOX_SPAN}° per axis)` }, - 400, - ); - } - - logAuthOnce(); - - // Snap bbox to grid so nearby viewports share cache entries - const snap = (v: number) => - Math.round(v / CACHE_GRID_STEP) * CACHE_GRID_STEP; - const snapped = { - lamin: snap(coords.lamin), - lamax: snap(coords.lamax), - lomin: snap(coords.lomin), - lomax: snap(coords.lomax), - }; - - const url = `${OPENSKY_BASE}/states/all?lamin=${snapped.lamin}&lamax=${snapped.lamax}&lomin=${snapped.lomin}&lomax=${snapped.lomax}`; - const cacheKey = `${snapped.lamin},${snapped.lamax},${snapped.lomin},${snapped.lomax}`; - - const cached = getCached(cacheKey); - if (cached) { - return json(cached, 200, { "X-Cache": "HIT" }); - } - - const useAuth = detectAuthMode() !== "anonymous"; - - try { - let res = await fetchOpenSky(url, useAuth); - - if (res.status === 401 && useAuth) { - cachedToken = null; - tokenExpiresAt = 0; - authDisabled = true; - console.warn("[aeris] Auth rejected (401), falling back to anonymous"); - res = await fetchOpenSky(url, false); - } - - if (res.status === 429) { - const retryAfter = res.headers.get( - "X-Rate-Limit-Retry-After-Seconds", - ); - return json( - { - time: 0, - states: null, - rateLimited: true, - retryAfter: retryAfter ? parseInt(retryAfter, 10) : null, - }, - 200, - ); - } - - if (!res.ok) { - const body = await res.text().catch(() => ""); - console.error(`[aeris] OpenSky ${res.status}: ${body.slice(0, 300)}`); - return json( - { error: "Upstream data source error", status: res.status }, - 502, - ); - } - - const creditsRaw = res.headers.get("X-Rate-Limit-Remaining"); - const creditsRemaining = - creditsRaw !== null ? parseInt(creditsRaw, 10) : null; - - let data; - try { - data = await res.json(); - } catch { - console.error("[aeris] OpenSky returned non-JSON response"); - return json({ error: "Upstream returned invalid response" }, 502); - } - - if (creditsRemaining !== null && !Number.isNaN(creditsRemaining)) { - data.creditsRemaining = creditsRemaining; - } - - setCache(cacheKey, data); - return json(data, 200, { "X-Cache": "MISS" }); - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") { - console.error(`[aeris] OpenSky timed out (${FETCH_TIMEOUT_MS}ms)`); - return json( - { error: "Upstream request timed out", timeout: true }, - 504, - ); - } - - const msg = err instanceof Error ? err.message : String(err); - console.error(`[aeris] Proxy error: ${msg}`); - return json( - { error: "Failed to fetch flight data", detail: msg }, - 502, - ); - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 93071d0..9eff7f6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -61,8 +61,7 @@ export default function RootLayout({ return ( - - {GA_ID && ( + {GA_ID && /^G-[A-Z0-9]+$/.test(GA_ID) && ( <>