93 lines
3.2 KiB
TypeScript
93 lines
3.2 KiB
TypeScript
|
|
import type { NextRequest } from "next/server";
|
||
|
|
import { OWM_TILE_LAYERS } from "@/lib/radar/layers";
|
||
|
|
import { forbidCrossSite } from "@/lib/weather/respond";
|
||
|
|
|
||
|
|
const CARTO_LAYERS: Record<string, string> = {
|
||
|
|
"carto-dark-nolabels": "dark_nolabels",
|
||
|
|
"carto-dark-labels": "dark_only_labels",
|
||
|
|
};
|
||
|
|
|
||
|
|
const CARTO_SUBDOMAINS = ["a", "b", "c", "d"] as const;
|
||
|
|
const MAX_ZOOM = 18;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* GET /api/tiles/[layer]/[z]/[x]/[y] → one map tile (PNG).
|
||
|
|
*
|
||
|
|
* Proxies both OpenWeather weather overlays (OPENWEATHER_API_KEY) and CARTO
|
||
|
|
* dark basemap/label tiles (CARTO_API_KEY) server-side so keys stay in `.env`
|
||
|
|
* or `.env.local` and cannot be hotlinked from other domains.
|
||
|
|
*/
|
||
|
|
export async function GET(
|
||
|
|
request: NextRequest,
|
||
|
|
context: { params: Promise<{ layer: string; z: string; x: string; y: string }> },
|
||
|
|
) {
|
||
|
|
const blocked = forbidCrossSite(request);
|
||
|
|
if (blocked) return blocked;
|
||
|
|
|
||
|
|
const { layer, z, x, y } = await context.params;
|
||
|
|
const [zi, xi, yi] = [z, x, y].map(Number);
|
||
|
|
const maxIndex = 2 ** zi;
|
||
|
|
|
||
|
|
if (
|
||
|
|
![zi, xi, yi].every((n) => Number.isInteger(n) && n >= 0) ||
|
||
|
|
zi > MAX_ZOOM ||
|
||
|
|
xi >= maxIndex ||
|
||
|
|
yi >= maxIndex
|
||
|
|
) {
|
||
|
|
return new Response("Bad tile coordinates", { status: 400 });
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1. CARTO dark basemap & place-label tiles
|
||
|
|
const cartoStyle = CARTO_LAYERS[layer];
|
||
|
|
if (cartoStyle) {
|
||
|
|
const key = (process.env.CARTO_API_KEY ?? process.env.NEXT_PUBLIC_CARTO_API_KEY ?? "").trim();
|
||
|
|
const scale = request.nextUrl.searchParams.get("r") === "@2x" ? "@2x" : "";
|
||
|
|
const sub = CARTO_SUBDOMAINS[(xi + yi) % CARTO_SUBDOMAINS.length];
|
||
|
|
const query = key ? `?key=${encodeURIComponent(key)}` : "";
|
||
|
|
const url = `https://${sub}.basemaps.cartocdn.com/${cartoStyle}/${z}/${x}/${y}${scale}.png${query}`;
|
||
|
|
|
||
|
|
const headers: HeadersInit = {};
|
||
|
|
const referer = request.headers.get("referer");
|
||
|
|
const origin = request.headers.get("origin");
|
||
|
|
if (referer) headers.Referer = referer;
|
||
|
|
if (origin) headers.Origin = origin;
|
||
|
|
|
||
|
|
const upstream = await fetch(
|
||
|
|
url,
|
||
|
|
key ? { headers, next: { revalidate: 86_400 } } : { headers, cache: "no-store" },
|
||
|
|
).catch(() => null);
|
||
|
|
|
||
|
|
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
|
||
|
|
|
||
|
|
// CARTO tags unauthenticated placeholder tiles with an `"wm-..."` ETag; never cache those.
|
||
|
|
const isWatermarked = !key || Boolean(upstream.headers.get("etag")?.includes("wm-"));
|
||
|
|
|
||
|
|
return new Response(upstream.body, {
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "image/png",
|
||
|
|
"Cache-Control": isWatermarked ? "no-store" : "public, max-age=86400",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. OpenWeather weather-model overlay tiles
|
||
|
|
const owmLayer = OWM_TILE_LAYERS[layer];
|
||
|
|
const owmKey = process.env.OPENWEATHER_API_KEY?.trim();
|
||
|
|
|
||
|
|
if (!owmLayer) return new Response("Unknown layer", { status: 404 });
|
||
|
|
if (!owmKey) return new Response("OPENWEATHER_API_KEY is not set", { status: 503 });
|
||
|
|
|
||
|
|
const upstream = await fetch(`https://tile.openweathermap.org/map/${owmLayer}/${z}/${x}/${y}.png?appid=${owmKey}`, {
|
||
|
|
next: { revalidate: 600 },
|
||
|
|
}).catch(() => null);
|
||
|
|
|
||
|
|
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
|
||
|
|
|
||
|
|
return new Response(upstream.body, {
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "image/png",
|
||
|
|
"Cache-Control": "public, max-age=600",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|