mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-06 10:31:42 +00:00
157 lines
7.3 KiB
Python
157 lines
7.3 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import threading
|
|
|
|
import tornado
|
|
from tornado.web import StaticFileHandler
|
|
|
|
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
|
|
from core.data_store import DATA_STORE
|
|
from server.handlers.api.addspot import APISpotHandler
|
|
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
|
from server.handlers.api.dxstats import APIDxStatsHandler
|
|
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
|
|
from server.handlers.api.options import APIOptionsHandler
|
|
from server.handlers.api.solar_conditions import APISolarConditionsHandler
|
|
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
|
from server.handlers.api.status import APIStatusHandler
|
|
from server.handlers.manifesthandler import ManifestHandler
|
|
from server.handlers.metrics import PrometheusMetricsHandler
|
|
from server.handlers.pagetemplate import PageTemplateHandler
|
|
from server.sse_broadcaster import SSEBroadcaster
|
|
|
|
_HERE = os.path.dirname(__file__ or "")
|
|
|
|
|
|
class WebServer:
|
|
"""Provides the public-facing web server."""
|
|
|
|
def __init__(self):
|
|
"""Constructor"""
|
|
|
|
self._data_store = DATA_STORE
|
|
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.web_server_metrics = {
|
|
"last_page_access_time": None,
|
|
"last_api_access_time": None,
|
|
"page_access_counter": 0,
|
|
"api_access_counter": 0,
|
|
"status": "Starting"
|
|
}
|
|
|
|
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)
|
|
|
|
def start(self):
|
|
"""Start the web server"""
|
|
|
|
asyncio.run(self._start_inner())
|
|
|
|
def stop(self):
|
|
"""Stop the web server"""
|
|
|
|
self._shutdown_event.set()
|
|
|
|
async def _start_inner(self):
|
|
"""Start method (async). Sets up the Tornado application."""
|
|
|
|
# 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()
|
|
|
|
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
|
|
# to avoid copy-pasting the same thing to every route declaration below.
|
|
handler_opts = {"web_server_metrics": self.web_server_metrics}
|
|
|
|
# API endpoints are always enabled
|
|
api_routes = [
|
|
(r"/api/v1/spots", APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}),
|
|
(r"/api/v1/alerts", APIAlertsHandler, {"alerts": self._data_store.alerts, **handler_opts}),
|
|
(r"/api/v1/spots/stream", APISpotsStreamHandler,
|
|
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}),
|
|
(r"/api/v1/alerts/stream", APIAlertsStreamHandler,
|
|
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}),
|
|
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar_conditions,
|
|
**handler_opts}),
|
|
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}),
|
|
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}),
|
|
(r"/api/v1/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}),
|
|
(r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
|
|
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
|
(r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}),
|
|
(r"/api/v1/spot", APISpotHandler, {"spots": self._data_store.spots, **handler_opts}),
|
|
]
|
|
|
|
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
|
|
if self._api_only_mode:
|
|
logging.info("API-only mode is enabled. Web UI will not be served.")
|
|
ui_routes = [
|
|
(r"/", PageTemplateHandler, {"template_name": "api_only_home", **handler_opts})
|
|
]
|
|
else:
|
|
ui_routes = [
|
|
(r"/", PageTemplateHandler, {"template_name": "spots", **handler_opts}),
|
|
(r"/map", PageTemplateHandler, {"template_name": "map", **handler_opts}),
|
|
(r"/bands", PageTemplateHandler, {"template_name": "bands", **handler_opts}),
|
|
(r"/alerts", PageTemplateHandler, {"template_name": "alerts", **handler_opts}),
|
|
(r"/conditions", PageTemplateHandler, {"template_name": "conditions", **handler_opts}),
|
|
(r"/status", PageTemplateHandler, {"template_name": "status", **handler_opts}),
|
|
(r"/about", PageTemplateHandler, {"template_name": "about", **handler_opts})
|
|
]
|
|
# Only allow the Add Spot page if spotting is allowed
|
|
if ALLOW_SPOTTING:
|
|
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
|
|
|
|
# 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", **handler_opts}),
|
|
(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 + ui_routes + misc_routes,
|
|
template_path=os.path.join(_HERE, "../templates"),
|
|
log_function=request_log,
|
|
debug=False)
|
|
app.listen(self._port, xheaders=True)
|
|
logging.info("Web server running on port " + str(WEB_SERVER_PORT))
|
|
logging.info("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."""
|
|
|
|
if LOG_WEB_REQUESTS:
|
|
if handler.get_status() < 500:
|
|
log_method = logging.info
|
|
else:
|
|
log_method = logging.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}" '
|
|
f'{handler.get_status()} {request.request_time():.2f}ms | '
|
|
f'Ref: {referrer} | UA: {user_agent}'
|
|
)
|
|
|
|
|
|
# Global object
|
|
WEB_SERVER = WebServer() |