Improve checking callsign validity by regex, and fix passing v1 type credentials into v2 APIs

This commit is contained in:
Ian Renton
2026-08-17 16:30:24 +01:00
parent 03164d747f
commit 38bc03a603
19 changed files with 48 additions and 36 deletions
+3 -1
View File
@@ -1,3 +1,4 @@
from core.constants import CALL_ONLY_PATTERN
from core.data_providers import DATA_PROVIDERS from core.data_providers import DATA_PROVIDERS
from data.callsign import Callsign from data.callsign import Callsign
@@ -9,7 +10,8 @@ def get_call_info(callsign, lookup_credentials):
callsign_data = Callsign(call=callsign) callsign_data = Callsign(call=callsign)
if callsign: # First check our input looks like a real callsign
if callsign and CALL_ONLY_PATTERN.match(callsign):
# Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only # 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. # 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): for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p2: p2.priority):
+7
View File
@@ -1,3 +1,5 @@
import re
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from data.band import Band from data.band import Band
from data.sig import SIG from data.sig import SIG
@@ -9,6 +11,11 @@ SOFTWARE_VERSION = "2.0-pre"
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} 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(" ", "_") 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 # Special Interest Groups
SIGS = [ SIGS = [
SIG( SIG(
+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.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import MODE_ALIASES, PROPAGATION_MODES from core.constants import CALL_REGEX, MODE_ALIASES, PROPAGATION_MODES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.sig_lookup_helper import populate_missing_sig_ref_info from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import ( 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. # 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. # 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: if self.de_call == "RBNHOLE" and self.comment:
rbnhole_call_match = re.search(r"\Wat ([a-z0-9/]+)\W", self.comment, re.IGNORECASE) rbnhole_call_match = re.search(rf"\Wat ({CALL_REGEX})\W", self.comment, re.IGNORECASE)
if rbnhole_call_match: if rbnhole_call_match:
self.de_call = rbnhole_call_match.group(1).upper() 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. # 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. # 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: if self.de_call == "SOTAMAT" and self.comment:
sotamat_call_match = re.search(r"\Wfrom ([a-z0-9/]+)]", self.comment, re.IGNORECASE) sotamat_call_match = re.search(rf"\Wfrom ({CALL_REGEX})]", self.comment, re.IGNORECASE)
if sotamat_call_match: if sotamat_call_match:
self.de_call = sotamat_call_match.group(1).upper() self.de_call = sotamat_call_match.group(1).upper()
+2 -1
View File
@@ -6,6 +6,7 @@ import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.constants import CALL_REGEX
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -15,7 +16,7 @@ class NG3K(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.ng3k.com/adxo.xml" ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE) AS_CALL_PATTERN = re.compile(rf"as ({CALL_REGEX})", re.IGNORECASE)
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
+3 -2
View File
@@ -8,6 +8,7 @@ import pytz
import telnetlib3 import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import CALL_REGEX
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
@@ -19,11 +20,11 @@ class DXCluster(SpotProvider):
See config-example.yml for examples.""" See config-example.yml for examples."""
_LINE_PATTERN_EXCLUDE_RBN = re.compile( _LINE_PATTERN_EXCLUDE_RBN = re.compile(
r"^DX de ([a-z0-9/]+):\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)", rf"^DX de ({CALL_REGEX}):\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE, re.IGNORECASE,
) )
_LINE_PATTERN_ALLOW_RBN = re.compile( _LINE_PATTERN_ALLOW_RBN = re.compile(
r"^DX de ([a-z0-9/]+)-?#?:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)", rf"^DX de ({CALL_REGEX})-?#?:\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE, re.IGNORECASE,
) )
+2 -2
View File
@@ -5,7 +5,7 @@ from datetime import datetime
import pytz import pytz
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS, CALL_REGEX
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider 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 # Extract a de_call if it's in the comment but not in the "actSpoter" field
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment or "") m = re.search(rf"\(de ({CALL_REGEX})\)", spot.comment or "")
if not spot.de_call and m: if not spot.de_call and m:
spot.de_call = str(m.group(1)) spot.de_call = str(m.group(1))
+2 -1
View File
@@ -8,6 +8,7 @@ import pytz
import telnetlib3 import telnetlib3
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import CALL_REGEX
from data.spot import Spot from data.spot import Spot
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
@@ -19,7 +20,7 @@ class RBN(SpotProvider):
(port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor.""" (port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor."""
_LINE_PATTERN = re.compile( _LINE_PATTERN = re.compile(
r"^DX de ([a-z0-9/]+)-.*:\s+([0-9.]+)\s+([a-z0-9/]+)\s+(.*)\s+(\d{4}Z)", rf"^DX de ({CALL_REGEX})-.*:\s+([0-9.]+)\s+({CALL_REGEX})\s+(.*)\s+(\d{4}Z)",
re.IGNORECASE, re.IGNORECASE,
) )
+3 -3
View File
@@ -11,7 +11,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
from core.constants import UNKNOWN_BAND from core.constants import CALL_ONLY_PATTERN, UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps from core.utils import infer_band_from_freq, safe_json_dumps
@@ -121,13 +121,13 @@ class APISpotHandler(tornado.web.RequestHandler):
return return
# Reject invalid-looking callsigns # Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call): if not CALL_ONLY_PATTERN.match(spot.dx_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign.")) 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("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call): if not CALL_ONLY_PATTERN.match(spot.de_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign.")) self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+2 -2
View File
@@ -9,7 +9,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.call_lookup_helper import get_call_info from core.call_lookup_helper import get_call_info
from core.constants import SIGS from core.constants import CALL_ONLY_PATTERN, SIGS
from core.geo_utils import ( from core.geo_utils import (
lat_lon_for_grid_sw_corner_plus_size, lat_lon_for_grid_sw_corner_plus_size,
lat_lon_to_cq_zone, 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 # The "call" query param must exist and look like a callsign
if "call" in query_params: if "call" in query_params:
call = str(query_params.get("call")).upper() call = str(query_params.get("call")).upper()
if re.match(r"^[A-Z0-9/\-]*$", call): if CALL_ONLY_PATTERN.match(call):
credentials = extract_credentials(self.request.headers) credentials = extract_credentials(self.request.headers)
callsign_data = get_call_info(call, credentials) callsign_data = get_call_info(call, credentials)
self.write(safe_json_dumps(callsign_data)) 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 tornado.web import Application
from core.config import ALLOW_SPOTTING from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND from core.constants import CALL_ONLY_PATTERN, UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps from core.utils import infer_band_from_freq, safe_json_dumps
@@ -83,13 +83,13 @@ class V1APISpotHandler(tornado.web.RequestHandler):
return return
# Reject invalid-looking callsigns # Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call): if not CALL_ONLY_PATTERN.match(spot.dx_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.dx_call}' does not look like a valid callsign.")) 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("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call): if not CALL_ONLY_PATTERN.match(spot.de_call):
self.set_status(422) self.set_status(422)
self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign.")) self.write(safe_json_dumps(f"Error - '{spot.de_call}' does not look like a valid callsign."))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+3 -3
View File
@@ -2,7 +2,6 @@ import tornado
from tornado.httpclient import AsyncHTTPClient from tornado.httpclient import AsyncHTTPClient
from tornado.httputil import HTTPHeaders from tornado.httputil import HTTPHeaders
_LEGACY_PARAM_TO_HEADER_MAP = { _LEGACY_PARAM_TO_HEADER_MAP = {
"qrz_username": "X-QRZ-Username", "qrz_username": "X-QRZ-Username",
"qrz_password": "X-QRZ-Password", "qrz_password": "X-QRZ-Password",
@@ -12,6 +11,7 @@ _LEGACY_PARAM_TO_HEADER_MAP = {
"hamqth_session_id": "X-HamQTH-Session-ID", "hamqth_session_id": "X-HamQTH-Session-ID",
} }
class V1RedirectHandler(tornado.web.RequestHandler): class V1RedirectHandler(tornado.web.RequestHandler):
"""Transparently proxies requests from the old API to the new one, """Transparently proxies requests from the old API to the new one,
returning whatever the v2 endpoint returns, for endpoints with no breaking changes.""" 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( response = await client.fetch(
new_url, new_url,
method=self.request.method, method=self.request.method,
headers=self.request.headers, headers=headers,
body=None if self.request.method == "GET" else (self.request.body or b""), body=None if self.request.method == "GET" else (self.request.body or b""),
raise_error=False, raise_error=False,
follow_redirects=False, follow_redirects=False,
@@ -60,4 +60,4 @@ class V1RedirectHandler(tornado.web.RequestHandler):
await self._proxy(path) await self._proxy(path)
async def post(self, path): async def post(self, path):
await self._proxy(path) await self._proxy(path)
+1 -1
View File
@@ -76,7 +76,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1786970620"></script> <script src="/static/js/add-spot.js?v=1786980624"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -84,7 +84,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1786970620"></script> <script src="/static/js/alerts.js?v=1786980625"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1786970620"></script> <script src="/static/js/spotsbandsandmap.js?v=1786980624"></script>
<script src="/static/js/bands.js?v=1786970620"></script> <script src="/static/js/bands.js?v=1786980624"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1786970620" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1786980624" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <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/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-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; window.fetchEventSource = fetchEventSource;
</script> </script>
<script src="/static/js/utils.js?v=1786970620"></script> <script src="/static/js/utils.js?v=1786980624"></script>
<script src="/static/js/ui-ham.js?v=1786970620"></script> <script src="/static/js/ui-ham.js?v=1786980624"></script>
<script src="/static/js/geo.js?v=1786970620"></script> <script src="/static/js/geo.js?v=1786980624"></script>
<script src="/static/js/common.js?v=1786970620"></script> <script src="/static/js/common.js?v=1786980624"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1786970620"></script> <script src="/static/js/conditions.js?v=1786980624"></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>
+2 -2
View File
@@ -109,8 +109,8 @@
<script src="/static/vendor/js/leaflet-cqzones.js"></script> <script src="/static/vendor/js/leaflet-cqzones.js"></script>
<script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script> <script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script>
<script src="/static/js/spotsbandsandmap.js?v=1786970620"></script> <script src="/static/js/spotsbandsandmap.js?v=1786980625"></script>
<script src="/static/js/map.js?v=1786970620"></script> <script src="/static/js/map.js?v=1786980625"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1786970620"></script> <script src="/static/js/spotsbandsandmap.js?v=1786980624"></script>
<script src="/static/js/spots.js?v=1786970620"></script> <script src="/static/js/spots.js?v=1786980624"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div> </div>
</div> </div>
<script src="/static/js/status.js?v=1786970620"></script> <script src="/static/js/status.js?v=1786980624"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");