Flight lookup: OpenSky live data, i18n, PWA, visual redesign
This commit is contained in:
commit
fde38516b4
10 files changed
+2022
No files matched your search
@@ -0,0 +1,207 @@
|
||||
// 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"}`);
|
||||
});
|
||||
Reference in new issue
Block a user