diff --git a/data/lookup_credentials.py b/data/lookup_credentials.py index ae80f83..65737df 100644 --- a/data/lookup_credentials.py +++ b/data/lookup_credentials.py @@ -12,15 +12,15 @@ class LookupCredentials: hamqth_session_id: str = "" # alternative to username/password -def extract_credentials(query_params): - """Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present.""" +def extract_credentials(headers): + """Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present.""" creds = LookupCredentials( - qrz_username=query_params.get("qrz_username", ""), - qrz_password=query_params.get("qrz_password", ""), - qrz_session_key=query_params.get("qrz_session_key", ""), - hamqth_username=query_params.get("hamqth_username", ""), - hamqth_password=query_params.get("hamqth_password", ""), - hamqth_session_id=query_params.get("hamqth_session_id", ""), + qrz_username=headers.get("X-QRZ-Username", ""), + qrz_password=headers.get("X-QRZ-Password", ""), + qrz_session_key=headers.get("X-QRZ-Session-Key", ""), + hamqth_username=headers.get("X-HamQTH-Username", ""), + hamqth_password=headers.get("X-HamQTH-Password", ""), + hamqth_session_id=headers.get("X-HamQTH-Session-ID", ""), ) has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password) has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password) diff --git a/server/handlers/api/alerts.py b/server/handlers/api/alerts.py index 3649fde..10083e9 100644 --- a/server/handlers/api/alerts.py +++ b/server/handlers/api/alerts.py @@ -53,7 +53,7 @@ class APIAlertsHandler(tornado.web.RequestHandler): query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} # Fetch all alerts matching the query, then optionally enrich with online data - credentials = extract_credentials(query_params) + credentials = extract_credentials(self.request.headers) data = get_alert_list_with_filters(self._alerts, query_params) if credentials: data = self._enrich(data, credentials) @@ -104,7 +104,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): # request.arguments contains lists for each param key because technically the client can supply multiple, # reduce that to just the first entry, and convert bytes to string self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} - self._credentials = extract_credentials(self._query_params) + self._credentials = extract_credentials(self.request.headers) # Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive self._alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE) diff --git a/server/handlers/api/lookups.py b/server/handlers/api/lookups.py index 3672269..8e6db2c 100644 --- a/server/handlers/api/lookups.py +++ b/server/handlers/api/lookups.py @@ -47,7 +47,7 @@ class APILookupCallHandler(tornado.web.RequestHandler): if re.match(r"^[A-Z0-9/\-]*$", call): # Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the # resulting data in the correct way for the API response. - credentials = extract_credentials(query_params) + credentials = extract_credentials(self.request.headers) fake_spot = Spot(dx_call=call) fake_spot.infer_missing(credentials) data = { diff --git a/server/handlers/api/spots.py b/server/handlers/api/spots.py index 6695347..6882265 100644 --- a/server/handlers/api/spots.py +++ b/server/handlers/api/spots.py @@ -53,7 +53,7 @@ class APISpotsHandler(tornado.web.RequestHandler): query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} # Fetch all spots matching the query, then optionally enrich with online data - credentials = extract_credentials(query_params) + credentials = extract_credentials(self.request.headers) data = get_spot_list_with_filters(self._spots, query_params) if credentials: data = self._enrich(data, credentials) @@ -106,7 +106,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): # request.arguments contains lists for each param key because technically the client can supply multiple, # reduce that to just the first entry, and convert bytes to string self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} - self._credentials = extract_credentials(self._query_params) + self._credentials = extract_credentials(self.request.headers) # Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive self._spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE) diff --git a/templates/bands.html b/templates/bands.html index 54a70fc..bda6c4a 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -75,10 +75,7 @@ diff --git a/templates/map.html b/templates/map.html index a85ab7a..b3a95ac 100644 --- a/templates/map.html +++ b/templates/map.html @@ -93,10 +93,7 @@ diff --git a/templates/spots.html b/templates/spots.html index d9883f9..1b28f56 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -114,10 +114,7 @@ diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index 71c305c..355d43a 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -17,9 +17,11 @@ info: ### 2.0 - * POST `/spot` now supports upstream submission to external providers such as POTA and SOTA. The "add spot" API has a **breaking change** to enable this: instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot. + * POST `/spot` now supports upstream submission to the spotting services associated with various SIGs. + * **Breaking change:** The "add spot" API has changed to enable this: instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot. * 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. (This allows clients to present the user with options of where a new spot can be sent to.) + * **Breaking change:** A user's QRZ.com and HamQTH credentials are now supplied as request headers (`X-QRZ-Username`, `X-QRZ-Password`, `X-QRZ-Session-Key`, `X-HamQTH-Username`, `X-HamQTH-Password`, `X-HamQTH-Session-ID`) rather than query parameters, to keep credentials out of server logs. ### 1.3 @@ -398,39 +400,39 @@ paths: components: parameters: QrzUsername: - name: qrz_username - in: query - description: "QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with `qrz_password`, or supply `qrz_session_key` instead." + name: X-QRZ-Username + in: header + description: "QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with `X-QRZ-Password`, or supply `X-QRZ-Session-Key` instead." schema: type: string QrzPassword: - name: qrz_password - in: query - description: "QRZ.com password. Supply together with `qrz_username`." + name: X-QRZ-Password + in: header + description: "QRZ.com password. Supply together with `X-QRZ-Username`." schema: type: string QrzSessionKey: - name: qrz_session_key - in: query - description: "A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and `qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to obtain one for the user." + name: X-QRZ-Session-Key + in: header + description: "A pre-obtained QRZ.com XML session key, as an alternative to supplying `X-QRZ-Username` and `X-QRZ-Password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to obtain one for the user." schema: type: string HamqthUsername: - name: hamqth_username - in: query - description: "HamQTH username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead." + name: X-HamQTH-Username + in: header + description: "HamQTH username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Supply together with `X-HamQTH-Password`, or supply `X-HamQTH-Session-ID` instead." schema: type: string HamqthPassword: - name: hamqth_password - in: query - description: "HamQTH password. Supply together with `hamqth_username`." + name: X-HamQTH-Password + in: header + description: "HamQTH password. Supply together with `X-HamQTH-Username`." schema: type: string HamqthSessionId: - name: hamqth_session_id - in: query - description: "A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and `hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve one for a user." + name: X-HamQTH-Session-ID + in: header + description: "A pre-obtained HamQTH session ID, as an alternative to supplying `X-HamQTH-Username` and `X-HamQTH-Password`. See https://www.hamqth.com/developers.php for details on how to retrieve one for a user." schema: type: string SpotSource: diff --git a/webassets/js/alerts.js b/webassets/js/alerts.js index d9f3970..9cb9205 100644 --- a/webassets/js/alerts.js +++ b/webassets/js/alerts.js @@ -6,7 +6,7 @@ let alerts = []; // Load alerts and populate the table. function loadAlerts() { - $.getJSON('/api/v2/alerts' + buildQueryString(false), function (jsonData) { + $.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { // Store last updated time lastUpdateTime = moment.utc(); updateRefreshDisplay(); @@ -14,11 +14,11 @@ function loadAlerts() { alerts = jsonData; // Update table updateTable(); - }); + }}); } // Build a query string for the API, based on the filters that the user has selected. -function buildQueryString(includeCredentials) { +function buildQueryString() { let str = "?"; ["dx_continent", "source"].forEach(fn => { if (!allFilterOptionsSelected(fn)) { @@ -33,9 +33,6 @@ function buildQueryString(includeCredentials) { if ($("#dxpeditions_skip_max_duration_check")[0].checked) { str = str + "&dxpeditions_skip_max_duration_check=true"; } - if (includeCredentials) { - str = str + getCredentialQueryString(); - } return str; } diff --git a/webassets/js/bands.js b/webassets/js/bands.js index 39229dd..ef10f64 100644 --- a/webassets/js/bands.js +++ b/webassets/js/bands.js @@ -12,7 +12,7 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6; // Load spots and populate the bands display. function loadSpots() { - $.getJSON('/api/v2/spots' + buildQueryString(false), function (jsonData) { + $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { // Store last updated time lastUpdateTime = moment.utc(); updateRefreshDisplay(); @@ -20,11 +20,11 @@ function loadSpots() { spots = jsonData; // Update bands display updateBands(); - }); + }}); } // Build a query string for the API, based on the filters that the user has selected. -function buildQueryString(includeCredentials) { +function buildQueryString() { let str = "?"; ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { if (!allFilterOptionsSelected(fn)) { @@ -34,9 +34,6 @@ function buildQueryString(includeCredentials) { str = str + "max_age=" + $("#max-spot-age option:selected").val(); // Additional filters for the bands view: No dupes, no QRT str = str + "&dedupe=true&allow_qrt=false"; - if (includeCredentials) { - str = str + getCredentialQueryString(); - } return str; } diff --git a/webassets/js/common.js b/webassets/js/common.js index 3580075..0b0b43c 100644 --- a/webassets/js/common.js +++ b/webassets/js/common.js @@ -273,23 +273,23 @@ function closeDataPanel() { closePanel("#data-area"); } -// Build a query string fragment containing any QRZ.com / HamQTH credentials the user has supplied, +// Build a headers object containing any QRZ.com / HamQTH credentials the user has supplied, // provided the corresponding "enabled" checkbox is ticked. -function getCredentialQueryString() { - let str = ""; +function getCredentialHeaders() { + const headers = {}; if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) { const qrzUsername = $("#qrz-username").val(); const qrzPassword = $("#qrz-password").val(); - if (qrzUsername) str += "&qrz_username=" + encodeURIComponent(qrzUsername); - if (qrzPassword) str += "&qrz_password=" + encodeURIComponent(qrzPassword); + if (qrzUsername) headers["X-QRZ-Username"] = qrzUsername; + if (qrzPassword) headers["X-QRZ-Password"] = qrzPassword; } if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) { const hamqthUsername = $("#hamqth-username").val(); const hamqthPassword = $("#hamqth-password").val(); - if (hamqthUsername) str += "&hamqth_username=" + encodeURIComponent(hamqthUsername); - if (hamqthPassword) str += "&hamqth_password=" + encodeURIComponent(hamqthPassword); + if (hamqthUsername) headers["X-HamQTH-Username"] = hamqthUsername; + if (hamqthPassword) headers["X-HamQTH-Password"] = hamqthPassword; } - return str; + return headers; } diff --git a/webassets/js/map.js b/webassets/js/map.js index 889f3ef..9e25907 100644 --- a/webassets/js/map.js +++ b/webassets/js/map.js @@ -28,7 +28,7 @@ let firstLoad = true; // Load spots and populate the map. function loadSpots() { - $.getJSON('/api/v2/spots' + buildQueryString(true), function (jsonData) { + $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { // Store data spots = jsonData; // Update map @@ -36,11 +36,11 @@ function loadSpots() { if ($("#showTerminator")[0].checked) { terminator.setTime(); } - }); + }}); } // Build a query string for the API, based on the filters that the user has selected. -function buildQueryString(includeCredentials) { +function buildQueryString() { let str = "?"; ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { if (!allFilterOptionsSelected(fn)) { @@ -50,9 +50,6 @@ function buildQueryString(includeCredentials) { str = str + "max_age=" + $("#max-spot-age option:selected").val(); // Additional filters for the map view: No dupes, no QRT, only spots with good locations str = str + "&dedupe=true&allow_qrt=false"; - if (includeCredentials) { - str = str + getCredentialQueryString(); - } return str; } diff --git a/webassets/js/spots.js b/webassets/js/spots.js index 18cb17e..5586865 100644 --- a/webassets/js/spots.js +++ b/webassets/js/spots.js @@ -20,7 +20,7 @@ function loadSpots() { } // Make the new query - $.getJSON('/api/v2/spots' + buildQueryString(false), function (jsonData) { + $.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { // Store data spots = jsonData; // Update table @@ -30,7 +30,7 @@ function loadSpots() { if (run) { startSSEConnection(); } - }); + }}); } // Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the @@ -39,7 +39,7 @@ function startSSEConnection() { if (evtSource != null) { evtSource.close(); } - evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString(true)); + evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString()); evtSource.onmessage = function (event) { // Get the new spot @@ -86,7 +86,7 @@ function startSSEConnection() { } // Build a query string for the API, based on the filters that the user has selected. -function buildQueryString(includeCredentials) { +function buildQueryString() { let str = "?"; ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { if (!allFilterOptionsSelected(fn)) { @@ -97,9 +97,6 @@ function buildQueryString(includeCredentials) { if ($("#search").val() !== "") { str = str + "&text_includes=" + encodeURIComponent($("#search").val()); } - if (includeCredentials) { - str = str + getCredentialQueryString(); - } return str; }