Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/workflow
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Build & Publish Docker

on:
release:
types: [published]

jobs:
docker:
if: ${{ !github.event.release.prerelease }}
runs-on: ubuntu-latest

permissions:
contents: read
packages: write
security-events: write

steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit

- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Extract and validate version
id: version
run: |
TAG=${GITHUB_REF#refs/tags/}
if [[ -z "$TAG" ]]; then
echo "No tag found"
exit 1
fi
echo "TAG=$TAG" >> $GITHUB_OUTPUT

- name: Build and push Docker image
id: build
uses: docker/build-push-action@v6
with:
context: .
file: dockerfile
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/plane-tracker:latest
${{ secrets.DOCKER_USERNAME }}/plane-tracker:${{ steps.version.outputs.TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ secrets.DOCKER_USERNAME }}/plane-tracker:${{ steps.version.outputs.TAG }}
format: 'sarif'
output: 'trivy-results.sarif'
continue-on-error: true

- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always() && hashFiles('trivy-results.sarif') != ''
with:
sarif_file: 'trivy-results.sarif'

- name: Check for critical vulnerabilities
run: |
if [ -f "trivy-results.sarif" ]; then
CRITICAL_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' trivy-results.sarif 2>/dev/null || echo "0")
if [ "$CRITICAL_COUNT" -gt 0 ]; then
echo "::warning::Critical vulnerabilities found: $CRITICAL_COUNT"
else
echo "No critical vulnerabilities found"
fi
else
echo "::warning::Trivy scan file not found - skipping vulnerability check"
fi

- name: Scan for secrets in image
uses: trufflesecurity/trufflehog@main
with:
base: ""
head: ${{ github.sha }}
extra_args: --only-verified

- name: Docker Scout CVE scanning
uses: docker/scout-action@v1
with:
command: cves
image: ${{ secrets.DOCKER_USERNAME }}/plane-tracker:${{ steps.version.outputs.TAG }}
only-severities: critical,high
exit-code: false

- name: Generate security report
if: always()
run: |
echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY
echo "### Build Information" >> $GITHUB_STEP_SUMMARY
echo "- Image: ${{ secrets.DOCKER_USERNAME }}/plane-tracker:${{ steps.version.outputs.TAG }}" >> $GITHUB_STEP_SUMMARY
echo "- Scan completed at: $(date)" >> $GITHUB_STEP_SUMMARY
echo "- Build status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY

if [ "${{ steps.build.outcome }}" == "success" ]; then
echo "- Build completed successfully" >> $GITHUB_STEP_SUMMARY
else
echo "- Build failed or was skipped" >> $GITHUB_STEP_SUMMARY
fi
10 changes: 1 addition & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Nearest Plane — Realtime (single-container)
# Near-Plane

Minimal single-container app that shows the **nearest aircraft** to the user's location in realtime. Server polls `adsb.lol` and proxies data to clients over Socket.IO. Client is React + Vite + Leaflet.

Expand All @@ -11,13 +11,6 @@ Minimal single-container app that shows the **nearest aircraft** to the user's l
- Per-location poller: single server poller per distinct lat/lon/radius key (prevents duplication)
- Request token bucket to avoid spamming third-party APIs

## Files
- `server.js` — Express + Socket.IO backend
- `client/` — React app (Vite)
- `src/App.jsx` — UI + socket logic
- `src/MapPlane.jsx` — Leaflet map and markers
- `src/style.css` — styling (paste provided CSS)
- `Dockerfile` — multi-stage build for single container

## Build & Run (Docker)
```bash
Expand All @@ -27,7 +20,6 @@ docker run -p 3000:3000 --name nearest-plane \
-e POLL_MS=5000 \
-e OTHER_POLL_MS=20000 \
-e OTHERS_LIMIT=10 \
-e MAX_REQUESTS_PER_MIN=60 \
nearest-plane:latest
```

Expand Down
7 changes: 4 additions & 3 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ function haversineKm(lat1, lon1, lat2, lon2) {
function renderAirportShort(obj) {
if (!obj) return '—';

// obj expected shape: { city, location, iata, name, countryiso }
const cc = (obj.countryiso || obj.country || '').toUpperCase();
const emoji = (cc && cc.length === 2)
? String.fromCodePoint(...cc.split('').map(c => 127397 + c.charCodeAt(0)))
Expand Down Expand Up @@ -160,7 +159,8 @@ export default function App() {
{(shown.flight || shown.callsign || shown.reg || shown.hex) || '—'}
</h2>
<div style={{color:'#9aa', fontSize:13, display:'grid', gap:6}}>
<div><strong>Type:</strong> {shown.type || 'unknown'}</div>
{/* Minimal change: prefer aircraft_name (MANUFACTURER, Model (TYPE)) if available */}
<div><strong>Type:</strong> {shown.aircraft_name || shown.type || 'unknown'}</div>
<div><strong>Registration:</strong> {shown.reg || '—'}</div>
<div><strong>Airline / Operator:</strong> {shown.airline || '—'}</div>
</div>
Expand Down Expand Up @@ -221,7 +221,8 @@ export default function App() {

<div style={{color:'#cfe', marginTop:6}}>
<div style={{fontSize:13, opacity:0.95}}>
{ o.type ? `${o.type}` : '' }{ o.type ? ' • ' : '' }{ o.reg ? `${o.reg}` : '' }
{/* show mapped aircraft_name if available, otherwise show type */}
{ o.aircraft_name ? `${o.aircraft_name}` : (o.type ? `${o.type}` : '') }{ o.type ? (o.type && o.reg ? ' • ' : '') : '' }{ o.reg ? `${o.reg}` : '' }
</div>
<div style={{marginTop:6}}>
{ o.from_obj ? renderAirportShort(o.from_obj) : (o.from || '—') }
Expand Down
102 changes: 86 additions & 16 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function haversineKm(lat1, lon1, lat2, lon2) {
return R * c;
}

/* --- load two-letter airlines map (unchanged) --- */
/* --- load airline map (unchanged) --- */
let AIRLINE_MAP = {};
(async () => {
try {
Expand Down Expand Up @@ -130,6 +130,63 @@ const THREE_LETTER_MAP = {};
}
})();

/* --- NEW: load aircraft designator CSV (ICAOList.csv) into AIRCRAFT_TYPE_MAP --- */
const AIRCRAFT_TYPE_MAP = {};
(async function loadAircraftTypeMap() {
try {
const url = 'https://raw.githubusercontent.com/rikgale/ICAOList/refs/heads/main/ICAOList.csv';
const resp = await fetch(url);
if (!resp.ok) {
console.warn('failed to fetch ICAOList.csv', resp.status);
return;
}
const txt = await resp.text();

// CSV parse (same style as above)
function parseCSV(text) {
const rows = [];
let cur = [];
let i = 0;
let field = '';
let inQuotes = false;
while (i < text.length) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i+1] === '"') { field += '"'; i += 2; continue; }
inQuotes = false; i++; continue;
} else { field += ch; i++; continue; }
} else {
if (ch === '"') { inQuotes = true; i++; continue; }
if (ch === ',') { cur.push(field); field = ''; i++; continue; }
if (ch === '\r') { i++; continue; }
if (ch === '\n') { cur.push(field); rows.push(cur); cur = []; field = ''; i++; continue; }
field += ch; i++;
}
}
if (field !== '' || cur.length) cur.push(field);
if (cur.length) rows.push(cur);
return rows;
}

const rows = parseCSV(txt);
for (let r of rows) {
// expected columns: [TypeDesignator, Class, Number+Engine Type, "MANUFACTURER, Model"]
if (!r || r.length < 4) continue;
const designator = (r[0] || '').trim().toUpperCase();
const manufacturerModel = (r[3] || '').trim();
if (designator && manufacturerModel) {
// normalize designator (remove spaces/illegal chars)
const key = designator.replace(/[^A-Z0-9_-]/g, '');
AIRCRAFT_TYPE_MAP[key] = manufacturerModel;
}
}
console.log('aircraft type map loaded entries=', Object.keys(AIRCRAFT_TYPE_MAP).length);
} catch (e) {
console.warn('failed to load aircraft type map', e && e.message ? e.message : e);
}
})();

/* --- rate-limited fetch (token bucket) --- */
class TokenBucket {
constructor(limitPerMin) {
Expand Down Expand Up @@ -203,30 +260,23 @@ function parallelLimit(items, fn, concurrency = 3) {
return Promise.all(workers).then(()=>results);
}

/* --- DOC8643 image proxy route --- */
/*
Purpose: avoid CORS/opaque responses by proxying doc8643 images via the server.
Caching: 24 hours (public). Logs use rateLimitedFetch so it consumes token budget.
*/
/* --- DOC8643 image proxy route (unchanged) --- */
app.get('/api/docimg/:code.jpg', async (req, res) => {
try {
const codeRaw = req.params.code || '';
const code = String(codeRaw).replace(/[^A-Za-z0-9_-]/g, '').toUpperCase();
if (!code) return res.status(400).send('bad code');

// remote doc8643 URL
const remote = `https://doc8643.com/static/img/aircraft/large/${encodeURIComponent(code)}.jpg`;

// fetch via rate limited fetch (so logs & limits apply)
const r = await rateLimitedFetch(remote, { redirect: 'follow' });

if (!r.ok) {
const txt = await (r.text().catch(()=>'')); // try to peek text
const txt = await (r.text().catch(()=>''));
res.status(r.status).type('text/plain').send(`Upstream returned ${r.status}: ${txt.slice ? txt.slice(0,200) : ''}`);
return;
}

// stream bytes back
const arrayBuffer = await r.arrayBuffer();
const buf = Buffer.from(arrayBuffer);
res.setHeader('Content-Type', r.headers.get('content-type') || 'image/jpeg');
Expand All @@ -238,7 +288,7 @@ app.get('/api/docimg/:code.jpg', async (req, res) => {
}
});

/* --- poller implementation (almost unchanged) --- */
/* --- poller implementation (unchanged except for aircraft_name assignments) --- */
function ensurePoller(key, lat, lon, radius) {
if (pollers.has(key)) return pollers.get(key);

Expand All @@ -250,6 +300,15 @@ function ensurePoller(key, lat, lon, radius) {
timer: null
};

function getAircraftFullName(type) {
if (!type) return null;
const code = String(type).trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '');
if (!code) return null;
const found = AIRCRAFT_TYPE_MAP[code];
if (found) return `${found} (${code})`;
return null;
}

async function fetchCycle() {
try {
const closestUrl = `https://api.adsb.lol/v2/closest/${encodeURIComponent(lat)}/${encodeURIComponent(lon)}/${encodeURIComponent(radius)}`;
Expand All @@ -263,6 +322,12 @@ function ensurePoller(key, lat, lon, radius) {
const nearestRaw = (json.ac && json.ac.length) ? json.ac[0] : null;
const nearest = sanitizeAircraft(nearestRaw);

// Attach aircraft_name for nearest if mapping exists (do this early so UI gets it)
if (nearest && nearest.type) {
const full = getAircraftFullName(nearest.type);
if (full) nearest.aircraft_name = full;
}

// enrich nearest callsign (cache)
if (nearest && nearest.flight) {
const callsignKey = nearest.flight.trim();
Expand Down Expand Up @@ -309,13 +374,10 @@ function ensurePoller(key, lat, lon, radius) {
}
}

// --- NEW: attach normalized thumb URL for nearest if we have a type ---
// NEW: ensure thumb for nearest (keeps existing behavior)
if (nearest && nearest.type) {
const code = String(nearest.type).trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '');
if (code) {
// use our proxy endpoint so the browser always gets a proxied image (no CORS issues)
nearest.thumb = `/api/docimg/${code}.jpg`;
}
if (code) nearest.thumb = `/api/docimg/${code}.jpg`;
}

// OTHERS: refresh at most every OTHER_POLL_MS
Expand All @@ -338,6 +400,14 @@ function ensurePoller(key, lat, lon, radius) {
return da - db;
});

// attach aircraft_name for each other (if mapped)
arr.forEach(a => {
if (a && a.type) {
const full = getAircraftFullName(a.type);
if (full) a.aircraft_name = full;
}
});

const filtered = arr.filter(a => !(nearest && a.hex && nearest.hex && a.hex === nearest.hex)).slice(0, OTHERS_LIMIT);
state.cachedOthers = filtered;
state.lastOthersFetch = Date.now();
Expand Down