From a2581de599c7cf975082799ed363a8b57055d7c4 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Sat, 23 May 2026 22:52:57 +0300 Subject: [PATCH] fix(geoportal-v2): proxy OpenFreeMap planet TileJSON to bypass origin block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause traced today: tiles.openfreemap.org/planet (the openmaptiles source's TileJSON ref inside the liberty + dark styles) returns 403 to ANY request that carries a browser Origin header. Cloudflare hot-link rule, presumably; bare curl (no Origin) gets 200 fine. Verified live with `curl -H 'Origin: https://tools.beletage.ro' …/planet` → 403, and with Playwright loading a minimal MapLibre test against openfreemap → "CORS policy: No 'Access-Control-Allow-Origin' header is present". Effect on the V2 panel: MapLibre fetches the liberty style fine, but the openmaptiles vector source is defined as { url: ".../planet" } and relies on a follow-up TileJSON fetch to learn the actual versioned tile URL (e.g. /planet/20260513_001001_pt/{z}/{x}/{y}.pbf). That fetch dies in the browser. No labels, no roads, no buildings — only the natural_earth raster background renders and the page looks like an empty cream sheet plus our PMTiles UAT outlines. That's exactly the "harta nu se mai vede bine" complaint. Other openfreemap endpoints (the style itself, sprites, glyphs, the individual versioned tile PBFs) all work fine with an Origin header — only /planet is blocked, so we only need to bypass that one. Fix: GET /api/basemap-style/[id] fetches the style + every source defined with `url:` server-side (no Origin → 200), inlines the resolved `tiles[]`/zoom range into the source, and returns a self-contained style. Browser only ever talks to tile endpoints directly afterwards, which work. Liberty + dark basemaps in the V2 map-viewer now route through this proxy. Cache-Control: 1h public + 1d SWR so we pick up new openfreemap versions promptly without hammering on every map load. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/basemap-style/[id]/route.ts | 124 ++++++++++++++++++++++++ src/modules/geoportal/v2/map-viewer.tsx | 11 ++- 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/app/api/basemap-style/[id]/route.ts diff --git a/src/app/api/basemap-style/[id]/route.ts b/src/app/api/basemap-style/[id]/route.ts new file mode 100644 index 0000000..6b6053d --- /dev/null +++ b/src/app/api/basemap-style/[id]/route.ts @@ -0,0 +1,124 @@ +// GET /api/basemap-style/:id +// +// Server-side proxy for OpenFreeMap styles that resolves any `url:`-based +// source TileJSON inline. Background: +// +// OpenFreeMap's https://tiles.openfreemap.org/styles/ works fine, but +// the openmaptiles source inside it is defined as +// "openmaptiles": { "type": "vector", "url": "https://tiles.openfreemap.org/planet" } +// That `/planet` TileJSON endpoint returns 403 to ANY request carrying an +// `Origin` header (i.e., every browser request) — Cloudflare hot-link +// rule. Result: MapLibre can't resolve the planet tile URL → no labels, +// no roads, no buildings. Only the natural_earth raster background and +// our PMTiles overlay render, which is exactly the empty cream-coloured +// map users started reporting on 2026-05-23. +// +// We bypass the browser-side /planet fetch by doing it ourselves (no +// Origin header sent → 200) and inlining the resolved `tiles` array into +// the source. The resulting style is browser-loadable without any +// further /planet round-trip. +// +// We cache the resolved style for 1h via Cache-Control. OpenFreeMap +// versions their tile path by datestamp (e.g. `20260513_001001_pt`); the +// 1h TTL is short enough to pick up new versions promptly and long enough +// to avoid hammering openfreemap on every map load. + +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const ALLOWED_STYLES = new Set(["liberty", "dark", "positron", "bright"]); +const OFM_BASE = "https://tiles.openfreemap.org"; + +type StyleSource = { + type: string; + url?: string; + tiles?: string[]; + maxzoom?: number; + minzoom?: number; + tileSize?: number; + attribution?: string; + [k: string]: unknown; +}; + +type TileJson = { + tiles?: string[]; + minzoom?: number; + maxzoom?: number; + attribution?: string; +}; + +type StyleSpec = { + version: number; + sources: Record; + [k: string]: unknown; +}; + +async function fetchJson(url: string): Promise { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + throw new Error(`upstream ${res.status} for ${url}`); + } + return (await res.json()) as T; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + if (!ALLOWED_STYLES.has(id)) { + return NextResponse.json({ error: "unknown_style" }, { status: 404 }); + } + + try { + const style = await fetchJson(`${OFM_BASE}/styles/${id}`); + + // Resolve every source defined as { url: "..." } into an explicit + // tiles[] + zoom range so the browser never has to fetch the + // origin-blocked TileJSON itself. + const resolved: Record = {}; + for (const [name, source] of Object.entries(style.sources)) { + if (source.url && !source.tiles) { + try { + const tj = await fetchJson(source.url); + const { url: _drop, ...rest } = source; + resolved[name] = { + ...rest, + tiles: tj.tiles ?? [], + ...(tj.minzoom !== undefined ? { minzoom: tj.minzoom } : {}), + ...(tj.maxzoom !== undefined ? { maxzoom: tj.maxzoom } : {}), + ...(tj.attribution && !rest.attribution + ? { attribution: tj.attribution } + : {}), + }; + } catch (err) { + console.warn( + `[basemap-style] failed to resolve source "${name}" (${source.url}):`, + err instanceof Error ? err.message : err, + ); + resolved[name] = source; + } + } else { + resolved[name] = source; + } + } + + return NextResponse.json( + { ...style, sources: resolved }, + { + headers: { + "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", + }, + }, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[basemap-style]", id, "failed:", msg); + return NextResponse.json( + { error: "upstream_failed", hint: msg.slice(0, 200) }, + { status: 502 }, + ); + } +} diff --git a/src/modules/geoportal/v2/map-viewer.tsx b/src/modules/geoportal/v2/map-viewer.tsx index 5a247b4..3dbb6bb 100644 --- a/src/modules/geoportal/v2/map-viewer.tsx +++ b/src/modules/geoportal/v2/map-viewer.tsx @@ -43,8 +43,15 @@ type BasemapDef = | { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number }; const BASEMAPS: Record = { - liberty: { type: "style", url: "https://tiles.openfreemap.org/styles/liberty" }, - dark: { type: "style", url: "https://tiles.openfreemap.org/styles/dark" }, + // OpenFreeMap styles routed through our own /api/basemap-style/* proxy + // because tiles.openfreemap.org/planet (the openmaptiles source's + // TileJSON) returns 403 to any request carrying a browser Origin + // header. Without the proxy, MapLibre can't resolve the planet tile + // URL and the map ends up cream-blank with only the natural_earth + // raster + our PMTiles overlay. The proxy fetches /planet server- + // side (no Origin → 200) and inlines the resolved `tiles` array. + liberty: { type: "style", url: "/api/basemap-style/liberty" }, + dark: { type: "style", url: "/api/basemap-style/dark" }, satellite: { type: "raster", tiles: [