mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
- 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
612 lines
20 KiB
JavaScript
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,
|
|
});
|
|
}
|
|
}
|