Merge branch '95-send-spots-to-xota' into 2.0-pre

# Conflicts:
#	README.md
#	core/config.py
#	server/handlers/api/addspot.py
#	server/handlers/api/lookups.py
#	server/handlers/api/options.py
#	server/webserver.py
#	spothole.py
#	static/apidocs/openapi.yml
This commit is contained in:
Ian Renton
2026-08-08 09:32:27 +01:00
38 changed files with 947 additions and 213 deletions
+134 -12
View File
@@ -1,33 +1,40 @@
import logging
import re
import threading
from datetime import datetime
from typing import Any
import pytz
import requests
import tornado
from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
from core.constants import UNKNOWN_BAND
from core.utils import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import safe_json_dumps
from data.spot import Spot
from spotproviders.spot_provider import SpotProvider
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST)"""
"""API request handler for /api/v2/spot (POST)"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics):
def initialize(self, spots, web_server_metrics, spot_providers=None):
self._spots = spots
self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def post(self):
try:
@@ -62,9 +69,38 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# Read in the request body as JSON then convert to a Spot object
json_spot = tornado.escape.json_decode(post_data)
spot = Spot(**json_spot)
# Read in the request body as JSON
json_body = tornado.escape.json_decode(post_data)
# Extract the "spot" and "handling" sub-objects from the request body
spot_data = json_body.get("spot", {})
handling = json_body.get("handling", {})
# Extract individual parameters that say how this spot should be handled by the server
submit_upstream = handling.get("submit_upstream", False)
upstream_provider_name = handling.get("upstream_provider", None)
upstream_credentials = handling.get("upstream_credentials", {})
captcha_token = handling.get("captcha_token", None)
# Verify CAPTCHA if required
if RECAPTCHA_SECRET_KEY:
if not captcha_token:
self.set_status(422)
self.write(json.dumps("Error - CAPTCHA token is required for spot submission.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not self._verify_recaptcha(captcha_token):
self.set_status(422)
self.write(json.dumps("Error - CAPTCHA verification failed.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Convert spot field to a Spot object
spot = Spot(**spot_data)
# Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
@@ -116,13 +152,78 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# infer missing data, and add it to our database.
spot.source = "API"
spot.infer_missing()
self._spots.set(spot.id, spot)
# Reject upstream submission if not permitted
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
self.set_status(403)
self.write(json.dumps("Error - this server does not allow upstream spot submission.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
self.write(safe_json_dumps("OK"))
self.set_status(201)
# Validate upstream submission requirements
if submit_upstream and upstream_provider_name:
if not spot.sig:
self.set_status(422)
self.write(json.dumps("Error - a SIG must be selected to submit upstream.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.sig_refs and upstream_provider_name != "Tiles":
self.set_status(422)
self.write(json.dumps("Error - a SIG reference is required to submit upstream.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.dx_grid and upstream_provider_name == "Tiles":
self.set_status(422)
self.write(json.dumps("Error - a grid reference is required to submit upstream to Tiles on the Air.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.mode and upstream_provider_name == "Tiles":
self.set_status(422)
self.write(json.dumps("Error - a mode is required to submit upstream to Tiles on the Air.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Submit upstream if requested
upstream_warning = None
if submit_upstream and upstream_provider_name:
provider = self._find_provider(upstream_provider_name, spot.sig)
if provider:
try:
# Submit spot to the upstream provider
provider.submit_spot(spot, upstream_credentials)
# Trigger a re-poll after 1 second so the spot appears quickly
threading.Timer(1.0, provider.force_poll).start()
except NotImplementedError as e:
upstream_warning = str(e)
except Exception as e:
logging.warning("Failed to submit spot upstream to " + upstream_provider_name + ": " + str(e))
upstream_warning = "Spot was saved locally but upstream submission to " + upstream_provider_name + " failed: " + str(
e)
else:
upstream_warning = "No enabled provider named '" + upstream_provider_name + "' supports upstream submission for " + spot.sig + " spots."
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
# we were but it failed, we should still add it to our database anyway.
if not submit_upstream or upstream_warning:
spot.infer_missing()
self._spots.set(spot.id, spot)
if upstream_warning:
self.write(json.dumps("Warning - " + upstream_warning, default=serialize_everything))
self.set_status(201)
else:
self.write(safe_json_dumps("OK"))
self.set_status(201)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
@@ -132,3 +233,24 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, sig) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given SIG."""
for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(sig):
return p
return None
@staticmethod
def _verify_recaptcha(token):
"""Verify a Google reCAPTCHA v2 token. Returns True if valid."""
try:
response = requests.post(RECAPTCHA_VERIFY_URL,
data={"secret": RECAPTCHA_SECRET_KEY, "response": token},
timeout=(5, 10))
return response.ok and response.json().get("success", False)
except Exception as e:
logging.warning("reCAPTCHA verification request failed: " + str(e))
return False
+4 -4
View File
@@ -15,7 +15,7 @@ from data.lookup_credentials import extract_credentials
class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/alerts"""
"""API request handler for /api/v2/alerts"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._alerts = None
@@ -48,7 +48,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all alerts matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params)
credentials = extract_credentials(self.request.headers)
data = get_alert_list_with_filters(self._alerts, query_params)
if credentials:
data = self._enrich(data, credentials)
@@ -66,7 +66,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v1/alerts/stream"""
"""API request handler for /api/v2/alerts/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_alert_broadcaster = None
@@ -96,7 +96,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
self._credentials = extract_credentials(self.request.headers)
# Flush headers immediately so nginx doesn't time out waiting for a response
self.write_message("keepalive", "")
+1 -1
View File
@@ -19,7 +19,7 @@ BANDS_SET = frozenset(BANDS)
class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/dxstats"""
"""API request handler for /api/v2/dxstats"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
+4 -4
View File
@@ -20,7 +20,7 @@ from data.sig_ref import SIGRef
class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/call"""
"""API request handler for /api/v2/lookup/call"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
@@ -45,7 +45,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
if "call" in query_params.keys():
call = str(query_params.get("call")).upper()
if re.match(r"^[A-Z0-9/\-]*$", call):
credentials = extract_credentials(query_params)
credentials = extract_credentials(self.request.headers)
callsign_data = get_call_info(call, credentials)
self.write(safe_json_dumps(callsign_data))
@@ -66,7 +66,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupSIGRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/sigref"""
"""API request handler for /api/v2/lookup/sigref"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
@@ -118,7 +118,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/grid"""
"""API request handler for /api/v2/lookup/grid"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
+31 -14
View File
@@ -14,16 +14,18 @@ from core.utils import safe_json_dumps
class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/options"""
"""API request handler for /api/v2/options"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None
self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics):
def initialize(self, status_data, web_server_metrics, spot_providers=None):
self._status_data = status_data
self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def get(self):
try:
@@ -33,25 +35,40 @@ class APIOptionsHandler(tornado.web.RequestHandler):
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
spot_submit_providers = {}
for provider in self._spot_providers:
if not provider.enabled:
continue
for sig in SIGS:
if provider.can_submit_spot(sig.name):
spot_submit_providers.setdefault(sig.name, []).append(provider.name)
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
# things that aren't even available.
spot_providers: list = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"])))
alert_providers = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"])))
callsign_data_providers = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["callsign_data_providers"])))
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our providers.
if ALLOW_SPOTTING:
spot_providers.append("API")
options = {"bands": BANDS,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
"sigs": SIGS,
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
"spot_providers": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"]))),
"alert_providers": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
"callsign_data_providers": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["callsign_data_providers"]))),
"spot_providers": spot_providers,
"alert_providers": alert_providers,
"callsign_data_providers": callsign_data_providers,
"continents": CONTINENTS,
"propagation_modes": list(PROPAGATION_MODES.values()),
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING}
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers.
if ALLOW_SPOTTING:
options["spot_providers"].append("API")
"spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers}
self.write(safe_json_dumps(options))
self.set_status(200)
+1 -1
View File
@@ -12,7 +12,7 @@ from core.utils import safe_json_dumps
class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/solar"""
"""API request handler for /api/v2/solar"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._solar_conditions = None
+4 -4
View File
@@ -15,7 +15,7 @@ from data.lookup_credentials import extract_credentials
class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spots"""
"""API request handler for /api/v2/spots"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
@@ -48,7 +48,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all spots matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params)
credentials = extract_credentials(self.request.headers)
data = get_spot_list_with_filters(self._spots, query_params)
if credentials:
data = self._enrich(data, credentials)
@@ -66,7 +66,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v1/spots/stream"""
"""API request handler for /api/v2/spots/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_spot_broadcaster = None
@@ -98,7 +98,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
self._credentials = extract_credentials(self.request.headers)
# Flush headers immediately so nginx doesn't time out waiting for a response
self.write_message("keepalive", "")
+1 -1
View File
@@ -12,7 +12,7 @@ from core.utils import safe_json_dumps
class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/status"""
"""API request handler for /api/v2/status"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None
+31
View File
@@ -0,0 +1,31 @@
import json
import tornado
from core.utils import serialize_everything
class V1GoneHandler(tornado.web.RequestHandler):
"""Returns 410 Gone with a message for any endpoints in the old API that have breaking changes in the new one or
have been retired."""
def post(self):
self.set_status(410)
self.write(json.dumps(
"This API endpoint has a breaking change or has been removed in the current version of the Spothole API. Please see /apidocs for details of the current API version and the endpoints available.",
default=serialize_everything
))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
class V1RedirectHandler(tornado.web.RequestHandler):
"""Returns 308 Permanent Redirect from any path in the old API to the new one, where there were no breaking changes."""
def get(self, path):
new_url = "/api/v2/" + path
if self.request.query:
new_url += "?" + self.request.query
self.set_status(308)
self.set_header("Location", new_url)
self.finish()