Files
spothole/webserver/webserver.py
T

297 lines
11 KiB
Python

import asyncio
import logging
import os
import threading
import tornado
from tornado.web import StaticFileHandler
from core.config import (
ALLOW_SPOTTING,
API_ONLY_MODE,
BASE_URL,
LOG_WEB_REQUESTS,
WEB_SERVER_PORT,
)
from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE
from webserver.handlers.api.addspot import APISpotHandler
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from webserver.handlers.api.dxstats import APIDxStatsHandler
from webserver.handlers.api.lookups import (
APILookupActivityRefHandler,
APILookupCallHandler,
APILookupGridHandler,
)
from webserver.handlers.api.options import APIOptionsHandler
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from webserver.handlers.api.status import APIStatusHandler
from webserver.handlers.api.v1_addspot import V1APISpotHandler
from webserver.handlers.api.v1_compatability import V1RedirectHandler
from webserver.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler
from webserver.handlers.manifesthandler import ManifestHandler
from webserver.handlers.metrics import PrometheusMetricsHandler
from webserver.handlers.pagetemplate import PageTemplateHandler
from webserver.sse_broadcaster import SSEBroadcaster
from webserver.webserver_metrics import WebServerMetrics
logger = logging.getLogger(__name__)
_HERE = os.path.dirname(__file__ or "")
class WebServer:
"""Provides the public-facing web server."""
def __init__(self):
"""Constructor"""
self._data_store = DATA_STORE
self._data_providers = DATA_PROVIDERS
self._spot_broadcaster = SSEBroadcaster()
self._alert_broadcaster = SSEBroadcaster()
self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event()
self._loop = None
self._thread = None
self.web_server_metrics = WebServerMetrics()
def setup(self):
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
@property
def sse_client_count(self) -> int:
"""Number of connected SSE clients, across both the spots and alerts streams."""
return self._spot_broadcaster.client_count + self._alert_broadcaster.client_count
def start(self):
"""Start the web server"""
self._thread = threading.Thread(target=asyncio.run, args=(self._start_inner(),), name="WebServer", daemon=True)
self._thread.start()
def stop(self):
"""Stop the web server"""
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._shutdown_event.set)
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
logger.warning("Web server background thread did not exit on time and will be killed.")
def _handle_loop_exception(self, loop, context):
"""Ignore "cancelled" exceptions from the asyncio loop to avoid printing exceptions to the log on shutdown when
we cancel the SSE handler threads"""
exception = context.get("exception")
if isinstance(exception, asyncio.CancelledError):
return
loop.default_exception_handler(context)
async def _start_inner(self):
"""Start method (async). Sets up the Tornado application."""
self._loop = asyncio.get_running_loop()
self._loop.set_exception_handler(self._handle_loop_exception)
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
self._spot_broadcaster.bind_to_web_server_loop()
self._alert_broadcaster.bind_to_web_server_loop()
# API endpoints are always enabled
api_routes = [
(
r"/api/v2/spots",
APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/alerts",
APIAlertsHandler,
{"alerts": self._data_store.alerts},
),
(
r"/api/v2/spots/stream",
APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v2/alerts/stream",
APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v2/solar",
APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v2/dxstats",
APIDxStatsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v2/options",
APIOptionsHandler,
{"status_data": self._data_store.status.get()},
),
(
r"/api/v2/status",
APIStatusHandler,
{"status_data": self._data_store.status.get()},
),
(r"/api/v2/lookup/call", APILookupCallHandler),
(r"/api/v2/lookup/sigref", APILookupActivityRefHandler),
(r"/api/v2/lookup/grid", APILookupGridHandler),
(
r"/api/v2/spot",
APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
},
),
]
# v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones
# that have the major breaking changes get a bespoke handler.
v1_compat_routes = [
(
r"/api/v1/spots",
V1APISpotsHandler,
{"spots": self._data_store.spots},
),
(
r"/api/v1/spots/stream",
V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v1/spot",
V1APISpotHandler,
{
"spots": self._data_store.spots,
},
),
(r"/api/v1/(.*)", V1RedirectHandler),
]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
if self._api_only_mode:
logger.info("API-only mode is enabled. Web UI will not be served.")
ui_routes = [
(
r"/",
PageTemplateHandler,
{"template_name": "api_only_home"},
)
]
else:
ui_routes = [
(r"/", PageTemplateHandler, {"template_name": "spots"}),
(
r"/map",
PageTemplateHandler,
{"template_name": "map"},
),
(
r"/bands",
PageTemplateHandler,
{"template_name": "bands"},
),
(
r"/alerts",
PageTemplateHandler,
{"template_name": "alerts"},
),
(
r"/conditions",
PageTemplateHandler,
{"template_name": "conditions"},
),
(
r"/status",
PageTemplateHandler,
{"template_name": "status"},
),
(
r"/about",
PageTemplateHandler,
{"template_name": "about"},
),
]
# Only allow the Add Spot page if spotting is allowed
if ALLOW_SPOTTING:
ui_routes += [
(
r"/add-spot",
PageTemplateHandler,
{"template_name": "add_spot"},
)
]
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only
# mode.
misc_routes = [
(
r"/apidocs",
PageTemplateHandler,
{"template_name": "apidocs"},
),
(r"/metrics", PrometheusMetricsHandler),
(r"/manifest.webmanifest", ManifestHandler),
# If e.g. nginx is configured as a reverse proxy with a hard-coded path to static files, as per the README,
# this will never have to handle anything, but having it here allows Spothole to work without nginx for
# testing.
(
r"/static/(.*)",
StaticFileHandler,
{"path": os.path.join(_HERE, "../static")},
),
]
app = tornado.web.Application(
api_routes + v1_compat_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"),
log_function=request_log,
debug=False,
)
app.listen(self._port, xheaders=True)
logger.info(f"Web server running on port {WEB_SERVER_PORT!s}")
logger.info(f"You can access your copy of Spothole at {BASE_URL}")
await self._shutdown_event.wait()
def request_log(handler):
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
web request logging altogetether. Also records the time of the request and status in the webserver metrics. Probably
not what this method is supposed to be used for but it's a convenient thing that gets called on every request, so
saves having to pass the metrics around each handler individually."""
WEB_SERVER.web_server_metrics.record(handler.request.path, handler.get_status())
if LOG_WEB_REQUESTS:
if handler.get_status() < 500:
log_method = logger.info
else:
log_method = logger.warning
request = handler.request
client_ip = request.remote_ip
referrer = request.headers.get("Referer", "-")
user_agent = request.headers.get("User-Agent", "-")
log_method(
f'{client_ip} - "{request.method} {request.uri}" {handler.get_status()} {request.request_time():.2f}ms | Ref: {referrer} | UA: {user_agent}'
)
# Global object
WEB_SERVER = WebServer()