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: [