import json import logging import secrets import threading import pykka import tornado.httpserver import tornado.ioloop import tornado.netutil import tornado.web import tornado.websocket from mopidy import exceptions, models, zeroconf from mopidy.core import CoreListener from mopidy.http import Extension, handlers from mopidy.internal import formatting, network try: import asyncio except ImportError: asyncio = None logger = logging.getLogger(__name__) class HttpFrontend(pykka.ThreadingActor, CoreListener): apps = [] statics = [] def __init__(self, config, core): super().__init__() self.hostname = network.format_hostname(config["http"]["hostname"]) self.port = config["http"]["port"] tornado_hostname = config["http"]["hostname"] if tornado_hostname == "::": tornado_hostname = None try: logger.debug("Starting HTTP server") sockets = tornado.netutil.bind_sockets(self.port, tornado_hostname) self.server = HttpServer( config=config, core=core, sockets=sockets, apps=self.apps, statics=self.statics, ) except OSError as exc: raise exceptions.FrontendError(f"HTTP server startup failed: {exc}") self.zeroconf_name = config["http"]["zeroconf"] self.zeroconf_http = None self.zeroconf_mopidy_http = None def on_start(self): logger.info("HTTP server running at [%s]:%s", self.hostname, self.port) self.server.start() if self.zeroconf_name: self.zeroconf_http = zeroconf.Zeroconf( name=self.zeroconf_name, stype="_http._tcp", port=self.port ) self.zeroconf_mopidy_http = zeroconf.Zeroconf( name=self.zeroconf_name, stype="_mopidy-http._tcp", port=self.port, ) self.zeroconf_http.publish() self.zeroconf_mopidy_http.publish() def on_stop(self): if self.zeroconf_http: self.zeroconf_http.unpublish() if self.zeroconf_mopidy_http: self.zeroconf_mopidy_http.unpublish() self.server.stop() def on_event(self, name, **data): on_event(name, self.server.io_loop, **data) def on_event(name, io_loop, **data): event = data event["event"] = name message = json.dumps(event, cls=models.ModelJSONEncoder) handlers.WebSocketHandler.broadcast(message, io_loop) class HttpServer(threading.Thread): name = "HttpServer" def __init__(self, config, core, sockets, apps, statics): super().__init__() self.config = config self.core = core self.sockets = sockets self.apps = apps self.statics = statics self.app = None self.server = None self.io_loop = None def run(self): if asyncio: # If asyncio is available, Tornado uses it as its IO loop. Since we # start Tornado in a another thread than the main thread, we must # explicitly create an asyncio loop for the current thread. asyncio.set_event_loop(asyncio.new_event_loop()) self.app = tornado.web.Application( self._get_request_handlers(), cookie_secret=self._get_cookie_secret(), ) self.server = tornado.httpserver.HTTPServer(self.app) self.server.add_sockets(self.sockets) self.io_loop = tornado.ioloop.IOLoop.current() self.io_loop.start() logger.debug("Stopped HTTP server") def stop(self): logger.debug("Stopping HTTP server") self.io_loop.add_callback(self.io_loop.stop) def _get_request_handlers(self): request_handlers = [] request_handlers.extend(self._get_app_request_handlers()) request_handlers.extend(self._get_static_request_handlers()) request_handlers.extend(self._get_default_request_handlers()) logger.debug( "HTTP routes from extensions: %s", formatting.indent( "\n".join( f"{path!r}: {handler!r}" for (path, handler, *_) in request_handlers ) ), ) return request_handlers def _get_app_request_handlers(self): result = [] for app in self.apps: try: request_handlers = app["factory"](self.config, self.core) except Exception: logger.exception("Loading %s failed.", app["name"]) continue result.append((f"/{app['name']}", handlers.AddSlashHandler)) for handler in request_handlers: handler = list(handler) handler[0] = f"/{app['name']}{handler[0]}" result.append(tuple(handler)) logger.debug("Loaded HTTP extension: %s", app["name"]) return result def _get_static_request_handlers(self): result = [] for static in self.statics: result.append((f"/{static['name']}", handlers.AddSlashHandler)) result.append( ( f"/{static['name']}/(.*)", handlers.StaticFileHandler, {"path": static["path"], "default_filename": "index.html"}, ) ) logger.debug("Loaded static HTTP extension: %s", static["name"]) return result def _get_default_request_handlers(self): sites = [app["name"] for app in self.apps + self.statics] default_app = self.config["http"]["default_app"] if default_app not in sites: logger.warning( f"HTTP server's default app {default_app!r} not found" ) default_app = "mopidy" logger.debug(f"Default webclient is {default_app}") return [ ( r"/", tornado.web.RedirectHandler, {"url": f"/{default_app}/", "permanent": False}, ) ] def _get_cookie_secret(self): file_path = Extension.get_data_dir(self.config) / "cookie_secret" if not file_path.is_file(): cookie_secret = secrets.token_hex(32) file_path.write_text(cookie_secret) else: cookie_secret = file_path.read_text().strip() if not cookie_secret: logging.error( f"HTTP server could not find cookie secret in {file_path}" ) return cookie_secret