mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 14:27:42 +00:00
Add telnet server
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
import copy
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
import tornado_eventsource.handler
|
||||
from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import safe_json_dumps
|
||||
from data.lookup_credentials import extract_credentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APISpotsHandler(tornado.web.RequestHandler):
|
||||
"""API request handler for /api/v2/spots"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
application: "Application",
|
||||
request: httputil.HTTPServerRequest,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._spots = None
|
||||
self._web_server_metrics = None
|
||||
super().__init__(application, request, **kwargs)
|
||||
|
||||
def initialize(self, spots, web_server_metrics):
|
||||
self._spots = spots
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
@staticmethod
|
||||
def _enrich(spots, credentials):
|
||||
enriched = []
|
||||
for spot in spots:
|
||||
spot_copy = copy.deepcopy(spot)
|
||||
spot_copy.infer_missing(credentials)
|
||||
enriched.append(spot_copy)
|
||||
return enriched
|
||||
|
||||
def get(self):
|
||||
try:
|
||||
# Metrics
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
api_requests_counter.inc()
|
||||
|
||||
# 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
|
||||
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(self.request.headers)
|
||||
fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else []
|
||||
data = get_spot_list_with_filters(self._spots, query_params)
|
||||
if credentials:
|
||||
data = self._enrich(data, credentials)
|
||||
# Filter for only the required fields, if necessary
|
||||
if fields:
|
||||
data = filter_fields(data, fields)
|
||||
self.write(safe_json_dumps(data))
|
||||
self.set_status(200)
|
||||
except ValueError as e:
|
||||
self.write(safe_json_dumps(f"Bad request - {e!s}"))
|
||||
self.set_status(400)
|
||||
except Exception:
|
||||
logger.exception("Excedption when handling client request to spots API")
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
|
||||
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
"""API request handler for /api/v2/spots/stream"""
|
||||
|
||||
def __init__(self, application, request, **kwargs: Any):
|
||||
self._sse_spot_broadcaster = None
|
||||
self._web_server_metrics = None
|
||||
self._query_params = None
|
||||
self._credentials = None
|
||||
self._fields = None
|
||||
super().__init__(application, request, **kwargs)
|
||||
|
||||
def initialize(self, sse_spot_broadcaster, web_server_metrics):
|
||||
self._sse_spot_broadcaster = sse_spot_broadcaster
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def custom_headers(self):
|
||||
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
|
||||
|
||||
return {"Cache-Control": "no-store", "X-Accel-Buffering": "no"}
|
||||
|
||||
def open(self):
|
||||
"""Called once on the client opening a connection, set things up"""
|
||||
|
||||
try:
|
||||
# Metrics
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
api_requests_counter.inc()
|
||||
|
||||
# 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.request.headers)
|
||||
self._fields = (
|
||||
[f.strip() for f in self._query_params["fields"].split(",")] if "fields" in self._query_params else []
|
||||
)
|
||||
|
||||
# Flush headers immediately so nginx doesn't time out waiting for a response
|
||||
self.write_message("keepalive", "")
|
||||
|
||||
# Register to handle new spots arriving. The callback() method will get called with the new spot as an
|
||||
# argument.
|
||||
self._sse_spot_broadcaster.register(self)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Exception when serving SSE socket")
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""When the user closes the socket, deregister ourselves from the spot broadcaster"""
|
||||
|
||||
self._sse_spot_broadcaster.unregister(self)
|
||||
super().close()
|
||||
|
||||
def callback(self, spot):
|
||||
"""Callback when a new spot arrives"""
|
||||
|
||||
try:
|
||||
# If the new spot matches our param filters, send it to the client. If not, ignore it.
|
||||
if spot_allowed_by_query(spot, self._query_params):
|
||||
# Add lookup data if we have credentials
|
||||
if self._credentials:
|
||||
spot = copy.deepcopy(spot)
|
||||
spot.infer_missing(self._credentials)
|
||||
# Filter fields returned if necessary
|
||||
if self._fields:
|
||||
spot = filter_fields([spot], self._fields)[0]
|
||||
self.write_message(msg=safe_json_dumps(spot))
|
||||
except Exception:
|
||||
logger.exception("Exception in SSE callback, connection will be closed")
|
||||
self.close()
|
||||
|
||||
|
||||
def get_spot_list_with_filters(all_spots, query):
|
||||
"""Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
|
||||
the main "spots" GET call."""
|
||||
|
||||
# Create a shallow copy of the spot list, ordered by spot time, then filter the list to reduce it only to spots
|
||||
# that match the filter parameters in the query string. Finally, apply a limit to the number of spots returned.
|
||||
# The list of query string filters is defined in the API docs.
|
||||
spot_ids = all_spots.keys()
|
||||
spots = []
|
||||
for k in spot_ids:
|
||||
s = all_spots.get(k)
|
||||
if s is not None:
|
||||
spots.append(s)
|
||||
spots = sorted(spots, key=lambda spot: spot.time if spot and spot.time else 0, reverse=True)
|
||||
spots = list(filter(lambda spot: spot_allowed_by_query(spot, query), spots))
|
||||
if "limit" in query:
|
||||
spots = spots[: int(query.get("limit"))]
|
||||
|
||||
# Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the
|
||||
# list being in reverse time order, so if any future change allows re-ordering the list, that should
|
||||
# be done *after* this. SSIDs are deliberately included here (see issue #68) because e.g. M0TRT-7
|
||||
# and M0TRT-9 APRS transponders could well be in different locations, on different frequencies etc.
|
||||
# This is a special consideration for the geo map and band map views (and Field Spotter) because while
|
||||
# duplicates are fine in the main spot list (e.g. different cluster spots of the same DX) this doesn't
|
||||
# work well for the other views.
|
||||
if "dedupe" in query:
|
||||
dedupe = query.get("dedupe").upper() == "TRUE"
|
||||
if dedupe:
|
||||
spots_temp = []
|
||||
already_seen = []
|
||||
for s in spots:
|
||||
call_plus_ssid = s.dx_call + (s.dx_ssid if s.dx_ssid else "")
|
||||
if call_plus_ssid not in already_seen:
|
||||
spots_temp.append(s)
|
||||
already_seen.append(call_plus_ssid)
|
||||
spots = spots_temp
|
||||
|
||||
return spots
|
||||
|
||||
|
||||
def spot_allowed_by_query(spot, query):
|
||||
"""Given URL query params and a spot, figure out if the spot "passes" the requested filters or is rejected. The list
|
||||
of query parameters and their function is defined in the API docs."""
|
||||
|
||||
for k in query:
|
||||
match k:
|
||||
case "since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
|
||||
if not spot.time or spot.time <= since:
|
||||
return False
|
||||
case "max_age":
|
||||
max_age = int(query.get(k))
|
||||
since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
|
||||
if not spot.time or spot.time <= since:
|
||||
return False
|
||||
case "received_since":
|
||||
since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC).timestamp()
|
||||
if not spot.received_time or spot.received_time <= since:
|
||||
return False
|
||||
case "source":
|
||||
sources = query.get(k).split(",")
|
||||
if not spot.source or spot.source not in sources:
|
||||
return False
|
||||
case "sig":
|
||||
# If a list of sigs is provided, the spot must have a sig and it must match one of them.
|
||||
# The special "sig" "NO_SIG", when supplied in the list, mathches spots with no sig.
|
||||
sigs = query.get(k).split(",")
|
||||
include_no_sig = "NO_SIG" in sigs
|
||||
if not spot.sig and not include_no_sig:
|
||||
return False
|
||||
if spot.sig and spot.sig not in sigs:
|
||||
return False
|
||||
case "needs_sig":
|
||||
# If true, a sig is required, regardless of what it is, it just can't be missing. Mutually
|
||||
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
|
||||
needs_sig = query.get(k).upper() == "TRUE"
|
||||
if needs_sig and not spot.sig:
|
||||
return False
|
||||
case "needs_sig_ref":
|
||||
# If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
|
||||
needs_sig_ref = query.get(k).upper() == "TRUE"
|
||||
if needs_sig_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
|
||||
return False
|
||||
case "band":
|
||||
bands = query.get(k).split(",")
|
||||
if not spot.band or spot.band not in bands:
|
||||
return False
|
||||
case "mode":
|
||||
modes = query.get(k).split(",")
|
||||
if not spot.mode or spot.mode not in modes:
|
||||
return False
|
||||
case "mode_type":
|
||||
mode_types = query.get(k).split(",")
|
||||
if not spot.mode_type or spot.mode_type not in mode_types:
|
||||
return False
|
||||
case "dx_continent":
|
||||
dxconts = query.get(k).split(",")
|
||||
if not spot.dx_continent or spot.dx_continent not in dxconts:
|
||||
return False
|
||||
case "de_continent":
|
||||
deconts = query.get(k).split(",")
|
||||
if not spot.de_continent or spot.de_continent not in deconts:
|
||||
return False
|
||||
case "comment_includes":
|
||||
comment_includes = query.get(k).strip()
|
||||
if not spot.comment or comment_includes.upper() not in spot.comment.upper():
|
||||
return False
|
||||
case "dx_call_includes":
|
||||
dx_call_includes = query.get(k).strip()
|
||||
if not spot.dx_call or dx_call_includes.upper() not in spot.dx_call.upper():
|
||||
return False
|
||||
case "text_includes":
|
||||
text_includes = query.get(k).strip()
|
||||
if (not spot.dx_call or text_includes.upper() not in spot.dx_call.upper()) and (
|
||||
not spot.comment or text_includes.upper() not in spot.comment.upper()
|
||||
):
|
||||
return False
|
||||
case "allow_qrt":
|
||||
# If false, spots that are flagged as QRT are not returned.
|
||||
prevent_qrt = query.get(k).upper() == "FALSE"
|
||||
if prevent_qrt and spot.qrt:
|
||||
return False
|
||||
case "needs_good_location":
|
||||
# If true, spots require a "good" location to be returned
|
||||
needs_good_location = query.get(k).upper() == "TRUE"
|
||||
if needs_good_location and not spot.dx_location_good:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def filter_fields(spots, fields):
|
||||
"""Given a list of spot objects, return copies containing only the named fields."""
|
||||
|
||||
return [{k: v for k, v in spot.__dict__.items() if k in fields} for spot in spots]
|
||||
Reference in New Issue
Block a user