diff --git a/apps/planner/app/lib/overpass.ts b/apps/planner/app/lib/overpass.ts index e106811..6a14fbd 100644 --- a/apps/planner/app/lib/overpass.ts +++ b/apps/planner/app/lib/overpass.ts @@ -1,9 +1,6 @@ import type { PoiCategory } from "@trails-cool/map-core"; -const OVERPASS_ENDPOINTS = [ - "https://overpass.private.coffee/api/interpreter", - "https://overpass-api.de/api/interpreter", -]; +const OVERPASS_PROXY = "/api/overpass"; export interface Poi { id: number; @@ -97,6 +94,10 @@ export function deduplicateById(pois: Poi[]): Poi[] { /** * Query the Overpass API for POIs within a bounding box. + * + * All queries route through the Planner's own `/api/overpass` proxy, which + * adds a User-Agent identifying trails.cool, rate-limits per IP, and enforces + * same-origin. The client never talks to a public Overpass host directly. */ export async function queryPois( bbox: BBox, @@ -107,30 +108,7 @@ export async function queryPois( const query = buildQuery(bbox, categories); - for (const endpoint of OVERPASS_ENDPOINTS) { - try { - return await fetchFromEndpoint(endpoint, query, categories, signal); - } catch (err) { - // If aborted, don't try fallback - if (signal?.aborted) throw err; - // If rate limited on all endpoints, throw - if (err instanceof OverpassRateLimitError && endpoint === OVERPASS_ENDPOINTS[OVERPASS_ENDPOINTS.length - 1]) throw err; - // Try next endpoint - if (endpoint !== OVERPASS_ENDPOINTS[OVERPASS_ENDPOINTS.length - 1]) continue; - throw err; - } - } - - return []; -} - -async function fetchFromEndpoint( - endpoint: string, - query: string, - categories: PoiCategory[], - signal?: AbortSignal, -): Promise { - const response = await fetch(endpoint, { + const response = await fetch(OVERPASS_PROXY, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `data=${encodeURIComponent(query)}`, diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts index 1abbf77..bfead19 100644 --- a/apps/planner/app/routes.ts +++ b/apps/planner/app/routes.ts @@ -5,5 +5,6 @@ export default [ route("new", "routes/new.tsx"), route("api/sessions", "routes/api.sessions.ts"), route("api/route", "routes/api.route.ts"), + route("api/overpass", "routes/api.overpass.ts"), route("session/:id", "routes/session.$id.tsx"), ] satisfies RouteConfig; diff --git a/apps/planner/app/routes/api.overpass.ts b/apps/planner/app/routes/api.overpass.ts new file mode 100644 index 0000000..e1b90ea --- /dev/null +++ b/apps/planner/app/routes/api.overpass.ts @@ -0,0 +1,114 @@ +import type { Route } from "./+types/api.overpass"; +import { checkRateLimit } from "~/lib/rate-limit"; + +const UPSTREAM_URL = process.env.OVERPASS_URL ?? "https://overpass.private.coffee/api/interpreter"; +const USER_AGENT = "trails.cool Planner (https://trails.cool; legal@trails.cool)"; + +const CACHE_TTL_MS = 10 * 60 * 1000; +const CACHE_MAX_ENTRIES = 200; + +interface CacheEntry { + body: string; + contentType: string; + expiresAt: number; +} + +const cache = new Map(); +const inFlight = new Map>(); + +function getFromCache(key: string): CacheEntry | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + // LRU touch: move to end so oldest-first eviction works + cache.delete(key); + cache.set(key, entry); + return entry; +} + +function putInCache(key: string, body: string, contentType: string) { + while (cache.size >= CACHE_MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } + cache.set(key, { body, contentType, expiresAt: Date.now() + CACHE_TTL_MS }); +} + +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const origin = request.headers.get("origin"); + const requestUrl = new URL(request.url); + const expectedOrigin = `${requestUrl.protocol}//${requestUrl.host}`; + if (!origin || origin !== expectedOrigin) { + return new Response("Forbidden", { status: 403 }); + } + + const body = await request.text(); + const cacheKey = body; + + // Cache hit: skip rate limit and upstream entirely + const cached = getFromCache(cacheKey); + if (cached) { + return new Response(cached.body, { + status: 200, + headers: { "Content-Type": cached.contentType, "X-Cache": "hit" }, + }); + } + + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const limit = checkRateLimit(`overpass:${ip}`, { maxRequests: 120, windowMs: 60 * 1000 }); + if (!limit.allowed) { + return new Response("Rate limit exceeded", { + status: 429, + headers: { "Retry-After": String(limit.retryAfterSeconds ?? 60) }, + }); + } + + // Coalesce concurrent misses for the same key so multiple clients in the + // same session don't each hit upstream for the identical bbox. + let pending = inFlight.get(cacheKey); + if (!pending) { + pending = (async () => { + try { + const upstream = await fetch(UPSTREAM_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + }, + body, + }); + const responseBody = await upstream.text(); + const contentType = upstream.headers.get("content-type") ?? "application/json"; + const cacheable = upstream.status === 200 && !responseBody.includes("rate_limited"); + return { body: responseBody, contentType, status: upstream.status, cacheable }; + } finally { + inFlight.delete(cacheKey); + } + })(); + inFlight.set(cacheKey, pending); + } + + let result; + try { + result = await pending; + } catch { + return new Response("Upstream Overpass unavailable", { status: 502 }); + } + + if (result.cacheable) { + putInCache(cacheKey, result.body, result.contentType); + } + + return new Response(result.body, { + status: result.status, + headers: { "Content-Type": result.contentType, "X-Cache": "miss" }, + }); +} diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index b9e8055..0258461 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -48,6 +48,7 @@ services: restart: unless-stopped environment: BROUTER_URL: http://brouter:17777 + OVERPASS_URL: https://overpass.private.coffee/api/interpreter DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails NODE_ENV: production PORT: 3001