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}
${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 ? `` : '');
+ 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