Python3 Migrate
This commit is contained in:
53
venv/lib/python3.7/site-packages/mopidy/http/__init__.py
Normal file
53
venv/lib/python3.7/site-packages/mopidy/http/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import mopidy
|
||||
from mopidy import config as config_lib
|
||||
from mopidy import exceptions, ext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Extension(ext.Extension):
|
||||
dist_name = "Mopidy-HTTP"
|
||||
ext_name = "http"
|
||||
version = mopidy.__version__
|
||||
|
||||
def get_default_config(self):
|
||||
conf_file = os.path.join(os.path.dirname(__file__), "ext.conf")
|
||||
return config_lib.read(conf_file)
|
||||
|
||||
def get_config_schema(self):
|
||||
schema = super().get_config_schema()
|
||||
schema["hostname"] = config_lib.Hostname()
|
||||
schema["port"] = config_lib.Port()
|
||||
schema["static_dir"] = config_lib.Deprecated()
|
||||
schema["zeroconf"] = config_lib.String(optional=True)
|
||||
schema["allowed_origins"] = config_lib.List(optional=True)
|
||||
schema["csrf_protection"] = config_lib.Boolean(optional=True)
|
||||
schema["default_app"] = config_lib.String(optional=True)
|
||||
return schema
|
||||
|
||||
def validate_environment(self):
|
||||
try:
|
||||
import tornado.web # noqa
|
||||
except ImportError as e:
|
||||
raise exceptions.ExtensionError("tornado library not found", e)
|
||||
|
||||
def setup(self, registry):
|
||||
from .actor import HttpFrontend
|
||||
from .handlers import make_mopidy_app_factory
|
||||
|
||||
HttpFrontend.apps = registry["http:app"]
|
||||
HttpFrontend.statics = registry["http:static"]
|
||||
|
||||
registry.add("frontend", HttpFrontend)
|
||||
registry.add(
|
||||
"http:app",
|
||||
{
|
||||
"name": "mopidy",
|
||||
"factory": make_mopidy_app_factory(
|
||||
registry["http:app"], registry["http:static"]
|
||||
),
|
||||
},
|
||||
)
|
||||
210
venv/lib/python3.7/site-packages/mopidy/http/actor.py
Normal file
210
venv/lib/python3.7/site-packages/mopidy/http/actor.py
Normal file
@@ -0,0 +1,210 @@
|
||||
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
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mopidy</title>
|
||||
<link rel="stylesheet" type="text/css" href="mopidy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="box focus">
|
||||
<h1>Mopidy</h1>
|
||||
|
||||
<p>This web server is a part of the Mopidy music server. To learn more
|
||||
about Mopidy, please visit
|
||||
<a href="http://www.mopidy.com/">www.mopidy.com</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2>Web clients</h2>
|
||||
|
||||
<ul>
|
||||
{% for app in apps %}
|
||||
<li><a href="/{{ url_escape(app) }}/">{{ escape(app) }}</a></li>
|
||||
{% end %}
|
||||
</ul>
|
||||
|
||||
<p>Web clients which are installed as Mopidy extensions will
|
||||
automatically appear here.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
venv/lib/python3.7/site-packages/mopidy/http/data/favicon.ico
Normal file
BIN
venv/lib/python3.7/site-packages/mopidy/http/data/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
43
venv/lib/python3.7/site-packages/mopidy/http/data/mopidy.css
Normal file
43
venv/lib/python3.7/site-packages/mopidy/http/data/mopidy.css
Normal file
@@ -0,0 +1,43 @@
|
||||
html {
|
||||
background: #f8f8f8;
|
||||
color: #555;
|
||||
font-family: Geneva, Tahoma, Verdana, sans-serif;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
body {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1, h2 {
|
||||
font-weight: 500;
|
||||
line-height: 1.1em;
|
||||
}
|
||||
a {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.box {
|
||||
background: white;
|
||||
box-shadow: 0px 5px 5px #f0f0f0;
|
||||
margin: 1em;
|
||||
padding: 1em;
|
||||
}
|
||||
.box.focus {
|
||||
background: #465158;
|
||||
color: #e8ecef;
|
||||
}
|
||||
|
||||
.box a {
|
||||
color: #465158;
|
||||
}
|
||||
.box a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.box.focus a {
|
||||
color: #e8ecef;
|
||||
}
|
||||
8
venv/lib/python3.7/site-packages/mopidy/http/ext.conf
Normal file
8
venv/lib/python3.7/site-packages/mopidy/http/ext.conf
Normal file
@@ -0,0 +1,8 @@
|
||||
[http]
|
||||
enabled = true
|
||||
hostname = 127.0.0.1
|
||||
port = 6680
|
||||
zeroconf = Mopidy HTTP server on $hostname
|
||||
allowed_origins =
|
||||
csrf_protection = true
|
||||
default_app = mopidy
|
||||
273
venv/lib/python3.7/site-packages/mopidy/http/handlers.py
Normal file
273
venv/lib/python3.7/site-packages/mopidy/http/handlers.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import urllib
|
||||
|
||||
import tornado.escape
|
||||
import tornado.ioloop
|
||||
import tornado.web
|
||||
import tornado.websocket
|
||||
|
||||
import mopidy
|
||||
from mopidy import core, models
|
||||
from mopidy.internal import jsonrpc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_mopidy_app_factory(apps, statics):
|
||||
def mopidy_app_factory(config, core):
|
||||
if not config["http"]["csrf_protection"]:
|
||||
logger.warning(
|
||||
"HTTP Cross-Site Request Forgery protection is disabled"
|
||||
)
|
||||
allowed_origins = {
|
||||
x.lower() for x in config["http"]["allowed_origins"] if x
|
||||
}
|
||||
return [
|
||||
(
|
||||
r"/ws/?",
|
||||
WebSocketHandler,
|
||||
{
|
||||
"core": core,
|
||||
"allowed_origins": allowed_origins,
|
||||
"csrf_protection": config["http"]["csrf_protection"],
|
||||
},
|
||||
),
|
||||
(
|
||||
r"/rpc",
|
||||
JsonRpcHandler,
|
||||
{
|
||||
"core": core,
|
||||
"allowed_origins": allowed_origins,
|
||||
"csrf_protection": config["http"]["csrf_protection"],
|
||||
},
|
||||
),
|
||||
(
|
||||
r"/(.+)",
|
||||
StaticFileHandler,
|
||||
{"path": os.path.join(os.path.dirname(__file__), "data")},
|
||||
),
|
||||
(r"/", ClientListHandler, {"apps": apps, "statics": statics}),
|
||||
]
|
||||
|
||||
return mopidy_app_factory
|
||||
|
||||
|
||||
def make_jsonrpc_wrapper(core_actor):
|
||||
inspector = jsonrpc.JsonRpcInspector(
|
||||
objects={
|
||||
"core.get_uri_schemes": core.Core.get_uri_schemes,
|
||||
"core.get_version": core.Core.get_version,
|
||||
"core.history": core.HistoryController,
|
||||
"core.library": core.LibraryController,
|
||||
"core.mixer": core.MixerController,
|
||||
"core.playback": core.PlaybackController,
|
||||
"core.playlists": core.PlaylistsController,
|
||||
"core.tracklist": core.TracklistController,
|
||||
}
|
||||
)
|
||||
return jsonrpc.JsonRpcWrapper(
|
||||
objects={
|
||||
"core.describe": inspector.describe,
|
||||
"core.get_uri_schemes": core_actor.get_uri_schemes,
|
||||
"core.get_version": core_actor.get_version,
|
||||
"core.history": core_actor.history,
|
||||
"core.library": core_actor.library,
|
||||
"core.mixer": core_actor.mixer,
|
||||
"core.playback": core_actor.playback,
|
||||
"core.playlists": core_actor.playlists,
|
||||
"core.tracklist": core_actor.tracklist,
|
||||
},
|
||||
decoders=[models.model_json_decoder],
|
||||
encoders=[models.ModelJSONEncoder],
|
||||
)
|
||||
|
||||
|
||||
def _send_broadcast(client, msg):
|
||||
# We could check for client.ws_connection, but we don't really
|
||||
# care why the broadcast failed, we just want the rest of them
|
||||
# to succeed, so catch everything.
|
||||
try:
|
||||
client.write_message(msg)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"Broadcast of WebSocket message to "
|
||||
f"{client.request.remote_ip} failed: {exc}"
|
||||
)
|
||||
# TODO: should this do the same cleanup as the on_message code?
|
||||
|
||||
|
||||
class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
# XXX This set is shared by all WebSocketHandler objects. This isn't
|
||||
# optimal, but there's currently no use case for having more than one of
|
||||
# these anyway.
|
||||
clients = set()
|
||||
|
||||
@classmethod
|
||||
def broadcast(cls, msg, io_loop):
|
||||
# This can be called from outside the Tornado ioloop, so we need to
|
||||
# safely cross the thread boundary by adding a callback to the loop.
|
||||
for client in cls.clients:
|
||||
# One callback per client to keep time we hold up the loop short
|
||||
io_loop.add_callback(
|
||||
functools.partial(_send_broadcast, client, msg)
|
||||
)
|
||||
|
||||
def initialize(self, core, allowed_origins, csrf_protection):
|
||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||
self.allowed_origins = allowed_origins
|
||||
self.csrf_protection = csrf_protection
|
||||
|
||||
def open(self):
|
||||
self.set_nodelay(True)
|
||||
self.clients.add(self)
|
||||
logger.debug("New WebSocket connection from %s", self.request.remote_ip)
|
||||
|
||||
def on_close(self):
|
||||
self.clients.discard(self)
|
||||
logger.debug(
|
||||
"Closed WebSocket connection from %s", self.request.remote_ip
|
||||
)
|
||||
|
||||
def on_message(self, message):
|
||||
if not message:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Received WebSocket message from %s: %r",
|
||||
self.request.remote_ip,
|
||||
message,
|
||||
)
|
||||
|
||||
try:
|
||||
response = self.jsonrpc.handle_json(
|
||||
tornado.escape.native_str(message)
|
||||
)
|
||||
if response and self.write_message(response):
|
||||
logger.debug(
|
||||
"Sent WebSocket message to %s: %r",
|
||||
self.request.remote_ip,
|
||||
response,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"WebSocket request error: {exc}")
|
||||
self.close()
|
||||
|
||||
def check_origin(self, origin):
|
||||
if not self.csrf_protection:
|
||||
return True
|
||||
return check_origin(origin, self.request.headers, self.allowed_origins)
|
||||
|
||||
|
||||
def set_mopidy_headers(request_handler):
|
||||
request_handler.set_header("Cache-Control", "no-cache")
|
||||
request_handler.set_header("X-Mopidy-Version", mopidy.__version__.encode())
|
||||
|
||||
|
||||
def check_origin(origin, request_headers, allowed_origins):
|
||||
if origin is None:
|
||||
logger.warning("HTTP request denied for missing Origin header")
|
||||
return False
|
||||
allowed_origins.add(request_headers.get("Host"))
|
||||
parsed_origin = urllib.parse.urlparse(origin).netloc.lower()
|
||||
# Some frameworks (e.g. Apache Cordova) use local files. Requests from
|
||||
# these files don't really have a sensible Origin so the browser sets the
|
||||
# header to something like 'file://' or 'null'. This results here in an
|
||||
# empty parsed_origin which we choose to allow.
|
||||
if parsed_origin and parsed_origin not in allowed_origins:
|
||||
logger.warning('HTTP request denied for Origin "%s"', origin)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class JsonRpcHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, core, allowed_origins, csrf_protection):
|
||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||
self.allowed_origins = allowed_origins
|
||||
self.csrf_protection = csrf_protection
|
||||
|
||||
def head(self):
|
||||
self.set_extra_headers()
|
||||
self.finish()
|
||||
|
||||
def post(self):
|
||||
if self.csrf_protection:
|
||||
content_type = self.request.headers.get("Content-Type", "")
|
||||
if content_type != "application/json":
|
||||
self.set_status(415, "Content-Type must be application/json")
|
||||
return
|
||||
|
||||
data = self.request.body
|
||||
if not data:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Received RPC message from %s: %r", self.request.remote_ip, data
|
||||
)
|
||||
|
||||
try:
|
||||
self.set_extra_headers()
|
||||
response = self.jsonrpc.handle_json(tornado.escape.native_str(data))
|
||||
if response and self.write(response):
|
||||
logger.debug(
|
||||
"Sent RPC message to %s: %r",
|
||||
self.request.remote_ip,
|
||||
response,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("HTTP JSON-RPC request error: %s", e)
|
||||
self.write_error(500)
|
||||
|
||||
def set_extra_headers(self):
|
||||
set_mopidy_headers(self)
|
||||
self.set_header("Accept", "application/json")
|
||||
self.set_header("Content-Type", "application/json; utf-8")
|
||||
|
||||
def options(self):
|
||||
if self.csrf_protection:
|
||||
origin = self.request.headers.get("Origin")
|
||||
if not check_origin(
|
||||
origin, self.request.headers, self.allowed_origins
|
||||
):
|
||||
self.set_status(403, f"Access denied for origin {origin}")
|
||||
return
|
||||
|
||||
self.set_header("Access-Control-Allow-Origin", f"{origin}")
|
||||
self.set_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
self.set_status(204)
|
||||
self.finish()
|
||||
|
||||
|
||||
class ClientListHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, apps, statics):
|
||||
self.apps = apps
|
||||
self.statics = statics
|
||||
|
||||
def get_template_path(self):
|
||||
return os.path.dirname(__file__)
|
||||
|
||||
def get(self):
|
||||
set_mopidy_headers(self)
|
||||
|
||||
names = set()
|
||||
for app in self.apps:
|
||||
names.add(app["name"])
|
||||
for static in self.statics:
|
||||
names.add(static["name"])
|
||||
names.discard("mopidy")
|
||||
|
||||
self.render("data/clients.html", apps=sorted(list(names)))
|
||||
|
||||
|
||||
class StaticFileHandler(tornado.web.StaticFileHandler):
|
||||
def set_extra_headers(self, path):
|
||||
set_mopidy_headers(self)
|
||||
|
||||
|
||||
class AddSlashHandler(tornado.web.RequestHandler):
|
||||
@tornado.web.addslash
|
||||
def prepare(self):
|
||||
return super().prepare()
|
||||
Reference in New Issue
Block a user