moonraker/moonraker/app.py

447 lines
17 KiB
Python

# Klipper Web Server Rest API
#
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license
import os
import mimetypes
import logging
import json
import tornado
from inspect import isclass
from tornado.escape import url_unescape
from tornado.routing import Rule, PathMatches, AnyMatches
from tornado.log import access_log
from utils import ServerError
from websockets import WebRequest, WebsocketManager, WebSocket
from authorization import AuthorizedRequestHandler, AuthorizedFileHandler
from authorization import Authorization
from streaming_form_data import StreamingFormDataParser
from streaming_form_data.targets import FileTarget, ValueTarget
# These endpoints are reserved for klippy/server communication only and are
# not exposed via http or the websocket
RESERVED_ENDPOINTS = [
"list_endpoints", "gcode/subscribe_output",
"register_remote_method"
]
# 50 MiB Max Standard Body Size
MAX_BODY_SIZE = 50 * 1024 * 1024
EXCLUDED_ARGS = ["_", "token", "connection_id"]
DEFAULT_KLIPPY_LOG_PATH = "/tmp/klippy.log"
class MutableRouter(tornado.web.ReversibleRuleRouter):
def __init__(self, application):
self.application = application
self.pattern_to_rule = {}
super(MutableRouter, self).__init__(None)
def get_target_delegate(self, target, request, **target_params):
if isclass(target) and issubclass(target, tornado.web.RequestHandler):
return self.application.get_handler_delegate(
request, target, **target_params)
return super(MutableRouter, self).get_target_delegate(
target, request, **target_params)
def has_rule(self, pattern):
return pattern in self.pattern_to_rule
def add_handler(self, pattern, target, target_params):
if pattern in self.pattern_to_rule:
self.remove_handler(pattern)
new_rule = Rule(PathMatches(pattern), target, target_params)
self.pattern_to_rule[pattern] = new_rule
self.rules.append(new_rule)
def remove_handler(self, pattern):
rule = self.pattern_to_rule.pop(pattern, None)
if rule is not None:
try:
self.rules.remove(rule)
except Exception:
logging.exception(f"Unable to remove rule: {pattern}")
class APIDefinition:
def __init__(self, endpoint, http_uri, ws_methods,
request_methods, need_object_parser):
self.endpoint = endpoint
self.uri = http_uri
self.ws_methods = ws_methods
if not isinstance(request_methods, list):
request_methods = [request_methods]
self.request_methods = request_methods
self.need_object_parser = need_object_parser
class MoonrakerApp:
def __init__(self, config):
self.server = config.get_server()
self.tornado_server = None
self.api_cache = {}
self.registered_base_handlers = []
self.max_upload_size = config.getint('max_upload_size', 1024)
self.max_upload_size *= 1024 * 1024
# Set Up Websocket and Authorization Managers
self.wsm = WebsocketManager(self.server)
self.auth = Authorization(config['authorization'])
mimetypes.add_type('text/plain', '.log')
mimetypes.add_type('text/plain', '.gcode')
mimetypes.add_type('text/plain', '.cfg')
debug = config.getboolean('enable_debug_logging', False)
log_level = logging.DEBUG if debug else logging.INFO
logging.getLogger().setLevel(log_level)
app_args = {
'serve_traceback': debug,
'websocket_ping_interval': 10,
'websocket_ping_timeout': 30,
'parent': self,
'default_handler_class': AuthorizedErrorHandler,
'default_handler_args': {}
}
if not debug:
app_args['log_function'] = self.log_release_mode
# Set up HTTP only requests
self.mutable_router = MutableRouter(self)
app_handlers = [
(AnyMatches(), self.mutable_router),
(r"/websocket", WebSocket)]
self.app = tornado.web.Application(app_handlers, **app_args)
self.get_handler_delegate = self.app.get_handler_delegate
# Register handlers
logfile = config['system_args'].get('logfile')
if logfile:
self.register_static_file_handler(
"moonraker.log", logfile, force=True)
self.register_static_file_handler(
"klippy.log", DEFAULT_KLIPPY_LOG_PATH, force=True)
self.auth.register_handlers(self)
def listen(self, host, port):
self.tornado_server = self.app.listen(
port, address=host, max_body_size=MAX_BODY_SIZE,
xheaders=True)
def log_release_mode(self, handler):
status_code = handler.get_status()
if status_code in [200, 204]:
# don't log OK and No Content
return
if status_code < 400:
log_method = access_log.info
elif status_code < 500:
log_method = access_log.warning
else:
log_method = access_log.error
request_time = 1000.0 * handler.request.request_time()
log_method("%d %s %.2fms", status_code,
handler._request_summary(), request_time)
def get_server(self):
return self.server
def get_auth(self):
return self.auth
def get_websocket_manager(self):
return self.wsm
async def close(self):
if self.tornado_server is not None:
self.tornado_server.stop()
await self.tornado_server.close_all_connections()
await self.wsm.close()
self.auth.close()
def register_remote_handler(self, endpoint):
if endpoint in RESERVED_ENDPOINTS:
return
api_def = self._create_api_definition(endpoint)
if api_def.uri in self.registered_base_handlers:
# reserved handler or already registered
return
logging.info(
f"Registering remote endpoint - "
f"HTTP: ({' '.join(api_def.request_methods)}) {api_def.uri}; "
f"Websocket: {', '.join(api_def.ws_methods)}")
self.wsm.register_remote_handler(api_def)
params = {}
params['methods'] = api_def.request_methods
params['callback'] = api_def.endpoint
params['need_object_parser'] = api_def.need_object_parser
self.mutable_router.add_handler(
api_def.uri, DynamicRequestHandler, params)
self.registered_base_handlers.append(api_def.uri)
def register_local_handler(self, uri, request_methods,
callback, protocol=["http", "websocket"],
wrap_result=True):
if uri in self.registered_base_handlers:
return
api_def = self._create_api_definition(
uri, request_methods, is_remote=False)
msg = "Registering local endpoint"
if "http" in protocol:
msg += f" - HTTP: ({' '.join(request_methods)}) {uri}"
params = {}
params['methods'] = request_methods
params['callback'] = callback
params['wrap_result'] = wrap_result
params['is_remote'] = False
self.mutable_router.add_handler(uri, DynamicRequestHandler, params)
self.registered_base_handlers.append(uri)
if "websocket" in protocol:
msg += f" - Websocket: {', '.join(api_def.ws_methods)}"
self.wsm.register_local_handler(api_def, callback)
logging.info(msg)
def register_static_file_handler(self, pattern, file_path, force=False):
if pattern[0] != "/":
pattern = "/server/files/" + pattern
if os.path.isfile(file_path) or force:
pattern += '()'
elif os.path.isdir(file_path):
if pattern[-1] != "/":
pattern += "/"
pattern += "(.*)"
else:
logging.info(f"Invalid file path: {file_path}")
return
logging.debug(f"Registering static file: ({pattern}) {file_path}")
params = {'path': file_path}
self.mutable_router.add_handler(pattern, FileRequestHandler, params)
def register_upload_handler(self, pattern):
self.mutable_router.add_handler(
pattern, FileUploadHandler,
{'max_upload_size': self.max_upload_size})
def remove_handler(self, endpoint):
api_def = self.api_cache.get(endpoint)
if api_def is not None:
self.wsm.remove_handler(api_def.uri)
self.mutable_router.remove_handler(api_def.ws_method)
def _create_api_definition(self, endpoint, request_methods=[],
is_remote=True):
if endpoint in self.api_cache:
return self.api_cache[endpoint]
if endpoint[0] == '/':
uri = endpoint
elif is_remote:
uri = "/printer/" + endpoint
else:
uri = "/server/" + endpoint
ws_methods = []
if is_remote:
# Remote requests accept both GET and POST requests. These
# requests execute the same callback, thus they resolve to
# only a single websocket method.
ws_methods.append(uri[1:].replace('/', '.'))
request_methods = ['GET', 'POST']
else:
name_parts = uri[1:].split('/')
if len(request_methods) > 1:
for req_mthd in request_methods:
func_name = req_mthd.lower() + "_" + name_parts[-1]
ws_methods.append(".".join(name_parts[:-1] + [func_name]))
else:
ws_methods.append(".".join(name_parts))
if not is_remote and len(request_methods) != len(ws_methods):
raise self.server.error(
"Invalid API definition. Number of websocket methods must "
"match the number of request methods")
need_object_parser = endpoint.startswith("objects/")
api_def = APIDefinition(endpoint, uri, ws_methods,
request_methods, need_object_parser)
self.api_cache[endpoint] = api_def
return api_def
class DynamicRequestHandler(AuthorizedRequestHandler):
def initialize(self, callback, methods, need_object_parser=False,
is_remote=True, wrap_result=True):
super(DynamicRequestHandler, self).initialize()
self.callback = callback
self.methods = methods
self.wrap_result = wrap_result
self._do_request = self._do_remote_request if is_remote \
else self._do_local_request
self._parse_query = self._object_parser if need_object_parser \
else self._default_parser
# Converts query string values with type hints
def _convert_type(self, value, hint):
type_funcs = {
"int": int, "float": float,
"bool": lambda x: x.lower() == "true",
"json": json.loads}
if hint not in type_funcs:
logging.info(f"No conversion method for type hint {hint}")
return value
func = type_funcs[hint]
try:
converted = func(value)
except Exception:
logging.exception("Argument conversion error: Hint: "
f"{hint}, Arg: {value}")
return value
return converted
def _default_parser(self):
args = {}
for key in self.request.arguments.keys():
if key in EXCLUDED_ARGS:
continue
key_parts = key.rsplit(":", 1)
val = self.get_argument(key)
if len(key_parts) == 1:
args[key] = val
else:
args[key_parts[0]] = self._convert_type(val, key_parts[1])
return args
def _object_parser(self):
args = {}
for key in self.request.arguments.keys():
if key in EXCLUDED_ARGS:
continue
val = self.get_argument(key)
if not val:
args[key] = None
else:
args[key] = val.split(',')
logging.debug(f"Parsed Arguments: {args}")
return {'objects': args}
def parse_args(self):
try:
args = self._parse_query()
except Exception:
raise ServerError(
"Error Parsing Request Arguments. "
"Is the Content-Type correct?")
content_type = self.request.headers.get('Content-Type', "").strip()
if content_type.startswith("application/json"):
try:
args.update(json.loads(self.request.body))
except json.JSONDecodeError:
pass
for key, value in self.path_kwargs.items():
if value is not None:
args[key] = value
return args
async def get(self, *args, **kwargs):
await self._process_http_request()
async def post(self, *args, **kwargs):
await self._process_http_request()
async def delete(self, *args, **kwargs):
await self._process_http_request()
async def _do_local_request(self, args, conn):
return await self.callback(
WebRequest(self.request.path, args, self.request.method,
conn=conn))
async def _do_remote_request(self, args, conn):
return await self.server.make_request(
WebRequest(self.callback, args, conn=conn))
async def _process_http_request(self):
if self.request.method not in self.methods:
raise tornado.web.HTTPError(405)
conn = self.get_associated_websocket()
args = self.parse_args()
try:
result = await self._do_request(args, conn)
except ServerError as e:
raise tornado.web.HTTPError(
e.status_code, str(e)) from e
if self.wrap_result:
result = {'result': result}
self.finish(result)
class FileRequestHandler(AuthorizedFileHandler):
def set_extra_headers(self, path):
# The call below shold never return an empty string,
# as the path should have already been validated to be
# a file
basename = os.path.basename(self.absolute_path)
self.set_header(
"Content-Disposition", f"attachment; filename={basename}")
async def delete(self, path):
path = self.request.path.lstrip("/").split("/", 2)[-1]
path = url_unescape(path, plus=False)
file_manager = self.server.lookup_component('file_manager')
try:
filename = await file_manager.delete_file(path)
except self.server.error as e:
if e.status_code == 403:
raise tornado.web.HTTPError(
403, "File is loaded, DELETE not permitted")
else:
raise tornado.web.HTTPError(e.status_code, str(e))
self.finish({'result': filename})
@tornado.web.stream_request_body
class FileUploadHandler(AuthorizedRequestHandler):
def initialize(self, max_upload_size):
super(FileUploadHandler, self).initialize()
self.file_manager = self.server.lookup_component('file_manager')
self.max_upload_size = max_upload_size
def prepare(self):
if self.request.method == "POST":
self.request.connection.set_max_body_size(self.max_upload_size)
tmpname = self.file_manager.gen_temp_upload_path()
self._targets = {
'root': ValueTarget(),
'print': ValueTarget(),
'path': ValueTarget(),
}
self._file = FileTarget(tmpname)
self._parser = StreamingFormDataParser(self.request.headers)
self._parser.register('file', self._file)
for name, target in self._targets.items():
self._parser.register(name, target)
def data_received(self, chunk):
if self.request.method == "POST":
self._parser.data_received(chunk)
async def post(self):
form_args = {}
for name, target in self._targets.items():
if target.value:
form_args[name] = target.value.decode()
form_args['filename'] = self._file.multipart_filename
form_args['tmp_file_path'] = self._file.filename
debug_msg = "\nFile Upload Arguments:"
for name, value in form_args.items():
debug_msg += f"\n{name}: {value}"
logging.debug(debug_msg)
try:
result = await self.file_manager.finalize_upload(form_args)
except ServerError as e:
raise tornado.web.HTTPError(
e.status_code, str(e))
self.finish(result)
# Default Handler for unregistered endpoints
class AuthorizedErrorHandler(AuthorizedRequestHandler):
def prepare(self):
super(AuthorizedRequestHandler, self).prepare()
self.set_status(404)
raise tornado.web.HTTPError(404)
def check_xsrf_cookie(self):
pass