From efcfa66c07349f4b5cf28a0fa2c07f9938768cbb Mon Sep 17 00:00:00 2001 From: Claude VM Date: Sun, 24 May 2026 10:43:34 +0300 Subject: [PATCH] fix(geoportal-v2): proxy all openfreemap tiles, not just /planet TileJSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenFreeMap's nginx blocks browser-origin requests on every endpoint — not only /planet (the TileJSON) but the versioned vector tiles too. Verified live: GET tiles.openfreemap.org/planet/20260520_001001_pt/6/36/22.pbf with Origin: https://tools.beletage.ro returns 403 (plain nginx, not Cloudflare). Yesterday's /api/basemap-style proxy fixed the TileJSON resolution, but every subsequent tile fetch still died at openfreemap's edge → empty cream map again. Two pieces here: 1. New /api/basemap-tile/[...path] catch-all that proxies ANY openfreemap resource (tiles, sprite, glyphs). Plain server-side fetch with no Origin header — passes openfreemap's filter — then streams the upstream body back to the browser. Cache-Control aggressive (24h public + 7d SWR + immutable) since openfreemap paths are versioned and never mutate. 2. /api/basemap-style rewrites every tiles.openfreemap.org URL inside the resolved style (tile templates + sprite + glyphs) to point at the proxy prefix above. The browser now never talks to openfreemap directly. Plus the middleware bypass widens from `api/basemap-style` to the prefix `api/basemap-` so both proxy routes load without auth. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/basemap-style/[id]/route.ts | 81 +++++++++++++------ src/app/api/basemap-tile/[...path]/route.ts | 88 +++++++++++++++++++++ src/middleware.ts | 2 +- 3 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 src/app/api/basemap-tile/[...path]/route.ts diff --git a/src/app/api/basemap-style/[id]/route.ts b/src/app/api/basemap-style/[id]/route.ts index 6b6053d..7bee38b 100644 --- a/src/app/api/basemap-style/[id]/route.ts +++ b/src/app/api/basemap-style/[id]/route.ts @@ -1,27 +1,21 @@ // GET /api/basemap-style/:id // -// Server-side proxy for OpenFreeMap styles that resolves any `url:`-based -// source TileJSON inline. Background: +// Server-side proxy for OpenFreeMap styles. Two responsibilities: // -// 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. +// 1. Resolve every `url:`-based source TileJSON inline so the browser +// doesn't have to do a follow-up fetch (some endpoints — e.g. +// tiles.openfreemap.org/planet — return 403 to any request carrying +// a browser `Origin` header). +// 2. Rewrite every tiles.openfreemap.org URL inside the style — tile +// templates, sprite, glyphs — to point at our /api/basemap-tile +// catch-all proxy. OpenFreeMap's nginx blocks ALL browser-origin +// tile fetches (verified 2026-05-24 on a versioned tile path), not +// just /planet. Without the rewrite, MapLibre fetches the style +// fine but every tile PBF dies with 403 → empty cream-coloured map. // -// 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. +// Resolved style is cached 1h via Cache-Control. OpenFreeMap rotates the +// versioned planet path (`20260520_001001_pt` etc.) — 1h TTL is short +// enough to pick up new versions promptly. import { NextResponse } from "next/server"; @@ -30,6 +24,7 @@ export const dynamic = "force-dynamic"; const ALLOWED_STYLES = new Set(["liberty", "dark", "positron", "bright"]); const OFM_BASE = "https://tiles.openfreemap.org"; +const TILE_PROXY_PREFIX = "/api/basemap-tile"; type StyleSource = { type: string; @@ -52,6 +47,8 @@ type TileJson = { type StyleSpec = { version: number; sources: Record; + sprite?: string | unknown; + glyphs?: string; [k: string]: unknown; }; @@ -63,6 +60,17 @@ async function fetchJson(url: string): Promise { return (await res.json()) as T; } +/** Replace `https://tiles.openfreemap.org/` with the architots + * proxy prefix so the browser never talks to openfreemap directly. + * Leaves `{z}/{x}/{y}` template tokens intact. */ +function rewriteUpstreamUrl(value: string): string { + if (typeof value !== "string") return value; + if (value.startsWith(OFM_BASE)) { + return `${TILE_PROXY_PREFIX}${value.slice(OFM_BASE.length)}`; + } + return value; +} + export async function GET( _request: Request, { params }: { params: Promise<{ id: string }> }, @@ -75,7 +83,7 @@ export async function GET( try { const style = await fetchJson(`${OFM_BASE}/styles/${id}`); - // Resolve every source defined as { url: "..." } into an explicit + // Step 1: resolve every `url:`-based source into an explicit // tiles[] + zoom range so the browser never has to fetch the // origin-blocked TileJSON itself. const resolved: Record = {}; @@ -105,8 +113,37 @@ export async function GET( } } + // Step 2: rewrite every openfreemap URL — tiles, sprite, glyphs — + // to point at our /api/basemap-tile proxy. We have to do this AFTER + // resolving the TileJSON because the tiles[] array only exists at + // that point. + for (const source of Object.values(resolved)) { + if (Array.isArray(source.tiles)) { + source.tiles = source.tiles.map(rewriteUpstreamUrl); + } + } + + // The `sprite` field is sometimes a string ("https://…/sprite") and + // sometimes an array of { id, url } objects (MapLibre v2+ for + // multi-image-set styles). Handle both. + let sprite: unknown = style.sprite; + if (typeof sprite === "string") { + sprite = rewriteUpstreamUrl(sprite); + } else if (Array.isArray(sprite)) { + sprite = sprite.map((s) => + s && typeof s === "object" && "url" in s + ? { ...s, url: rewriteUpstreamUrl(String((s as { url: unknown }).url)) } + : s, + ); + } + + const glyphs = + typeof style.glyphs === "string" + ? rewriteUpstreamUrl(style.glyphs) + : style.glyphs; + return NextResponse.json( - { ...style, sources: resolved }, + { ...style, sources: resolved, sprite, glyphs }, { headers: { "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", diff --git a/src/app/api/basemap-tile/[...path]/route.ts b/src/app/api/basemap-tile/[...path]/route.ts new file mode 100644 index 0000000..6009a13 --- /dev/null +++ b/src/app/api/basemap-tile/[...path]/route.ts @@ -0,0 +1,88 @@ +// GET /api/basemap-tile/[...path] +// +// Catch-all proxy for tiles.openfreemap.org resources. Background: +// OpenFreeMap's nginx returns 403 to any request carrying a browser +// `Origin` header — not just the /planet TileJSON but every vector +// tile PBF and the sprite/glyph endpoints too (verified 2026-05-24 +// against tiles.openfreemap.org/planet/20260520_001001_pt/{z}/{x}/{y}.pbf). +// Cloudflare-fronted but the 403 is plain nginx, so it's a server-side +// access rule. Without proxying, our MapLibre instance can fetch the +// style but every tile fetch dies → empty cream-coloured map. +// +// This route does a no-Origin server-side fetch to the openfreemap CDN +// and streams the response back with permissive caching. The path after +// /api/basemap-tile/ is forwarded verbatim, so we cover tiles, sprites, +// glyphs, and any future endpoint without per-resource code paths. + +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const UPSTREAM = "https://tiles.openfreemap.org"; + +// Hard ceiling so a typo in the path can't proxy something huge. +const MAX_PROXIED_BYTES = 10 * 1024 * 1024; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + const upstreamPath = path.map(encodeURIComponent).join("/"); + const url = `${UPSTREAM}/${upstreamPath}${req.nextUrl.search}`; + + let upstream: Response; + try { + // Plain server-side fetch — no Origin header, so openfreemap's + // browser-block rule doesn't fire. + upstream = await fetch(url, { + headers: { + // Accept-Encoding default lets fetch handle gzip transparently. + // Forward Range so MapLibre's byte-range PMTiles-style reads + // work if we ever serve range-friendly resources. + ...(req.headers.get("range") + ? { Range: req.headers.get("range")! } + : {}), + // Some upstreams 429 on missing UA; send a stable identifier. + "User-Agent": "ArchiTools-basemap-proxy/1.0", + }, + cache: "no-store", + signal: AbortSignal.timeout(15_000), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { error: "upstream_unreachable", hint: msg.slice(0, 200) }, + { status: 504 }, + ); + } + + if (!upstream.ok) { + return NextResponse.json( + { error: "upstream_failed", status: upstream.status, path: upstreamPath }, + { status: upstream.status }, + ); + } + + const contentLength = upstream.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_PROXIED_BYTES) { + return NextResponse.json({ error: "upstream_too_large" }, { status: 413 }); + } + + const headers = new Headers(); + const ct = upstream.headers.get("content-type"); + if (ct) headers.set("Content-Type", ct); + const ce = upstream.headers.get("content-encoding"); + if (ce) headers.set("Content-Encoding", ce); + if (contentLength) headers.set("Content-Length", contentLength); + + // Tiles + sprites + glyphs are immutable per path (versioned). Cache + // aggressively to keep architots out of the per-tile critical path. + headers.set( + "Cache-Control", + "public, max-age=86400, stale-while-revalidate=604800, immutable", + ); + + return new NextResponse(upstream.body, { status: 200, headers }); +} diff --git a/src/middleware.ts b/src/middleware.ts index 9e52a94..1f3600c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -58,6 +58,6 @@ export const config = { * - /favicon.ico, /robots.txt, /sitemap.xml * - Files with extensions (images, fonts, etc.) */ - "/((?!api/auth|api/version|api/basemap-style|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)", + "/((?!api/auth|api/version|api/basemap-|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)", ], };