1025 lines
41 KiB
Python
1025 lines
41 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
|
|
|
|
from __future__ import annotations
|
|
import os
|
|
import mimetypes
|
|
import logging
|
|
import json
|
|
import traceback
|
|
import ssl
|
|
import pathlib
|
|
import urllib.parse
|
|
import tornado
|
|
import tornado.iostream
|
|
import tornado.httputil
|
|
import tornado.web
|
|
from inspect import isclass
|
|
from tornado.escape import url_unescape, url_escape
|
|
from tornado.routing import Rule, PathMatches, AnyMatches
|
|
from tornado.http1connection import HTTP1Connection
|
|
from tornado.log import access_log
|
|
from utils import ServerError
|
|
from websockets import WebRequest, WebsocketManager, WebSocket, APITransport
|
|
from streaming_form_data import StreamingFormDataParser
|
|
from streaming_form_data.targets import FileTarget, ValueTarget, SHA256Target
|
|
|
|
# Annotation imports
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Optional,
|
|
Callable,
|
|
Coroutine,
|
|
Union,
|
|
Dict,
|
|
List,
|
|
Tuple,
|
|
AsyncGenerator,
|
|
)
|
|
if TYPE_CHECKING:
|
|
from tornado.httpserver import HTTPServer
|
|
from moonraker import Server
|
|
from eventloop import EventLoop
|
|
from confighelper import ConfigHelper
|
|
from klippy_connection import KlippyConnection as Klippy
|
|
from components.file_manager.file_manager import FileManager
|
|
from components.announcements import Announcements
|
|
from io import BufferedReader
|
|
import components.authorization
|
|
MessageDelgate = Optional[tornado.httputil.HTTPMessageDelegate]
|
|
AuthComp = Optional[components.authorization.Authorization]
|
|
APICallback = Callable[[WebRequest], Coroutine]
|
|
|
|
# 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", "access_token", "connection_id"]
|
|
AUTHORIZED_EXTS = [".png"]
|
|
DEFAULT_KLIPPY_LOG_PATH = "/tmp/klippy.log"
|
|
ALL_TRANSPORTS = ["http", "websocket", "mqtt", "internal"]
|
|
|
|
class MutableRouter(tornado.web.ReversibleRuleRouter):
|
|
def __init__(self, application: MoonrakerApp) -> None:
|
|
self.application = application
|
|
self.pattern_to_rule: Dict[str, Rule] = {}
|
|
super(MutableRouter, self).__init__(None)
|
|
|
|
def get_target_delegate(self,
|
|
target: Any,
|
|
request: tornado.httputil.HTTPServerRequest,
|
|
**target_params
|
|
) -> MessageDelgate:
|
|
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: str) -> bool:
|
|
return pattern in self.pattern_to_rule
|
|
|
|
def add_handler(self,
|
|
pattern: str,
|
|
target: Any,
|
|
target_params: Optional[Dict[str, Any]]
|
|
) -> None:
|
|
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: str) -> None:
|
|
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: str,
|
|
http_uri: str,
|
|
jrpc_methods: List[str],
|
|
request_methods: Union[str, List[str]],
|
|
transports: List[str],
|
|
callback: Optional[APICallback],
|
|
need_object_parser: bool):
|
|
self.endpoint = endpoint
|
|
self.uri = http_uri
|
|
self.jrpc_methods = jrpc_methods
|
|
if not isinstance(request_methods, list):
|
|
request_methods = [request_methods]
|
|
self.request_methods = request_methods
|
|
self.supported_transports = transports
|
|
self.callback = callback
|
|
self.need_object_parser = need_object_parser
|
|
|
|
class InternalTransport(APITransport):
|
|
def __init__(self, server: Server) -> None:
|
|
self.server = server
|
|
self.callbacks: Dict[str, Tuple[str, str, APICallback]] = {}
|
|
|
|
def register_api_handler(self, api_def: APIDefinition) -> None:
|
|
ep = api_def.endpoint
|
|
cb = api_def.callback
|
|
if cb is None:
|
|
# Request to Klippy
|
|
method = api_def.jrpc_methods[0]
|
|
action = ""
|
|
klippy: Klippy = self.server.lookup_component("klippy_connection")
|
|
cb = klippy.request
|
|
self.callbacks[method] = (ep, action, cb)
|
|
else:
|
|
for method, action in \
|
|
zip(api_def.jrpc_methods, api_def.request_methods):
|
|
self.callbacks[method] = (ep, action, cb)
|
|
|
|
def remove_api_handler(self, api_def: APIDefinition) -> None:
|
|
for method in api_def.jrpc_methods:
|
|
self.callbacks.pop(method, None)
|
|
|
|
async def call_method(self,
|
|
method_name: str,
|
|
request_arguments: Dict[str, Any] = {},
|
|
**kwargs
|
|
) -> Any:
|
|
if method_name not in self.callbacks:
|
|
raise self.server.error(f"No method {method_name} available")
|
|
ep, action, func = self.callbacks[method_name]
|
|
# Request arguments can be suppplied either through a dict object
|
|
# or via keyword arugments
|
|
args = request_arguments or kwargs
|
|
return await func(WebRequest(ep, dict(args), action))
|
|
|
|
class MoonrakerApp:
|
|
def __init__(self, config: ConfigHelper) -> None:
|
|
self.server = config.get_server()
|
|
self.http_server: Optional[HTTPServer] = None
|
|
self.secure_server: Optional[HTTPServer] = None
|
|
self.api_cache: Dict[str, APIDefinition] = {}
|
|
self.registered_base_handlers: List[str] = []
|
|
self.max_upload_size = config.getint('max_upload_size', 1024)
|
|
self.max_upload_size *= 1024 * 1024
|
|
|
|
# SSL config
|
|
self.cert_path: str = self._get_path_option(
|
|
config, 'ssl_certificate_path')
|
|
self.key_path: str = self._get_path_option(
|
|
config, 'ssl_key_path')
|
|
|
|
# Set Up Websocket and Authorization Managers
|
|
self.wsm = WebsocketManager(self.server)
|
|
self.internal_transport = InternalTransport(self.server)
|
|
self.api_transports: Dict[str, APITransport] = {
|
|
"websocket": self.wsm,
|
|
"internal": self.internal_transport
|
|
}
|
|
|
|
mimetypes.add_type('text/plain', '.log')
|
|
mimetypes.add_type('text/plain', '.gcode')
|
|
mimetypes.add_type('text/plain', '.cfg')
|
|
|
|
self.debug = self.server.is_debug_enabled()
|
|
app_args: Dict[str, Any] = {
|
|
'serve_traceback': self.debug,
|
|
'websocket_ping_interval': 10,
|
|
'websocket_ping_timeout': 30,
|
|
'server': self.server,
|
|
'default_handler_class': AuthorizedErrorHandler,
|
|
'default_handler_args': {},
|
|
'log_function': self.log_request,
|
|
'compiled_template_cache': False,
|
|
}
|
|
|
|
# Set up HTTP only requests
|
|
self.mutable_router = MutableRouter(self)
|
|
app_handlers: List[Any] = [
|
|
(AnyMatches(), self.mutable_router),
|
|
(r"/", WelcomeHandler),
|
|
(r"/websocket", WebSocket),
|
|
(r"/server/redirect", RedirectHandler)
|
|
]
|
|
self.app = tornado.web.Application(app_handlers, **app_args)
|
|
self.get_handler_delegate = self.app.get_handler_delegate
|
|
|
|
# Register handlers
|
|
logfile = self.server.get_app_args().get('log_file')
|
|
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.register_upload_handler("/server/files/upload")
|
|
|
|
# Register Server Components
|
|
self.server.register_component("application", self)
|
|
self.server.register_component("websockets", self.wsm)
|
|
self.server.register_component("internal_transport",
|
|
self.internal_transport)
|
|
|
|
def _get_path_option(self, config: ConfigHelper, option: str) -> str:
|
|
path: Optional[str] = config.get(option, None)
|
|
if path is None:
|
|
return ""
|
|
expanded = os.path.abspath(os.path.expanduser(path))
|
|
if not os.path.exists(expanded):
|
|
raise self.server.error(
|
|
f"Invalid path for option '{option}', "
|
|
f"{path} does not exist")
|
|
return expanded
|
|
|
|
def listen(self, host: str, port: int, ssl_port: int) -> None:
|
|
if host.lower() == "all":
|
|
host = ""
|
|
self.http_server = self.app.listen(
|
|
port, address=host, max_body_size=MAX_BODY_SIZE,
|
|
xheaders=True)
|
|
if os.path.exists(self.cert_path) and os.path.exists(self.key_path):
|
|
logging.info(f"Starting secure server on port {ssl_port}")
|
|
ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
ssl_ctx.load_cert_chain(self.cert_path, self.key_path)
|
|
self.secure_server = self.app.listen(
|
|
ssl_port, address=host, max_body_size=MAX_BODY_SIZE,
|
|
xheaders=True, ssl_options=ssl_ctx)
|
|
else:
|
|
logging.info("SSL Certificate/Key not configured, "
|
|
"aborting HTTPS Server startup")
|
|
|
|
def log_request(self, handler: tornado.web.RequestHandler) -> None:
|
|
status_code = handler.get_status()
|
|
if not self.debug and status_code in [200, 204, 206, 304]:
|
|
# don't log successful requests in release mode
|
|
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()
|
|
user = handler.current_user
|
|
username = "No User"
|
|
if user is not None and 'username' in user:
|
|
username = user['username']
|
|
log_method(
|
|
f"{status_code} {handler._request_summary()} "
|
|
f"[{username}] {request_time:.2f}ms")
|
|
|
|
def get_server(self) -> Server:
|
|
return self.server
|
|
|
|
async def close(self) -> None:
|
|
if self.http_server is not None:
|
|
self.http_server.stop()
|
|
await self.http_server.close_all_connections()
|
|
if self.secure_server is not None:
|
|
self.secure_server.stop()
|
|
await self.secure_server.close_all_connections()
|
|
await self.wsm.close()
|
|
|
|
def register_api_transport(self,
|
|
name: str,
|
|
transport: APITransport
|
|
) -> Dict[str, APIDefinition]:
|
|
self.api_transports[name] = transport
|
|
return self.api_cache
|
|
|
|
def register_remote_handler(self, endpoint: str) -> None:
|
|
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 HTTP endpoint: "
|
|
f"({' '.join(api_def.request_methods)}) {api_def.uri}")
|
|
params: Dict[str, Any] = {}
|
|
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)
|
|
for name, transport in self.api_transports.items():
|
|
transport.register_api_handler(api_def)
|
|
|
|
def register_local_handler(self,
|
|
uri: str,
|
|
request_methods: List[str],
|
|
callback: APICallback,
|
|
transports: List[str] = ALL_TRANSPORTS,
|
|
wrap_result: bool = True
|
|
) -> None:
|
|
if uri in self.registered_base_handlers:
|
|
return
|
|
api_def = self._create_api_definition(
|
|
uri, request_methods, callback, transports=transports)
|
|
if "http" in transports:
|
|
logging.info(
|
|
f"Registering HTTP Endpoint: "
|
|
f"({' '.join(request_methods)}) {uri}")
|
|
params: dict[str, Any] = {}
|
|
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)
|
|
for name, transport in self.api_transports.items():
|
|
if name in transports:
|
|
transport.register_api_handler(api_def)
|
|
|
|
def register_static_file_handler(self,
|
|
pattern: str,
|
|
file_path: str,
|
|
force: bool = False
|
|
) -> None:
|
|
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: str,
|
|
location_prefix: Optional[str] = None
|
|
) -> None:
|
|
params: Dict[str, Any] = {'max_upload_size': self.max_upload_size}
|
|
if location_prefix is not None:
|
|
params['location_prefix'] = location_prefix
|
|
self.mutable_router.add_handler(pattern, FileUploadHandler, params)
|
|
|
|
def remove_handler(self, endpoint: str) -> None:
|
|
api_def = self.api_cache.pop(endpoint, None)
|
|
if api_def is not None:
|
|
self.mutable_router.remove_handler(api_def.uri)
|
|
for name, transport in self.api_transports.items():
|
|
transport.remove_api_handler(api_def)
|
|
|
|
def _create_api_definition(self,
|
|
endpoint: str,
|
|
request_methods: List[str] = [],
|
|
callback: Optional[APICallback] = None,
|
|
transports: List[str] = ALL_TRANSPORTS
|
|
) -> APIDefinition:
|
|
is_remote = callback is None
|
|
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
|
|
jrpc_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.
|
|
jrpc_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]
|
|
jrpc_methods.append(".".join(
|
|
name_parts[:-1] + [func_name]))
|
|
else:
|
|
jrpc_methods.append(".".join(name_parts))
|
|
if not is_remote and len(request_methods) != len(jrpc_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, jrpc_methods, request_methods,
|
|
transports, callback, need_object_parser)
|
|
self.api_cache[endpoint] = api_def
|
|
return api_def
|
|
|
|
class AuthorizedRequestHandler(tornado.web.RequestHandler):
|
|
def initialize(self) -> None:
|
|
self.server: Server = self.settings['server']
|
|
|
|
def set_default_headers(self) -> None:
|
|
origin: Optional[str] = self.request.headers.get("Origin")
|
|
# it is necessary to look up the parent app here,
|
|
# as initialize() may not yet be called
|
|
server: Server = self.settings['server']
|
|
auth: AuthComp = server.lookup_component('authorization', None)
|
|
self.cors_enabled = False
|
|
if auth is not None:
|
|
self.cors_enabled = auth.check_cors(origin, self)
|
|
|
|
def prepare(self) -> None:
|
|
auth: AuthComp = self.server.lookup_component('authorization', None)
|
|
if auth is not None:
|
|
self.current_user = auth.check_authorized(self.request)
|
|
|
|
def options(self, *args, **kwargs) -> None:
|
|
# Enable CORS if configured
|
|
if self.cors_enabled:
|
|
self.set_status(204)
|
|
self.finish()
|
|
else:
|
|
super(AuthorizedRequestHandler, self).options()
|
|
|
|
def get_associated_websocket(self) -> Optional[WebSocket]:
|
|
# Return associated websocket connection if an id
|
|
# was provided by the request
|
|
conn = None
|
|
conn_id: Any = self.get_argument('connection_id', None)
|
|
if conn_id is not None:
|
|
try:
|
|
conn_id = int(conn_id)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
wsm: WebsocketManager = self.server.lookup_component(
|
|
"websockets")
|
|
conn = wsm.get_websocket(conn_id)
|
|
return conn
|
|
|
|
def write_error(self, status_code: int, **kwargs) -> None:
|
|
err = {'code': status_code, 'message': self._reason}
|
|
if 'exc_info' in kwargs:
|
|
err['traceback'] = "\n".join(
|
|
traceback.format_exception(*kwargs['exc_info']))
|
|
self.finish({'error': err})
|
|
|
|
# Due to the way Python treats multiple inheritance its best
|
|
# to create a separate authorized handler for serving files
|
|
class AuthorizedFileHandler(tornado.web.StaticFileHandler):
|
|
def initialize(self,
|
|
path: str,
|
|
default_filename: Optional[str] = None
|
|
) -> None:
|
|
super(AuthorizedFileHandler, self).initialize(path, default_filename)
|
|
self.server: Server = self.settings['server']
|
|
|
|
def set_default_headers(self) -> None:
|
|
origin: Optional[str] = self.request.headers.get("Origin")
|
|
# it is necessary to look up the parent app here,
|
|
# as initialize() may not yet be called
|
|
server: Server = self.settings['server']
|
|
auth: AuthComp = server.lookup_component('authorization', None)
|
|
self.cors_enabled = False
|
|
if auth is not None:
|
|
self.cors_enabled = auth.check_cors(origin, self)
|
|
|
|
def prepare(self) -> None:
|
|
auth: AuthComp = self.server.lookup_component('authorization', None)
|
|
if auth is not None and self._check_need_auth():
|
|
self.current_user = auth.check_authorized(self.request)
|
|
|
|
def options(self, *args, **kwargs) -> None:
|
|
# Enable CORS if configured
|
|
if self.cors_enabled:
|
|
self.set_status(204)
|
|
self.finish()
|
|
else:
|
|
super(AuthorizedFileHandler, self).options()
|
|
|
|
def write_error(self, status_code: int, **kwargs) -> None:
|
|
err = {'code': status_code, 'message': self._reason}
|
|
if 'exc_info' in kwargs:
|
|
err['traceback'] = "\n".join(
|
|
traceback.format_exception(*kwargs['exc_info']))
|
|
self.finish({'error': err})
|
|
|
|
def _check_need_auth(self) -> bool:
|
|
if self.request.method != "GET":
|
|
return True
|
|
ext = os.path.splitext(self.request.path)[-1].lower()
|
|
if ext in AUTHORIZED_EXTS:
|
|
return False
|
|
return True
|
|
|
|
class DynamicRequestHandler(AuthorizedRequestHandler):
|
|
def initialize(
|
|
self,
|
|
callback: Union[str, Callable[[WebRequest], Coroutine]] = "",
|
|
methods: List[str] = [],
|
|
need_object_parser: bool = False,
|
|
is_remote: bool = True,
|
|
wrap_result: bool = True
|
|
) -> None:
|
|
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: str, hint: str) -> Any:
|
|
type_funcs: Dict[str, Callable] = {
|
|
"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) -> Dict[str, Any]:
|
|
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) -> Dict[str, Dict[str, Any]]:
|
|
args: Dict[str, Any] = {}
|
|
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) -> Dict[str, Any]:
|
|
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
|
|
|
|
def _log_debug(self, header: str, args: Dict[str, Any]) -> None:
|
|
if self.server.is_debug_enabled():
|
|
if self.request.path.startswith('/access'):
|
|
resp = {key: "<sanitized>" for key in args}
|
|
else:
|
|
resp = args
|
|
logging.debug(f"{header}::{resp}")
|
|
|
|
async def get(self, *args, **kwargs) -> None:
|
|
await self._process_http_request()
|
|
|
|
async def post(self, *args, **kwargs) -> None:
|
|
await self._process_http_request()
|
|
|
|
async def delete(self, *args, **kwargs) -> None:
|
|
await self._process_http_request()
|
|
|
|
async def _do_local_request(self,
|
|
args: Dict[str, Any],
|
|
conn: Optional[WebSocket]
|
|
) -> Any:
|
|
assert callable(self.callback)
|
|
return await self.callback(
|
|
WebRequest(self.request.path, args, self.request.method,
|
|
conn=conn, ip_addr=self.request.remote_ip,
|
|
user=self.current_user))
|
|
|
|
async def _do_remote_request(self,
|
|
args: Dict[str, Any],
|
|
conn: Optional[WebSocket]
|
|
) -> Any:
|
|
assert isinstance(self.callback, str)
|
|
klippy: Klippy = self.server.lookup_component("klippy_connection")
|
|
return await klippy.request(
|
|
WebRequest(self.callback, args, conn=conn,
|
|
ip_addr=self.request.remote_ip,
|
|
user=self.current_user))
|
|
|
|
async def _process_http_request(self) -> None:
|
|
if self.request.method not in self.methods:
|
|
raise tornado.web.HTTPError(405)
|
|
conn = self.get_associated_websocket()
|
|
args = self.parse_args()
|
|
req = f"{self.request.method} {self.request.path}"
|
|
self._log_debug(f"HTTP Request::{req}", 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}
|
|
if result is None:
|
|
self.set_status(204)
|
|
self._log_debug(f"HTTP Response::{req}", result)
|
|
self.finish(result)
|
|
|
|
class FileRequestHandler(AuthorizedFileHandler):
|
|
def set_extra_headers(self, path: str) -> None:
|
|
# The call below shold never return an empty string,
|
|
# as the path should have already been validated to be
|
|
# a file
|
|
assert isinstance(self.absolute_path, str)
|
|
basename = os.path.basename(self.absolute_path)
|
|
ascii_basename = self._escape_filename_to_ascii(basename)
|
|
utf8_basename = self._escape_filename_to_utf8(basename)
|
|
self.set_header(
|
|
"Content-Disposition",
|
|
f"attachment; filename=\"{ascii_basename}\"; "
|
|
f"filename*=UTF-8\'\'{utf8_basename}")
|
|
|
|
async def delete(self, path: str) -> None:
|
|
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})
|
|
|
|
async def get(self, path: str, include_body: bool = True) -> None:
|
|
# Set up our path instance variables.
|
|
self.path = self.parse_url_path(path)
|
|
del path # make sure we don't refer to path instead of self.path again
|
|
absolute_path = self.get_absolute_path(self.root, self.path)
|
|
self.absolute_path = self.validate_absolute_path(
|
|
self.root, absolute_path)
|
|
if self.absolute_path is None:
|
|
return
|
|
|
|
self.modified = self.get_modified_time()
|
|
self.set_headers()
|
|
|
|
self.request.headers.pop("If-None-Match", None)
|
|
if self.should_return_304():
|
|
self.set_status(304)
|
|
return
|
|
|
|
request_range = None
|
|
range_header = self.request.headers.get("Range")
|
|
if range_header:
|
|
# As per RFC 2616 14.16, if an invalid Range header is specified,
|
|
# the request will be treated as if the header didn't exist.
|
|
request_range = tornado.httputil._parse_request_range(range_header)
|
|
|
|
size = self.get_content_size()
|
|
if request_range:
|
|
start, end = request_range
|
|
if start is not None and start < 0:
|
|
start += size
|
|
if start < 0:
|
|
start = 0
|
|
if (
|
|
start is not None
|
|
and (start >= size or (end is not None and start >= end))
|
|
) or end == 0:
|
|
# As per RFC 2616 14.35.1, a range is not satisfiable only: if
|
|
# the first requested byte is equal to or greater than the
|
|
# content, or when a suffix with length 0 is specified.
|
|
# https://tools.ietf.org/html/rfc7233#section-2.1
|
|
# A byte-range-spec is invalid if the last-byte-pos value is
|
|
# present and less than the first-byte-pos.
|
|
self.set_status(416) # Range Not Satisfiable
|
|
self.set_header("Content-Type", "text/plain")
|
|
self.set_header("Content-Range", "bytes */%s" % (size,))
|
|
return
|
|
if end is not None and end > size:
|
|
# Clients sometimes blindly use a large range to limit their
|
|
# download size; cap the endpoint at the actual file size.
|
|
end = size
|
|
# Note: only return HTTP 206 if less than the entire range has been
|
|
# requested. Not only is this semantically correct, but Chrome
|
|
# refuses to play audio if it gets an HTTP 206 in response to
|
|
# ``Range: bytes=0-``.
|
|
if size != (end or size) - (start or 0):
|
|
self.set_status(206) # Partial Content
|
|
self.set_header(
|
|
"Content-Range", tornado.httputil._get_content_range(
|
|
start, end, size)
|
|
)
|
|
else:
|
|
start = end = None
|
|
|
|
if start is not None and end is not None:
|
|
content_length = end - start
|
|
elif end is not None:
|
|
content_length = end
|
|
elif start is not None:
|
|
end = size
|
|
content_length = size - start
|
|
else:
|
|
end = size
|
|
content_length = size
|
|
self.set_header("Content-Length", content_length)
|
|
|
|
if include_body:
|
|
evt_loop = self.server.get_event_loop()
|
|
content = self.get_content_nonblock(
|
|
evt_loop, self.absolute_path, start, end)
|
|
async for chunk in content:
|
|
try:
|
|
self.write(chunk)
|
|
await self.flush()
|
|
except tornado.iostream.StreamClosedError:
|
|
return
|
|
else:
|
|
assert self.request.method == "HEAD"
|
|
|
|
def _escape_filename_to_ascii(self, basename: str) -> str:
|
|
ret = basename.encode("ascii", "replace").decode()
|
|
return ret.replace('"', '\\"')
|
|
|
|
def _escape_filename_to_utf8(self, basename: str) -> str:
|
|
return urllib.parse.quote(basename, encoding="utf-8")
|
|
|
|
@classmethod
|
|
async def get_content_nonblock(
|
|
cls,
|
|
evt_loop: EventLoop,
|
|
abspath: str,
|
|
start: Optional[int] = None,
|
|
end: Optional[int] = None
|
|
) -> AsyncGenerator[bytes, None]:
|
|
file: BufferedReader = await evt_loop.run_in_thread(open, abspath, "rb")
|
|
try:
|
|
if start is not None:
|
|
file.seek(start)
|
|
if end is not None:
|
|
remaining = end - (start or 0) # type: Optional[int]
|
|
else:
|
|
remaining = None
|
|
while True:
|
|
chunk_size = 64 * 1024
|
|
if remaining is not None and remaining < chunk_size:
|
|
chunk_size = remaining
|
|
chunk = await evt_loop.run_in_thread(file.read, chunk_size)
|
|
if chunk:
|
|
if remaining is not None:
|
|
remaining -= len(chunk)
|
|
yield chunk
|
|
else:
|
|
if remaining is not None:
|
|
assert remaining == 0
|
|
return
|
|
finally:
|
|
await evt_loop.run_in_thread(file.close)
|
|
|
|
@classmethod
|
|
def _get_cached_version(cls, abs_path: str) -> Optional[str]:
|
|
return None
|
|
|
|
@tornado.web.stream_request_body
|
|
class FileUploadHandler(AuthorizedRequestHandler):
|
|
def initialize(self,
|
|
location_prefix: str = "server/files",
|
|
max_upload_size: int = MAX_BODY_SIZE
|
|
) -> None:
|
|
self.location_prefix = location_prefix
|
|
super(FileUploadHandler, self).initialize()
|
|
self.file_manager: FileManager = self.server.lookup_component(
|
|
'file_manager')
|
|
self.max_upload_size = max_upload_size
|
|
|
|
def prepare(self) -> None:
|
|
super(FileUploadHandler, self).prepare()
|
|
if self.request.method == "POST":
|
|
assert isinstance(self.request.connection, HTTP1Connection)
|
|
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(),
|
|
'checksum': ValueTarget(),
|
|
}
|
|
self._file = FileTarget(tmpname)
|
|
self._sha256_target = SHA256Target()
|
|
self._parser = StreamingFormDataParser(self.request.headers)
|
|
self._parser.register('file', self._file)
|
|
self._parser.register('file', self._sha256_target)
|
|
for name, target in self._targets.items():
|
|
self._parser.register(name, target)
|
|
|
|
async def data_received(self, chunk: bytes) -> None:
|
|
if self.request.method == "POST":
|
|
evt_loop = self.server.get_event_loop()
|
|
await evt_loop.run_in_thread(self._parser.data_received, chunk)
|
|
|
|
async def post(self) -> None:
|
|
form_args = {}
|
|
chk_target = self._targets.pop('checksum')
|
|
calc_chksum = self._sha256_target.value.lower()
|
|
if chk_target.value:
|
|
# Validate checksum
|
|
recd_cksum = chk_target.value.decode().lower()
|
|
if calc_chksum != recd_cksum:
|
|
# remove temporary file
|
|
try:
|
|
os.remove(self._file.filename)
|
|
except Exception:
|
|
pass
|
|
raise self.server.error(
|
|
f"File checksum mismatch: expected {recd_cksum}, "
|
|
f"calculated {calc_chksum}", 422)
|
|
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}"
|
|
debug_msg += f"\nChecksum: {calc_chksum}"
|
|
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))
|
|
# Return 201 and add the Location Header
|
|
item: Dict[str, Any] = result.get('item', {})
|
|
root: Optional[str] = item.get('root', None)
|
|
fpath: Optional[str] = item.get('path', None)
|
|
if root is not None and fpath is not None:
|
|
path_parts = fpath.split("/")
|
|
fpath = "/".join([url_escape(p, plus=False) for p in path_parts])
|
|
proto = self.request.protocol
|
|
if not isinstance(proto, str):
|
|
proto = "http"
|
|
host = self.request.host
|
|
if not isinstance(host, str):
|
|
si = self.server.get_host_info()
|
|
port = si['port'] if proto == "http" else si['ssl_port']
|
|
host = f"{si['address']}:{port}"
|
|
location = f"{proto}://{host}/{self.location_prefix}/{root}/{fpath}"
|
|
self.set_header("Location", location)
|
|
logging.debug(f"Upload Location header set: {location}")
|
|
self.set_status(201)
|
|
self.finish(result)
|
|
|
|
# Default Handler for unregistered endpoints
|
|
class AuthorizedErrorHandler(AuthorizedRequestHandler):
|
|
def prepare(self) -> None:
|
|
super(AuthorizedRequestHandler, self).prepare()
|
|
self.set_status(404)
|
|
raise tornado.web.HTTPError(404)
|
|
|
|
def check_xsrf_cookie(self) -> None:
|
|
pass
|
|
|
|
def write_error(self, status_code: int, **kwargs) -> None:
|
|
err = {'code': status_code, 'message': self._reason}
|
|
if 'exc_info' in kwargs:
|
|
err['traceback'] = "\n".join(
|
|
traceback.format_exception(*kwargs['exc_info']))
|
|
self.finish({'error': err})
|
|
|
|
class RedirectHandler(AuthorizedRequestHandler):
|
|
def get(self, *args, **kwargs) -> None:
|
|
url: Optional[str] = self.get_argument('url', None)
|
|
if url is None:
|
|
try:
|
|
body_args: Dict[str, Any] = json.loads(self.request.body)
|
|
except json.JSONDecodeError:
|
|
body_args = {}
|
|
if 'url' not in body_args:
|
|
raise tornado.web.HTTPError(
|
|
400, "No url argument provided")
|
|
url = body_args['url']
|
|
assert url is not None
|
|
# validate the url origin
|
|
auth: AuthComp = self.server.lookup_component('authorization', None)
|
|
if auth is None or not auth.check_cors(url.rstrip("/")):
|
|
raise tornado.web.HTTPError(
|
|
400, f"Unauthorized URL redirect: {url}")
|
|
self.redirect(url)
|
|
|
|
class WelcomeHandler(tornado.web.RequestHandler):
|
|
def initialize(self) -> None:
|
|
self.server: Server = self.settings['server']
|
|
|
|
async def get(self) -> None:
|
|
summary: List[str] = []
|
|
auth: AuthComp = self.server.lookup_component("authorization", None)
|
|
if auth is not None:
|
|
try:
|
|
user = auth.check_authorized(self.request)
|
|
except tornado.web.HTTPError:
|
|
authorized = False
|
|
else:
|
|
authorized = True
|
|
if authorized:
|
|
summary.append(
|
|
"Your device is authorized to access Moonraker's API."
|
|
)
|
|
else:
|
|
summary.append(
|
|
"Your device is not authorized to access Moonraker's API. "
|
|
"This is normal if you intend to use API Key "
|
|
"authentication or log in as an authenticated user. "
|
|
"Otherwise you need to add your IP address to the "
|
|
"'trusted_clients' option in the [authorization] section "
|
|
"of moonraker.conf."
|
|
)
|
|
cors_enabled = auth.cors_enabled()
|
|
if cors_enabled:
|
|
summary.append(
|
|
"CORS is enabled. Cross origin requests will be allowed "
|
|
"for origins that match one of the patterns specified in "
|
|
"the 'cors_domain' option of the [authorization] section."
|
|
)
|
|
else:
|
|
summary.append(
|
|
"All cross origin requests will be blocked by the browser. "
|
|
"The 'cors_domains' option in [authorization] must be "
|
|
"configured to enable CORS."
|
|
)
|
|
else:
|
|
authorized = True
|
|
cors_enabled = False
|
|
summary.append(
|
|
"The [authorization] component is not enabled in "
|
|
"moonraker.conf. All connections will be considered trusted."
|
|
)
|
|
summary.append(
|
|
"All cross origin requests will be blocked by the browser. "
|
|
"The [authorization] section in moonraker.conf must be "
|
|
"configured to enable CORS."
|
|
)
|
|
kstate = self.server.get_klippy_state()
|
|
if kstate != "disconnected":
|
|
kinfo = self.server.get_klippy_info()
|
|
kmsg = kinfo.get("state_message", kstate)
|
|
summary.append(f"Klipper reports {kmsg.lower()}")
|
|
else:
|
|
summary.append(
|
|
"Moonraker is not currently connected to Klipper. Make sure "
|
|
"that the klipper service has successfully started and that "
|
|
"its unix is enabled."
|
|
)
|
|
ancomp: Announcements
|
|
ancomp = self.server.lookup_component("announcements")
|
|
wsm: WebsocketManager = self.server.lookup_component("websockets")
|
|
context: Dict[str, Any] = {
|
|
"ip_address": self.request.remote_ip,
|
|
"authorized": authorized,
|
|
"cors_enabled": cors_enabled,
|
|
"version": self.server.get_app_args()["software_version"],
|
|
"ws_count": wsm.get_count(),
|
|
"klippy_state": kstate,
|
|
"warnings": self.server.get_warnings(),
|
|
"summary": summary,
|
|
"announcements": await ancomp.get_announcements()
|
|
}
|
|
self.render("welcome.html", **context)
|
|
|
|
def get_template_path(self) -> Optional[str]:
|
|
tpath = pathlib.Path(__file__).parent.joinpath("assets")
|
|
return str(tpath)
|