Files

208 lines
6.4 KiB
JavaScript

// Flight Lookup — Node proxy + static server
// Serves the static site and proxies lookups to the OpenSky Network API.
// OpenSky gives live ADS-B positions (no scheduled status), so a lookup
// returns the flight's current position if it's airborne, otherwise a
// "not airborne" result. Mock data is used as a fallback when OpenSky
// has no live match for the callsign.
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = process.env.PORT || 9000;
const ROOT = __dirname;
// ---- OpenSky credentials (from credentials.json) ----
let OPENSKY_USER = "";
let OPENSKY_PASS = "";
try {
const creds = JSON.parse(fs.readFileSync(path.join(ROOT, "credentials.json"), "utf8"));
OPENSKY_USER = creds.clientId || "";
OPENSKY_PASS = creds.clientSecret || "";
} catch (e) {
console.warn("Could not read credentials.json — OpenSky lookups will use anonymous access.");
}
const OPENSKY_BASE = "https://opensky-network.org";
// ---- MIME types for static files ----
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webmanifest": "application/manifest+json",
};
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(body);
}
function serveStatic(req, res, urlPath) {
let filePath = urlPath === "/" ? "/index.html" : urlPath;
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, "");
const full = path.join(ROOT, filePath);
if (!full.startsWith(ROOT)) {
res.writeHead(403);
return res.end("Forbidden");
}
fs.readFile(full, (err, data) => {
if (err) {
res.writeHead(404, { "Content-Type": "text/plain" });
return res.end("Not found");
}
const ext = path.extname(full).toLowerCase();
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
res.end(data);
});
}
// ---- OpenSky call ----
function openSkyFetch(pathname) {
const auth = Buffer.from(`${OPENSKY_USER}:${OPENSKY_PASS}`).toString("base64");
const headers = { "User-Agent": "flight-lookup/1.0 (personal project)" };
if (OPENSKY_USER) headers["Authorization"] = "Basic " + auth;
return fetch(OPENSKY_BASE + pathname, { headers });
}
// OpenSky states/all returns an array of arrays. Index map:
// 0 0: icao24, 1: callsign, 2: origin_country, 3: time_position, 4: last_seen,
// 5: longitude, 6: latitude, 7: baro_altitude, 8: on_ground, 9: velocity,
// 10: true_track, 11: vertical_rate, 12: sensors, 13: geo_altitude,
// 14: squawk, 15: spi, 16: position_source
function normalizeState(s) {
return {
icao24: s[0],
callsign: (s[1] || "").trim(),
country: s[2],
lastSeen: s[4],
lon: s[5],
lat: s[6],
baroAlt: s[7],
onGround: s[8],
velocity: s[9],
track: s[10],
vertRate: s[11],
geoAlt: s[13],
squawk: s[14],
};
}
// Find a live state matching the callsign. OpenSky callsigns are the
// flight number (e.g. "AA100"). We match on the numeric flight number and
// prefer an exact callsign match.
function findFlight(states, airlineCode, flightNumber) {
const num = String(flightNumber);
let exact = null;
let numeric = null;
for (const s of states) {
const cs = s.callsign.toUpperCase();
if (!cs) continue;
// Exact: callsign equals "AA100"
if (cs === `${airlineCode}${num}`) {
exact = s;
break;
}
// Numeric: callsign ends with the flight number (e.g. "AAL100", "100")
if (!numeric && cs.endsWith(num) && cs.length >= num.length) {
numeric = s;
}
}
return exact || numeric;
}
async function handleLookup(req, res, query) {
const code = (query.get("code") || "").toUpperCase();
const num = (query.get("num") || "").trim();
if (!code || !num) {
return sendJson(res, 400, { error: "code and num query params required" });
}
let states = [];
let source = "opensky";
let openSkyError = null;
try {
const r = await openSkyFetch("/api/states/all");
if (!r.ok) {
openSkyError = `OpenSky HTTP ${r.status}`;
} else {
const data = await r.json();
states = (data.states || []).map(normalizeState).filter((s) => s.callsign);
}
} catch (e) {
openSkyError = String(e.message || e);
}
const match = states.length ? findFlight(states, code, num) : null;
if (match) {
const now = Date.now();
const lastSeenMs = match.lastSeen ? match.lastSeen * 1000 : now;
const altitudeFt = match.geoAlt != null ? Math.round(match.geoAlt * 3.28084) : null;
return sendJson(res, 200, {
source: "opensky",
live: true,
airlineCode: code,
flightNumber: num,
callsign: match.callsign,
country: match.country,
status: match.onGround ? "onGround" : "airborne",
position: {
lat: match.lat,
lon: match.lon,
altitudeFt,
velocityKt: match.velocity != null ? Math.round(match.velocity * 1.94384) : null,
trackDeg: match.track != null ? Math.round(match.track) : null,
vertRateFpm: match.vertRate != null ? Math.round(match.vertRate * 196.85) : null,
},
lastSeen: lastSeenMs,
squawk: match.squawk,
icao24: match.icao24,
});
}
// No live match — return a "not airborne" result so the UI can show
// a friendly message (and optionally fall back to mock).
return sendJson(res, 200, {
source: openSkyError ? "none" : "opensky",
live: false,
airlineCode: code,
flightNumber: num,
error: openSkyError || "notAirborne",
message: openSkyError
? `OpenSky unavailable: ${openSkyError}`
: "No live position found for this flight right now.",
});
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
if (url.pathname === "/api/lookup") {
handleLookup(req, res, url.searchParams).catch((e) => {
sendJson(res, 500, { error: String(e.message || e) });
});
return;
}
if (url.pathname === "/api/health") {
return sendJson(res, 200, { ok: true, opensky: Boolean(OPENSKY_USER) });
}
serveStatic(req, res, url.pathname);
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`Flight Lookup running at http://0.0.0.0:${PORT}`);
console.log(`OpenSky auth: ${OPENSKY_USER ? "configured" : "anonymous"}`);
});