4 Commits
31 changed files with 753 additions and 99 deletions
+14
View File
@@ -212,6 +212,20 @@ clublog-api-key: ""
# 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: ""
# Options for the web UI.
web-ui-options:
spot-count: [10, 25, 50, 100]
+8 -1
View File
@@ -14,20 +14,27 @@ with open("config.yml") as f:
config = yaml.safe_load(f)
logging.info("Loaded config.")
# TODO load other keys with config.get(key, default) instead of config[key]
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"]
ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True)
WEB_UI_OPTIONS = config["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", "")
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
# 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
+4 -2
View File
@@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
# Special Interest Groups
SIGS = [
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
SIG(name="POTA", description="Parks on the Air", ref_regex=r"([A-Z]{2}\-\d{4,5}|K\-TEST)"),
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
@@ -37,10 +37,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
+9 -3
View File
@@ -14,7 +14,6 @@ from core.constants import MODE_ALIASES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode
from data.lookup_credentials import LookupCredentials
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef
@@ -252,9 +251,16 @@ class Spot:
if self.comment:
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
# See what SIG we think this is
found_sig = sig_match.group(2).upper()
# "TOTA" is now ambiguous, with Toilets and Towers both using it. If we have found "TOTA" in a comment,
# ignore it as we can't tell what it is.
if found_sig != "TOTA":
continue
# Now, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
if not self.sig:
self.sig = found_sig
+99 -12
View File
@@ -1,12 +1,14 @@
import json
import logging
import re
import threading
from datetime import datetime
import pytz
import requests
import tornado
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
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
@@ -15,13 +17,16 @@ 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 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:
@@ -58,15 +63,43 @@ 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 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_refs:
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
@@ -126,13 +159,47 @@ 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.add(spot.id, spot, expire=MAX_SPOT_AGE)
# 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(json.dumps("OK", default=serialize_everything))
self.set_status(201)
# 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")
@@ -142,3 +209,23 @@ 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):
"""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
+13 -2
View File
@@ -13,9 +13,10 @@ from core.utils import serialize_everything
class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/options"""
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):
# Metrics
@@ -24,6 +25,15 @@ 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)
options = {"bands": BANDS,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
@@ -35,7 +45,8 @@ class APIOptionsHandler(tornado.web.RequestHandler):
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
"continents": CONTINENTS,
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING}
"spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_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.
if ALLOW_SPOTTING:
+4 -3
View File
@@ -22,7 +22,7 @@ from server.handlers.pagetemplate import PageTemplateHandler
class WebServer:
"""Provides the public-facing web server."""
def __init__(self, spots, alerts, solar_conditions, status_data):
def __init__(self, spots, alerts, solar_conditions, status_data, spot_providers=None):
"""Constructor"""
self._spots = spots
@@ -31,6 +31,7 @@ class WebServer:
self._sse_spot_queues = []
self._sse_alert_queues = []
self._status_data = status_data
self._spot_providers = spot_providers or []
self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event()
@@ -69,12 +70,12 @@ class WebServer:
{"sse_alert_queues": self._sse_alert_queues, **handler_opts}),
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}),
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, **handler_opts}),
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, "spot_providers": self._spot_providers, **handler_opts}),
(r"/api/v1/status", APIStatusHandler, {"status_data": self._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._spots, **handler_opts}),
(r"/api/v1/spot", APISpotHandler, {"spots": self._spots, "spot_providers": self._spot_providers, **handler_opts}),
]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
+9 -6
View File
@@ -98,18 +98,21 @@ if __name__ == '__main__':
# Set up lookup helper
lookup_helper.start()
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data)
# Fetch, set up and start spot providers
# Create spot providers
for entry in config["spot-providers"]:
spot_providers.append(get_spot_provider_from_config(entry))
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data,
spot_providers=spot_providers)
# Set up and start spot providers
for p in spot_providers:
p.setup(spots=spots, web_server=web_server)
if p.enabled:
p.start()
# Fetch, set up and start alert providers
# Create, set up and start alert providers
for entry in config["alert-providers"]:
alert_providers.append(get_alert_provider_from_config(entry))
for p in alert_providers:
@@ -117,7 +120,7 @@ if __name__ == '__main__':
if p.enabled:
p.start()
# Fetch, set up and start solar conditions providers
# Create, set up and start solar conditions providers
for entry in config.get("solar-condition-providers", []):
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
for p in solar_condition_providers:
+8
View File
@@ -89,3 +89,11 @@ class GMA(HTTPSpotProvider):
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
"REF"] + ", ignoring this spot for now")
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")
+7
View File
@@ -64,3 +64,10 @@ class HEMA(HTTPSpotProvider):
# that for us.
new_spots.append(spot)
return new_spots
def can_submit_spot(self, sig):
return sig == "HEMA"
def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to HEMA is covered in the original email from the team.
raise NotImplementedError("HEMA upstream spot submission is not yet implemented")
+10 -1
View File
@@ -19,6 +19,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
@@ -29,11 +30,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):
+28
View File
@@ -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 spotproviders.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__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -62,3 +66,27 @@ 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)
+25
View File
@@ -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 spotproviders.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__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -40,3 +43,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.")
+46 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import requests
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 spotproviders.http_spot_provider import HTTPSpotProvider
@@ -20,6 +20,9 @@ class SOTA(HTTPSpotProvider):
# SOTA spots don't contain lat/lon, we need a separate lookup for that
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
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__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
self._api_epoch = ""
@@ -56,3 +59,45 @@ class SOTA(HTTPSpotProvider):
# that for us.
new_spots.append(spot)
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.")
+17
View File
@@ -68,3 +68,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
+47
View File
@@ -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 spotproviders.http_spot_provider import HTTPSpotProvider
@@ -10,6 +13,8 @@ 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__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -41,6 +46,48 @@ 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".
def strip_extra_decimal_points(s):
+7
View File
@@ -77,3 +77,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")
+7
View File
@@ -41,3 +41,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")
+7
View File
@@ -38,3 +38,10 @@ 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")
+7
View File
@@ -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")
+1 -1
View File
@@ -69,7 +69,7 @@
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/common.js?v=1781335058"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+49 -9
View File
@@ -1,13 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>This page is implemented as a proof of concept for adding spots to the Spothole system. Currently, spots added in this way are only visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality might be extended to include this in future if there is demand for it. If you'd like this to be added, please give a thumbs-up on <a href="https://git.ianrenton.com/ian/spothole/issues/39" target="_new" class="alert-link">issue #39</a> or get in touch via email.
<button type="button" id="add-spot-intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<div class="mt-3">
<div id="add-spot-area" class="card mb-3">
<div class="card-header">
@@ -60,6 +53,31 @@
</div>
</form>
<div id="upstream-area" class="mt-3" style="display:none;">
<div class="row g-2 align-items-center">
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="submit-upstream">
<label class="form-check-label" for="submit-upstream">
Send spot to <span id="upstream-provider-label"></span>
</label>
</div>
</div>
<div class="col-auto" id="upstream-provider-select-col" style="display:none;">
<select id="upstream-provider-select" class="form-select form-select-sm"></select>
</div>
<div class="col-auto">
<button type="button" class="btn btn-sm btn-outline-secondary" id="upstream-credentials-btn" onclick="showCredentialsModal();" style="display:none;">
<i class="fa-solid fa-key"></i> Credentials
</button>
</div>
</div>
</div>
<div id="recaptcha-area" class="mt-3" style="display:none;">
<div id="recaptcha-widget"></div>
</div>
<div id="result-good"></div>
<div id="result-bad"></div>
@@ -69,8 +87,30 @@
</div>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/add-spot.js?v=1780999608"></script>
<!-- Credentials modal -->
<div class="modal fade" id="credentials-modal" tabindex="-1" aria-labelledby="credentials-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="credentials-modal-label">Credentials for <span id="credentials-provider-name"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-muted">Credentials are stored only in your browser and are never saved on the server.</p>
<div id="credentials-fields"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveCredentials();">Save</button>
</div>
</div>
</div>
</div>
<script>window._recaptchaSiteKey = {% raw json_encode(web_ui_options.get('recaptcha-site-key', '')) %};
window._allowUpstreamSpotting = {% raw json_encode(web_ui_options.get('allow-upstream-spotting', True)) %};</script>
<script src="/js/common.js?v=1781335058"></script>
<script src="/js/add-spot.js?v=1781335058"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+2 -2
View File
@@ -70,8 +70,8 @@
</div>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/alerts.js?v=1780999608"></script>
<script src="/js/common.js?v=1781335058"></script>
<script src="/js/alerts.js?v=1781335058"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+3 -3
View File
@@ -76,9 +76,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
<script src="/js/bands.js?v=1780999608"></script>
<script src="/js/common.js?v=1781335058"></script>
<script src="/js/spotsbandsandmap.js?v=1781335058"></script>
<script src="/js/bands.js?v=1781335058"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+4 -4
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/css/style.css?v=1780999608" type="text/css">
<link rel="stylesheet" href="/css/style.css?v=1781335058" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
@@ -19,9 +19,9 @@
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
crossorigin="anonymous"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1780999608"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1780999608"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1780999608"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1781335058"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1781335058"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1781335058"></script>
{% end %}
{% block body %}
<div class="container">
+2 -2
View File
@@ -284,8 +284,8 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/conditions.js?v=1780999608"></script>
<script src="/js/common.js?v=1781335058"></script>
<script src="/js/conditions.js?v=1781335058"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script>
+3 -3
View File
@@ -94,9 +94,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
<script src="/js/map.js?v=1780999608"></script>
<script src="/js/common.js?v=1781335058"></script>
<script src="/js/spotsbandsandmap.js?v=1781335058"></script>
<script src="/js/map.js?v=1781335058"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+3 -3
View File
@@ -104,9 +104,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
<script src="/js/spots.js?v=1780999608"></script>
<script src="/js/common.js?v=1781335058"></script>
<script src="/js/spotsbandsandmap.js?v=1781335058"></script>
<script src="/js/spots.js?v=1781335058"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+2 -2
View File
@@ -59,8 +59,8 @@
</div>
</div>
<script src="/js/common.js?v=1780999608"></script>
<script src="/js/status.js?v=1780999608"></script>
<script src="/js/common.js?v=1781335058"></script>
<script src="/js/status.js?v=1781335058"></script>
<script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script>
+84 -23
View File
@@ -15,6 +15,12 @@ info:
## Changelog
### 1.4
* POST `/spot` now supports upstream submission to external providers such as POTA and SOTA via new `submit_upstream`, `upstream_provider`, and `upstream_credentials` request body fields.
* 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.
### 1.3
* `/solar` response now includes `ionosonde_data`, which contains ionosonde station measurements (LUF, foF2 and MUF) sourced from the GIRO Data Center as well as implied band states.
@@ -36,7 +42,7 @@ info:
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: v1.3
version: 1.4
servers:
- url: https://spothole.app/api/v1
@@ -288,7 +294,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/lookup/sigref:
@@ -313,7 +320,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
@@ -339,7 +347,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/spot:
@@ -347,40 +356,44 @@ paths:
tags:
- 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`"
description: "Supply a new spot object, which will be added to the system. Optionally, set `submit_upstream` to true to forward the spot to an external provider such as POTA or SOTA. Check `spot_submit_providers` in the `/options` response to see which SIGs and providers support this. cURL example (local-only): `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`"
operationId: spot
requestBody:
description: The JSON spot object
description: The JSON spot object, plus optional upstream submission control fields
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:
@@ -982,6 +995,48 @@ components:
example: "GUID-123456"
SpotSubmission:
description: >
Request body for POST /spot. Contains all the fields of a Spot, plus optional
upstream submission control fields that are consumed by the server and never stored in the spot.
allOf:
- $ref: '#/components/schemas/Spot'
- type: object
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 (i.e. `submit_upstream` is true and the server
operator has set up reCAPTCHA keys). 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
@@ -1294,7 +1349,7 @@ components:
solar_storm_forecast:
type: object
description: >
NOAA Solar Radiation Storm forecast probability (%) of S1 or greater events per day.
NOAA Solar Radiation Storm forecast containing probability (%) of S1 or greater events per day.
Keys are UNIX timestamps (UTC seconds since epoch) for the start of each forecast day.
Values are integer percentages (0100).
additionalProperties:
@@ -1308,7 +1363,7 @@ components:
blackout_forecast_r1r2:
type: object
description: >
NOAA Radio Blackout forecast probability (%) of R1R2 (MinorModerate) blackout events
NOAA Radio Blackout forecast containing probability (%) of R1R2 (MinorModerate) blackout events
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100).
additionalProperties:
@@ -1322,7 +1377,7 @@ components:
blackout_forecast_r3_or_greater:
type: object
description: >
NOAA Radio Blackout forecast probability (%) of R3 or greater (StrongExtreme) blackout
NOAA Radio Blackout forecast containing probability (%) of R3 or greater (StrongExtreme) blackout
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100).
additionalProperties:
@@ -1493,14 +1548,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
@@ -1637,6 +1684,20 @@ components:
type: boolean
description: 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]
CallLookup:
type: object
+222 -14
View File
@@ -1,3 +1,27 @@
// Credentials schema per provider name. Defines the fields to collect and how to label them.
var 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() {
@@ -21,11 +45,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')) {
var 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;
}
var sig = $("#sig").val();
var 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($('<option>', { value: name, text: name }));
});
if (providers.length > 1) {
$("#upstream-provider-label").text("upstream spot sources:");
$("#upstream-provider-select-col").show();
} else {
$("#upstream-provider-label").text(providers[0]);
$("#upstream-provider-select-col").hide();
}
// Show the credentials button if this provider has an authentication mechanism and we need input from the user
updateCredentialsButton();
}
// Update the credentials button visibility based on selected provider
function updateCredentialsButton() {
var providerName = getSelectedUpstreamProvider();
if (providerName && PROVIDER_CREDENTIAL_SCHEMAS[providerName]) {
$("#upstream-credentials-btn").show();
} else {
$("#upstream-credentials-btn").hide();
}
}
// Get the currently selected upstream provider name
function getSelectedUpstreamProvider() {
var providers = (options && options["spot_submit_providers"] && $("#sig").val())
? (options["spot_submit_providers"][$("#sig").val()] || [])
: [];
if (providers.length === 0) return null;
if (providers.length === 1) return providers[0];
return $("#upstream-provider-select").val();
}
// Show the credentials modal for the currently selected upstream provider
function showCredentialsModal() {
var providerName = getSelectedUpstreamProvider();
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
var schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
var stored = loadCredentials(providerName);
$("#credentials-provider-name").text(providerName);
$("#credentials-fields").empty();
$.each(schema, function(i, field) {
var val = stored[field.key] || "";
var html = '<div class="mb-3">';
html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>';
html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">';
if (field.help) {
html += '<div class="form-text">' + field.help + '</div>';
}
html += '</div>';
$("#credentials-fields").append(html);
});
// Store provider name for saveCredentials()
$("#credentials-modal").data("provider", providerName);
new bootstrap.Modal(document.getElementById('credentials-modal')).show();
}
// Save credentials from the modal to local storage
function saveCredentials() {
var providerName = $("#credentials-modal").data("provider");
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
var schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
var creds = {};
$.each(schema, function(i, field) {
creds[field.key] = $("#cred-" + field.key).val();
});
localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds));
bootstrap.Modal.getInstance(document.getElementById('credentials-modal')).hide();
}
// Load credentials for a provider from local storage
function loadCredentials(providerName) {
var stored = localStorage.getItem("upstream-credentials-" + providerName);
return stored ? JSON.parse(stored) : {};
}
// Method called to add a spot to the server
function addSpot() {
try {
@@ -46,6 +203,7 @@ function addSpot() {
if (dx != "") {
spot["dx_call"] = dx;
} else {
// todo maybe for neatness just make all these error/rejections server side rather than having logic in two places
showAddSpotError("A DX callsign is required in order to spot.");
return;
}
@@ -78,21 +236,73 @@ function addSpot() {
}
spot["time"] = moment.utc().valueOf() / 1000.0;
// Upstream submission
var submitUpstream = $("#submit-upstream").is(":checked");
var upstreamProviderName = getSelectedUpstreamProvider();
if (submitUpstream && upstreamProviderName) {
if (!sig) {
showAddSpotError("A SIG must be selected to submit upstream.");
return;
}
if (!sigRef && upstreamProviderName !== "Tiles") {
showAddSpotError("A SIG reference is required to submit upstream.");
return;
}
if (!dxGrid && upstreamProviderName === "Tiles") {
showAddSpotError("A grid reference is required to submit upstream to Tiles on the Air.");
return;
}
if (!mode && upstreamProviderName === "Tiles") {
showAddSpotError("A mode is required to submit upstream to Tiles on the Air.");
return;
}
var creds = loadCredentials(upstreamProviderName);
spot["submit_upstream"] = true;
spot["upstream_provider"] = upstreamProviderName;
spot["upstream_credentials"] = creds;
// Add CAPTCHA token if reCAPTCHA is loaded
if (window._recaptchaWidgetId !== undefined) {
var token = grecaptcha.getResponse(window._recaptchaWidgetId);
if (!token) {
showAddSpotError("Please complete the CAPTCHA to submit upstream.");
return;
}
spot["captcha_token"] = token;
}
}
$.ajax("/api/v1/spot", {
data : JSON.stringify(spot),
contentType : 'application/json',
type : 'POST',
timeout: 10000,
success: async function (result) {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
// Reset CAPTCHA for next use
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result && result.startsWith && result.startsWith("Warning")) {
$("#result-good").html("<div class='alert alert-warning fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + result + " Returning you to the spots list...</div>");
} else {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
}
$("#result-bad").html("");
setTimeout(() => {
$("#result-good").hide();
window.location.replace("/");
}, 1000);
}, 2000);
},
error: function (result) {
showAddSpotError(result.responseText.slice(1,-1));
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result.responseText) {
showAddSpotError(result.responseText.slice(1, -1));
} else {
showAddSpotError("The server did not return a response.");
}
}
});
} catch (error) {
@@ -121,20 +331,18 @@ $("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
// Display the intro box, unless the user has already dismissed it once.
function displayIntroBox() {
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
$("#add-spot-intro-box").show();
}
$("#add-spot-intro-box-dismiss").click(function() {
localStorage.setItem("add-spot-intro-box-dismissed", true);
});
}
// Update upstream area and credentials button when SIG changes
$("#sig").change(function () {
updateUpstreamArea();
});
// Update credentials button when provider selector changes
$("#upstream-provider-select").change(function () {
updateCredentialsButton();
});
// Startup
$(document).ready(function() {
// Load options
loadOptions();
// Display intro box
displayIntroBox();
});