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()