import logging import re import threading from typing import Any import requests import tornado from tornado import httputil 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.spot_provider import SpotProvider logger = logging.getLogger(__name__) RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" class APISpotHandler(tornado.web.RequestHandler): """API request handler for /api/v2/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 [] def post(self): 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) # Spothole v2.0 release only: deny upstream spotting. Spothole API breaking changes were in v2.0 but # functionality is not ready yet. TODO submit_upstream = False # 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 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 activity ref format incorrect for activity if ( spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_activity(spot.sig) and not re.match(get_ref_regex_for_activity(spot.sig), spot.sig_refs[0].id) ): self.set_status(422) self.write( safe_json_dumps( f"Error - '{spot.sig_refs[0].id}' does not look like a valid reference for {spot.sig}." ) ) 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: if not spot.sig: self.set_status(422) self.write(safe_json_dumps("Error - an activity must be selected to submit upstream.")) 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(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 upstream_provider_name == "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 upstream_provider_name == "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.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: 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." ) else: upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {spot.sig if spot.sig else ''} 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(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, activity) -> SpotProvider | None: """Find an enabled provider by name that can submit spots for the given activity.""" for p in self._spot_providers: if p.enabled and p.name == provider_name and p.can_submit_spot(activity): 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