208 lines
6.4 KiB
JavaScript
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"}`);
|
|
});
|