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:
@@ -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/<id> 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<string, StyleSource>;
|
||||
sprite?: string | unknown;
|
||||
glyphs?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -63,6 +60,17 @@ async function fetchJson<T>(url: string): Promise<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(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
@@ -75,7 +83,7 @@ export async function GET(
|
||||
try {
|
||||
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
|
||||
// origin-blocked TileJSON itself.
|
||||
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(
|
||||
{ ...style, sources: resolved },
|
||||
{ ...style, sources: resolved, sprite, glyphs },
|
||||
{
|
||||
headers: {
|
||||
"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
@@ -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|.*\\..*).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user