import logging import re import threading from typing import Any import requests import tornado from tornado import httputil from tornado.ioloop import IOLoop from tornado.web import Application from core.activity_utils import get_ref_regex_for_activity 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, safe_json_dumps from data.spot import Spot from providers.spot.dxcluster import DXCluster from providers.spot.spot_provider import SpotProvider, SpotSubmissionError from providers.spot.tiles import Tiles logger = logging.getLogger(__name__) RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" class APISpotHandler(tornado.web.RequestHandler): """API request handler for /api/v3/spot (POST)""" def __init__( self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any, ): self._spots = None self._spot_providers = None super().__init__(application, request, **kwargs) def initialize(self, spots, spot_providers=None): self._spots = spots self._spot_providers = spot_providers or [] async def post(self): """Handle the post request with spot data. This is async because it could go on to submit spots to providers which could be slow, so we need to stop it blocking the whole web server""" try: # Reject if not allowed if not ALLOW_SPOTTING: self.set_status(401) self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject if format not json if not self.request.headers.get("Content-Type", "").startswith("application/json"): self.set_status(415) self.write(safe_json_dumps("Error - request Content-Type must be application/json")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject if request body is empty post_data = self.request.body if not post_data: self.set_status(422) self.write(safe_json_dumps("Error - request body is empty")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # 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(safe_json_dumps("Error - CAPTCHA token is required for spot submission.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return if not await IOLoop.current().run_in_executor(None, self._verify_recaptcha, captcha_token): self.set_status(422) self.write(safe_json_dumps("Error - CAPTCHA verification failed.")) 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: self.set_status(422) self.write( safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.") ) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject invalid-looking callsigns if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call): self.set_status(422) self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call): self.set_status(422) self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject if frequency not in a known band if infer_band_from_freq(spot.freq) == UNKNOWN_BAND: self.set_status(422) self.write(safe_json_dumps(f"Error - Frequency of {spot.freq / 1000.0!s}kHz is not in a known band.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject if grid formatting incorrect if spot.dx_grid and not re.match( r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$", spot.dx_grid.upper(), ): self.set_status(422) self.write(safe_json_dumps(f"Error - '{spot.dx_grid}' does not look like a valid Maidenhead grid.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject if any activity ref format is incorrect for its activity for activity_ref in spot.activity_refs: ref_regex = get_ref_regex_for_activity(activity_ref.activity) if activity_ref.activity else None if activity_ref.id and ref_regex and not re.match(ref_regex, activity_ref.id): self.set_status(422) self.write( safe_json_dumps( f"Error - '{activity_ref.id}' does not look like a valid reference for {activity_ref.activity}." ) ) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject upstream submission if not permitted if submit_upstream and not ALLOW_UPSTREAM_SPOTTING: self.set_status(403) self.write(safe_json_dumps("Error - this server does not allow upstream spot submission.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Validate upstream submission requirements if submit_upstream and upstream_provider_name: is_cluster = isinstance(self._find_provider(upstream_provider_name, spot.activities), DXCluster) is_tiles = isinstance(self._find_provider(upstream_provider_name, spot.activities), Tiles) if not spot.activity_refs and not is_tiles and not is_cluster: self.set_status(422) self.write(safe_json_dumps("Error - an activity reference is required to submit upstream.")) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return if not spot.dx_grid and is_tiles: self.set_status(422) self.write( safe_json_dumps("Error - a grid reference is required to submit upstream to Tiles on the Air.") ) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return if not spot.mode and is_tiles: self.set_status(422) self.write(safe_json_dumps("Error - a mode is required to submit upstream to Tiles on the Air.")) 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.activities) if provider: try: # Submit spot to the upstream provider. Run in a separate thread otherwise this blocks the whole # web server for everyone! await IOLoop.current().run_in_executor(None, provider.submit_spot, spot, upstream_credentials) # Trigger a re-poll after 3 second so the spot appears quickly. (Submitting to a cluster node is # slower than this, but we get data as a live stream from cluster anyway, so force_poll does # nothing in that case. This is really just for the HTTP providers when we submit a spot to them) threading.Timer(3.0, provider.force_poll).start() except NotImplementedError as e: upstream_warning = str(e) except SpotSubmissionError as e: logger.warning(f"Upstream submission to {upstream_provider_name} was not accepted: {e}") upstream_warning = ( f"Spot was saved locally but upstream submission to {upstream_provider_name} failed: {e}" ) except Exception: logger.exception(f"Failed to submit spot upstream to {upstream_provider_name}") upstream_warning = ( f"Spot was saved locally but upstream submission to {upstream_provider_name} failed." ) elif spot.activities: upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {', '.join(spot.activities)} spots." else: upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for spots with no activity." # 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.source = "API" spot.infer_missing() self._spots.set(spot.id, spot) if upstream_warning: self.write(safe_json_dumps(f"Warning - {upstream_warning}")) 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") except Exception: logger.exception("Exception when handling client request to add spot API") self.write(safe_json_dumps("Error - an internal server error occurred.")) self.set_status(500) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") def _find_provider(self, provider_name, activities) -> SpotProvider | None: """Find an enabled provider by name that can submit spots for at least one of the given activities. If there are no activities, find one that can submit spots with no activity.""" for p in self._spot_providers: if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities or [None]): 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: logger.exception("reCAPTCHA verification request failed") return False