Stop fudging the server-side handling instructions for "add spot" into the spot data structure itself, instead break them out into a new area. This is a breaking change to the API so all API endpoints have been bumped to v2.

This commit is contained in:
Ian Renton
2026-06-20 09:57:09 +01:00
parent 1e42c69b78
commit ae17839096
20 changed files with 132 additions and 82 deletions

View File

@@ -14,12 +14,12 @@ info:
Spothole's source code is located at https://git.ianrenton.com/ian/spothole and the README there provides setup instructions if you would like to run your own copy. A demonstration server of Spothole is located at https://spothole.app.
## 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.
### 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 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.
* 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.)
### 1.3
@@ -42,10 +42,10 @@ info:
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: 1.4
version: 2.0
servers:
- url: https://spothole.app/api/v1
- url: https://spothole.app/api/v2
tags:
- name: Spots
@@ -356,10 +356,10 @@ paths:
tags:
- Spots
summary: Add a 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`"
description: "Supply a spot submission object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object (server-side instructions such as upstream submission). Check `spot_submit_providers` in the `/options` response to see which SIGs and providers support upstream submission. cURL example: `curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v2/spot`"
operationId: spot
requestBody:
description: The JSON spot object, plus optional upstream submission control fields
description: Object containing a "spot" sub-object with the spot data, and an optional "handling" sub-object with server-side instructions of what to do with it.
required: true
content:
application/json:
@@ -997,11 +997,18 @@ components:
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
Request body for POST /spot. Contains a "spot" sub-object with the spot data, and an optional
"handling" sub-object with server-side instructions consumed by Spothole.
type: object
required:
- spot
properties:
spot:
$ref: '#/components/schemas/Spot'
handling:
type: object
description: >
Optional server-side instructions for how to process this spot submission.
properties:
submit_upstream:
type: boolean
@@ -1021,7 +1028,7 @@ components:
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
The required keys depend on the provider. Credentials are used only for the upstream
call and are never stored by Spothole.
additionalProperties:
type: string
@@ -1032,8 +1039,7 @@ components:
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
server has reCAPTCHA configured. Obtain the token by completing the reCAPTCHA
widget rendered on the Add Spot page.
example: "03AFY_a8Xq..."
@@ -1678,7 +1684,7 @@ components:
example: "EU"
max_spot_age:
type: integer
description: The maximum age, in seconds, of any spot before it will be deleted by the system. When querying the /api/v1/spots endpoint and providing a "max_age" or "since" parameter, there is no point providing a number larger than this, because the system drops all spots older than this.
description: The maximum age, in seconds, of any spot before it will be deleted by the system. When querying the /api/v2/spots endpoint and providing a "max_age" or "since" parameter, there is no point providing a number larger than this, because the system drops all spots older than this.
example: 3600
spot_allowed:
type: boolean

View File

@@ -29,7 +29,7 @@ const PROVIDER_CREDENTIAL_SCHEMAS = {
// 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() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;
@@ -203,6 +203,7 @@ function addSpot() {
const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase();
// Prepare the spot object for the server
const spot = {};
if (dx !== "") {
spot["dx_call"] = dx;
@@ -240,6 +241,19 @@ function addSpot() {
}
spot["time"] = moment.utc().valueOf() / 1000.0;
// Prepare "handling" structure to tell the server what to do with this spot
const handling = {};
// Add CAPTCHA token if reCAPTCHA is loaded
if (window._recaptchaWidgetId !== undefined) {
const token = grecaptcha.getResponse(window._recaptchaWidgetId);
if (!token) {
showAddSpotError("Please complete the CAPTCHA to submit upstream.");
return;
}
handling["captcha_token"] = token;
}
// Upstream submission
const submitUpstream = $("#submit-upstream").is(":checked");
const upstreamProviderName = getSelectedUpstreamProvider();
@@ -261,24 +275,13 @@ function addSpot() {
return;
}
const 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) {
const token = grecaptcha.getResponse(window._recaptchaWidgetId);
if (!token) {
showAddSpotError("Please complete the CAPTCHA to submit upstream.");
return;
}
spot["captcha_token"] = token;
}
handling["submit_upstream"] = true;
handling["upstream_provider"] = upstreamProviderName;
handling["upstream_credentials"] = loadCredentials(upstreamProviderName);
}
$.ajax("/api/v1/spot", {
data: JSON.stringify(spot),
$.ajax("/api/v2/spot", {
data: JSON.stringify({spot, handling}),
contentType: 'application/json',
type: 'POST',
timeout: 10000,

View File

@@ -6,7 +6,7 @@ let alerts = [];
// Load alerts and populate the table.
function loadAlerts() {
$.getJSON('/api/v1/alerts' + buildQueryString(false), function (jsonData) {
$.getJSON('/api/v2/alerts' + buildQueryString(false), function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
@@ -280,7 +280,7 @@ function addAlertRowsToTable(tbody, alerts) {
// Load server options. Once a successful callback is made from this, we then query alerts.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -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/v1/spots' + buildQueryString(false), function (jsonData) {
$.getJSON('/api/v2/spots' + buildQueryString(false), function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
@@ -229,7 +229,7 @@ function removeDuplicatesForBandPanel(spotList) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -10,7 +10,7 @@ let ionosondeChart = null;
// Load solar conditions
function loadSolarConditions() {
$.getJSON('/api/v1/solar', function (jsonData) {
$.getJSON('/api/v2/solar', function (jsonData) {
// HF
@@ -660,7 +660,7 @@ function dxStatsContientChanged() {
// Fetch DX stats from the API and render
function loadDxStats() {
$.getJSON('/api/v1/dxstats', function (jsonData) {
$.getJSON('/api/v2/dxstats', function (jsonData) {
dxStatsData = jsonData;
renderDxStats();
});

View File

@@ -28,7 +28,7 @@ let firstLoad = true;
// Load spots and populate the map.
function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData) {
$.getJSON('/api/v2/spots' + buildQueryString(true), function (jsonData) {
// Store data
spots = jsonData;
// Update map
@@ -194,7 +194,7 @@ function getTooltipText(s) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -20,7 +20,7 @@ function loadSpots() {
}
// Make the new query
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
$.getJSON('/api/v2/spots' + buildQueryString(false), function (jsonData) {
// Store data
spots = jsonData;
// Update table
@@ -39,7 +39,7 @@ function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString(true));
evtSource.onmessage = function (event) {
// Get the new spot
@@ -418,7 +418,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -1,6 +1,6 @@
// Load server status
function loadStatus() {
$.getJSON('/api/v1/status', function (jsonData) {
$.getJSON('/api/v2/status', function (jsonData) {
$("#software-version").text(jsonData["software-version"]);
$("#server-owner-callsign").text(jsonData["server-owner-callsign"]);
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());