Files
worldmonitor/scripts/seed-fire-detections.mjs
Elie Habib 483d859ceb Triage security alerts (#1903)
* fix(cors): use ACAO: * for bootstrap to fix CF cache origin pinning

CF ignores Vary: Origin and pins the first request's ACAO header on the
cached response. Preview deployments from *.vercel.app got ACAO: worldmonitor.app
from CF's cache, blocking CORS. Bootstrap data is fully public (world events,
market prices, seismic data) so ACAO: * is safe and allows CF to cache one
entry valid for all origins. isDisallowedOrigin() still gates non-cache paths.

* chore: finish security triage

* fix(aviation): update isArray callback signature for fast-xml-parser 5.5.x

fast-xml-parser bumped from 5.4.2 to 5.5.7 changed the isArray callback's
second parameter type from string to unknown. Guard with typeof check before
calling .test() to satisfy the new type contract.

* docs: fix MD032 blank lines around lists in tradingview-screener-integration

* fix(security): address code review findings from PR #1903

- api/_json-response.js: add recursion depth limit (20) to sanitizeJsonValue
  and strip Error.cause chain alongside stack/stackTrace
- scripts/ais-relay.cjs: extract WORLD_BANK_COUNTRY_ALLOWLIST to module level
  to eliminate duplicate; clamp years param to [1,30] to prevent unbounded
  World Bank date ranges
- src-tauri/sidecar/local-api-server.mjs: use JSON.stringify for vq value
  in inline JS, consistent with safeVideoId/safeOrigin handling
- src/services/story-share.ts: simplify sanitizeStoryType to use typed array
  instead of repeated as-casts

* fix(desktop): use parent window origin for YouTube embed postMessage

Sidecar youtube-embed route was targeting the iframe's own localhost origin
for all window.parent.postMessage calls, so browsers dropped yt-ready/
yt-state/yt-error on Tauri builds where the parent is tauri://localhost or
asset://localhost. LiveNewsPanel and LiveWebcamsPanel already pass
parentOrigin=window.location.origin in the embed URL; the sidecar now reads,
validates, and uses it as the postMessage target for all player event
messages. The YT API playerVars origin/widget_referrer continue to use the
sidecar's own localhost origin which YouTube requires.

Also restore World Bank relay to a generic proxy: replace TECH_INDICATORS
membership check with a format-only regex so any valid indicator code
(NY.GDP.MKTP.CD etc.) is accepted, not just the 16 tech-sector codes.
2026-03-20 12:37:24 +04:00

129 lines
4.3 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { loadEnvFile, runSeed, CHROME_UA, sleep } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'wildfire:fires:v1';
const FIRMS_SOURCES = ['VIIRS_SNPP_NRT', 'VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT'];
const MONITORED_REGIONS = {
'Ukraine': '22,44,40,53',
'Russia': '20,50,180,82',
'Iran': '44,25,63,40',
'Israel/Gaza': '34,29,36,34',
'Syria': '35,32,42,37',
'Taiwan': '119,21,123,26',
'North Korea': '124,37,131,43',
'Saudi Arabia': '34,16,56,32',
'Turkey': '26,36,45,42',
};
function mapConfidence(c) {
switch ((c || '').toLowerCase()) {
case 'h': return 'FIRE_CONFIDENCE_HIGH';
case 'n': return 'FIRE_CONFIDENCE_NOMINAL';
case 'l': return 'FIRE_CONFIDENCE_LOW';
default: return 'FIRE_CONFIDENCE_UNSPECIFIED';
}
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map(h => h.trim());
const results = [];
for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(',').map(v => v.trim());
if (vals.length < headers.length) continue;
const row = {};
headers.forEach((h, idx) => { row[h] = vals[idx]; });
results.push(row);
}
return results;
}
function parseDetectedAt(acqDate, acqTime) {
const padded = (acqTime || '').padStart(4, '0');
const hours = padded.slice(0, 2);
const minutes = padded.slice(2);
return new Date(`${acqDate}T${hours}:${minutes}:00Z`).getTime();
}
async function fetchRegionSource(apiKey, regionName, bbox, source) {
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${apiKey}/${source}/${bbox}/1`;
const res = await fetch(url, {
headers: { Accept: 'text/csv', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`FIRMS ${res.status} for ${regionName}/${source}`);
const csv = await res.text();
return parseCSV(csv);
}
async function fetchAllRegions(apiKey) {
const entries = Object.entries(MONITORED_REGIONS);
const seen = new Set();
const fireDetections = [];
let fulfilled = 0;
let failed = 0;
for (const source of FIRMS_SOURCES) {
for (const [regionName, bbox] of entries) {
try {
const rows = await fetchRegionSource(apiKey, regionName, bbox, source);
fulfilled++;
for (const row of rows) {
const id = `${row.latitude ?? ''}-${row.longitude ?? ''}-${row.acq_date ?? ''}-${row.acq_time ?? ''}`;
if (seen.has(id)) continue;
seen.add(id);
const detectedAt = parseDetectedAt(row.acq_date || '', row.acq_time || '');
fireDetections.push({
id,
location: {
latitude: parseFloat(row.latitude ?? '0') || 0,
longitude: parseFloat(row.longitude ?? '0') || 0,
},
brightness: parseFloat(row.bright_ti4 ?? '0') || 0,
frp: parseFloat(row.frp ?? '0') || 0,
confidence: mapConfidence(row.confidence || ''),
satellite: row.satellite || '',
detectedAt,
region: regionName,
dayNight: row.daynight || '',
});
}
} catch (err) {
failed++;
console.error(` [FIRMS] ${source}/${regionName}: ${err.message || err}`);
}
await sleep(6_000); // FIRMS free tier: 10 req/min — 6s between calls stays safely under limit
}
console.log(` ${source}: ${fireDetections.length} total (${fulfilled} ok, ${failed} failed)`);
}
return { fireDetections, pagination: undefined };
}
async function main() {
const apiKey = process.env.NASA_FIRMS_API_KEY || process.env.FIRMS_API_KEY || '';
if (!apiKey) {
console.log('NASA_FIRMS_API_KEY not set — skipping fire detections seed');
process.exit(0);
}
console.log(' FIRMS key configured');
await runSeed('wildfire', 'fires', CANONICAL_KEY, () => fetchAllRegions(apiKey), {
validateFn: (data) => Array.isArray(data?.fireDetections) && data.fireDetections.length > 0,
ttlSeconds: 7200,
lockTtlMs: 600_000, // 10 min — 27 calls × (6s pace + up to 30s timeout) can exceed 5 min under partial slowness
sourceVersion: FIRMS_SOURCES.join('+'),
});
}
main().catch(err => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});