First stab at submitting spots upstream. POTA is working, all other providers still to do. #95

This commit is contained in:
Ian Renton
2026-06-12 09:14:21 +01:00
parent 930d5357fe
commit 1afb407ca5
29 changed files with 640 additions and 92 deletions

View File

@@ -15,6 +15,12 @@ info:
## 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
* `/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:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: v1.3
version: 1.4
servers:
- url: https://spothole.app/api/v1
@@ -288,7 +294,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/lookup/sigref:
@@ -313,7 +320,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
@@ -339,7 +347,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/spot:
@@ -347,40 +356,44 @@ paths:
tags:
- Spots
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
requestBody:
description: The JSON spot object
description: The JSON spot object, plus optional upstream submission control fields
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Spot'
$ref: '#/components/schemas/SpotSubmission'
responses:
'200':
'201':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/OkResponse'
type: string
example: "OK"
'415':
description: Incorrect Content-Type
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
'422':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
components:
parameters:
@@ -982,6 +995,48 @@ components:
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:
type: object
description: A server-sent event containing a spot
@@ -1294,7 +1349,7 @@ components:
solar_storm_forecast:
type: object
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.
Values are integer percentages (0100).
additionalProperties:
@@ -1308,7 +1363,7 @@ components:
blackout_forecast_r1r2:
type: object
description: >
NOAA Radio Blackout forecast probability (%) of R1R2 (MinorModerate) blackout events
NOAA Radio Blackout forecast containing probability (%) of R1R2 (MinorModerate) blackout events
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100).
additionalProperties:
@@ -1322,7 +1377,7 @@ components:
blackout_forecast_r3_or_greater:
type: object
description: >
NOAA Radio Blackout forecast probability (%) of R3 or greater (StrongExtreme) blackout
NOAA Radio Blackout forecast containing probability (%) of R3 or greater (StrongExtreme) blackout
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100).
additionalProperties:
@@ -1493,14 +1548,6 @@ components:
items:
$ref: '#/components/schemas/Alert'
OkResponse:
type: string
example: "OK"
ErrorResponse:
type: string
example: "Failed"
DxStats:
type: object
description: Spot counts keyed by DE continent
@@ -1637,6 +1684,20 @@ components:
type: boolean
description: Whether the POST /spot call, to add spots to the server directly via its API, is permitted on this server.
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:
type: object

View File

@@ -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
// any saved values from local storage.
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
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
function addSpot() {
try {
@@ -78,21 +231,65 @@ function addSpot() {
}
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", {
data : JSON.stringify(spot),
contentType : 'application/json',
type : 'POST',
timeout: 10000,
success: async function (result) {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
// Reset CAPTCHA for next use
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result && result.startsWith && result.startsWith("Warning")) {
$("#result-good").html("<div class='alert alert-warning fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + result + " Returning you to the spots list...</div>");
} else {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
}
$("#result-bad").html("");
setTimeout(() => {
$("#result-good").hide();
window.location.replace("/");
}, 1000);
}, 2000);
},
error: function (result) {
showAddSpotError(result.responseText.slice(1,-1));
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result.responseText) {
showAddSpotError(result.responseText.slice(1, -1));
} else {
showAddSpotError("The server did not return a response.");
}
}
});
} catch (error) {
@@ -121,20 +318,18 @@ $("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
// Display the intro box, unless the user has already dismissed it once.
function displayIntroBox() {
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
$("#add-spot-intro-box").show();
}
$("#add-spot-intro-box-dismiss").click(function() {
localStorage.setItem("add-spot-intro-box-dismissed", true);
});
}
// Update upstream area and credentials button when SIG changes
$("#sig").change(function () {
updateUpstreamArea();
});
// Update credentials button when provider selector changes
$("#upstream-provider-select").change(function () {
updateCredentialsButton();
});
// Startup
$(document).ready(function() {
// Load options
loadOptions();
// Display intro box
displayIntroBox();
});
});