mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-06-24 05:35:10 +00:00
First stab at submitting spots upstream. POTA is working, all other providers still to do. #95
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import re
|
|||||||
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 +16,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 +62,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,11 +158,45 @@ 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:
|
||||||
|
self.set_status(403)
|
||||||
|
self.write(json.dumps("Error - this server does not allow upstream spot submission.",
|
||||||
|
default=serialize_everything))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Submit upstream if requested
|
||||||
|
upstream_warning = None
|
||||||
|
if submit_upstream and upstream_provider_name and spot.sig:
|
||||||
|
provider = self._find_provider(upstream_provider_name, spot.sig)
|
||||||
|
if provider:
|
||||||
|
try:
|
||||||
|
# Submit spot to the upstream provider
|
||||||
|
provider.submit_spot(spot, upstream_credentials)
|
||||||
|
# Trigger an immediate re-poll so the spot appears quickly
|
||||||
|
provider.force_poll()
|
||||||
|
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()
|
spot.infer_missing()
|
||||||
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
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.write(json.dumps("OK", default=serialize_everything))
|
||||||
self.set_status(201)
|
self.set_status(201)
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
@@ -142,3 +208,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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
15
spothole.py
15
spothole.py
@@ -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.")
|
||||||
|
|||||||
@@ -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,10 @@ 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 +60,44 @@ 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
|
||||||
|
if spot.mode and spot.mode not in self.VALID_MODES:
|
||||||
|
if spot.mode in SSB_SUB_MODES:
|
||||||
|
spot.mode = "SSB"
|
||||||
|
elif spot.mode in DV_SUB_MODES:
|
||||||
|
spot.mode = "DV"
|
||||||
|
else:
|
||||||
|
spot.mode = "Data"
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"activatorCallsign": spot.dx_call,
|
||||||
|
"associationCode": ref_split[0],
|
||||||
|
"summitCode": ref_split[1],
|
||||||
|
"frequency": str(spot.freq / 1000000.0),
|
||||||
|
"mode": spot.mode or "",
|
||||||
|
"posterCallsign": spot.de_call,
|
||||||
|
"comments": spot.comment or "",
|
||||||
|
"type": "TEST" # todo remove 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
|
||||||
|
|||||||
@@ -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=1781245126"></script>
|
<script src="/js/common.js?v=1781252061"></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 %}
|
||||||
@@ -54,13 +54,12 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="upstream-area" class="mt-3" style="display:none;">
|
<div id="upstream-area" class="mt-3" style="display:none;">
|
||||||
<hr/>
|
|
||||||
<div class="row g-2 align-items-center">
|
<div class="row g-2 align-items-center">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="submit-upstream">
|
<input class="form-check-input" type="checkbox" id="submit-upstream">
|
||||||
<label class="form-check-label" for="submit-upstream">
|
<label class="form-check-label" for="submit-upstream">
|
||||||
Also submit to <span id="upstream-provider-label"></span>
|
Send spot to <span id="upstream-provider-label"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,8 +74,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="hcaptcha-area" class="mt-3" style="display:none;">
|
<div id="recaptcha-area" class="mt-3" style="display:none;">
|
||||||
<div id="hcaptcha-widget"></div>
|
<div id="recaptcha-widget"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="result-good"></div>
|
<div id="result-good"></div>
|
||||||
@@ -108,8 +107,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1781245126"></script>
|
<script>window._recaptchaSiteKey = {% raw json_encode(web_ui_options.get('recaptcha-site-key', '')) %};
|
||||||
<script src="/js/add-spot.js?v=1781245126"></script>
|
window._allowUpstreamSpotting = {% raw json_encode(web_ui_options.get('allow-upstream-spotting', True)) %};</script>
|
||||||
|
<script src="/js/common.js?v=1781252061"></script>
|
||||||
|
<script src="/js/add-spot.js?v=1781252061"></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=1781245127"></script>
|
<script src="/js/common.js?v=1781252061"></script>
|
||||||
<script src="/js/alerts.js?v=1781245127"></script>
|
<script src="/js/alerts.js?v=1781252061"></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=1781245126"></script>
|
<script src="/js/common.js?v=1781252061"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1781245126"></script>
|
<script src="/js/spotsbandsandmap.js?v=1781252061"></script>
|
||||||
<script src="/js/bands.js?v=1781245126"></script>
|
<script src="/js/bands.js?v=1781252061"></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 %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/css/style.css?v=1781245126" type="text/css">
|
<link rel="stylesheet" href="/css/style.css?v=1781252061" 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=1781245126"></script>
|
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1781252061"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1781245126"></script>
|
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1781252061"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1781245126"></script>
|
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1781252061"></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=1781245126"></script>
|
<script src="/js/common.js?v=1781252061"></script>
|
||||||
<script src="/js/conditions.js?v=1781245126"></script>
|
<script src="/js/conditions.js?v=1781252061"></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>
|
||||||
|
|||||||
@@ -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=1781245127"></script>
|
<script src="/js/common.js?v=1781252061"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1781245127"></script>
|
<script src="/js/spotsbandsandmap.js?v=1781252061"></script>
|
||||||
<script src="/js/map.js?v=1781245127"></script>
|
<script src="/js/map.js?v=1781252061"></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=1781245126"></script>
|
<script src="/js/common.js?v=1781252061"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1781245126"></script>
|
<script src="/js/spotsbandsandmap.js?v=1781252061"></script>
|
||||||
<script src="/js/spots.js?v=1781245126"></script>
|
<script src="/js/spots.js?v=1781252061"></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=1781245126"></script>
|
<script src="/js/common.js?v=1781252061"></script>
|
||||||
<script src="/js/status.js?v=1781245126"></script>
|
<script src="/js/status.js?v=1781252061"></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
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
// 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?
|
||||||
|
"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." }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
// 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 +41,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 {
|
||||||
@@ -78,21 +231,65 @@ 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) {
|
||||||
|
showAddSpotError("A SIG reference is required to submit upstream.");
|
||||||
|
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) {
|
||||||
|
// 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-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) {
|
||||||
|
if (window._recaptchaWidgetId !== undefined) {
|
||||||
|
grecaptcha.reset(window._recaptchaWidgetId);
|
||||||
|
}
|
||||||
|
if (result.responseText) {
|
||||||
showAddSpotError(result.responseText.slice(1, -1));
|
showAddSpotError(result.responseText.slice(1, -1));
|
||||||
|
} else {
|
||||||
|
showAddSpotError("The server did not return a response.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,20 +318,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