mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-06 10:31:42 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b81f5eeb5a | ||
|
|
fd21e01c9d | ||
|
|
1afb407ca5 | ||
|
|
930d5357fe |
@@ -212,6 +212,20 @@ clublog-api-key: ""
|
|||||||
# Allow submitting spots to the Spothole API?
|
# Allow submitting spots to the Spothole API?
|
||||||
allow-spotting: true
|
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.
|
# Options for the web UI.
|
||||||
web-ui-options:
|
web-ui-options:
|
||||||
spot-count: [10, 25, 50, 100]
|
spot-count: [10, 25, 50, 100]
|
||||||
|
|||||||
+8
-1
@@ -14,20 +14,27 @@ with open("config.yml") as f:
|
|||||||
config = yaml.safe_load(f)
|
config = yaml.safe_load(f)
|
||||||
logging.info("Loaded config.")
|
logging.info("Loaded config.")
|
||||||
|
|
||||||
|
# TODO load other keys with config.get(key, default) instead of config[key]
|
||||||
BASE_URL = config["base-url"]
|
BASE_URL = config["base-url"]
|
||||||
MAX_SPOT_AGE = config["max-spot-age-sec"]
|
MAX_SPOT_AGE = config["max-spot-age-sec"]
|
||||||
MAX_ALERT_AGE = config["max-alert-age-sec"]
|
MAX_ALERT_AGE = config["max-alert-age-sec"]
|
||||||
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
|
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
|
||||||
WEB_SERVER_PORT = config["web-server-port"]
|
WEB_SERVER_PORT = config["web-server-port"]
|
||||||
ALLOW_SPOTTING = config["allow-spotting"]
|
ALLOW_SPOTTING = config["allow-spotting"]
|
||||||
|
ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True)
|
||||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
WEB_UI_OPTIONS = config["web-ui-options"]
|
||||||
API_ONLY_MODE = config.get("api-only-mode", False)
|
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
|
# 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.
|
# 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 (
|
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"])]
|
"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
|
# 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:
|
if ALLOW_SPOTTING:
|
||||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
|
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
@@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
|
|||||||
|
|
||||||
# Special Interest Groups
|
# Special Interest Groups
|
||||||
SIGS = [
|
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="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="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}"),
|
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".
|
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||||
CW_MODES = ["CW"]
|
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"]
|
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
|
||||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
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
|
# 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
|
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||||
|
|||||||
+9
-3
@@ -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.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, \
|
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
|
||||||
infer_mode_from_frequency, infer_mode_type_from_mode
|
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 core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
@@ -252,9 +251,16 @@ class Spot:
|
|||||||
if self.comment:
|
if self.comment:
|
||||||
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
|
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
|
||||||
for sig_match in sig_matches:
|
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
|
# See what SIG we think this is
|
||||||
# spots where the comment is just "POTA".
|
|
||||||
found_sig = sig_match.group(2).upper()
|
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:
|
if not self.sig:
|
||||||
self.sig = found_sig
|
self.sig = found_sig
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
import tornado
|
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.constants import UNKNOWN_BAND
|
||||||
from core.lookup_helper import infer_band_from_freq
|
from core.lookup_helper import infer_band_from_freq
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
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.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
|
|
||||||
|
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
|
||||||
|
|
||||||
|
|
||||||
class APISpotHandler(tornado.web.RequestHandler):
|
class APISpotHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/spot (POST)"""
|
"""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._spots = spots
|
||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
self._spot_providers = spot_providers or []
|
||||||
|
|
||||||
def post(self):
|
def post(self):
|
||||||
try:
|
try:
|
||||||
@@ -58,15 +63,43 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Read in the request body as JSON then convert to a Spot object
|
# Read in the request body as JSON
|
||||||
json_spot = tornado.escape.json_decode(post_data)
|
json_body = tornado.escape.json_decode(post_data)
|
||||||
spot = Spot(**json_spot)
|
|
||||||
|
# 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
|
# 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)
|
# redo this in a functional style)
|
||||||
if spot.sig_refs:
|
if spot.sig and spot.sig_refs:
|
||||||
real_sig_refs = []
|
real_sig_refs = []
|
||||||
for dict_obj in spot.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)))
|
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
|
||||||
spot.sig_refs = real_sig_refs
|
spot.sig_refs = real_sig_refs
|
||||||
|
|
||||||
@@ -126,13 +159,47 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
|
|
||||||
# infer missing data, and add it to our database.
|
# Reject upstream submission if not permitted
|
||||||
spot.source = "API"
|
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
|
||||||
spot.infer_missing()
|
self.set_status(403)
|
||||||
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
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))
|
# Submit upstream if requested
|
||||||
self.set_status(201)
|
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("Cache-Control", "no-store")
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
@@ -142,3 +209,23 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
self.set_header("Content-Type", "application/json")
|
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,9 +13,10 @@ from core.utils import serialize_everything
|
|||||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/options"""
|
"""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._status_data = status_data
|
||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
self._spot_providers = spot_providers or []
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
# Metrics
|
# Metrics
|
||||||
@@ -24,6 +25,15 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
self._web_server_metrics["status"] = "OK"
|
self._web_server_metrics["status"] = "OK"
|
||||||
api_requests_counter.inc()
|
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,
|
options = {"bands": BANDS,
|
||||||
"modes": ALL_MODES,
|
"modes": ALL_MODES,
|
||||||
"mode_types": MODE_TYPES,
|
"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"]))),
|
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
|
||||||
"continents": CONTINENTS,
|
"continents": CONTINENTS,
|
||||||
"max_spot_age": MAX_SPOT_AGE,
|
"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
|
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||||
# one of our proviers.
|
# one of our proviers.
|
||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
|
|||||||
+4
-3
@@ -22,7 +22,7 @@ from server.handlers.pagetemplate import PageTemplateHandler
|
|||||||
class WebServer:
|
class WebServer:
|
||||||
"""Provides the public-facing web server."""
|
"""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"""
|
"""Constructor"""
|
||||||
|
|
||||||
self._spots = spots
|
self._spots = spots
|
||||||
@@ -31,6 +31,7 @@ class WebServer:
|
|||||||
self._sse_spot_queues = []
|
self._sse_spot_queues = []
|
||||||
self._sse_alert_queues = []
|
self._sse_alert_queues = []
|
||||||
self._status_data = status_data
|
self._status_data = status_data
|
||||||
|
self._spot_providers = spot_providers or []
|
||||||
self._port = WEB_SERVER_PORT
|
self._port = WEB_SERVER_PORT
|
||||||
self._api_only_mode = API_ONLY_MODE
|
self._api_only_mode = API_ONLY_MODE
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
@@ -69,12 +70,12 @@ class WebServer:
|
|||||||
{"sse_alert_queues": self._sse_alert_queues, **handler_opts}),
|
{"sse_alert_queues": self._sse_alert_queues, **handler_opts}),
|
||||||
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **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/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/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}),
|
||||||
(r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
|
(r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
|
||||||
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
||||||
(r"/api/v1/lookup/grid", APILookupGridHandler, {**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
|
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
|
||||||
|
|||||||
+9
-6
@@ -98,18 +98,21 @@ if __name__ == '__main__':
|
|||||||
# Set up lookup helper
|
# Set up lookup helper
|
||||||
lookup_helper.start()
|
lookup_helper.start()
|
||||||
|
|
||||||
# Set up web server
|
# Create spot providers
|
||||||
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data)
|
|
||||||
|
|
||||||
# Fetch, set up and start spot providers
|
|
||||||
for entry in config["spot-providers"]:
|
for entry in config["spot-providers"]:
|
||||||
spot_providers.append(get_spot_provider_from_config(entry))
|
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:
|
for p in spot_providers:
|
||||||
p.setup(spots=spots, web_server=web_server)
|
p.setup(spots=spots, web_server=web_server)
|
||||||
if p.enabled:
|
if p.enabled:
|
||||||
p.start()
|
p.start()
|
||||||
|
|
||||||
# Fetch, set up and start alert providers
|
# Create, set up and start alert providers
|
||||||
for entry in config["alert-providers"]:
|
for entry in config["alert-providers"]:
|
||||||
alert_providers.append(get_alert_provider_from_config(entry))
|
alert_providers.append(get_alert_provider_from_config(entry))
|
||||||
for p in alert_providers:
|
for p in alert_providers:
|
||||||
@@ -117,7 +120,7 @@ if __name__ == '__main__':
|
|||||||
if p.enabled:
|
if p.enabled:
|
||||||
p.start()
|
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", []):
|
for entry in config.get("solar-condition-providers", []):
|
||||||
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
|
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
|
||||||
for p in solar_condition_providers:
|
for p in solar_condition_providers:
|
||||||
|
|||||||
@@ -89,3 +89,11 @@ class GMA(HTTPSpotProvider):
|
|||||||
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
|
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
|
||||||
"REF"] + ", ignoring this spot for now")
|
"REF"] + ", ignoring this spot for now")
|
||||||
return new_spots
|
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")
|
||||||
|
|||||||
@@ -64,3 +64,10 @@ class HEMA(HTTPSpotProvider):
|
|||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
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")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
|
self._wakeup_event = Event()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# 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):
|
def stop(self):
|
||||||
self._stop_event.set()
|
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):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
self._wakeup_event.clear()
|
||||||
self._poll()
|
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
|
break
|
||||||
|
|
||||||
def _poll(self):
|
def _poll(self):
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -14,7 +16,9 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
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"
|
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):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -62,3 +66,27 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
# Add new spot to the list
|
# Add new spot to the list
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
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)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -12,6 +14,7 @@ class POTA(HTTPSpotProvider):
|
|||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||||
|
SUBMIT_URL = "https://api.pota.app/spot"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -40,3 +43,25 @@ class POTA(HTTPSpotProvider):
|
|||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
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
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import requests
|
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.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
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
|
# 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/"
|
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):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||||
self._api_epoch = ""
|
self._api_epoch = ""
|
||||||
@@ -56,3 +59,45 @@ class SOTA(HTTPSpotProvider):
|
|||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
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.")
|
||||||
|
|||||||
@@ -68,3 +68,20 @@ class SpotProvider:
|
|||||||
"""Stop any threads and prepare for application shutdown"""
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
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
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -10,6 +13,8 @@ class Tiles(HTTPSpotProvider):
|
|||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
|
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):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -41,6 +46,48 @@ class Tiles(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
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'
|
# 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".
|
# strange frequency format where we can sometimes have e.g. "14.123.5".
|
||||||
def strip_extra_decimal_points(s):
|
def strip_extra_decimal_points(s):
|
||||||
|
|||||||
@@ -77,3 +77,10 @@ class WOTA(HTTPSpotProvider):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception parsing WOTA spot", e)
|
logging.error("Exception parsing WOTA spot", e)
|
||||||
return new_spots
|
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")
|
||||||
|
|||||||
@@ -41,3 +41,10 @@ class WWBOTA(SSESpotProvider):
|
|||||||
|
|
||||||
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
||||||
return spot if source_spot["type"] != "Test" else None
|
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")
|
||||||
|
|||||||
@@ -38,3 +38,10 @@ class WWFF(HTTPSpotProvider):
|
|||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
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")
|
||||||
|
|||||||
@@ -41,3 +41,10 @@ class ZLOTA(HTTPSpotProvider):
|
|||||||
|
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
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")
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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>
|
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
+50
-10
@@ -1,13 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% 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 class="mt-3">
|
||||||
<div id="add-spot-area" class="card mb-3">
|
<div id="add-spot-area" class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -60,6 +53,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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-good"></div>
|
||||||
<div id="result-bad"></div>
|
<div id="result-bad"></div>
|
||||||
|
|
||||||
@@ -69,8 +87,30 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1780999608"></script>
|
<!-- Credentials modal -->
|
||||||
<script src="/js/add-spot.js?v=1780999608"></script>
|
<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>
|
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|||||||
@@ -70,8 +70,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1780999608"></script>
|
<script src="/js/common.js?v=1781335058"></script>
|
||||||
<script src="/js/alerts.js?v=1780999608"></script>
|
<script src="/js/alerts.js?v=1781335058"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -76,9 +76,9 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common.js?v=1780999608"></script>
|
<script src="/js/common.js?v=1781335058"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
|
<script src="/js/spotsbandsandmap.js?v=1781335058"></script>
|
||||||
<script src="/js/bands.js?v=1780999608"></script>
|
<script src="/js/bands.js?v=1781335058"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block head_extra %}
|
{% 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"
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||||
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
|
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
|
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script src="https://misc.ianrenton.com/jsutils/utils.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=1780999608"></script>
|
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1781335058"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1780999608"></script>
|
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1781335058"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -284,8 +284,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
<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/common.js?v=1781335058"></script>
|
||||||
<script src="/js/conditions.js?v=1780999608"></script>
|
<script src="/js/conditions.js?v=1781335058"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-conditions").addClass("active");
|
$("#nav-link-conditions").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+3
-3
@@ -94,9 +94,9 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common.js?v=1780999608"></script>
|
<script src="/js/common.js?v=1781335058"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
|
<script src="/js/spotsbandsandmap.js?v=1781335058"></script>
|
||||||
<script src="/js/map.js?v=1780999608"></script>
|
<script src="/js/map.js?v=1781335058"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -104,9 +104,9 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/common.js?v=1780999608"></script>
|
<script src="/js/common.js?v=1781335058"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
|
<script src="/js/spotsbandsandmap.js?v=1781335058"></script>
|
||||||
<script src="/js/spots.js?v=1780999608"></script>
|
<script src="/js/spots.js?v=1781335058"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1780999608"></script>
|
<script src="/js/common.js?v=1781335058"></script>
|
||||||
<script src="/js/status.js?v=1780999608"></script>
|
<script src="/js/status.js?v=1781335058"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ info:
|
|||||||
|
|
||||||
## Changelog
|
## 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
|
### 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.
|
* `/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:
|
license:
|
||||||
name: The Unlicense
|
name: The Unlicense
|
||||||
url: https://unlicense.org/#the-unlicense
|
url: https://unlicense.org/#the-unlicense
|
||||||
version: v1.3
|
version: 1.4
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: https://spothole.app/api/v1
|
- url: https://spothole.app/api/v1
|
||||||
@@ -288,7 +294,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
|
|
||||||
/lookup/sigref:
|
/lookup/sigref:
|
||||||
@@ -313,7 +320,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -339,7 +347,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
|
|
||||||
/spot:
|
/spot:
|
||||||
@@ -347,40 +356,44 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Spots
|
- Spots
|
||||||
summary: Add a spot
|
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
|
operationId: spot
|
||||||
requestBody:
|
requestBody:
|
||||||
description: The JSON spot object
|
description: The JSON spot object, plus optional upstream submission control fields
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Spot'
|
$ref: '#/components/schemas/SpotSubmission'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'201':
|
||||||
description: Success
|
description: Success
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/OkResponse'
|
type: string
|
||||||
|
example: "OK"
|
||||||
'415':
|
'415':
|
||||||
description: Incorrect Content-Type
|
description: Incorrect Content-Type
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
'422':
|
'422':
|
||||||
description: Validation error
|
description: Validation error
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
'500':
|
'500':
|
||||||
description: Internal server error
|
description: Internal server error
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -982,6 +995,48 @@ components:
|
|||||||
example: "GUID-123456"
|
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:
|
SpotStream:
|
||||||
type: object
|
type: object
|
||||||
description: A server-sent event containing a spot
|
description: A server-sent event containing a spot
|
||||||
@@ -1294,7 +1349,7 @@ components:
|
|||||||
solar_storm_forecast:
|
solar_storm_forecast:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
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.
|
Keys are UNIX timestamps (UTC seconds since epoch) for the start of each forecast day.
|
||||||
Values are integer percentages (0–100).
|
Values are integer percentages (0–100).
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -1308,7 +1363,7 @@ components:
|
|||||||
blackout_forecast_r1r2:
|
blackout_forecast_r1r2:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
description: >
|
||||||
NOAA Radio Blackout forecast — probability (%) of R1–R2 (Minor–Moderate) blackout events
|
NOAA Radio Blackout forecast containing probability (%) of R1–R2 (Minor–Moderate) blackout events
|
||||||
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
||||||
forecast day. Values are integer percentages (0–100).
|
forecast day. Values are integer percentages (0–100).
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -1322,7 +1377,7 @@ components:
|
|||||||
blackout_forecast_r3_or_greater:
|
blackout_forecast_r3_or_greater:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
description: >
|
||||||
NOAA Radio Blackout forecast — probability (%) of R3 or greater (Strong–Extreme) blackout
|
NOAA Radio Blackout forecast containing probability (%) of R3 or greater (Strong–Extreme) blackout
|
||||||
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
||||||
forecast day. Values are integer percentages (0–100).
|
forecast day. Values are integer percentages (0–100).
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -1493,14 +1548,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Alert'
|
$ref: '#/components/schemas/Alert'
|
||||||
|
|
||||||
OkResponse:
|
|
||||||
type: string
|
|
||||||
example: "OK"
|
|
||||||
|
|
||||||
ErrorResponse:
|
|
||||||
type: string
|
|
||||||
example: "Failed"
|
|
||||||
|
|
||||||
DxStats:
|
DxStats:
|
||||||
type: object
|
type: object
|
||||||
description: Spot counts keyed by DE continent
|
description: Spot counts keyed by DE continent
|
||||||
@@ -1637,6 +1684,20 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the POST /spot call, to add spots to the server directly via its API, is permitted on this server.
|
description: Whether the POST /spot call, to add spots to the server directly via its API, is permitted on this server.
|
||||||
example: true
|
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:
|
CallLookup:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+223
-15
@@ -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
|
// 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.
|
// any saved values from local storage.
|
||||||
function loadOptions() {
|
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
|
// Load settings from settings storage now all the controls are available
|
||||||
loadSettings();
|
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
|
// Method called to add a spot to the server
|
||||||
function addSpot() {
|
function addSpot() {
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +203,7 @@ function addSpot() {
|
|||||||
if (dx != "") {
|
if (dx != "") {
|
||||||
spot["dx_call"] = dx;
|
spot["dx_call"] = dx;
|
||||||
} else {
|
} 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.");
|
showAddSpotError("A DX callsign is required in order to spot.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,21 +236,73 @@ function addSpot() {
|
|||||||
}
|
}
|
||||||
spot["time"] = moment.utc().valueOf() / 1000.0;
|
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", {
|
$.ajax("/api/v1/spot", {
|
||||||
data : JSON.stringify(spot),
|
data : JSON.stringify(spot),
|
||||||
contentType : 'application/json',
|
contentType : 'application/json',
|
||||||
type : 'POST',
|
type : 'POST',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
success: async function (result) {
|
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("");
|
$("#result-bad").html("");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
$("#result-good").hide();
|
$("#result-good").hide();
|
||||||
window.location.replace("/");
|
window.location.replace("/");
|
||||||
}, 1000);
|
}, 2000);
|
||||||
},
|
},
|
||||||
error: function (result) {
|
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) {
|
} catch (error) {
|
||||||
@@ -121,20 +331,18 @@ $("#mode").change(function () {
|
|||||||
$(this).val($(this).val().trim().toUpperCase());
|
$(this).val($(this).val().trim().toUpperCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Display the intro box, unless the user has already dismissed it once.
|
// Update upstream area and credentials button when SIG changes
|
||||||
function displayIntroBox() {
|
$("#sig").change(function () {
|
||||||
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
|
updateUpstreamArea();
|
||||||
$("#add-spot-intro-box").show();
|
});
|
||||||
}
|
|
||||||
$("#add-spot-intro-box-dismiss").click(function() {
|
// Update credentials button when provider selector changes
|
||||||
localStorage.setItem("add-spot-intro-box-dismissed", true);
|
$("#upstream-provider-select").change(function () {
|
||||||
});
|
updateCredentialsButton();
|
||||||
}
|
});
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
// Load options
|
// Load options
|
||||||
loadOptions();
|
loadOptions();
|
||||||
// Display intro box
|
});
|
||||||
displayIntroBox();
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user