Compare commits

..
18 Commits
Author SHA1 Message Date
Ian Renton 2a4190ec4a Fix GIRO data source. Release v2.0.3 2026-08-28 21:47:25 +01:00
Ian Renton c71c0e9d75 Release v2.0.2 2026-08-28 16:10:02 +01:00
Ian Renton f2ef6e93fa Merge remote-tracking branch 'origin/main'
# Conflicts:
#	core/constants.py
#	pyproject.toml
#	templates/add_spot.html
#	templates/alerts.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
2026-08-28 15:46:24 +01:00
Ian Renton d63897f8ad Provide CartoDB API key 2026-08-28 15:43:52 +01:00
Ian Renton 02e2730e47 v2.0.1 release 2026-08-28 07:59:10 +01:00
Ian Renton b2719d2cd6 Fix a bug where the default maximum spot age was not being applied on first access of the web UI 2026-08-28 07:52:15 +01:00
ian b9268dd4ca Update pyproject.toml 2026-08-25 20:42:07 +00:00
ian 0cce38b0ab Update core/constants.py 2026-08-25 20:41:53 +00:00
ian 5a77248dbc Fix a bug in calling the V1 add spot endpoint 2026-08-25 20:41:12 +00:00
Ian Renton 14e4886c1b v2.0 release 2026-08-22 08:11:03 +01:00
Ian Renton f346444ec3 Defensive coding improvements 2026-08-22 08:08:27 +01:00
Ian Renton 6f21ac83b7 Add timestamp to log output 2026-08-22 08:07:34 +01:00
Ian Renton a20c29cdd1 GMA Islands support 2026-08-22 08:04:24 +01:00
Ian Renton 1fd6611ac2 GMA Islands support 2026-08-22 08:02:57 +01:00
ian a27a04b65d Update templates/about.html 2026-08-20 22:36:18 +00:00
Ian Renton c5d6a65786 Bug fixes and minor doc clarifications 2026-08-20 07:30:21 +01:00
Ian Renton 85f63a1258 Add zoom controls 2026-08-18 17:59:54 +01:00
Ian Renton 7f1a5ae3be Overnight bug fixes 2026-08-18 17:56:31 +01:00
36 changed files with 236 additions and 188 deletions
+5 -3
View File
@@ -67,7 +67,7 @@ spot_providers:
- class: "DXCluster"
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
# several clusters and name them separately.
# several clusters and name them separately. The name will be used as the "source" field for the spots received.
name: "HRD Cluster"
enabled: true
host: "hrd.wa9pie.net"
@@ -88,7 +88,7 @@ spot_providers:
enabled: false
host: "w3lpl.net"
port: 7373
login_prompt: "Please enter your call:"
login_prompt: "login:"
login_callsign: "N0CALL-99"
allow_rbn_spots: false
@@ -345,4 +345,6 @@ web_ui_options:
# (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This
# is designed for a "donate/support the server" type button, though you are free to do whatever you want with it.
# As the server owner you are responsible for the safe usage of this option!
support_button_html: ""
support_button_html: ""
# CartoDB API key. Required to use the CartoDB layers without watermark. Get one from https://carto.com/basemaps/apikey/.
cartodb_api_key: ""
+3 -2
View File
@@ -1,4 +1,5 @@
from core.constants import CALL_ONLY_PATTERN
import re
from core.data_providers import DATA_PROVIDERS
from data.callsign import Callsign
@@ -11,7 +12,7 @@ def get_call_info(callsign, lookup_credentials):
callsign_data = Callsign(call=callsign)
# First check our input looks like a real callsign
if callsign and CALL_ONLY_PATTERN.match(callsign):
if callsign and re.match(r"^[A-Za-z0-9/\-]*$", callsign):
# Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only
# query other providers for data we are missing as we go along.
for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p2: p2.priority):
+7 -8
View File
@@ -1,21 +1,14 @@
import re
from core.config import SERVER_OWNER_CALLSIGN
from data.band import Band
from data.sig import SIG
# General software
SOFTWARE_VERSION = "2.0-pre"
SOFTWARE_VERSION = "2.0.3"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
# Generally useful regexes
CALL_REGEX = r"[A-Za-z0-9/\-]+"
CALL_PATTERN = re.compile(CALL_REGEX)
CALL_ONLY_PATTERN = re.compile(rf"^{CALL_REGEX}$")
# Special Interest Groups
SIGS = [
SIG(
@@ -60,6 +53,12 @@ SIGS = [
description="Islands on the Air",
ref_regex=r"[A-Z]{2}\-\d{3}",
),
SIG(
name="GMA Island",
comment_names=[],
description="Global Mountain Activity - Islands",
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
),
SIG(
name="MOTA",
comment_names=["MOTA"],
+14
View File
@@ -73,6 +73,20 @@ def get_sig_ref_info(sig, ref_id):
sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}"
return sig_ref
elif sig.upper() == "GMA Islands":
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
# the best result.
iota_lookup = get_sig_ref_info("IOTA", ref_id)
gma_lookup = get_sig_ref_info("GMA", ref_id)
for key, value in iota_lookup.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
for key, value in gma_lookup.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
sig_ref.ref_type = "Island"
return sig_ref
### ACTUAL LOOKUP ###
#
# OK, this is something we have to look up. Now check to see if our data store contains reference data and use
+3 -3
View File
@@ -11,7 +11,7 @@ from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE
from core.constants import CALL_REGEX, MODE_ALIASES, PROPAGATION_MODES
from core.constants import MODE_ALIASES, PROPAGATION_MODES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import (
@@ -201,14 +201,14 @@ class Spot:
# If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it.
# RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code.
if self.de_call == "RBNHOLE" and self.comment:
rbnhole_call_match = re.search(rf"\Wat ({CALL_REGEX})\W", self.comment, re.IGNORECASE)
rbnhole_call_match = re.search(r"\Wat ([a-z0-9/]+)\W", self.comment, re.IGNORECASE)
if rbnhole_call_match:
self.de_call = rbnhole_call_match.group(1).upper()
# If we have a spotter of "SOTAMAT", we might have the actual spotter callsign in the comment, if so extract it.
# SOTAMAT can do POTA as well as SOTA, so it's dealt with here in the generic spot handling code.
if self.de_call == "SOTAMAT" and self.comment:
sotamat_call_match = re.search(rf"\Wfrom ({CALL_REGEX})]", self.comment, re.IGNORECASE)
sotamat_call_match = re.search(r"\Wfrom ([a-z0-9/]+)]", self.comment, re.IGNORECASE)
if sotamat_call_match:
self.de_call = sotamat_call_match.group(1).upper()
+1 -2
View File
@@ -6,7 +6,6 @@ import pytz
from rss_parser import Parser
from rss_parser.models.rss import RSS
from core.constants import CALL_REGEX
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -16,7 +15,7 @@ class NG3K(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile(rf"as ({CALL_REGEX})", re.IGNORECASE)
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config):
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
+54 -47
View File
@@ -1,6 +1,7 @@
import logging
from datetime import datetime
from typing import cast
from xml.parsers.expat import ExpatError
import pytz
from rss_parser import Parser as RSSParser
@@ -25,56 +26,62 @@ class WOTA(HTTPAlertProvider):
def _http_response_to_alerts(self, http_response):
new_alerts = []
rss = cast(RSS, RSSParser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data
for source_alert in rss.channel.items:
# Reject GUID missing or zero
if (
not source_alert.guid
or not source_alert.guid.content
or source_alert.guid.content == "http://www.wota.org.uk/alerts/0"
):
continue
# Pick apart the title
dx_call = None
ref = None
ref_name = None
try:
title_split = source_alert.title.split(" on ")
dx_call = title_split[0]
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = str(ref_split[0])
if len(ref_split) > 1:
ref_name = str(ref_split[1])
except Exception:
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}")
try:
rss = cast(RSS, RSSParser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data
for source_alert in rss.channel.items:
# Reject GUID missing or zero
if (
not source_alert.guid
or not source_alert.guid.content
or source_alert.guid.content == "http://www.wota.org.uk/alerts/0"
):
continue
# Pick apart the description
comment = None
freqs_modes = None
try:
desc_split = source_alert.description.split(". ")
freqs_modes = desc_split[0].replace("Frequencies/modes:", "").strip()
if len(desc_split) > 1:
comment = desc_split[1].strip()
except Exception:
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}")
# Pick apart the title
dx_call = None
ref = None
ref_name = None
try:
title_split = source_alert.title.split(" on ")
dx_call = title_split[0]
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = str(ref_split[0])
if len(ref_split) > 1:
ref_name = str(ref_split[1])
except Exception:
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}")
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
# Pick apart the description
comment = None
freqs_modes = None
try:
desc_split = source_alert.description.split(". ")
freqs_modes = desc_split[0].replace("Frequencies/modes:", "").strip()
if len(desc_split) > 1:
comment = desc_split[1].strip()
except Exception:
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}")
# Convert to our alert format
alert = Alert(
source=self.name,
source_id=source_alert.guid.content,
dx_calls=[dx_call],
freqs_modes=freqs_modes,
comment=comment,
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
start_time=time.timestamp(),
)
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
# Convert to our alert format
alert = Alert(
source=self.name,
source_id=source_alert.guid.content,
dx_calls=[dx_call],
freqs_modes=freqs_modes,
comment=comment,
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
start_time=time.timestamp(),
)
# Add to our list.
new_alerts.append(alert)
except ExpatError:
logger.warning("WOTA alert RSS feed was fetched but was invalid")
# Add to our list.
new_alerts.append(alert)
return new_alerts
+6 -6
View File
@@ -13,14 +13,14 @@ from providers.solarconditions.solar_conditions_provider import SolarConditionsP
logger = logging.getLogger(__name__)
# Each station gets polled roughly once every hour (3600 seconds). Note that to avoid a burst of requests to the server
# every hour, the requests for data from each station are spaced out throughout the hour, leading to one request being
# sent every 1-2 minutes.
POLL_INTERVAL = 3600
# Each station gets polled roughly once every two hours (7200 seconds). Note that to avoid a burst of requests to the
# server every two hours, the requests for data from each station are spaced out throughout the period, leading to one
# request being sent every 3-4 minutes.
POLL_INTERVAL = 7200
# To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been
# manually determined and a CSV provided of all the stations that we can query for live data.
STATIONS_INDEX = "datafiles/didbase-stations.csv"
LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues"
LGDC_URL = "https://lgdc.uml.edu/fastchar/getbest"
HISTORY_HOURS = 24
@@ -140,7 +140,7 @@ class GIROIonosonde(SolarConditionsProvider):
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUF%28D%29,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
try:
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if not http_response.ok:
+4 -4
View File
@@ -8,7 +8,6 @@ import pytz
import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN
from core.constants import CALL_REGEX
from data.spot import Spot
from providers.spot.spot_provider import SpotProvider
@@ -20,11 +19,11 @@ class DXCluster(SpotProvider):
See config-example.yml for examples."""
_LINE_PATTERN_EXCLUDE_RBN = re.compile(
rf"^DX de ({CALL_REGEX}):\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)",
r"^DX de ([a-z0-9/]+):\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE,
)
_LINE_PATTERN_ALLOW_RBN = re.compile(
rf"^DX de ({CALL_REGEX})-?#?:\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)",
r"^DX de ([a-z0-9/]+)-?#?:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE,
)
@@ -51,7 +50,8 @@ class DXCluster(SpotProvider):
def stop(self):
self._running = False
self._telnet.close()
if self._telnet:
self._telnet.close()
self._thread.join()
def _handle(self):
+3
View File
@@ -117,6 +117,9 @@ class GMA(HTTPSpotProvider):
case "IOTA Island":
spot.sig_refs[0].sig = "IOTA"
spot.sig = "IOTA"
case "GMA Island":
spot.sig_refs[0].sig = "GMA Island"
spot.sig = "GMA Island"
case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = "ILLW"
spot.sig = "ILLW"
+2 -2
View File
@@ -5,7 +5,7 @@ from datetime import datetime
import pytz
import requests
from core.constants import HTTP_HEADERS, CALL_REGEX
from core.constants import HTTP_HEADERS
from data.sig_ref import SIGRef
from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -60,7 +60,7 @@ class ParksNPeaks(HTTPSpotProvider):
)
# Extract a de_call if it's in the comment but not in the "actSpoter" field
m = re.search(rf"\(de ({CALL_REGEX})\)", spot.comment or "")
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment or "")
if not spot.de_call and m:
spot.de_call = str(m.group(1))
+3 -3
View File
@@ -8,7 +8,6 @@ import pytz
import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN
from core.constants import CALL_REGEX
from data.spot import Spot
from providers.spot.spot_provider import SpotProvider
@@ -20,7 +19,7 @@ class RBN(SpotProvider):
(port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor."""
_LINE_PATTERN = re.compile(
rf"^DX de ({CALL_REGEX})-.*:\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)",
r"^DX de ([a-z0-9/]+)-.*:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE,
)
@@ -40,7 +39,8 @@ class RBN(SpotProvider):
def stop(self):
self._running = False
self._telnet.close()
if self._telnet:
self._telnet.close()
self._thread.join()
def _handle(self):
+10 -1
View File
@@ -1,3 +1,4 @@
import logging
from datetime import datetime
import requests
@@ -7,6 +8,8 @@ from data.sig_ref import SIGRef
from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider
logger = logging.getLogger(__name__)
class Tiles(HTTPSpotProvider):
"""Spot provider for Tiles on the Air"""
@@ -39,13 +42,19 @@ class Tiles(HTTPSpotProvider):
# Iterate through source data
for source_spot in http_response.json()["spots"]:
# Convert to our spot format
freq = None
try:
freq = float(strip_extra_decimal_points(source_spot["frequency"])) * 1000000
except ValueError:
logger.debug(f"Malformed frequency received from Tiles API: {source_spot['frequency']}")
spot = Spot(
source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["call_sign"].upper(),
# No separate spotter callsign, assume all spots are self-spots
de_call=source_spot["call_sign"].upper(),
freq=float(strip_extra_decimal_points(source_spot["frequency"])) * 1000000,
freq=freq,
mode=source_spot["mode"].upper(),
comment=source_spot["notes"],
sig="Tiles",
+69 -63
View File
@@ -2,6 +2,7 @@ import logging
import re
from datetime import datetime
from typing import cast
from xml.parsers.expat import ExpatError
import pytz
from rss_parser import Parser
@@ -26,73 +27,78 @@ class WOTA(HTTPSpotProvider):
def _http_response_to_spots(self, http_response):
new_spots = []
rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data
for source_spot in rss.channel.items:
try:
# Reject GUID missing or zero
if (
not source_spot.guid
or not source_spot.guid.content
or source_spot.guid.content == "http://www.wota.org.uk/spots/0"
):
continue
# Pick apart the title
dx_call = None
ref = None
ref_name = None
try:
rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data
for source_spot in rss.channel.items:
try:
title_split = source_spot.title.split(" on ")
dx_call = title_split[0]
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = str(ref_split[0])
if len(ref_split) > 1:
ref_name = str(ref_split[1])
# Reject GUID missing or zero
if (
not source_spot.guid
or not source_spot.guid.content
or source_spot.guid.content == "http://www.wota.org.uk/spots/0"
):
continue
# Pick apart the title
dx_call = None
ref = None
ref_name = None
try:
title_split = source_spot.title.split(" on ")
dx_call = title_split[0]
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = str(ref_split[0])
if len(ref_split) > 1:
ref_name = str(ref_split[1])
except Exception:
logger.warning(f"Could not parse WOTA spot title: {source_spot.title}")
# Pick apart the description
freq_hz = None
mode = None
comment = None
spotter = None
try:
desc_split = source_spot.description.split(". ")
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
if freq_mode and freq_mode != "-":
freq_mode_split = re.split(r"[\-\s]+", freq_mode)
freq_hz = float(freq_mode_split[0].replace("'", ".")) * 1000000
if len(freq_mode_split) > 1:
mode = freq_mode_split[1].upper()
if len(desc_split) > 1:
comment = desc_split[1].strip()
if len(desc_split) > 2:
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").upper().strip()
except Exception:
logger.warning(f"Could not parse WOTA spot description: {source_spot.description}")
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
# Convert to our spot format
spot = Spot(
source=self.name,
source_id=source_spot.guid.content,
dx_call=dx_call,
de_call=spotter,
freq=freq_hz,
mode=mode,
comment=comment,
sig="WOTA",
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
time=time.timestamp(),
)
new_spots.append(spot)
except Exception:
logger.warning(f"Could not parse WOTA spot title: {source_spot.title}")
logger.exception("Exception parsing WOTA spot")
# Pick apart the description
freq_hz = None
mode = None
comment = None
spotter = None
try:
desc_split = source_spot.description.split(". ")
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
if freq_mode and freq_mode != "-":
freq_mode_split = re.split(r"[\-\s]+", freq_mode)
freq_hz = float(freq_mode_split[0].replace("'", ".")) * 1000000
if len(freq_mode_split) > 1:
mode = freq_mode_split[1].upper()
except ExpatError:
logger.warning("WOTA spot RSS feed was fetched but was invalid")
if len(desc_split) > 1:
comment = desc_split[1].strip()
if len(desc_split) > 2:
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").upper().strip()
except Exception:
logger.warning(f"Could not parse WOTA spot description: {source_spot.description}")
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
# Convert to our spot format
spot = Spot(
source=self.name,
source_id=source_spot.guid.content,
dx_call=dx_call,
de_call=spotter,
freq=freq_hz,
mode=mode,
comment=comment,
sig="WOTA",
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
time=time.timestamp(),
)
new_spots.append(spot)
except Exception:
logger.exception("Exception parsing WOTA spot")
return new_spots
def can_submit_spot(self, sig):
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "Spothole"
version = "2.0"
version = "2.0.3"
authors = [
{ name="Ian Renton", email="ian@ianrenton.com" },
]
+3 -3
View File
@@ -11,7 +11,7 @@ from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
from core.constants import CALL_ONLY_PATTERN, UNKNOWN_BAND
from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps
@@ -121,13 +121,13 @@ class APISpotHandler(tornado.web.RequestHandler):
return
# Reject invalid-looking callsigns
if not CALL_ONLY_PATTERN.match(spot.dx_call):
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not CALL_ONLY_PATTERN.match(spot.de_call):
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
+2 -2
View File
@@ -82,8 +82,8 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
self._credentials = None
super().__init__(application, request, **kwargs)
def initialize(self, _sse_alert_broadcaster, web_server_metrics):
self._sse_alert_broadcaster = _sse_alert_broadcaster
def initialize(self, sse_alert_broadcaster, web_server_metrics):
self._sse_alert_broadcaster = sse_alert_broadcaster
self._web_server_metrics = web_server_metrics
def custom_headers(self):
+2 -2
View File
@@ -9,7 +9,7 @@ from tornado import httputil
from tornado.web import Application
from core.call_lookup_helper import get_call_info
from core.constants import CALL_ONLY_PATTERN, SIGS
from core.constants import SIGS
from core.geo_utils import (
lat_lon_for_grid_sw_corner_plus_size,
lat_lon_to_cq_zone,
@@ -55,7 +55,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
# The "call" query param must exist and look like a callsign
if "call" in query_params:
call = str(query_params.get("call")).upper()
if CALL_ONLY_PATTERN.match(call):
if re.match(r"^[A-Z0-9/\-]*$", call):
credentials = extract_credentials(self.request.headers)
callsign_data = get_call_info(call, credentials)
self.write(safe_json_dumps(callsign_data))
+3 -3
View File
@@ -9,7 +9,7 @@ from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING
from core.constants import CALL_ONLY_PATTERN, UNKNOWN_BAND
from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps
@@ -83,13 +83,13 @@ class V1APISpotHandler(tornado.web.RequestHandler):
return
# Reject invalid-looking callsigns
if not CALL_ONLY_PATTERN.match(spot.dx_call):
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not CALL_ONLY_PATTERN.match(spot.de_call):
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store")
+3 -3
View File
@@ -2,6 +2,7 @@ import tornado
from tornado.httpclient import AsyncHTTPClient
from tornado.httputil import HTTPHeaders
_LEGACY_PARAM_TO_HEADER_MAP = {
"qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password",
@@ -11,7 +12,6 @@ _LEGACY_PARAM_TO_HEADER_MAP = {
"hamqth_session_id": "X-HamQTH-Session-ID",
}
class V1RedirectHandler(tornado.web.RequestHandler):
"""Transparently proxies requests from the old API to the new one,
returning whatever the v2 endpoint returns, for endpoints with no breaking changes."""
@@ -34,7 +34,7 @@ class V1RedirectHandler(tornado.web.RequestHandler):
response = await client.fetch(
new_url,
method=self.request.method,
headers=headers,
headers=self.request.headers,
body=None if self.request.method == "GET" else (self.request.body or b""),
raise_error=False,
follow_redirects=False,
@@ -60,4 +60,4 @@ class V1RedirectHandler(tornado.web.RequestHandler):
await self._proxy(path)
async def post(self, path):
await self._proxy(path)
await self._proxy(path)
-1
View File
@@ -160,7 +160,6 @@ class WebServer:
V1APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
**handler_opts,
},
),
+1 -1
View File
@@ -33,7 +33,7 @@ if __name__ == "__main__":
root.setLevel(LOG_LEVEL)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(LOG_LEVEL)
formatter = logging.Formatter("%(levelname)s : %(message)s")
formatter = logging.Formatter("%(asctime)s | %(levelname)7s | %(message)s", "%Y-%m-%d %H:%M:%S")
handler.setFormatter(formatter)
root.handlers.clear()
root.addHandler(handler)
+2 -1
View File
@@ -527,7 +527,8 @@ components:
in: query
description: >
Limit the spots to only ones from one or more sources. To select more than one source, supply a
comma-separated list.
comma-separated list. Note that this enumeration is not exhaustive; in some cases the server owner may have
named some sources like clusters differently, and provided multiple options.
schema:
$ref: "#/components/schemas/Source"
SpotSig:
+6 -3
View File
@@ -394,9 +394,11 @@ function setBasemap(basemapname) {
}
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
const isCartoDB = basemapname === "CartoDB.Voyager" || basemapname === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(providerName, {
opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1
edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
});
backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack();
@@ -522,7 +524,6 @@ function loadMapURLParams() {
function setUpMap() {
// Create map
map = L.map('map', {
zoomControl: false,
minZoom: 2,
maxZoom: 12
});
@@ -530,9 +531,11 @@ function setUpMap() {
// Add basemap
loadedBasemap = $("#basemap").val();
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
const isCartoDB = loadedBasemap === "CartoDB.Voyager" || loadedBasemap === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1
edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
});
backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack();
+2
View File
@@ -345,6 +345,7 @@ const SIG_ICONS = {
"SOTA": "fa-mountain-sun",
"WWFF": "fa-seedling",
"GMA": "fa-person-hiking",
"GMA Island": "fa-person-hiking",
"WWBOTA": "fa-radiation",
"HEMA": "fa-mound",
"IOTA": "fa-book-atlas",
@@ -372,6 +373,7 @@ const SIG_NAMES = {
"SOTA": "Summits on the Air",
"WWFF": "Worldwide Flora & Fauna",
"GMA": "Global Mountain Activity",
"GMA Island": "Global Mountain Activity - Islands",
"WWBOTA": "Bunkers on the Air",
"HEMA": "Humps Excluding Marilyns Award",
"IOTA": "Islands on the Air",
+3 -2
View File
@@ -801,12 +801,13 @@
}
},
CartoDB: {
url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png',
url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png?key={key}',
options: {
attribution: '{attribution.OpenStreetMap} &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20,
variant: 'light_all'
variant: 'light_all',
key: '<insert your API key here>'
},
variants: {
Positron: 'light_all',
+4 -6
View File
@@ -98,7 +98,7 @@
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air
(POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide
Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
(MOTA), the Amateur Radio Lighthouse Society (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
Parks Award (KRMNPA), South Australia National Parks and Conservation Parks Award (SANPCPA), Wainwrights on the
Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Tiles on
@@ -171,10 +171,6 @@
section, Privacy, for details of how these are handled. If you are looking at the map and see lots of spots in
the geographic centre of countries, allowing these lookups will help. For QRZ.com a paid account is required to
look up operator locations.</p>
<p>IOTA in particular causes a mapping problem, firstly that there is no official set of geodata beyond the names of
islands. We also have no way of telling whether priority should be given to IOTA reference or e.g. QRZ lookup,
for example for a G callsign in EU-005 Great Britain the QRZ home address is probably more accurate, but in
EU-002 Aaland islands, it's more likely a DXpedition and the IOTA would be more accurate.</p>
<h2 id="privacy" class="mt-4">Privacy</h2>
<p>Spothole collects no data about you on a permanent basis. All spots and alerts are "timed out" and deleted from
the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
@@ -182,11 +178,13 @@
filters. They are also stored in your browser's local storage, so that your preferences are remembered between
sessions.</p>
<p>The data you provide can optionally include your login credentials for QRZ.com and HamQTH. You can provide these
in the "Data" menu of most pages. If you do, Spothole will augment the data it produces with lookups from these
in the "Your Data" menu of most pages. If you do, Spothole will augment the data it produces with lookups from these
services, which can for example provide more accurate markers on the map tab, and operator names when you mouse
over a DX callsign. Spothole will still work fine if you don't provide these. The values you enter are sent to
Spothole via HTTPS so are protected in transit, though of course you do have to trust Spothole with this
sensitive data in order to use this feature.</p>
<p>Any data you send as part of a query, such as your QRZ or HamQTH credentials, is used only for the lifetime of that
query and is not saved anywhere apart from your own device.</p>
<p>Spothole uses no trackers, no ads, and no cookies.</p>
{% if len(web_ui_options["support_button_html"]) > 0 %}
<p><strong>Caveat: </strong> The owner of this server has chosen to inject their own content into the "spots" page.
+1 -1
View File
@@ -76,7 +76,7 @@
</div>
<script src="/static/js/add-spot.js?v=1786980624"></script>
<script src="/static/js/add-spot.js?v=1787950046"></script>
<script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -84,7 +84,7 @@
</div>
<script src="/static/js/alerts.js?v=1786980625"></script>
<script src="/static/js/alerts.js?v=1787950046"></script>
<script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1786980624"></script>
<script src="/static/js/bands.js?v=1786980624"></script>
<script src="/static/js/spotsbandsandmap.js?v=1787950046"></script>
<script src="/static/js/bands.js?v=1787950046"></script>
<script>$(document).ready(function () {
$("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1786980624" type="text/css">
<link rel="stylesheet" href="/static/css/style.css?v=1787950046" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -15,10 +15,10 @@
window.fetchEventSource = fetchEventSource;
</script>
<script src="/static/js/utils.js?v=1786980624"></script>
<script src="/static/js/ui-ham.js?v=1786980624"></script>
<script src="/static/js/geo.js?v=1786980624"></script>
<script src="/static/js/common.js?v=1786980624"></script>
<script src="/static/js/utils.js?v=1787950046"></script>
<script src="/static/js/ui-ham.js?v=1787950046"></script>
<script src="/static/js/geo.js?v=1787950046"></script>
<script src="/static/js/common.js?v=1787950046"></script>
{% end %}
{% block body %}
<div class="container">
+1 -1
View File
@@ -6,7 +6,7 @@
oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
{% for a in web_ui_options["max_spot_age"] %}
<option value="{{a*60}}" {% if web_ui_options[
"max_spot_age_default"] == a*60 %}selected{% end %}>{{a}}</option>
"max_spot_age_default"] == a %}selected{% end %}>{{a}}</option>
{% end %}
</select>
minutes
+1 -1
View File
@@ -284,7 +284,7 @@
</div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1786980624"></script>
<script src="/static/js/conditions.js?v=1787950046"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script>
+6 -2
View File
@@ -109,8 +109,12 @@
<script src="/static/vendor/js/leaflet-cqzones.js"></script>
<script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script>
<script src="/static/js/spotsbandsandmap.js?v=1786980625"></script>
<script src="/static/js/map.js?v=1786980625"></script>
<script>
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
</script>
<script src="/static/js/spotsbandsandmap.js?v=1787950045"></script>
<script src="/static/js/map.js?v=1787950045"></script>
<script>$(document).ready(function () {
$("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1786980624"></script>
<script src="/static/js/spots.js?v=1786980624"></script>
<script src="/static/js/spotsbandsandmap.js?v=1787950045"></script>
<script src="/static/js/spots.js?v=1787950045"></script>
<script>$(document).ready(function () {
$("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div>
</div>
<script src="/static/js/status.js?v=1786980624"></script>
<script src="/static/js/status.js?v=1787950046"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");