mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-13 15:27:25 +02:00
0fee36e8f7
Wikimedia's User-Agent policy asks API clients to identify themselves with a stable, contactable identifier so their operators can rate-limit or coordinate. Before this change, ShadowBroker was sending: - Backend (region_dossier.py): generic project default UA only; no Api-User-Agent. - Frontend (useRegionDossier.ts, WikiImage.tsx, NewsFeed.tsx): zero identifying header at all; three separate copy-pasted anonymous fetches with their own module-local caches. Three separate components doing the same broken thing meant policy fixes had to happen in three places, with no shared cache or kill switch. Fix (no UX change, zero hostility): == Backend == `backend/services/region_dossier.py` now sets explicit `User-Agent` + `Api-User-Agent` headers on every outbound Wikidata and Wikipedia request via a new `_WIKIMEDIA_REQUEST_HEADERS` constant. The identifier includes a contact path (issues page on the public GitHub repo). == Frontend == New shared helper `frontend/src/lib/wikimediaClient.ts`: - `fetchWikipediaSummary(title)` — single source of truth for Wikipedia REST summary lookups, with one shared LRU cache (in-flight requests deduplicated, 512-entry cap), `Api-User-Agent` on every fetch. - `fetchWikidataSparql(query)` — same shape for Wikidata SPARQL. - `WIKIMEDIA_API_USER_AGENT` — exported constant; one place to update if Wikimedia ever asks us to back off. Refactored three components to use the shared client: - `frontend/src/hooks/useRegionDossier.ts` — fetchLeader() and fetchLocalWikiSummary() now route through the shared helpers. - `frontend/src/components/WikiImage.tsx` — uses fetchWikipediaSummary, proper React state instead of module-mutation + forceUpdate trick. - `frontend/src/components/NewsFeed.tsx` — same shape. UX: byte-for-byte identical. Same thumbnails, same dossier content, same load behavior. The only observable difference is the outgoing request header. Note on #239 (route duplication): an audit-grade inventory shows 166 main.py routes are shadowed by router modules. That cleanup is too large to land safely in this PR; it will be staged as a separate ladder of small PRs grouped by router module. Tests: - `backend/tests/test_region_dossier_wikimedia_ua.py` — 3 tests asserting backend headers are present. - `frontend/src/__tests__/utils/wikimediaClient.test.ts` — 9 tests covering Api-User-Agent presence, shared cache, concurrent deduplication, disambiguation/HTTP-error/network-error fallthroughs, empty-input safety. Local: backend 76/76 security suite green, frontend 716/716 vitest suite green. Credit: tg12 (external security audit).
300 lines
12 KiB
Python
300 lines
12 KiB
Python
import logging
|
|
import time
|
|
import concurrent.futures
|
|
from urllib.parse import quote
|
|
import requests as _requests
|
|
from cachetools import TTLCache
|
|
from services.network_utils import fetch_with_curl, DEFAULT_USER_AGENT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cache dossier results for 24 hours — country data barely changes
|
|
# Key: rounded lat/lng grid (0.1 degree ≈ 11km)
|
|
dossier_cache = TTLCache(maxsize=500, ttl=86400)
|
|
|
|
# Nominatim requires max 1 req/sec — track last call time
|
|
_nominatim_last_call = 0.0
|
|
|
|
# Issue #218 / #219 (tg12): Wikimedia's User-Agent policy requires API
|
|
# clients to identify themselves with a stable User-Agent that includes
|
|
# a contact path. Bare "python-requests/x.y" or generic strings violate
|
|
# the policy and risk getting blocked. We send the project default UA
|
|
# (operator-overridable via SHADOWBROKER_USER_AGENT) on EVERY outbound
|
|
# Wikimedia request, plus the policy-recommended Api-User-Agent which
|
|
# Wikimedia explicitly accepts on top of the regular UA.
|
|
#
|
|
# This is documented and stable so a Wikimedia operator who wants to
|
|
# rate-limit or contact us has a fixed identifier to grep for.
|
|
_WIKIMEDIA_REQUEST_HEADERS = {
|
|
"User-Agent": DEFAULT_USER_AGENT,
|
|
"Api-User-Agent": (
|
|
f"{DEFAULT_USER_AGENT} "
|
|
"(+https://github.com/BigBodyCobain/Shadowbroker; "
|
|
"report issues at /issues)"
|
|
),
|
|
}
|
|
|
|
|
|
def _reverse_geocode_offline(lat: float, lng: float) -> dict:
|
|
"""Offline fallback via reverse_geocoder when external reverse geocoding is blocked."""
|
|
try:
|
|
import reverse_geocoder as rg
|
|
|
|
hit = rg.search((lat, lng), mode=1)[0]
|
|
country_code = (hit.get("cc") or "").upper()
|
|
city = hit.get("name") or ""
|
|
state = hit.get("admin1") or ""
|
|
display = ", ".join(part for part in [city, state, country_code] if part)
|
|
return {
|
|
"city": city,
|
|
"state": state,
|
|
"country": country_code or "Unknown",
|
|
"country_code": country_code,
|
|
"display_name": display,
|
|
"offline_fallback": True,
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Offline reverse geocode failed: {e}")
|
|
return {}
|
|
|
|
|
|
def _reverse_geocode(lat: float, lng: float) -> dict:
|
|
global _nominatim_last_call
|
|
url = (
|
|
f"https://nominatim.openstreetmap.org/reverse?"
|
|
f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en"
|
|
)
|
|
headers = {
|
|
"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"
|
|
}
|
|
|
|
for attempt in range(2):
|
|
# Enforce Nominatim's 1 req/sec policy
|
|
elapsed = time.time() - _nominatim_last_call
|
|
if elapsed < 1.1:
|
|
time.sleep(1.1 - elapsed)
|
|
_nominatim_last_call = time.time()
|
|
|
|
try:
|
|
# Use requests directly — fetch_with_curl raises on non-200 which breaks 429 handling
|
|
res = _requests.get(url, timeout=4, headers=headers)
|
|
if res.status_code == 200:
|
|
data = res.json()
|
|
addr = data.get("address", {})
|
|
return {
|
|
"city": addr.get("city")
|
|
or addr.get("town")
|
|
or addr.get("village")
|
|
or addr.get("county")
|
|
or "",
|
|
"state": addr.get("state") or addr.get("region") or "",
|
|
"country": addr.get("country") or "",
|
|
"country_code": (addr.get("country_code") or "").upper(),
|
|
"display_name": data.get("display_name", ""),
|
|
}
|
|
elif res.status_code == 429:
|
|
logger.warning(
|
|
f"Nominatim 429 rate-limited, retrying after 1s (attempt {attempt+1})"
|
|
)
|
|
time.sleep(1)
|
|
continue
|
|
else:
|
|
logger.warning(f"Nominatim returned {res.status_code}")
|
|
except (_requests.RequestException, ConnectionError, TimeoutError, OSError) as e:
|
|
logger.warning(f"Reverse geocode failed: {e}")
|
|
return _reverse_geocode_offline(lat, lng)
|
|
|
|
|
|
def _fetch_country_data(country_code: str) -> dict:
|
|
if not country_code:
|
|
return {}
|
|
url = (
|
|
f"https://restcountries.com/v3.1/alpha/{country_code}"
|
|
f"?fields=name,population,capital,languages,region,subregion,area,currencies,borders,flag"
|
|
)
|
|
try:
|
|
res = fetch_with_curl(url, timeout=5)
|
|
if res.status_code == 200:
|
|
data = res.json()
|
|
if isinstance(data, list):
|
|
return data[0] if data and isinstance(data[0], dict) else {}
|
|
return data if isinstance(data, dict) else {}
|
|
except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
|
logger.warning(f"RestCountries failed for {country_code}: {e}")
|
|
return {}
|
|
|
|
|
|
def _fetch_wikidata_leader(country_name: str) -> dict:
|
|
if not country_name:
|
|
return {"leader": "Unknown", "government_type": "Unknown"}
|
|
# SPARQL: get head of state (P35) and form of government (P122) for a sovereign state
|
|
safe_name = country_name.replace('"', '\\"').replace("'", "\\'")
|
|
sparql = f"""
|
|
SELECT ?leaderLabel ?govTypeLabel WHERE {{
|
|
?country wdt:P31 wd:Q6256 ;
|
|
rdfs:label "{safe_name}"@en .
|
|
OPTIONAL {{ ?country wdt:P35 ?leader . }}
|
|
OPTIONAL {{ ?country wdt:P122 ?govType . }}
|
|
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
|
|
}} LIMIT 1
|
|
"""
|
|
url = f"https://query.wikidata.org/sparql?query={quote(sparql)}&format=json"
|
|
try:
|
|
# Issue #218 (tg12): Wikimedia's User-Agent policy requires
|
|
# outbound API traffic to be identifiable. fetch_with_curl()
|
|
# sends the project default, and we also add the Wikimedia-
|
|
# specific Api-User-Agent that the policy specifically asks
|
|
# for, since this request originates from a backend service
|
|
# that proxies on behalf of (potentially many) browser users.
|
|
res = fetch_with_curl(url, timeout=6, headers=_WIKIMEDIA_REQUEST_HEADERS)
|
|
if res.status_code == 200:
|
|
results = res.json().get("results", {}).get("bindings", [])
|
|
if results:
|
|
r = results[0]
|
|
return {
|
|
"leader": r.get("leaderLabel", {}).get("value", "Unknown"),
|
|
"government_type": r.get("govTypeLabel", {}).get("value", "Unknown"),
|
|
}
|
|
except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
|
|
logger.warning(f"Wikidata SPARQL failed for {country_name}: {e}")
|
|
return {"leader": "Unknown", "government_type": "Unknown"}
|
|
|
|
|
|
def _fetch_local_wiki_summary(place_name: str, country_name: str = "") -> dict:
|
|
if not place_name:
|
|
return {}
|
|
# Try exact match first, then with country qualifier
|
|
candidates = [place_name]
|
|
if country_name:
|
|
candidates.append(f"{place_name}, {country_name}")
|
|
|
|
for name in candidates:
|
|
slug = quote(name.replace(" ", "_"))
|
|
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{slug}"
|
|
try:
|
|
# Issue #219 (tg12): identify ourselves to Wikimedia per
|
|
# their UA policy; see _fetch_wikidata_leader above.
|
|
res = fetch_with_curl(url, timeout=5, headers=_WIKIMEDIA_REQUEST_HEADERS)
|
|
if res.status_code == 200:
|
|
data = res.json()
|
|
if data.get("type") != "disambiguation":
|
|
return {
|
|
"description": data.get("description", ""),
|
|
"extract": data.get("extract", ""),
|
|
"thumbnail": data.get("thumbnail", {}).get("source", ""),
|
|
}
|
|
except (
|
|
ConnectionError,
|
|
TimeoutError,
|
|
ValueError,
|
|
KeyError,
|
|
OSError,
|
|
): # Intentional: optional enrichment
|
|
continue
|
|
return {}
|
|
|
|
|
|
def get_region_dossier(lat: float, lng: float) -> dict:
|
|
cache_key = f"{round(lat, 1)}_{round(lng, 1)}"
|
|
if cache_key in dossier_cache:
|
|
return dossier_cache[cache_key]
|
|
|
|
# Step 1: Reverse geocode
|
|
geo = _reverse_geocode(lat, lng)
|
|
if not geo or not geo.get("country"):
|
|
return {
|
|
"coordinates": {"lat": lat, "lng": lng},
|
|
"location": geo or {},
|
|
"country": None,
|
|
"local": None,
|
|
"error": "No country data — possibly international waters or uninhabited area",
|
|
}
|
|
|
|
country_code = geo.get("country_code", "")
|
|
country_name = geo.get("country", "")
|
|
city_name = geo.get("city", "")
|
|
state_name = geo.get("state", "")
|
|
|
|
# Step 2: Parallel fetch with real timeouts that do not block on executor shutdown
|
|
pool = concurrent.futures.ThreadPoolExecutor(max_workers=4)
|
|
try:
|
|
country_fut = pool.submit(_fetch_country_data, country_code)
|
|
leader_fut = pool.submit(_fetch_wikidata_leader, country_name)
|
|
local_fut = pool.submit(
|
|
_fetch_local_wiki_summary, city_name or state_name, country_name
|
|
)
|
|
country_wiki_fut = pool.submit(_fetch_local_wiki_summary, country_name, "")
|
|
|
|
try:
|
|
country_data = country_fut.result(timeout=6)
|
|
except Exception: # Intentional: optional enrichment
|
|
logger.warning("Country data fetch timed out or failed")
|
|
country_data = {}
|
|
try:
|
|
leader_data = leader_fut.result(timeout=6)
|
|
except Exception: # Intentional: optional enrichment
|
|
logger.warning("Leader data fetch timed out or failed")
|
|
leader_data = {"leader": "Unknown", "government_type": "Unknown"}
|
|
try:
|
|
local_data = local_fut.result(timeout=5)
|
|
except Exception: # Intentional: optional enrichment
|
|
logger.warning("Local wiki fetch timed out or failed")
|
|
local_data = {}
|
|
try:
|
|
country_wiki_data = country_wiki_fut.result(timeout=5)
|
|
except Exception: # Intentional: optional enrichment
|
|
country_wiki_data = {}
|
|
finally:
|
|
pool.shutdown(wait=False, cancel_futures=True)
|
|
|
|
# If no local data but we have country wiki summary, use that
|
|
if not local_data.get("extract") and country_wiki_data.get("extract"):
|
|
local_data = country_wiki_data
|
|
|
|
# Build languages list
|
|
languages = country_data.get("languages", {})
|
|
lang_list = list(languages.values()) if isinstance(languages, dict) else []
|
|
|
|
# Build currencies
|
|
currencies = country_data.get("currencies", {})
|
|
currency_list = []
|
|
if isinstance(currencies, dict):
|
|
for v in currencies.values():
|
|
if isinstance(v, dict):
|
|
symbol = v.get("symbol", "")
|
|
name = v.get("name", "")
|
|
currency_list.append(f"{name} ({symbol})" if symbol else name)
|
|
|
|
result = {
|
|
"coordinates": {"lat": lat, "lng": lng},
|
|
"location": geo,
|
|
"country": {
|
|
"name": country_data.get("name", {}).get("common", country_name),
|
|
"official_name": country_data.get("name", {}).get("official", ""),
|
|
"leader": leader_data.get("leader", "Unknown"),
|
|
"government_type": leader_data.get("government_type", "Unknown"),
|
|
"population": country_data.get("population", 0),
|
|
"capital": (
|
|
(country_data.get("capital") or ["Unknown"])[0]
|
|
if isinstance(country_data.get("capital"), list)
|
|
else "Unknown"
|
|
),
|
|
"languages": lang_list,
|
|
"currencies": currency_list,
|
|
"region": country_data.get("region", ""),
|
|
"subregion": country_data.get("subregion", ""),
|
|
"area_km2": country_data.get("area", 0),
|
|
"flag_emoji": country_data.get("flag", ""),
|
|
},
|
|
"local": {
|
|
"name": city_name,
|
|
"state": state_name,
|
|
"description": local_data.get("description", ""),
|
|
"summary": local_data.get("extract", ""),
|
|
"thumbnail": local_data.get("thumbnail", ""),
|
|
},
|
|
}
|
|
|
|
dossier_cache[cache_key] = result
|
|
return result
|