trails/apps/planner/app/routes/api.pois.ts
Ullrich Schäfer b45e69885d
poi-index: map-core selectors, planner.pois schema, /api/pois route + client, metrics
Replace the planner's Overpass proxy with a self-hosted POI index:
- map-core POI categories become structured tag selectors (single source of
  truth) + osmium filter / classification helpers
- planner.pois PostGIS table (centroid points, GiST + category indexes)
- /api/pois serving route (session + same-origin + rate limit, 100 cap)
- lib/pois.ts client (renamed from overpass.ts; GET, quantized bbox)
- metrics: poi_api_requests_total + DB-collected index rows/age gauges;
  drop overpass_* metrics
- remove /api/overpass proxy + dead OVERPASS_URLS on the planner service
  (Journal surface backfill still uses it via code default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:38:03 +02:00

140 lines
5 KiB
TypeScript

import { and, inArray, sql } from "drizzle-orm";
import { poiCategories } from "@trails-cool/map-core";
import { pois } from "@trails-cool/db/schema/planner";
import type { Route } from "./+types/api.pois";
import { getDb } from "~/lib/db.ts";
import { checkRateLimit } from "~/lib/rate-limit.ts";
import { requireSession } from "~/lib/require-session.ts";
import { poiApiRequests } from "~/lib/metrics.server.ts";
// Same per-IP budget the Overpass proxy enforced — it now protects our own
// database instead of a third-party upstream.
const RATE_LIMIT = { maxRequests: 120, windowMs: 60 * 1000 };
// Matches the former Overpass `out ... 100` cap so the client renders the
// same volume of markers per viewport.
const RESULT_CAP = 100;
const KNOWN_CATEGORY_IDS = new Set(poiCategories.map((c) => c.id));
interface PoiResult {
id: number;
lat: number;
lon: number;
name?: string;
category: string;
tags: Record<string, string>;
}
function bad(status: number, message: string, metric: string): Response {
poiApiRequests.inc({ status: metric });
return new Response(message, { status });
}
/**
* Parse `south,west,north,east` into a validated bbox. Returns null when the
* value is missing, malformed, out of WGS84 range, or has a non-positive
* extent.
*/
function parseBbox(raw: string | null) {
if (!raw) return null;
const parts = raw.split(",").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
const [south, west, north, east] = parts as [number, number, number, number];
if (south < -90 || north > 90 || west < -180 || east > 180) return null;
if (south >= north || west >= east) return null;
return { south, west, north, east };
}
export async function loader({ request }: Route.LoaderArgs) {
// Same-origin defense in depth. A cross-origin browser fetch always carries
// an Origin header (even on GET); a same-origin GET may omit it. So reject
// only on a *mismatched* Origin, and lean on the session gate below as the
// primary control.
const requestUrl = new URL(request.url);
const origin = request.headers.get("origin");
if (origin) {
const host = request.headers.get("x-forwarded-host") ?? requestUrl.host;
const proto =
request.headers.get("x-forwarded-proto") ?? requestUrl.protocol.replace(":", "");
if (origin !== `${proto}://${host}`) {
return bad(403, "Forbidden", "forbidden");
}
}
// Session-bind: every request must present a live planner session so the
// endpoint isn't anonymously reachable on our origin.
const session = await requireSession(request.headers.get("x-trails-session"));
if (session instanceof Response) {
poiApiRequests.inc({ status: "unauthorized" });
return session;
}
// Rate limit BEFORE touching the database — rejected requests never query.
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const limit = checkRateLimit(`pois:${ip}`, RATE_LIMIT);
if (!limit.allowed) {
poiApiRequests.inc({ status: "rate_limited" });
return new Response("Rate limit exceeded", {
status: 429,
headers: { "Retry-After": String(limit.retryAfterSeconds ?? 60) },
});
}
const bbox = parseBbox(requestUrl.searchParams.get("bbox"));
if (!bbox) return bad(400, "Invalid bbox", "bad_request");
const requested = (requestUrl.searchParams.get("categories") ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (requested.length === 0 || requested.some((c) => !KNOWN_CATEGORY_IDS.has(c))) {
return bad(400, "Invalid categories", "bad_request");
}
try {
const rows = await getDb()
.select({
osmId: pois.osmId,
category: pois.category,
name: pois.name,
tags: pois.tags,
lat: sql<number>`ST_Y(${pois.geom})`,
lon: sql<number>`ST_X(${pois.geom})`,
})
.from(pois)
.where(
and(
inArray(pois.category, requested),
sql`${pois.geom} && ST_MakeEnvelope(${bbox.west}, ${bbox.south}, ${bbox.east}, ${bbox.north}, 4326)`,
),
)
.limit(RESULT_CAP);
const result: PoiResult[] = rows.map((r) => ({
id: Number(r.osmId),
lat: r.lat,
lon: r.lon,
name: r.name ?? undefined,
category: r.category,
tags: r.tags ?? {},
}));
poiApiRequests.inc({ status: "ok" });
return new Response(JSON.stringify({ pois: result }), {
status: 200,
headers: {
"Content-Type": "application/json",
// Short private cache: repeated identical viewports (pan back, etc.)
// are served from the browser cache without re-querying.
"Cache-Control": "private, max-age=60",
},
});
} catch (err) {
// Missing table (fresh self-hosted instance) or DB outage: degrade
// gracefully so tile overlays keep working and the map doesn't break.
console.error("[api.pois] query failed:", err instanceof Error ? err.message : err);
poiApiRequests.inc({ status: "error" });
return new Response("POI service unavailable", { status: 503 });
}
}