From 20a266563c581181893c04b919aa1ab030de6b82 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Fri, 28 Aug 2026 23:28:28 -0400 Subject: [PATCH] fix(aircraft): show planespotters photo in overlay popup Planespotters 403s server clients whose User-Agent has no contact email; compose default UA lacked one. Leaflet popup.update() also re-ran the bindPopup factory and wiped the thumbnail after load. - Send a contact UA on planespotters lookups; align compose default. - Resize the popup without re-rendering content. - Keep hex/src on live firefighter upserts so they get a photo too. --- app/live_layers.py | 22 ++++++++++++++++---- app/static/index.html | 42 +++++++++++++++++++++++++++------------ docker-compose.yml | 4 ++-- tests/test_live_layers.py | 18 +++++++++++++---- 4 files changed, 63 insertions(+), 23 deletions(-) diff --git a/app/live_layers.py b/app/live_layers.py index ace617e..4e3b1fd 100644 --- a/app/live_layers.py +++ b/app/live_layers.py @@ -751,6 +751,18 @@ def _headers() -> dict[str, str]: return {"User-Agent": OSINT_USER_AGENT, "Accept": "application/json"} +def _planespotters_headers() -> dict[str, str]: + """Planespotters 403s server clients whose UA has no contact URL/email.""" + ua = (OSINT_USER_AGENT or "").strip() + if ua and ("@" in ua or "http://" in ua.lower() or "https://" in ua.lower()): + return {"User-Agent": ua, "Accept": "application/json"} + base = ua or "osint-dashboard/1.0" + return { + "User-Agent": f"{base} (lancewalters94@gmail.com)", + "Accept": "application/json", + } + + async def _lock_for(key: str) -> asyncio.Lock: async with _key_locks_guard: lock = _key_locks.get(key) @@ -794,15 +806,17 @@ async def close_http() -> None: _http = None -async def _get_json(url: str, params: dict | None = None) -> Any: +async def _get_json( + url: str, params: dict | None = None, headers: dict | None = None, +) -> Any: if _http is None: async with httpx.AsyncClient( timeout=_HTTP_TIMEOUT, follow_redirects=True, headers=_headers(), ) as client: - resp = await client.get(url, params=params) + resp = await client.get(url, params=params, headers=headers) resp.raise_for_status() return resp.json() - resp = await _http.get(url, params=params) + resp = await _http.get(url, params=params, headers=headers) resp.raise_for_status() return resp.json() @@ -851,7 +865,7 @@ async def fetch_planespotters_photo( return None async def _load() -> dict | None: - data = await _get_json(url) + data = await _get_json(url, headers=_planespotters_headers()) photos = data.get("photos") or [] return _normalize_planespotter_photo(photos[0]) if photos else None diff --git a/app/static/index.html b/app/static/index.html index 4d2622e..07cfd7f 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -356,9 +356,13 @@ .cam-pop td { padding: 0.12rem 0.2rem; vertical-align: top; } .cam-pop td.k { color: var(--muted); text-transform: uppercase; font-size: 0.6rem; letter-spacing: 0.05em; white-space: nowrap; width: 34%; font-family: 'Share Tech Mono', monospace; } .cam-pop a { color: var(--cyan); word-break: break-all; } - .cam-pop .ps-photo { margin-top: 0.35rem; } + .cam-pop .ps-photo { margin-top: 0.45rem; } .cam-pop .ps-photo a.ps-link { display: block; } - .cam-pop .ps-thumb { width: 100%; height: auto; max-height: 220px; object-fit: contain; border-radius: 4px; border: 1px solid var(--line); background: var(--bg-0); display: block; } + .cam-pop .ps-thumb { + width: 100%; height: auto; max-height: 220px; object-fit: contain; + border-radius: 4px; border: 1px solid var(--line); background: var(--bg-0); + display: block; box-shadow: 0 0 10px rgba(53,224,255,0.2); + } .cam-pop .ps-credit { font-size: 0.68rem; color: var(--muted); margin-top: 0.2rem; } .cam-pop .ps-credit a { color: var(--cyan); } .cam-pop .ps-loading { font-size: 0.68rem; color: var(--muted); margin-top: 0.35rem; } @@ -1872,7 +1876,7 @@ function applyLiveMarker(kind, p) { if (acOn && p.aircraft_lat != null) { acGroup = upsertLivePoint(acGroup, { id: p.aircraft_hex, lat: p.aircraft_lat, lon: p.aircraft_lon, - label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true }, + label: p.label || p.aircraft_hex, extra: { type: p.aircraft_type, firefighter: true, hex: p.aircraft_hex, src: 'adsb.lol' }, }, q => acColor(q), 'ac'); } } @@ -1990,7 +1994,7 @@ async function initMap() { } startHlsFrom(root); }); - map.on('popupopen', (e) => { loadPlanePhoto(e.popup.getElement()); }); + map.on('popupopen', (e) => { loadPlanePhoto(e.popup); }); map.on('popupclose', () => { if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; } }); @@ -2656,18 +2660,19 @@ function pointPopup(p) { .forEach(k => add(k, extra[k])); } let photoBlock = ''; - if (src === 'adsb.lol' && (extra.hex || extra.reg)) { - photoBlock = `
`; + const hex = extra.hex || p.id || ''; + if (src === 'adsb.lol' && (hex || extra.reg)) { + photoBlock = `
`; } return `
${esc(p.label || p.id)}${badge}${rows.join('')}
${photoBlock}
`; } /* ── Planespotters.net aircraft photos ──────────────────────────────────── - Lazy-loaded into the ADS-B popup. Server-side endpoint (/api/aircraft/photo) - proxies the JSON (planespotters 403s any Origin-bearing request, which every - browser fetch() sends), but the thumbnail binary is always loaded by the - browser straight from the planespotters CDN — never re-hosted. */ -function loadPlanePhoto(root) { + Lazy-loaded into the ADS-B popup. Server-side /api/aircraft/photo looks up + JSON (their ToS requires a contact User-Agent on server clients). The + thumbnail binary is loaded by the browser from their CDN — never re-hosted. */ +function loadPlanePhoto(popup) { + const root = popup && (popup.getElement ? popup.getElement() : popup); if (!root) return; root.querySelectorAll('.ps-photo').forEach(box => { if (box.dataset.loaded === '1' || box.dataset.loading === '1') return; @@ -2676,6 +2681,13 @@ function loadPlanePhoto(root) { if (!hex && !reg) { box.remove(); return; } box.dataset.loading = '1'; box.innerHTML = `
photo…
`; + const bump = () => { + if (!popup || !popup._map) return; + // popup.update() re-runs bindPopup()'s factory and wipes the . + if (popup._updateLayout) popup._updateLayout(); + if (popup._updatePosition) popup._updatePosition(); + if (popup._adjustPan) popup._adjustPan(); + }; const q = hex ? `hex=${encodeURIComponent(hex)}` : `reg=${encodeURIComponent(reg)}`; fetch(`${API}/api/aircraft/photo?${q}`, { headers: { 'Accept': 'application/json' } }) .then(r => { @@ -2684,7 +2696,7 @@ function loadPlanePhoto(root) { return r.json(); }) .then(ph => { - if (!ph || !ph.src) { box.remove(); return; } + if (!ph || !ph.src) { box.remove(); bump(); return; } const link = esc(ph.link || ''); const alt = `Photo of ${esc(reg || hex)}`; const credit = ph.photographer ? `© ${esc(ph.photographer)}` : ''; @@ -2695,8 +2707,11 @@ function loadPlanePhoto(root) { (ph.width ? ` width="${esc(ph.width)}"` : '') + (ph.height ? ` height="${esc(ph.height)}"` : '') + `>` + (credit ? `
${credit}
` : ''); + const img = box.querySelector('img'); + if (img && !img.complete) img.addEventListener('load', bump, { once: true }); + else bump(); }) - .catch(() => { box.remove(); }); + .catch(() => { box.remove(); bump(); }); }); } @@ -2988,6 +3003,7 @@ async function loadAircraft() { acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => acColor(p), true, 'ac'); document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString(); addExtraAttrib('ADSB.lol ODbL'); + addExtraAttrib('Photo © planespotters.net'); } catch (e) { if (isAbort(e)) return; console.error('Aircraft load failed', e); diff --git a/docker-compose.yml b/docker-compose.yml index e854010..00fd71c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,7 +93,7 @@ services: FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT} FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75} FIRMS_INTERVAL: ${FIRMS_INTERVAL:-900} - OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted)} + OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)} AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-} AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66} AISSTREAM_IN_INGEST: ${AISSTREAM_IN_INGEST:-0} @@ -127,7 +127,7 @@ services: FIRMS_DATASET: ${FIRMS_DATASET:-VIIRS_NOAA20_NRT} FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT} FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75} - OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted)} + OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)} AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-} AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66} AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1} diff --git a/tests/test_live_layers.py b/tests/test_live_layers.py index 6982c87..ff70c8a 100644 --- a/tests/test_live_layers.py +++ b/tests/test_live_layers.py @@ -610,8 +610,8 @@ def test_fetch_planespotters_photo_hex_builds_url_and_normalizes(monkeypatch): seen = [] - async def fake_get(url, params=None): - seen.append(url) + async def fake_get(url, params=None, headers=None): + seen.append((url, (headers or {}).get("User-Agent", ""))) return {"photos": [{ "id": "1", "thumbnail": {"src": "https://t.plnspttrs.net/a_t.jpg"}, "thumbnail_large": {"src": "https://t.plnspttrs.net/a_280.jpg"}, @@ -622,7 +622,8 @@ def test_fetch_planespotters_photo_hex_builds_url_and_normalizes(monkeypatch): _cache.clear() out = asyncio.run(fetch_planespotters_photo(hex_code="e8027e")) assert out["src"] == "https://t.plnspttrs.net/a_280.jpg" - assert seen == ["https://api.planespotters.net/pub/photos/hex/e8027e"] + assert seen[0][0] == "https://api.planespotters.net/pub/photos/hex/e8027e" + assert "@" in seen[0][1] or "http" in seen[0][1] def test_fetch_planespotters_photo_reg_fallback_and_no_result(monkeypatch): @@ -632,7 +633,7 @@ def test_fetch_planespotters_photo_reg_fallback_and_no_result(monkeypatch): seen = [] - async def fake_get(url, params=None): + async def fake_get(url, params=None, headers=None): seen.append(url) return {"photos": []} @@ -642,3 +643,12 @@ def test_fetch_planespotters_photo_reg_fallback_and_no_result(monkeypatch): assert seen == ["https://api.planespotters.net/pub/photos/reg/D-ABCD"] # no hex and no reg → no upstream call at all assert asyncio.run(fetch_planespotters_photo()) is None + + +def test_planespotters_headers_add_contact_when_ua_is_generic(monkeypatch): + import live_layers + + monkeypatch.setattr(live_layers, "OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted)") + ua = live_layers._planespotters_headers()["User-Agent"] + assert "osint-dashboard" in ua + assert "@" in ua -- 2.45.3