fix(geoportal-v2): proxy all openfreemap tiles, not just /planet TileJSON

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) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-24 10:43:34 +03:00
parent d65cfd86df
commit efcfa66c07
3 changed files with 148 additions and 23 deletions
+59 -22
View File
@@ -1,27 +1,21 @@
// GET /api/basemap-style/:id // GET /api/basemap-style/:id
// //
// Server-side proxy for OpenFreeMap styles that resolves any `url:`-based // Server-side proxy for OpenFreeMap styles. Two responsibilities:
// source TileJSON inline. Background:
// //
// OpenFreeMap's https://tiles.openfreemap.org/styles/<id> works fine, but // 1. Resolve every `url:`-based source TileJSON inline so the browser
// the openmaptiles source inside it is defined as // doesn't have to do a follow-up fetch (some endpoints — e.g.
// "openmaptiles": { "type": "vector", "url": "https://tiles.openfreemap.org/planet" } // tiles.openfreemap.org/planet — return 403 to any request carrying
// That `/planet` TileJSON endpoint returns 403 to ANY request carrying an // a browser `Origin` header).
// `Origin` header (i.e., every browser request) — Cloudflare hot-link // 2. Rewrite every tiles.openfreemap.org URL inside the style — tile
// rule. Result: MapLibre can't resolve the planet tile URL → no labels, // templates, sprite, glyphs — to point at our /api/basemap-tile
// no roads, no buildings. Only the natural_earth raster background and // catch-all proxy. OpenFreeMap's nginx blocks ALL browser-origin
// our PMTiles overlay render, which is exactly the empty cream-coloured // tile fetches (verified 2026-05-24 on a versioned tile path), not
// map users started reporting on 2026-05-23. // 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 // Resolved style is cached 1h via Cache-Control. OpenFreeMap rotates the
// Origin header sent → 200) and inlining the resolved `tiles` array into // versioned planet path (`20260520_001001_pt` etc.) — 1h TTL is short
// the source. The resulting style is browser-loadable without any // enough to pick up new versions promptly.
// 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"; import { NextResponse } from "next/server";
@@ -30,6 +24,7 @@ export const dynamic = "force-dynamic";
const ALLOWED_STYLES = new Set(["liberty", "dark", "positron", "bright"]); const ALLOWED_STYLES = new Set(["liberty", "dark", "positron", "bright"]);
const OFM_BASE = "https://tiles.openfreemap.org"; const OFM_BASE = "https://tiles.openfreemap.org";
const TILE_PROXY_PREFIX = "/api/basemap-tile";
type StyleSource = { type StyleSource = {
type: string; type: string;
@@ -52,6 +47,8 @@ type TileJson = {
type StyleSpec = { type StyleSpec = {
version: number; version: number;
sources: Record<string, StyleSource>; sources: Record<string, StyleSource>;
sprite?: string | unknown;
glyphs?: string;
[k: string]: unknown; [k: string]: unknown;
}; };
@@ -63,6 +60,17 @@ async function fetchJson<T>(url: string): Promise<T> {
return (await res.json()) as T; return (await res.json()) as T;
} }
/** Replace `https://tiles.openfreemap.org/<rest>` 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( export async function GET(
_request: Request, _request: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
@@ -75,7 +83,7 @@ export async function GET(
try { try {
const style = await fetchJson<StyleSpec>(`${OFM_BASE}/styles/${id}`); const style = await fetchJson<StyleSpec>(`${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 // tiles[] + zoom range so the browser never has to fetch the
// origin-blocked TileJSON itself. // origin-blocked TileJSON itself.
const resolved: Record<string, StyleSource> = {}; const resolved: Record<string, StyleSource> = {};
@@ -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( return NextResponse.json(
{ ...style, sources: resolved }, { ...style, sources: resolved, sprite, glyphs },
{ {
headers: { headers: {
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
@@ -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 });
}
+1 -1
View File
@@ -58,6 +58,6 @@ export const config = {
* - /favicon.ico, /robots.txt, /sitemap.xml * - /favicon.ico, /robots.txt, /sitemap.xml
* - Files with extensions (images, fonts, etc.) * - 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|.*\\..*).*)",
], ],
}; };