Files
worldmonitor/api/theater-posture.js
Elie Habib a9224254a5 fix: security hardening — CORS, auth bypass, origin validation & bump v2.2.7
- Tighten CORS regex to block worldmonitorEVIL.vercel.app spoofing
- Move sidecar /api/local-env-update behind token auth + add key allowlist
- Add postMessage origin/source validation in LiveNewsPanel
- Replace postMessage wildcard '*' targetOrigin with specific origin
- Add isDisallowedOrigin() check to 25 API endpoints missing it
- Migrate gdelt-geo & EIA from custom CORS to shared _cors.js
- Add CORS to firms-fires, stock-index, youtube/live endpoints
- Tighten youtube/embed.js ALLOWED_ORIGINS regex
- Remove 'unsafe-inline' from CSP script-src
- Add iframe sandbox attribute to YouTube embed
- Validate meta-tags URL query params with regex allowlist
2026-02-15 20:33:20 +04:00

612 lines
20 KiB
JavaScript

/**
* Theater Posture API - Aggregates military aircraft by theater
* Caches results in Upstash Redis for cross-user efficiency
* TTL: 5 minutes (matches OpenSky refresh rate)
*/
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const CACHE_TTL_SECONDS = 300; // 5 minutes
const STALE_CACHE_TTL_SECONDS = 86400; // 24 hours - serve stale data when API is down
const BACKUP_CACHE_TTL_SECONDS = 604800; // 7 days - last resort backup
const CACHE_KEY = 'theater-posture:v4';
const STALE_CACHE_KEY = 'theater-posture:stale:v4';
const BACKUP_CACHE_KEY = 'theater-posture:backup:v4';
// Theater definitions (matches client-side POSTURE_THEATERS)
const POSTURE_THEATERS = [
{
id: 'iran-theater',
name: 'Iran Theater',
shortName: 'IRAN',
targetNation: 'Iran',
bounds: { north: 42, south: 20, east: 65, west: 30 },
thresholds: { elevated: 8, critical: 20 },
strikeIndicators: { minTankers: 2, minAwacs: 1, minFighters: 5 },
},
{
id: 'taiwan-theater',
name: 'Taiwan Strait',
shortName: 'TAIWAN',
targetNation: 'Taiwan',
bounds: { north: 30, south: 18, east: 130, west: 115 },
thresholds: { elevated: 6, critical: 15 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 4 },
},
{
id: 'baltic-theater',
name: 'Baltic Theater',
shortName: 'BALTIC',
targetNation: null,
bounds: { north: 65, south: 52, east: 32, west: 10 },
thresholds: { elevated: 5, critical: 12 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'blacksea-theater',
name: 'Black Sea',
shortName: 'BLACK SEA',
targetNation: null,
bounds: { north: 48, south: 40, east: 42, west: 26 },
thresholds: { elevated: 4, critical: 10 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'korea-theater',
name: 'Korean Peninsula',
shortName: 'KOREA',
targetNation: 'North Korea',
bounds: { north: 43, south: 33, east: 132, west: 124 },
thresholds: { elevated: 5, critical: 12 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'south-china-sea',
name: 'South China Sea',
shortName: 'SCS',
targetNation: null,
bounds: { north: 25, south: 5, east: 121, west: 105 },
thresholds: { elevated: 6, critical: 15 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 4 },
},
{
id: 'east-med-theater',
name: 'Eastern Mediterranean',
shortName: 'E.MED',
targetNation: null,
bounds: { north: 37, south: 33, east: 37, west: 25 },
thresholds: { elevated: 4, critical: 10 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'israel-gaza-theater',
name: 'Israel/Gaza',
shortName: 'GAZA',
targetNation: 'Gaza',
bounds: { north: 33, south: 29, east: 36, west: 33 },
thresholds: { elevated: 3, critical: 8 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'yemen-redsea-theater',
name: 'Yemen/Red Sea',
shortName: 'RED SEA',
targetNation: 'Yemen',
bounds: { north: 22, south: 11, east: 54, west: 32 },
thresholds: { elevated: 4, critical: 10 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
];
// Military hex database from ADS-B Exchange (updated daily at adsbexchange.com)
// Contains ~20k verified military aircraft hex IDs
import { MILITARY_HEX_LIST } from './data/military-hex-db.js';
// Create Set for O(1) lookup
const MILITARY_HEX_SET = new Set(MILITARY_HEX_LIST.map(h => h.toLowerCase()));
console.log(`[TheaterPosture] Loaded ${MILITARY_HEX_SET.size} military hex IDs from ADS-B Exchange`);
// Check if ICAO hex is in military database
function isMilitaryHex(hexId) {
if (!hexId) return false;
// Handle both string and number, remove ~ prefix if present
const cleanHex = String(hexId).replace(/^~/, '').toLowerCase();
return MILITARY_HEX_SET.has(cleanHex);
}
// Military callsign prefixes for identification
const MILITARY_PREFIXES = [
// US Military
'RCH', 'REACH', 'MOOSE', 'EVAC', 'DUSTOFF', 'PEDRO', // Transport/medevac
'DUKE', 'HAVOC', 'KNIFE', 'WARHAWK', 'VIPER', 'RAGE', 'FURY', // Fighters
'SHELL', 'TEXACO', 'ARCO', 'ESSO', 'PETRO', // Tankers
'SENTRY', 'AWACS', 'MAGIC', 'DISCO', 'DARKSTAR', // AWACS/ISR
'COBRA', 'PYTHON', 'RAPTOR', 'EAGLE', 'HAWK', 'TALON', // Various
'BOXER', 'OMNI', 'TOPCAT', 'SKULL', 'REAPER', 'HUNTER', // More callsigns
'ARMY', 'NAVY', 'USAF', 'USMC', 'USCG', // Service prefixes
'AE', 'CNV', 'PAT', 'SAM', 'EXEC', // Special missions
'OPS', 'CTF', 'TF', // Operations/Task Force
// NATO
'NATO', 'GAF', 'RRF', 'RAF', 'FAF', 'IAF', 'RNLAF', 'BAF', 'DAF', 'HAF', 'PAF',
'SWORD', 'LANCE', 'ARROW', 'SPARTAN', // NATO tactical
// Middle East (avoid UAE - conflicts with Emirates airline)
'RSAF', 'EMIRI', 'UAEAF', 'KAF', 'QAF', 'BAHAF', 'OMAAF', // Gulf states
'IRIAF', 'IRG', 'IRGC', // Iran (IAF already in NATO section covers Israel)
'TAF', 'TUAF', // Turkey
// Russia
'RSD', 'RF', 'RFF', 'VKS',
// China (NOTE: CCA is Air China airline, not military)
'CHN', 'PLAAF', 'PLA',
];
// Airline ICAO codes to exclude from military detection (Set for O(1) lookup)
const AIRLINE_CODES = new Set([
// Middle East
'SVA', 'QTR', 'THY', 'UAE', 'ETD', 'GFA', 'MEA', 'RJA', 'KAC', 'ELY',
'IAW', 'IRA', 'MSR', 'SYR', 'PGT', 'AXB', 'FDB', 'KNE', 'FAD', 'ADY', 'OMA',
'ABQ', 'ABY', 'NIA', 'FJA', 'SWR', 'HZA', 'OMS', 'EGF', 'NOS', 'SXD',
// Europe
'BAW', 'AFR', 'DLH', 'KLM', 'AUA', 'SAS', 'FIN', 'LOT', 'AZA', 'TAP', 'IBE',
'VLG', 'RYR', 'EZY', 'WZZ', 'NOZ', 'BEL', 'AEE', 'ROT',
// Asia
'AIC', 'CPA', 'SIA', 'MAS', 'THA', 'VNM', 'JAL', 'ANA', 'KAL', 'AAR', 'EVA',
'CAL', 'CCA', 'CES', 'CSN', 'HDA', 'CHH', 'CXA', 'GIA', 'PAL', 'SLK',
// Americas
'AAL', 'DAL', 'UAL', 'SWA', 'JBU', 'FFT', 'ASA', 'NKS', 'WJA', 'ACA',
// Cargo
'FDX', 'UPS', 'GTI', 'ABW', 'CLX', 'MPH',
// Generic
'AIR', 'SKY', 'JET',
]);
// Aircraft type detection from callsign patterns
function detectAircraftType(callsign) {
if (!callsign) return 'unknown';
const cs = callsign.toUpperCase().trim();
// Tankers
if (/^(SHELL|TEXACO|ARCO|ESSO|PETRO)/.test(cs)) return 'tanker';
if (/^(KC|STRAT)/.test(cs)) return 'tanker';
// AWACS
if (/^(SENTRY|AWACS|MAGIC|DISCO|DARKSTAR)/.test(cs)) return 'awacs';
if (/^(E3|E8|E6)/.test(cs)) return 'awacs';
// Transport
if (/^(RCH|REACH|MOOSE|EVAC|DUSTOFF)/.test(cs)) return 'transport';
if (/^(C17|C5|C130|C40)/.test(cs)) return 'transport';
// Reconnaissance
if (/^(HOMER|OLIVE|JAKE|PSEUDO|GORDO)/.test(cs)) return 'reconnaissance';
if (/^(RC|U2|SR)/.test(cs)) return 'reconnaissance';
// Drones/UAVs
if (/^(RQ|MQ|REAPER|PREDATOR|GLOBAL)/.test(cs)) return 'drone';
// Bombers
if (/^(DEATH|BONE|DOOM)/.test(cs)) return 'bomber';
if (/^(B52|B1|B2)/.test(cs)) return 'bomber';
// Default to unknown for unrecognized military aircraft
return 'unknown';
}
// Check if callsign is military
function isMilitaryCallsign(callsign) {
if (!callsign) return false;
const cs = callsign.toUpperCase().trim();
// Check prefixes
for (const prefix of MILITARY_PREFIXES) {
if (cs.startsWith(prefix)) return true;
}
// Check patterns - tactical callsigns (word + small number)
// DUKE01, VIPER12, RAGE1 but NOT airline codes like PGT5873, IAW9011
if (/^[A-Z]{4,}\d{1,3}$/.test(cs)) return true;
// Short tactical: 3 letters + 1-2 digits (but exclude common airlines)
// This catches OPS4, CTF01, TF12 but blocks SVA12, QTR76, etc.
if (/^[A-Z]{3}\d{1,2}$/.test(cs)) {
const prefix = cs.slice(0, 3);
if (!AIRLINE_CODES.has(prefix)) return true;
}
return false;
}
// Fetch military flights from OpenSky
async function fetchMilitaryFlights() {
const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar');
// Desktop sidecar: fetch directly from OpenSky (single user, no rate limit concern)
// Cloud: use Railway relay to avoid OpenSky rate limits across many users
const baseUrl = isSidecar
? 'https://opensky-network.org/api/states/all'
: (process.env.WS_RELAY_URL ? process.env.WS_RELAY_URL + '/opensky' : null);
if (!baseUrl) return [];
// Fetch global data with 20s timeout (Edge has 25s limit)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
console.log('[TheaterPosture] Fetching from:', baseUrl);
const response = await fetch(baseUrl, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 WorldMonitor/1.0',
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`OpenSky API error: ${response.status}`);
}
const data = await response.json();
if (!data.states) return [];
// Filter and transform to military flights
const flights = [];
for (const state of data.states) {
const [icao24, callsign, , , , lon, lat, altitude, onGround, velocity, heading] = state;
// Skip if no position
if (lat == null || lon == null) continue;
// Skip if on ground
if (onGround) continue;
// Check if military (by callsign OR hex range)
const isMilitary = isMilitaryCallsign(callsign) || isMilitaryHex(icao24);
if (!isMilitary) continue;
flights.push({
id: icao24,
callsign: callsign?.trim() || '',
lat,
lon,
altitude: altitude || 0,
heading: heading || 0,
speed: velocity || 0,
aircraftType: detectAircraftType(callsign),
operator: 'unknown',
militaryHex: isMilitaryHex(icao24),
});
}
return flights;
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('OpenSky API timeout - try again');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
// Fetch military flights from Wingbits (fallback when OpenSky fails)
async function fetchMilitaryFlightsFromWingbits() {
const apiKey = process.env.WINGBITS_API_KEY;
if (!apiKey) {
console.log('[TheaterPosture] Wingbits not configured, skipping fallback');
return null;
}
console.log('[TheaterPosture] Trying Wingbits fallback...');
// Build batch request for all theaters
const areas = POSTURE_THEATERS.map(theater => ({
alias: theater.id,
by: 'box',
la: (theater.bounds.north + theater.bounds.south) / 2,
lo: (theater.bounds.east + theater.bounds.west) / 2,
w: Math.abs(theater.bounds.east - theater.bounds.west) * 60, // degrees to nm
h: Math.abs(theater.bounds.north - theater.bounds.south) * 60,
unit: 'nm',
}));
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch('https://customer-api.wingbits.com/v1/flights', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(areas),
signal: controller.signal,
});
if (!response.ok) {
console.warn('[TheaterPosture] Wingbits API error:', response.status);
return null;
}
const data = await response.json();
console.log('[TheaterPosture] Wingbits returned', data.length, 'theater results');
// Transform Wingbits data to our format
// Wingbits uses short field names: h=icao24, f=flight, la=lat, lo=lon, ab=alt, th=heading, gs=speed
const flights = [];
const seenIds = new Set();
for (const areaResult of data) {
// Batch response: each area result has flights in various possible formats
const areaFlights = areaResult.flights || areaResult.data || areaResult || [];
const flightList = Array.isArray(areaFlights) ? areaFlights : [];
for (const f of flightList) {
// Get icao24 - Wingbits uses 'h' for hex ID
const icao24 = f.h || f.icao24 || f.id;
if (!icao24) continue;
// Skip duplicates (aircraft may appear in multiple theaters)
if (seenIds.has(icao24)) continue;
seenIds.add(icao24);
// Get callsign - Wingbits uses 'f' for flight
const callsign = f.f || f.callsign || f.flight || '';
// Skip if not military (by callsign OR hex range)
const isMilitary = isMilitaryCallsign(callsign) || isMilitaryHex(icao24);
if (!isMilitary) continue;
flights.push({
id: icao24,
callsign: callsign.trim(),
lat: f.la || f.latitude || f.lat,
lon: f.lo || f.longitude || f.lon || f.lng,
altitude: f.ab || f.altitude || f.alt || 0,
heading: f.th || f.heading || f.track || 0,
speed: f.gs || f.groundSpeed || f.speed || f.velocity || 0,
aircraftType: detectAircraftType(callsign),
operator: f.operator || 'unknown',
source: 'wingbits',
militaryHex: isMilitaryHex(icao24),
});
}
}
console.log('[TheaterPosture] Wingbits: found', flights.length, 'military flights');
return flights;
} catch (err) {
console.error('[TheaterPosture] Wingbits fetch error:', err.message);
return null;
} finally {
clearTimeout(timeoutId);
}
}
// Calculate theater postures
function calculatePostures(flights) {
const summaries = [];
for (const theater of POSTURE_THEATERS) {
// Filter flights within theater bounds
const theaterFlights = flights.filter(f =>
f.lat >= theater.bounds.south &&
f.lat <= theater.bounds.north &&
f.lon >= theater.bounds.west &&
f.lon <= theater.bounds.east
);
// Count by type
const byType = {
fighters: theaterFlights.filter(f => f.aircraftType === 'fighter').length,
tankers: theaterFlights.filter(f => f.aircraftType === 'tanker').length,
awacs: theaterFlights.filter(f => f.aircraftType === 'awacs').length,
reconnaissance: theaterFlights.filter(f => f.aircraftType === 'reconnaissance').length,
transport: theaterFlights.filter(f => f.aircraftType === 'transport').length,
bombers: theaterFlights.filter(f => f.aircraftType === 'bomber').length,
drones: theaterFlights.filter(f => f.aircraftType === 'drone').length,
unknown: theaterFlights.filter(f => f.aircraftType === 'unknown').length,
};
const total = Object.values(byType).reduce((a, b) => a + b, 0);
// Determine posture level
const postureLevel = total >= theater.thresholds.critical ? 'critical' :
total >= theater.thresholds.elevated ? 'elevated' : 'normal';
// Check strike capability
const strikeCapable =
byType.tankers >= theater.strikeIndicators.minTankers &&
byType.awacs >= theater.strikeIndicators.minAwacs &&
byType.fighters >= theater.strikeIndicators.minFighters;
// Build summary string
const parts = [];
if (byType.fighters > 0) parts.push(`${byType.fighters} fighters`);
if (byType.tankers > 0) parts.push(`${byType.tankers} tankers`);
if (byType.awacs > 0) parts.push(`${byType.awacs} AWACS`);
if (byType.reconnaissance > 0) parts.push(`${byType.reconnaissance} recon`);
if (byType.bombers > 0) parts.push(`${byType.bombers} bombers`);
if (byType.transport > 0) parts.push(`${byType.transport} transport`);
if (byType.drones > 0) parts.push(`${byType.drones} drones`);
if (byType.unknown > 0) parts.push(`${byType.unknown} other`);
const summary = parts.join(', ') || 'No military aircraft';
// Build headline
const headline = postureLevel === 'critical'
? `Critical military buildup - ${theater.name}`
: postureLevel === 'elevated'
? `Elevated military activity - ${theater.name}`
: `Normal activity - ${theater.name}`;
// Build byOperator map for aircraft
const byOperator = {};
for (const f of theaterFlights) {
const op = f.operator || 'unknown';
byOperator[op] = (byOperator[op] || 0) + 1;
}
summaries.push({
theaterId: theater.id,
theaterName: theater.name,
shortName: theater.shortName,
targetNation: theater.targetNation,
// Aircraft
fighters: byType.fighters,
tankers: byType.tankers,
awacs: byType.awacs,
reconnaissance: byType.reconnaissance,
transport: byType.transport,
bombers: byType.bombers,
drones: byType.drones,
unknown: byType.unknown,
totalAircraft: total,
// Vessels (populated client-side)
destroyers: 0,
frigates: 0,
carriers: 0,
submarines: 0,
patrol: 0,
auxiliaryVessels: 0,
totalVessels: 0,
// By operator (aircraft + vessels added client-side)
byOperator,
// Metadata
postureLevel,
strikeCapable,
trend: 'stable',
changePercent: 0,
summary,
headline,
centerLat: (theater.bounds.north + theater.bounds.south) / 2,
centerLon: (theater.bounds.east + theater.bounds.west) / 2,
bounds: theater.bounds,
});
}
return summaries;
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: corsHeaders });
}
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
// Try to get from cache first
const cached = await getCachedJson(CACHE_KEY);
if (cached) {
console.log('[TheaterPosture] Cache hit');
return Response.json({
...cached,
cached: true,
}, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
}
// Fetch and calculate - try OpenSky first, then Wingbits fallback
console.log('[TheaterPosture] Fetching fresh data...');
let flights;
let source = 'opensky';
try {
flights = await fetchMilitaryFlights();
} catch (openskyError) {
console.warn('[TheaterPosture] OpenSky failed:', openskyError.message);
console.log('[TheaterPosture] Trying Wingbits fallback...');
flights = await fetchMilitaryFlightsFromWingbits();
if (flights && flights.length > 0) {
source = 'wingbits';
console.log('[TheaterPosture] Wingbits fallback succeeded:', flights.length, 'flights');
} else {
// Both failed, re-throw OpenSky error to trigger cache fallback
throw openskyError;
}
}
const postures = calculatePostures(flights);
const result = {
postures,
totalFlights: flights.length,
timestamp: new Date().toISOString(),
cached: false,
source, // 'opensky' or 'wingbits'
};
// Cache the result (regular, stale, and long-term backup)
await Promise.all([
setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS),
setCachedJson(STALE_CACHE_KEY, result, STALE_CACHE_TTL_SECONDS),
setCachedJson(BACKUP_CACHE_KEY, result, BACKUP_CACHE_TTL_SECONDS),
]);
return Response.json(result, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
} catch (error) {
console.warn('[TheaterPosture] Error:', error.message);
// Try to return cached data when API fails (stale first, then backup)
const stale = await getCachedJson(STALE_CACHE_KEY);
if (stale) {
console.log('[TheaterPosture] Returning stale cached data (24h) due to API error');
return Response.json({
...stale,
cached: true,
stale: true,
error: 'Using cached data - live feed temporarily unavailable',
}, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
},
});
}
const backup = await getCachedJson(BACKUP_CACHE_KEY);
if (backup) {
console.log('[TheaterPosture] Returning backup cached data (7d) due to API error');
return Response.json({
...backup,
cached: true,
stale: true,
error: 'Using backup data - live feed temporarily unavailable',
}, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
},
});
}
// No cached data available - return error
return Response.json({
error: error.message,
postures: [],
timestamp: new Date().toISOString(),
}, {
status: 500,
headers: corsHeaders,
});
}
}