Defense-in-depth for the public deploy: USGS/EONET place/title/category are now HTML-escaped before interpolation into Cesium InfoBox descriptions, and the USGS/EONET links are passed through lib.safeUrl (http(s)-only) so a hostile feed can't inject markup or a javascript:/data: href. Entity names/labels stay raw (Cesium renders those as text). Verified: escapeHtml neutralizes <img onerror>, safeUrl drops javascript:/data:, 219 quakes + 500 fires still render normally. ship-check pass: no auth surface (dev-only history/snap not deployed); no secrets in tree (AIS key empty, data/ gitignored); proxy upstreams are a fixed dict (no SSRF); no money paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
5.0 KiB
JavaScript
143 lines
5.0 KiB
JavaScript
// Wildfires layer (Wave 2) — REAL data from NASA's EONET events API.
|
|
// No time dynamics: EONET events are slow-moving, so availability games would
|
|
// mislead more than inform. Refreshed every 15 min, rebuilt wholesale.
|
|
//
|
|
// PITFALL baked in: EONET mislabels its Content-Type as `application/rss+xml`,
|
|
// but the body is JSON. fetch's res.json() ignores the header, so it works —
|
|
// we simply never gate on the content type.
|
|
|
|
export default function create(ctx) {
|
|
const { viewer, CONFIG, lib, ui, Cesium } = ctx;
|
|
const F = CONFIG.fires;
|
|
|
|
const ds = new Cesium.CustomDataSource('fires');
|
|
viewer.dataSources.add(ds);
|
|
|
|
const glyph = lib.flameGlyph(CONFIG.colors.fire); // one shared canvas
|
|
const fireColor = lib.cz(CONFIG.colors.fire);
|
|
|
|
let enabled = true;
|
|
let timer = null;
|
|
let inFlight = false;
|
|
|
|
ui.addLayer('fires', 'Wildfires (NASA EONET)', true, (on) => {
|
|
enabled = on;
|
|
ds.show = on;
|
|
});
|
|
ui.setStatus('fires', 'loading EONET feed…', 'warn');
|
|
|
|
// Pick the most recent dated geometry and reduce it to a {lon, lat, date}.
|
|
function locate(event) {
|
|
const geoms = Array.isArray(event.geometry) ? event.geometry : [];
|
|
if (!geoms.length) return null;
|
|
// Latest by date (EONET lists chronologically; be robust to ordering).
|
|
let latest = geoms[0];
|
|
for (const gm of geoms) {
|
|
if (gm.date && latest.date && gm.date > latest.date) latest = gm;
|
|
}
|
|
const c = latest.coordinates;
|
|
if (latest.type === 'Point' && Array.isArray(c) && isFinite(c[0]) && isFinite(c[1])) {
|
|
return { lon: c[0], lat: c[1], date: latest.date };
|
|
}
|
|
if (latest.type === 'Polygon' && Array.isArray(c) && Array.isArray(c[0])) {
|
|
const ring = c[0];
|
|
let sx = 0, sy = 0, n = 0;
|
|
for (const pt of ring) {
|
|
if (Array.isArray(pt) && isFinite(pt[0]) && isFinite(pt[1])) { sx += pt[0]; sy += pt[1]; n++; }
|
|
}
|
|
if (n) return { lon: sx / n, lat: sy / n, date: latest.date };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function build(events) {
|
|
ds.entities.removeAll();
|
|
let count = 0;
|
|
for (const ev of events) {
|
|
if (count >= F.cap) break;
|
|
const loc = locate(ev);
|
|
if (!loc) continue;
|
|
const title = ev.title || 'Wildfire';
|
|
const category = (ev.categories && ev.categories[0] && ev.categories[0].title) || 'Wildfires';
|
|
const link = lib.safeUrl(ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${encodeURIComponent(ev.id)}` : ''));
|
|
const dateTxt = loc.date ? new Date(loc.date).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—';
|
|
|
|
ds.entities.add({
|
|
name: title,
|
|
position: Cesium.Cartesian3.fromDegrees(loc.lon, loc.lat),
|
|
billboard: {
|
|
image: glyph,
|
|
width: 14,
|
|
height: 14,
|
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
label: {
|
|
text: title,
|
|
font: '11px "Segoe UI", system-ui, sans-serif',
|
|
fillColor: fireColor,
|
|
outlineColor: Cesium.Color.fromCssColorString('#0a0f14'),
|
|
outlineWidth: 3,
|
|
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
|
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
|
pixelOffset: new Cesium.Cartesian2(0, -12),
|
|
// Aggressive fade: fires cluster hard (California, Australia), so labels
|
|
// only resolve once you've zoomed well into a region.
|
|
translucencyByDistance: new Cesium.NearFarScalar(3.0e5, 1.0, 1.5e6, 0.0),
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
},
|
|
description:
|
|
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
|
`<tr><th>Event</th><td>${lib.escapeHtml(title)}</td></tr>` +
|
|
`<tr><th>Category</th><td>${lib.escapeHtml(category)}</td></tr>` +
|
|
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
|
|
`</tbody></table>` +
|
|
(link ? `<p><a href="${lib.escapeHtml(link)}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
|
|
`<p style="opacity:.7">Active wildfire event from NASA EONET (real data).</p>`,
|
|
});
|
|
count++;
|
|
}
|
|
ui.setStatus('fires', `${count} active fires`, 'ok');
|
|
}
|
|
|
|
async function poll() {
|
|
let res;
|
|
try {
|
|
res = await fetch(F.url);
|
|
} catch {
|
|
ui.setStatus('fires', 'network error — retrying', 'warn');
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
ui.setStatus('fires', `HTTP ${res.status} — retrying`, 'warn');
|
|
return;
|
|
}
|
|
let data;
|
|
try {
|
|
// Body is JSON despite the rss+xml Content-Type — do NOT check the header.
|
|
data = await res.json();
|
|
} catch {
|
|
ui.setStatus('fires', 'bad response — retrying', 'err');
|
|
return;
|
|
}
|
|
const events = Array.isArray(data && data.events) ? data.events : [];
|
|
build(events);
|
|
}
|
|
|
|
async function tick() {
|
|
timer = null;
|
|
if (inFlight) return;
|
|
inFlight = true;
|
|
try {
|
|
await poll();
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
timer = setTimeout(tick, F.refreshMs);
|
|
}
|
|
|
|
tick();
|
|
|
|
return { id: 'fires' };
|
|
}
|