import json 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, ALLOW_UPSTREAM_SPOTTING, MAX_SPOT_AGE, RECAPTCHA_SECRET_KEY from core.constants import UNKNOWN_BAND from core.lookup_helper 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 serialize_everything from data.sig_ref import SIGRef from data.spot import Spot RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" class APISpotHandler(tornado.web.RequestHandler): """API request handler for /api/v1/spot (POST)""" def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): self._spots = None self._web_server_metrics = None super().__init__(application, request, **kwargs) 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: # Metrics self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC) self._web_server_metrics["api_access_counter"] += 1 self._web_server_metrics["status"] = "OK" api_requests_counter.inc() # Reject if not allowed if not ALLOW_SPOTTING: self.set_status(401) self.write(json.dumps("Error - this server does not allow new spots to be added via the API.", default=serialize_everything)) 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( json.dumps("Error - request Content-Type must be application/json", default=serialize_everything)) 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(json.dumps("Error - request body is empty", default=serialize_everything)) 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 fields relating to how we handle the spot, such as CAPTCHA and upstream submission. Remove these # from the data so they don't accidentally end up in the spot object itself. # todo: Better way of separating these out. Possible without API change or not? submit_upstream = json_body.pop("submit_upstream", False) upstream_provider_name = json_body.pop("upstream_provider", None) upstream_credentials = json_body.pop("upstream_credentials", {}) captcha_token = json_body.pop("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 remaining fields to a Spot object spot = Spot(**json_body) # Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to # redo this in a functional style) if spot.sig and spot.sig_refs: real_sig_refs = [] for dict_obj in spot.sig_refs: dict_obj = {**dict_obj, "sig": spot.sig} real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d))) spot.sig_refs = real_sig_refs # 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(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.", default=serialize_everything)) 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(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.", default=serialize_everything)) 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(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.", default=serialize_everything)) 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(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.", default=serialize_everything)) 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(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.", default=serialize_everything)) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") return # Reject if sig_ref format incorrect for sig if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig( spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id): self.set_status(422) self.write(json.dumps( "Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".", default=serialize_everything)) 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(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 # Submit upstream if requested upstream_warning = None if submit_upstream and upstream_provider_name and spot.sig: 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, lambda: 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.add(spot.id, spot, expire=MAX_SPOT_AGE) if upstream_warning: self.write(json.dumps("Warning - " + upstream_warning, default=serialize_everything)) self.set_status(201) else: self.write(json.dumps("OK", default=serialize_everything)) self.set_status(201) self.set_header("Cache-Control", "no-store") self.set_header("Content-Type", "application/json") except Exception as e: logging.error(e) self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) 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): """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 def _verify_recaptcha(self, 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