diff --git a/README.md b/README.md index d4c998f..908dc0f 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,11 @@ This project would not have been possible without these libraries, so many thank ### Third Party Libraries -A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to their -own licences and are not covered by the overall licence declared in the `LICENSE` file. +A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to +their own licences and are not covered by the overall licence declared in the `LICENSE` file. + +A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to +their own licences and are not covered by the overall licence declared in the `LICENSE` file. Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for diff --git a/config-example.yml b/config-example.yml index f98bfdb..0dd3866 100644 --- a/config-example.yml +++ b/config-example.yml @@ -167,7 +167,7 @@ alert-providers: # Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band -# conditions, etc.) and make it available via the /api/v1/solar endpoint. +# conditions, etc.) and make it available via the /api/v2/solar endpoint. solar-condition-providers: - class: "HamQSL" enabled: true @@ -299,6 +299,20 @@ max-alert-age-sec: 604800 # Allow submitting spots to the Spothole API? allow-spotting: true +# Allow upstream submission of spots to external providers (POTA, SOTA, etc.) via the API? +# Requires allow-spotting to also be true. Set to false to only accept spots into the local +# Spothole database, without forwarding them to any external service. +allow-upstream-spotting: true + +# Google reCAPTCHA v2 keys for CAPTCHA protection on upstream spot submission. Both keys must be set to enable CAPTCHA. +# Leave both empty to disable CAPTCHA (e.g. for a private/trusted server) or if allow-spotting is false, in which case +# they will do nothing. Note that with CAPTCHA enabled, this will prevent third-party clients submitting spots through +# Spothole unless the clients are web-based, use the same site key, have their domains enabled in your reCAPTCHA config, +# and of course their user solves the CAPTCHA. +# You can sign up for reCAPTCHA at https://www.google.com/recaptcha/ +recaptcha-site-key: "" +recaptcha-secret-key: "" + # Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up # your log quickly on a popular server. log-web-requests: false diff --git a/core/config.py b/core/config.py index 128ac96..01392b7 100644 --- a/core/config.py +++ b/core/config.py @@ -15,14 +15,17 @@ with open("config.yml") as f: config = yaml.safe_load(f) logging.info("Loaded config.") -BASE_URL = config["base-url"] -MAX_SPOT_AGE = config["max-spot-age-sec"] -MAX_ALERT_AGE = config["max-alert-age-sec"] -SERVER_OWNER_CALLSIGN = config["server-owner-callsign"] -WEB_SERVER_PORT = config["web-server-port"] -ALLOW_SPOTTING = config["allow-spotting"] -WEB_UI_OPTIONS = config["web-ui-options"] +BASE_URL = config.get("base-url", "http://localhost:8080") +MAX_SPOT_AGE = config.get("max-spot-age-sec", 3600) +MAX_ALERT_AGE = config.get("max-alert-age-sec", 604800) +SERVER_OWNER_CALLSIGN = config.get("server-owner-callsign", "N0CALL") +WEB_SERVER_PORT = config.get("web-server-port", 8080) +ALLOW_SPOTTING = config.get("allow-spotting", True) +ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True) +WEB_UI_OPTIONS = config.get("web-ui-options", {}) API_ONLY_MODE = config.get("api-only-mode", False) +RECAPTCHA_SECRET_KEY = config.get("recaptcha-secret-key", "") +RECAPTCHA_SITE_KEY = config.get("recaptcha-site-key", "") LOG_LEVEL = config.get("log-level", "INFO") LOG_WEB_REQUESTS = config.get("log-web-requests", False) @@ -33,9 +36,12 @@ WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config WEB_UI_OPTIONS["qrz-enabled"] = any(p["class"] == "QRZ" and p["enabled"] for p in config["callsign-data-providers"]) WEB_UI_OPTIONS["hamqth-enabled"] = any(p["class"] == "HamQTH" and p["enabled"] for p in config["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 proviers. We set that to also be enabled by default. +# one of our proviers. We set that to also be enabled by default. We can also include the reCaptcha site key so the UI +# can access it. if ALLOW_SPOTTING: WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API") + WEB_UI_OPTIONS["recaptcha-site-key"] = RECAPTCHA_SITE_KEY +WEB_UI_OPTIONS["allow-upstream-spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING def create_provider_from_config(package, config_providers_entry): diff --git a/core/constants.py b/core/constants.py index 6fb753d..a4c12a0 100644 --- a/core/constants.py +++ b/core/constants.py @@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C # Special Interest Groups SIGS = [ - SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"), + SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST"), SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"), SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), @@ -39,10 +39,12 @@ SIGS = [ # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". CW_MODES = ["CW"] -PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"] +PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "FUSION", "M17"] DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"] ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES MODE_TYPES = ["CW", "PHONE", "DATA"] +SSB_SUB_MODES = ["USB", "LSB"] +DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"] # Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode # we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots diff --git a/data/lookup_credentials.py b/data/lookup_credentials.py index ae80f83..65737df 100644 --- a/data/lookup_credentials.py +++ b/data/lookup_credentials.py @@ -12,15 +12,15 @@ class LookupCredentials: hamqth_session_id: str = "" # alternative to username/password -def extract_credentials(query_params): - """Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present.""" +def extract_credentials(headers): + """Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present.""" creds = LookupCredentials( - qrz_username=query_params.get("qrz_username", ""), - qrz_password=query_params.get("qrz_password", ""), - qrz_session_key=query_params.get("qrz_session_key", ""), - hamqth_username=query_params.get("hamqth_username", ""), - hamqth_password=query_params.get("hamqth_password", ""), - hamqth_session_id=query_params.get("hamqth_session_id", ""), + qrz_username=headers.get("X-QRZ-Username", ""), + qrz_password=headers.get("X-QRZ-Password", ""), + qrz_session_key=headers.get("X-QRZ-Session-Key", ""), + hamqth_username=headers.get("X-HamQTH-Username", ""), + hamqth_password=headers.get("X-HamQTH-Password", ""), + hamqth_session_id=headers.get("X-HamQTH-Session-ID", ""), ) has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password) has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password) diff --git a/providers/spot/gma.py b/providers/spot/gma.py index 7043ec5..f1e275a 100644 --- a/providers/spot/gma.py +++ b/providers/spot/gma.py @@ -115,3 +115,11 @@ class GMA(HTTPSpotProvider): logging.warning(f"The GMA API returned an unexpected response (HTTP {http_response.status_code}).") return new_spots + + def can_submit_spot(self, sig): + return sig == "GMA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. + # Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!) + raise NotImplementedError("GMA upstream spot submission is not yet implemented") diff --git a/providers/spot/hema.py b/providers/spot/hema.py index 41c98d8..76fc653 100644 --- a/providers/spot/hema.py +++ b/providers/spot/hema.py @@ -74,3 +74,11 @@ class HEMA(HTTPSpotProvider): except ConnectionError: logging.warning("Connection error when accessing HEMA spots API.") return new_spots + + def can_submit_spot(self, sig): + return sig == "HEMA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a + # reference and not a reference *number*. + raise NotImplementedError("HEMA upstream spot submission is not yet implemented") diff --git a/providers/spot/http_spot_provider.py b/providers/spot/http_spot_provider.py index bbe1477..1d34a61 100644 --- a/providers/spot/http_spot_provider.py +++ b/providers/spot/http_spot_provider.py @@ -21,6 +21,7 @@ class HTTPSpotProvider(SpotProvider): self._poll_interval = poll_interval self._thread = None self._stop_event = Event() + self._wakeup_event = Event() def start(self): # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between @@ -31,11 +32,19 @@ class HTTPSpotProvider(SpotProvider): def stop(self): self._stop_event.set() + self._wakeup_event.set() + + def force_poll(self): + """Trigger an immediate poll without waiting for the normal interval.""" + + self._wakeup_event.set() def _run(self): while True: + self._wakeup_event.clear() self._poll() - if self._stop_event.wait(timeout=self._poll_interval): + self._wakeup_event.wait(timeout=self._poll_interval) + if self._stop_event.is_set(): break def _poll(self): diff --git a/providers/spot/parksnpeaks.py b/providers/spot/parksnpeaks.py index 6600feb..08008a9 100644 --- a/providers/spot/parksnpeaks.py +++ b/providers/spot/parksnpeaks.py @@ -3,7 +3,9 @@ import re from datetime import datetime import pytz +import requests +from core.constants import HTTP_HEADERS from data.sig_ref import SIGRef from data.spot import Spot from providers.spot.http_spot_provider import HTTPSpotProvider @@ -14,7 +16,9 @@ class ParksNPeaks(HTTPSpotProvider): POLL_INTERVAL_SEC = 120 SPOTS_URL = "https://www.parksnpeaks.org/api/ALL" + SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/" SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv" + SUBMITTABLE_SIGS = ["POTA", "SOTA", "WWFF", "HEMA", "WOTA", "ZLOTA", "SIOTA", "KRMNPA"] def __init__(self, provider_config): super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) @@ -63,3 +67,28 @@ class ParksNPeaks(HTTPSpotProvider): # Add new spot to the list new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig in self.SUBMITTABLE_SIGS + + def submit_spot(self, spot, credentials): + # TODO test this works + user_id = credentials.get("user_id", "") + api_key = credentials.get("api_key", "") + if not user_id or not api_key: + raise ValueError( + "Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account.") + sig_ref = spot.sig_refs[0].id if spot.sig_refs else "" + body = { + "actClass": spot.sig or "", + "actCallsign": spot.dx_call, + "actSite": sig_ref, + "mode": spot.mode or "", + "freq": str(spot.freq / 1000000.0), + "comments": spot.comment or "", + "userID": user_id, + "APIKey": api_key, + } + response = requests.post(self.SUBMIT_URL, json=body, headers=HTTP_HEADERS, timeout=(5, 30)) + if not response.ok: + raise RuntimeError("Parks N Peaks API returned " + str(response.status_code) + ": " + response.text) diff --git a/providers/spot/pota.py b/providers/spot/pota.py index d7760f6..03397c0 100644 --- a/providers/spot/pota.py +++ b/providers/spot/pota.py @@ -1,7 +1,9 @@ from datetime import datetime import pytz +import requests +from core.constants import HTTP_HEADERS from data.sig_ref import SIGRef from data.spot import Spot from providers.spot.http_spot_provider import HTTPSpotProvider @@ -12,6 +14,7 @@ class POTA(HTTPSpotProvider): POLL_INTERVAL_SEC = 120 SPOTS_URL = "https://api.pota.app/spot/activator" + SUBMIT_URL = "https://api.pota.app/spot" def __init__(self, provider_config): super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) @@ -41,3 +44,25 @@ class POTA(HTTPSpotProvider): # that for us. new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "POTA" + + def submit_spot(self, spot, credentials): + sig_ref = spot.sig_refs[0].id if spot.sig_refs else None + if sig_ref: + body = { + "activator": spot.dx_call, + "spotter": spot.de_call, + "frequency": str(spot.freq / 1000.0), + "mode": spot.mode or "", + "reference": sig_ref, + "comments": spot.comment or "", + "source": "Spothole", + } + headers = {**HTTP_HEADERS, "Content-Type": "application/json"} + response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30)) + if not response.ok: + raise RuntimeError("POTA API returned " + str(response.status_code) + ": " + response.text) + else: + raise RuntimeError("Park reference is required for submitting POTA spots.") diff --git a/providers/spot/sota.py b/providers/spot/sota.py index e7296e2..0b9a7d8 100644 --- a/providers/spot/sota.py +++ b/providers/spot/sota.py @@ -4,7 +4,7 @@ from datetime import datetime import requests from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout -from core.constants import HTTP_HEADERS +from core.constants import HTTP_HEADERS, SSB_SUB_MODES, DV_SUB_MODES from data.sig_ref import SIGRef from data.spot import Spot from providers.spot.http_spot_provider import HTTPSpotProvider @@ -20,6 +20,9 @@ class SOTA(HTTPSpotProvider): EPOCH_URL = "https://api-db2.sota.org.uk/api/spots/epoch" SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all" + SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots" + VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"] + def __init__(self, provider_config): super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) self._api_epoch = "" @@ -65,3 +68,46 @@ class SOTA(HTTPSpotProvider): except (ConnectTimeout, ReadTimeout): logging.warning(f"Timeout when accessing SOTA spots API.") return new_spots + + def can_submit_spot(self, sig): + return sig == "SOTA" + + def submit_spot(self, spot, credentials): + # TODO test this method works + access_token = credentials.get("access_token", "") + id_token = credentials.get("id_token", "") + if not access_token or not id_token: + raise ValueError("SOTA API tokens are required. Please log into SOTA in order to spot to it.") + sig_ref = spot.sig_refs[0].id if spot.sig_refs else "" + if sig_ref: + # Split reference into association and summit codes + ref_split = sig_ref.split("/") + + # Figure out a valid mode. Borrowed this from PoLo :) + # https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js + mode = spot.mode + if mode and mode not in self.VALID_MODES: + if mode in SSB_SUB_MODES: + mode = "SSB" + elif mode in DV_SUB_MODES: + mode = "DV" + else: + mode = "Data" + + body = { + "activatorCallsign": spot.dx_call, + "associationCode": ref_split[0], + "summitCode": ref_split[1], + "frequency": spot.freq / 1000000.0, + "mode": mode or "", + "callsign": spot.de_call, + "comments": spot.comment or "", + "type": "TEST" # todo replatce with NORMAL/QRT once testing complete + } + headers = {**HTTP_HEADERS, "Authorization": "bearer " + access_token, "id_token": id_token, + "Content-Type": "application/json"} + response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30)) + if not response.ok: + raise RuntimeError("SOTA API returned " + str(response.status_code) + ": " + response.text) + else: + raise RuntimeError("Summit reference is required for submitting SOTA spots.") diff --git a/providers/spot/spot_provider.py b/providers/spot/spot_provider.py index a6ff2c6..c4f14d9 100644 --- a/providers/spot/spot_provider.py +++ b/providers/spot/spot_provider.py @@ -58,3 +58,20 @@ class SpotProvider: """Stop any threads and prepare for application shutdown""" raise NotImplementedError("Subclasses must implement this method") + + def can_submit_spot(self, sig): + """Return True if this provider supports submitting spots upstream for the given SIG.""" + + return False + + def submit_spot(self, spot, credentials): + """Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys. + Raises an exception with a descriptive message on failure.""" + + raise NotImplementedError("This provider does not support spot submission") + + def force_poll(self): + """Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing + because not all spot providers have a polling mechanism. Providers that do should override this method.""" + + return diff --git a/providers/spot/tiles.py b/providers/spot/tiles.py index bf9ad74..674d52f 100644 --- a/providers/spot/tiles.py +++ b/providers/spot/tiles.py @@ -1,5 +1,8 @@ from datetime import datetime +import requests + +from core.constants import HTTP_HEADERS, SSB_SUB_MODES from data.sig_ref import SIGRef from data.spot import Spot from providers.spot.http_spot_provider import HTTPSpotProvider @@ -10,6 +13,9 @@ class Tiles(HTTPSpotProvider): POLL_INTERVAL_SEC = 120 SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24" + SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot" + VALID_MODES = ["SSB", "CW", "FT8", "FT4", "FM", "DMR", "D-STAR", "M17", "AX.25", "JS8Call", "PSK31", "Olivia", + "VarAC", "Other"] def __init__(self, provider_config): super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) @@ -43,6 +49,49 @@ class Tiles(HTTPSpotProvider): new_spots.append(spot) return new_spots + def can_submit_spot(self, sig): + return sig == "Tiles" + + def submit_spot(self, spot, credentials): + # Tiles on the air currently only supports *self* spots + if spot.dx_call == spot.de_call: + + # Figure out a valid mode. Borrowed this from PoLo :) + # https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js + if spot.mode: + mode = spot.mode + if mode not in self.VALID_MODES: + if mode in SSB_SUB_MODES: + mode = "SSB" + elif mode == "OLIVIA": + mode = "Olivia" + elif mode == "JS8": + mode = "JS8Call" + else: + mode = "Other" + + body = { + "call_sign": spot.dx_call, + "frequency": str(spot.freq / 1000000.0), + "mode": mode or "", + "grid": spot.dx_grid or "", + "comment": spot.comment or "", + "lat": spot.dx_latitude or None, + "lon": spot.dx_longitude or None, + "qrt": spot.qrt or False, + "pin": credentials.get("offline_spot_gateway_pin", "") + } + headers = {**HTTP_HEADERS, "Content-Type": "application/json"} + response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30)) + if not response.ok: + raise RuntimeError( + "Tiles on the Air API returned " + str(response.status_code) + ": " + response.text) + else: + raise RuntimeError("The Tiles on the Air API requires a mode to be set.") + else: + raise RuntimeError( + "The Tiles on the Air API only supports self-spots, the DX call and spotter call must match.") + # Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles' # strange frequency format where we can sometimes have e.g. "14.123.5". diff --git a/providers/spot/wota.py b/providers/spot/wota.py index 902245e..8ad035d 100644 --- a/providers/spot/wota.py +++ b/providers/spot/wota.py @@ -79,3 +79,10 @@ class WOTA(HTTPSpotProvider): except Exception as e: logging.error("Exception parsing WOTA spot", e) return new_spots + + def can_submit_spot(self, sig): + return sig == "WOTA" + + def submit_spot(self, spot, credentials): + # TODO Ask M5TEA if he's happy to share how this is done from his app + raise NotImplementedError("WOTA upstream spot submission is not yet implemented") diff --git a/providers/spot/wwbota.py b/providers/spot/wwbota.py index 7fb8c74..52dde63 100644 --- a/providers/spot/wwbota.py +++ b/providers/spot/wwbota.py @@ -42,3 +42,10 @@ class WWBOTA(SSESpotProvider): # WWBOTA does support a special "Test" spot type, we need to avoid adding that. return spot if source_spot["type"] != "Test" else None + + def can_submit_spot(self, sig): + return sig == "WWBOTA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post + raise NotImplementedError("WWBOTA upstream spot submission is not yet implemented") diff --git a/providers/spot/wwff.py b/providers/spot/wwff.py index 2b56c82..35991f9 100644 --- a/providers/spot/wwff.py +++ b/providers/spot/wwff.py @@ -39,3 +39,11 @@ class WWFF(HTTPSpotProvider): # that for us. new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "WWFF" + + def submit_spot(self, spot, credentials): + # TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from + # PoLo. Either way I think we need an API key for the app (but maybe not for the user?) + raise NotImplementedError("WWFF upstream spot submission is not yet implemented") diff --git a/providers/spot/zlota.py b/providers/spot/zlota.py index 3de1767..788ee35 100644 --- a/providers/spot/zlota.py +++ b/providers/spot/zlota.py @@ -41,3 +41,10 @@ class ZLOTA(HTTPSpotProvider): new_spots.append(spot) return new_spots + + def can_submit_spot(self, sig): + return sig == "ZLOTA" + + def submit_spot(self, spot, credentials): + # TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api + raise NotImplementedError("ZLOTA upstream spot submission is not yet implemented") diff --git a/server/handlers/api/addspot.py b/server/handlers/api/addspot.py index e93ec6b..3ca83c3 100644 --- a/server/handlers/api/addspot.py +++ b/server/handlers/api/addspot.py @@ -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 diff --git a/server/handlers/api/alerts.py b/server/handlers/api/alerts.py index 54c7aa9..3000153 100644 --- a/server/handlers/api/alerts.py +++ b/server/handlers/api/alerts.py @@ -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", "") diff --git a/server/handlers/api/dxstats.py b/server/handlers/api/dxstats.py index 22dc7c9..18e87a7 100644 --- a/server/handlers/api/dxstats.py +++ b/server/handlers/api/dxstats.py @@ -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 diff --git a/server/handlers/api/lookups.py b/server/handlers/api/lookups.py index 5b497e1..9be0f0e 100644 --- a/server/handlers/api/lookups.py +++ b/server/handlers/api/lookups.py @@ -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 diff --git a/server/handlers/api/options.py b/server/handlers/api/options.py index a351f64..2395dc1 100644 --- a/server/handlers/api/options.py +++ b/server/handlers/api/options.py @@ -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) diff --git a/server/handlers/api/solar_conditions.py b/server/handlers/api/solar_conditions.py index 7193b30..1efe105 100644 --- a/server/handlers/api/solar_conditions.py +++ b/server/handlers/api/solar_conditions.py @@ -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 diff --git a/server/handlers/api/spots.py b/server/handlers/api/spots.py index b86b42f..c4b7331 100644 --- a/server/handlers/api/spots.py +++ b/server/handlers/api/spots.py @@ -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", "") diff --git a/server/handlers/api/status.py b/server/handlers/api/status.py index 589831c..5ee6f1d 100644 --- a/server/handlers/api/status.py +++ b/server/handlers/api/status.py @@ -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 diff --git a/server/handlers/api/v1_compatability.py b/server/handlers/api/v1_compatability.py new file mode 100644 index 0000000..9639bbc --- /dev/null +++ b/server/handlers/api/v1_compatability.py @@ -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() diff --git a/server/webserver.py b/server/webserver.py index 7e32325..78dbf49 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -9,6 +9,7 @@ from tornado.web import StaticFileHandler from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL from core.data_store import DATA_STORE from server.handlers.api.addspot import APISpotHandler +from server.handlers.api.v1_compatability import V1RedirectHandler, V1GoneHandler from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler from server.handlers.api.dxstats import APIDxStatsHandler from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler @@ -72,21 +73,28 @@ class WebServer: # API endpoints are always enabled api_routes = [ - (r"/api/v1/spots", APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}), - (r"/api/v1/alerts", APIAlertsHandler, {"alerts": self._data_store.alerts, **handler_opts}), - (r"/api/v1/spots/stream", APISpotsStreamHandler, + (r"/api/v2/spots", APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}), + (r"/api/v2/alerts", APIAlertsHandler, {"alerts": self._data_store.alerts, **handler_opts}), + (r"/api/v2/spots/stream", APISpotsStreamHandler, {"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}), - (r"/api/v1/alerts/stream", APIAlertsStreamHandler, + (r"/api/v2/alerts/stream", APIAlertsStreamHandler, {"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}), - (r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar_conditions, + (r"/api/v2/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar_conditions, **handler_opts}), - (r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}), - (r"/api/v1/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}), - (r"/api/v1/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}), - (r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}), - (r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}), - (r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}), - (r"/api/v1/spot", APISpotHandler, {"spots": self._data_store.spots, **handler_opts}), + (r"/api/v2/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}), + (r"/api/v2/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}), + (r"/api/v2/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}), + (r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}), + (r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}), + (r"/api/v2/lookup/grid", APILookupGridHandler, {**handler_opts}), + (r"/api/v2/spot", APISpotHandler, {"spots": self._data_store.spots, **handler_opts}), + ] + + # v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones + # that have the actual breaking changes get a bespoke handler. + v1_compat_routes = [ + (r"/api/v1/spot", V1GoneHandler), + (r"/api/v1/(.*)", V1RedirectHandler), ] # If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes @@ -121,7 +129,7 @@ class WebServer: (r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")}) ] - app = tornado.web.Application(api_routes + ui_routes + misc_routes, + app = tornado.web.Application(api_routes + v1_compat_routes + ui_routes + misc_routes, template_path=os.path.join(_HERE, "../templates"), log_function=request_log, debug=False) diff --git a/static/apidocs/openapi.yml b/static/apidocs/openapi.yml index 2a235b2..0d7f58d 100644 --- a/static/apidocs/openapi.yml +++ b/static/apidocs/openapi.yml @@ -16,13 +16,22 @@ info: ## Changelog ### 2.0 - + + * POST `/spot` now supports upstream submission to the spotting services associated with various SIGs. + * **Breaking change:** The "add spot" API has changed to enable this: instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot. + * POST `/spot` now supports Google reCaptcha and (if the site owner has set it up) now requires `captcha_token` in order to successfully submit. (This is used to lock down the submit function and prevent submission via Spothole by bots or third-party clients.) + * GET `/options` now returns `spot_submit_providers`, a map of SIG names to the names of providers that support upstream spot submission for that SIG. (This allows clients to present the user with options of where a new spot can be sent to.) + * **Breaking change:** A user's QRZ.com and HamQTH credentials are now supplied as request headers (`X-QRZ-Username`, `X-QRZ-Password`, `X-QRZ-Session-Key`, `X-HamQTH-Username`, `X-HamQTH-Password`, `X-HamQTH-Session-ID`) rather than query parameters, to keep credentials out of server logs. * Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to `/status` response * Added `callsign_data_providers` to `/options` response - * BREAKING: Removed `cleanup` from `/status` response - * BREAKING: in the `/options` response, renamed `spot_sources` and `alert_sources` to `spot_providers` and + * **Breaking change:** Removed `cleanup` from `/status` response + * **Breaking change:** in the `/options` response, renamed `spot_sources` and `alert_sources` to `spot_providers` and `alert_providers` + ### 1.5 + + No API changes. + ### 1.4 * Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have. @@ -52,9 +61,10 @@ info: license: name: The Unlicense url: https://unlicense.org/#the-unlicense - version: v2.0 + version: 2.0 + servers: - - url: https://spothole.app/api/v1 + - url: https://spothole.app/api/v2 tags: - name: Spots @@ -335,7 +345,8 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + type: string + example: "Failed" /lookup/sigref: @@ -363,7 +374,8 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + type: string + example: "Failed" @@ -389,7 +401,8 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + type: string + example: "Failed" /spot: @@ -398,50 +411,53 @@ paths: - Spots summary: Add a spot description: > - Supply a new spot object, which will be added to the system. Currently, this will not be - reported up the chain to a cluster, POTA, SOTA etc. This may be introduced in a future version. - cURL example: `curl --request POST --header "Content-Type: application/json" --data - '{"dx_call":"M0TRT","time":1760019539, "freq":14200000, "comment":"Test spot please ignore", - "de_call":"M0TRT"}' https://spothole.app/api/v1/spot` + Supply a JSON object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object + containing server-side instructions such as upstream submission). Check `spot_submit_providers` in the + `/options` response to see which SIGs and providers support upstream submission. cURL example: + `curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v2/spot`" operationId: spot requestBody: - description: The JSON spot object + description: Object containing a "spot" sub-object with the spot data, and an optional "handling" sub-object with server-side instructions of what to do with it. required: true content: application/json: schema: - $ref: '#/components/schemas/Spot' + $ref: '#/components/schemas/SpotSubmission' responses: - '200': + '201': description: Success content: application/json: schema: - $ref: '#/components/schemas/OkResponse' + type: string + example: "OK" '415': description: Incorrect Content-Type content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + type: string + example: "Failed" '422': description: Validation error content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + type: string + example: "Failed" '500': description: Internal server error content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + type: string + example: "Failed" components: parameters: QrzUsername: - name: qrz_username - in: query + name: X-QRZ-Username + in: header description: > QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with @@ -449,14 +465,14 @@ components: schema: type: string QrzPassword: - name: qrz_password - in: query + name: X-QRZ-Password + in: header description: QRZ.com password. Supply together with `qrz_username`. schema: type: string QrzSessionKey: - name: qrz_session_key - in: query + name: X-QRZ-Session-Key + in: header description: > A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and `qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to @@ -464,22 +480,22 @@ components: schema: type: string HamqthUsername: - name: hamqth_username - in: query + name: X-HamQTH-Username + in: header description: > HamQTH username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead. schema: type: string HamqthPassword: - name: hamqth_password - in: query + name: X-HamQTH-Password + in: header description: HamQTH password. Supply together with `hamqth_username`. schema: type: string HamqthSessionId: - name: hamqth_session_id - in: query + name: X-HamQTH-Session-ID + in: header description: > A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and `hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve @@ -1190,6 +1206,54 @@ components: $ref: "#/components/schemas/PropagationMode" + SpotSubmission: + description: > + Request body for POST /spot. Contains a "spot" sub-object with the spot data, and an optional + "handling" sub-object with server-side instructions consumed by Spothole. + type: object + required: + - spot + properties: + spot: + $ref: '#/components/schemas/Spot' + handling: + type: object + description: > + Optional server-side instructions for how to process this spot submission. + properties: + submit_upstream: + type: boolean + description: > + If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather + than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs` + entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the + /options response to see which SIGs and providers support this. + default: false + upstream_provider: + type: string + description: > + Name of the upstream provider to submit the spot to, e.g. "POTA" or "SOTA". Must + match one of the provider names returned in `spot_submit_providers` for the chosen SIG. + example: POTA + upstream_credentials: + type: object + description: > + Provider-specific credentials required to authenticate the upstream submission. + The required keys depend on the provider. Credentials are used only for the upstream + call and are never stored by Spothole. + additionalProperties: + type: string + example: + user_id: "12345" + api_key: "abc123" + captcha_token: + type: string + description: > + A Google reCAPTCHA v2 response token. Required when submitting upstream if the + server has reCAPTCHA configured. Obtain the token by completing the reCAPTCHA + widget rendered on the Add Spot page. + example: "03AFY_a8Xq..." + SpotStream: type: object description: A server-sent event containing a spot @@ -1810,14 +1874,6 @@ components: items: $ref: '#/components/schemas/Alert' - OkResponse: - type: string - example: "OK" - - ErrorResponse: - type: string - example: "Failed" - DxStats: type: object description: Spot counts keyed by DE continent @@ -1971,7 +2027,7 @@ components: type: integer description: > The maximum age, in seconds, of any spot before it will be deleted by the system. When - querying the /api/v1/spots endpoint and providing a "max_age" or "since" parameter, there + querying the /api/v2/spots endpoint and providing a "max_age" or "since" parameter, there is no point providing a number larger than this, because the system drops all spots older than this. example: 3600 @@ -1981,6 +2037,20 @@ components: Whether the POST /spot call, to add spots to the server directly via its API, is permitted on this server. example: true + spot_submit_providers: + type: object + description: > + A map of SIG name to a list of provider names that support upstream spot submission for that SIG. + If a SIG appears as a key here, the POST /spot endpoint accepts `submit_upstream: true` for + spots with that SIG, and will forward the spot to one of the listed providers. Omitted if no + providers support upstream submission. + additionalProperties: + type: array + items: + type: string + example: + POTA: [ POTA ] + SOTA: [ SOTA, GMA, ParksNPeaks ] CallsignData: type: object diff --git a/static/js/add-spot.js b/static/js/add-spot.js index ef912ad..1dbe594 100644 --- a/static/js/add-spot.js +++ b/static/js/add-spot.js @@ -1,7 +1,35 @@ +// Credentials schema per provider name. Defines the fields to collect and how to label them. +const PROVIDER_CREDENTIAL_SCHEMAS = { + // todo Figure out SOTA authentication + // see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx + // https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js + // Refresh token? Way to show user that they need to log in again because cached credentials aren't valid? + // todo type: text/password distinction on text boxes so API keys can be obscured + "SOTA": [ + {key: "access_token", label: "SOTA Access Token", help: ""}, + {key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..."} + ], + "ParksNPeaks": [ + {key: "user_id", label: "Parks N Peaks User ID", help: ""}, + {key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account."} + ], + "ZLOTA": [ + {key: "user_id", label: "ZLOTA User ID", help: ""}, + {key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account."} + ], + "Tiles": [ + { + key: "offline_spot_gateway_pin", + label: "Offline Spot Gateway PIN", + help: "Get your PIN from your Tiles on the Air account profile." + } + ] +}; + // Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load // any saved values from local storage. function loadOptions() { - $.getJSON('/api/v1/options', function (jsonData) { + $.getJSON('/api/v2/options', function (jsonData) { // Store options options = jsonData; @@ -21,11 +49,144 @@ function loadOptions() { })); }); + // Load reCAPTCHA if a site key is configured (key is inlined into page by server) + if (window._recaptchaSiteKey) { + loadRecaptcha(window._recaptchaSiteKey); + } + // Load settings from settings storage now all the controls are available loadSettings(); + + // Update the upstream area for any pre-selected SIG + updateUpstreamArea(); }); } +// Load and inject the reCAPTCHA script +function loadRecaptcha(siteKey) { + window._recaptchaSiteKey = siteKey; + if (!document.getElementById('recaptcha-script')) { + const script = document.createElement('script'); + script.id = 'recaptcha-script'; + script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha'; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + $("#recaptcha-area").show(); +} + +// Called by reCAPTCHA after its script loads +function renderRecaptcha() { + window._recaptchaWidgetId = grecaptcha.render('recaptcha-widget', { + sitekey: window._recaptchaSiteKey, + size: 'normal' + }); +} + +// Update the "Send spot to..." area based on the currently selected SIG +function updateUpstreamArea() { + if (!window._allowUpstreamSpotting || !options || !options["spot_submit_providers"]) { + $("#upstream-area").hide(); + return; + } + + const sig = $("#sig").val(); + const providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : []; + + if (providers.length === 0) { + $("#upstream-area").hide(); + return; + } + + $("#upstream-area").show(); + + // Update the provider selector + $("#upstream-provider-select").empty(); + $.each(providers, function (i, name) { + $("#upstream-provider-select").append($('