984 lines
36 KiB
Python
984 lines
36 KiB
Python
# coding=utf-8
|
|
from __future__ import absolute_import
|
|
|
|
__author__ = "Gina Häußge <osd@foosel.net>"
|
|
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
|
|
__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License"
|
|
|
|
import logging
|
|
import os
|
|
import datetime
|
|
import stat
|
|
import mimetypes
|
|
import email
|
|
import time
|
|
import re
|
|
|
|
import tornado
|
|
import tornado.web
|
|
import tornado.gen
|
|
import tornado.escape
|
|
import tornado.httputil
|
|
import tornado.httpserver
|
|
import tornado.httpclient
|
|
import tornado.http1connection
|
|
import tornado.iostream
|
|
import tornado.tcpserver
|
|
import tornado.util
|
|
|
|
import octoprint.util
|
|
|
|
|
|
#~~ Monkey patching
|
|
|
|
|
|
def fix_ioloop_scheduling():
|
|
"""
|
|
This monkey patches tornado's :meth:``tornado.ioloop.PeriodicCallback._schedule_next`` method so it no longer
|
|
blocks for long times on slow machines (RPi) when the system time happens to change by a large amount (e.g. due to
|
|
the first ever contact to an NTP server).
|
|
|
|
Patch by @nosyjoe on Github. See this PR against tornado: https://github.com/tornadoweb/tornado/pull/1290
|
|
"""
|
|
|
|
import math
|
|
|
|
# patched implementation taken from PR
|
|
def _schedule_next(self):
|
|
if self._running:
|
|
current_time = self.io_loop.time()
|
|
|
|
if self._next_timeout <= current_time:
|
|
callback_time_sec = self.callback_time / 1000.0
|
|
self._next_timeout += (math.floor((current_time - self._next_timeout) / callback_time_sec) + 1) * callback_time_sec
|
|
|
|
self._timeout = self.io_loop.add_timeout(self._next_timeout, self._run)
|
|
|
|
# replace original implementation with patched version
|
|
import tornado.ioloop
|
|
tornado.ioloop.PeriodicCallback._schedule_next = _schedule_next
|
|
|
|
|
|
#~~ WSGI middleware
|
|
|
|
|
|
@tornado.web.stream_request_body
|
|
class UploadStorageFallbackHandler(tornado.web.RequestHandler):
|
|
"""
|
|
A ``RequestHandler`` similar to ``tornado.web.FallbackHandler`` which fetches any files contained in the request bodies
|
|
of content type ``multipart``, stores them in temporary files and supplies the ``fallback`` with the file's ``name``,
|
|
``content_type``, ``path`` and ``size`` instead via a rewritten body.
|
|
|
|
Basically similar to what the nginx upload module does.
|
|
|
|
Basic request body example:
|
|
|
|
.. code-block:: none
|
|
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="file"; filename="test.gcode"
|
|
Content-Type: application/octet-stream
|
|
|
|
...
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="apikey"
|
|
|
|
my_funny_apikey
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="select"
|
|
|
|
true
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C--
|
|
|
|
That would get turned into:
|
|
|
|
.. code-block:: none
|
|
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="apikey"
|
|
|
|
my_funny_apikey
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="select"
|
|
|
|
true
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="file.path"
|
|
|
|
/tmp/tmpzupkro
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="file.name"
|
|
|
|
test.gcode
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="file.content_type"
|
|
|
|
application/octet-stream
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C
|
|
Content-Disposition: form-data; name="file.size"
|
|
|
|
349182
|
|
------WebKitFormBoundarypYiSUx63abAmhT5C--
|
|
|
|
The underlying application can then access the contained files via their respective paths and just move them
|
|
where necessary.
|
|
"""
|
|
|
|
BODY_METHODS = ("POST", "PATCH", "PUT")
|
|
""" The request methods that may contain a request body. """
|
|
|
|
def initialize(self, fallback, file_prefix="tmp", file_suffix="", path=None, suffixes=None):
|
|
if not suffixes:
|
|
suffixes = dict()
|
|
|
|
self._fallback = fallback
|
|
self._file_prefix = file_prefix
|
|
self._file_suffix = file_suffix
|
|
self._path = path
|
|
|
|
self._suffixes = dict((key, key) for key in ("name", "path", "content_type", "size"))
|
|
for suffix_type, suffix in suffixes.iteritems():
|
|
if suffix_type in self._suffixes and suffix is not None:
|
|
self._suffixes[suffix_type] = suffix
|
|
|
|
# Parts, files and values will be stored here
|
|
self._parts = dict()
|
|
self._files = []
|
|
|
|
# Part currently being processed
|
|
self._current_part = None
|
|
|
|
# content type of request body
|
|
self._content_type = None
|
|
|
|
# bytes left to read according to content_length of request body
|
|
self._bytes_left = 0
|
|
|
|
# buffer needed for identifying form data parts
|
|
self._buffer = b""
|
|
|
|
# buffer for new body
|
|
self._new_body = b""
|
|
|
|
# logger
|
|
self._logger = logging.getLogger(__name__)
|
|
|
|
def prepare(self):
|
|
"""
|
|
Prepares the processing of the request. If it's a request that may contain a request body (as defined in
|
|
:attr:`UploadStorageFallbackHandler.BODY_METHODS`) prepares the multipart parsing if content type fits. If it's a
|
|
body-less request, just calls the ``fallback`` with an empty body and finishes the request.
|
|
"""
|
|
if self.request.method in UploadStorageFallbackHandler.BODY_METHODS:
|
|
self._bytes_left = self.request.headers.get("Content-Length", 0)
|
|
self._content_type = self.request.headers.get("Content-Type", None)
|
|
|
|
# request might contain a body
|
|
if self.is_multipart():
|
|
if not self._bytes_left:
|
|
# we don't support requests without a content-length
|
|
raise tornado.web.HTTPError(400, reason="No Content-Length supplied")
|
|
|
|
# extract the multipart boundary
|
|
fields = self._content_type.split(";")
|
|
for field in fields:
|
|
k, sep, v = field.strip().partition("=")
|
|
if k == "boundary" and v:
|
|
if v.startswith(b'"') and v.endswith(b'"'):
|
|
self._multipart_boundary = tornado.escape.utf8(v[1:-1])
|
|
else:
|
|
self._multipart_boundary = tornado.escape.utf8(v)
|
|
break
|
|
else:
|
|
self._multipart_boundary = None
|
|
else:
|
|
self._fallback(self.request, b"")
|
|
self._finished = True
|
|
|
|
def data_received(self, chunk):
|
|
"""
|
|
Called by Tornado on receiving a chunk of the request body. If request is a multipart request, takes care of
|
|
processing the multipart data structure via :func:`_process_multipart_data`. If not, just adds the chunk to
|
|
internal in-memory buffer.
|
|
|
|
:param chunk: chunk of data received from Tornado
|
|
"""
|
|
|
|
data = self._buffer + chunk
|
|
if self.is_multipart():
|
|
self._process_multipart_data(data)
|
|
else:
|
|
self._buffer = data
|
|
|
|
def is_multipart(self):
|
|
"""Checks whether this request is a ``multipart`` request"""
|
|
return self._content_type is not None and self._content_type.startswith("multipart")
|
|
|
|
def _process_multipart_data(self, data):
|
|
"""
|
|
Processes the given data, parsing it for multipart definitions and calling the appropriate methods.
|
|
|
|
:param data: the data to process as a string
|
|
"""
|
|
|
|
# check for boundary
|
|
delimiter = b"--%s" % self._multipart_boundary
|
|
delimiter_loc = data.find(delimiter)
|
|
delimiter_len = len(delimiter)
|
|
end_of_header = None
|
|
if delimiter_loc != -1:
|
|
# found the delimiter in the currently available data
|
|
delimiter_data_end = 0 if delimiter_loc == 0 else delimiter_loc - 2
|
|
data, self._buffer = data[0:delimiter_data_end], data[delimiter_loc:]
|
|
end_of_header = self._buffer.find("\r\n\r\n")
|
|
else:
|
|
# make sure any boundary (with single or double ==) contained at the end of chunk does not get
|
|
# truncated by this processing round => save it to the buffer for next round
|
|
endlen = len(self._multipart_boundary) + 4
|
|
data, self._buffer = data[0:-endlen], data[-endlen:]
|
|
|
|
# stream data to part handler
|
|
if data and self._current_part:
|
|
self._on_part_data(self._current_part, data)
|
|
|
|
if end_of_header >= 0:
|
|
self._on_part_header(self._buffer[delimiter_len+2:end_of_header])
|
|
self._buffer = self._buffer[end_of_header + 4:]
|
|
|
|
if delimiter_loc != -1 and self._buffer.strip() == delimiter + "--":
|
|
# we saw the last boundary and are at the end of our request
|
|
if self._current_part:
|
|
self._on_part_finish(self._current_part)
|
|
self._current_part = None
|
|
self._buffer = b""
|
|
self._on_request_body_finish()
|
|
|
|
def _on_part_header(self, header):
|
|
"""
|
|
Called for a new multipart header, takes care of parsing the header and calling :func:`_on_part` with the
|
|
relevant data, setting the current part in the process.
|
|
|
|
:param header: header to parse
|
|
"""
|
|
|
|
# close any open parts
|
|
if self._current_part:
|
|
self._on_part_finish(self._current_part)
|
|
self._current_part = None
|
|
|
|
header_check = header.find(self._multipart_boundary)
|
|
if header_check != -1:
|
|
self._logger.warn("Header still contained multipart boundary, stripping it...")
|
|
header = header[header_check:]
|
|
|
|
# convert to dict
|
|
header = tornado.httputil.HTTPHeaders.parse(header.decode("utf-8"))
|
|
disp_header = header.get("Content-Disposition", "")
|
|
disposition, disp_params = tornado.httputil._parse_header(disp_header)
|
|
|
|
if disposition != "form-data":
|
|
self._logger.warn("Got a multipart header without form-data content disposition, ignoring that one")
|
|
return
|
|
if not disp_params.get("name"):
|
|
self._logger.warn("Got a multipart header without name, ignoring that one")
|
|
return
|
|
|
|
self._current_part = self._on_part_start(disp_params["name"], header.get("Content-Type", None), filename=disp_params["filename"] if "filename" in disp_params else None)
|
|
|
|
def _on_part_start(self, name, content_type, filename=None):
|
|
"""
|
|
Called for new parts in the multipart stream. If ``filename`` is given creates new ``file`` part (which leads
|
|
to storage of the data as temporary file on disk), if not creates a new ``data`` part (which stores
|
|
incoming data in memory).
|
|
|
|
Structure of ``file`` parts:
|
|
|
|
* ``name``: name of the part
|
|
* ``filename``: filename associated with the part
|
|
* ``path``: path to the temporary file storing the file's data
|
|
* ``content_type``: content type of the part
|
|
* ``file``: file handle for the temporary file (mode "wb", not deleted on close, will be deleted however after
|
|
handling of the request has finished in :func:`_handle_method`)
|
|
|
|
Structure of ``data`` parts:
|
|
|
|
* ``name``: name of the part
|
|
* ``content_type``: content type of the part
|
|
* ``data``: bytes of the part (initialized to an empty string)
|
|
|
|
:param name: name of the part
|
|
:param content_type: content type of the part
|
|
:param filename: filename associated with the part.
|
|
:return: dict describing the new part
|
|
"""
|
|
if filename is not None:
|
|
# this is a file
|
|
import tempfile
|
|
handle = tempfile.NamedTemporaryFile(mode="wb", prefix=self._file_prefix, suffix=self._file_suffix, dir=self._path, delete=False)
|
|
return dict(name=tornado.escape.utf8(name),
|
|
filename=tornado.escape.utf8(filename),
|
|
path=tornado.escape.utf8(handle.name),
|
|
content_type=tornado.escape.utf8(content_type),
|
|
file=handle)
|
|
|
|
else:
|
|
return dict(name=tornado.escape.utf8(name), content_type=content_type, data=b"")
|
|
|
|
def _on_part_data(self, part, data):
|
|
"""
|
|
Called when new bytes are received for the given ``part``, takes care of writing them to their storage.
|
|
|
|
:param part: part for which data was received
|
|
:param data: data chunk which was received
|
|
"""
|
|
if "file" in part:
|
|
part["file"].write(data)
|
|
else:
|
|
part["data"] += data
|
|
|
|
def _on_part_finish(self, part):
|
|
"""
|
|
Called when a part gets closed, takes care of storing the finished part in the internal parts storage and for
|
|
``file`` parts closing the temporary file and storing the part in the internal files storage.
|
|
|
|
:param part: part which was closed
|
|
"""
|
|
name = part["name"]
|
|
self._parts[name] = part
|
|
if "file" in part:
|
|
self._files.append(part["path"])
|
|
part["file"].close()
|
|
del part["file"]
|
|
|
|
def _on_request_body_finish(self):
|
|
"""
|
|
Called when the request body has been read completely. Takes care of creating the replacement body out of the
|
|
logged parts, turning ``file`` parts into new ``data`` parts.
|
|
"""
|
|
|
|
self._new_body = b""
|
|
for name, part in self._parts.iteritems():
|
|
if "filename" in part:
|
|
# add form fields for filename, path, size and content_type for all files contained in the request
|
|
if not "path" in part:
|
|
continue
|
|
|
|
parameters = dict(
|
|
name=part["filename"],
|
|
path=part["path"],
|
|
size=str(os.stat(part["path"]).st_size)
|
|
)
|
|
if "content_type" in part:
|
|
parameters["content_type"] = part["content_type"]
|
|
|
|
fields = dict((self._suffixes[key], value) for (key, value) in parameters.iteritems())
|
|
for n, p in fields.iteritems():
|
|
key = name + "." + n
|
|
self._new_body += b"--%s\r\n" % self._multipart_boundary
|
|
self._new_body += b"Content-Disposition: form-data; name=\"%s\"\r\n" % key
|
|
self._new_body += b"\r\n"
|
|
self._new_body += b"%s\r\n" % p
|
|
elif "data" in part:
|
|
self._new_body += b"--%s\r\n" % self._multipart_boundary
|
|
value = part["data"]
|
|
self._new_body += b"Content-Disposition: form-data; name=\"%s\"\r\n" % name
|
|
if "content_type" in part and part["content_type"] is not None:
|
|
self._new_body += b"Content-Type: %s\r\n" % part["content_type"]
|
|
self._new_body += b"\r\n"
|
|
self._new_body += value + b"\r\n"
|
|
self._new_body += b"--%s--\r\n" % self._multipart_boundary
|
|
|
|
def _handle_method(self, *args, **kwargs):
|
|
"""
|
|
Takes care of defining the new request body if necessary and forwarding
|
|
the current request and changed body to the ``fallback``.
|
|
"""
|
|
|
|
# determine which body to supply
|
|
body = b""
|
|
if self.is_multipart():
|
|
# make sure we really processed all data in the buffer
|
|
while len(self._buffer):
|
|
self._process_multipart_data(self._buffer)
|
|
|
|
# use rewritten body
|
|
body = self._new_body
|
|
|
|
elif self.request.method in UploadStorageFallbackHandler.BODY_METHODS:
|
|
# directly use data from buffer
|
|
body = self._buffer
|
|
|
|
# rewrite content length
|
|
self.request.headers["Content-Length"] = len(body)
|
|
|
|
try:
|
|
# call the configured fallback with request and body to use
|
|
self._fallback(self.request, body)
|
|
self._headers_written = True
|
|
finally:
|
|
# make sure the temporary files are removed again
|
|
for f in self._files:
|
|
octoprint.util.silent_remove(f)
|
|
|
|
# make all http methods trigger _handle_method
|
|
get = _handle_method
|
|
post = _handle_method
|
|
put = _handle_method
|
|
patch = _handle_method
|
|
delete = _handle_method
|
|
head = _handle_method
|
|
options = _handle_method
|
|
|
|
|
|
class WsgiInputContainer(object):
|
|
"""
|
|
A WSGI container for use with Tornado that allows supplying the request body to be used for ``wsgi.input`` in the
|
|
generated WSGI environment upon call.
|
|
|
|
A ``RequestHandler`` can thus provide the WSGI application with a stream for the request body, or a modified body.
|
|
|
|
Example usage:
|
|
|
|
.. code-block:: python
|
|
|
|
wsgi_app = octoprint.server.util.WsgiInputContainer(octoprint_app)
|
|
application = tornado.web.Application([
|
|
(r".*", UploadStorageFallbackHandler, dict(fallback=wsgi_app),
|
|
])
|
|
|
|
The implementation logic is basically the same as ``tornado.wsgi.WSGIContainer`` but the ``__call__`` and ``environ``
|
|
methods have been adjusted to allow for an optionally supplied ``body`` argument which is then used for ``wsgi.input``.
|
|
"""
|
|
|
|
def __init__(self, wsgi_application):
|
|
self.wsgi_application = wsgi_application
|
|
|
|
def __call__(self, request, body=None):
|
|
"""
|
|
Wraps the call against the WSGI app, deriving the WSGI environment from the supplied Tornado ``HTTPServerRequest``.
|
|
|
|
:param request: the ``tornado.httpserver.HTTPServerRequest`` to derive the WSGI environment from
|
|
:param body: an optional body to use as ``wsgi.input`` instead of ``request.body``, can be a string or a stream
|
|
"""
|
|
|
|
data = {}
|
|
response = []
|
|
|
|
def start_response(status, response_headers, exc_info=None):
|
|
data["status"] = status
|
|
data["headers"] = response_headers
|
|
return response.append
|
|
app_response = self.wsgi_application(
|
|
WsgiInputContainer.environ(request, body), start_response)
|
|
try:
|
|
response.extend(app_response)
|
|
body = b"".join(response)
|
|
finally:
|
|
if hasattr(app_response, "close"):
|
|
app_response.close()
|
|
if not data:
|
|
raise Exception("WSGI app did not call start_response")
|
|
|
|
status_code = int(data["status"].split()[0])
|
|
headers = data["headers"]
|
|
header_set = set(k.lower() for (k, v) in headers)
|
|
body = tornado.escape.utf8(body)
|
|
if status_code != 304:
|
|
if "content-length" not in header_set:
|
|
headers.append(("Content-Length", str(len(body))))
|
|
if "content-type" not in header_set:
|
|
headers.append(("Content-Type", "text/html; charset=UTF-8"))
|
|
if "server" not in header_set:
|
|
headers.append(("Server", "TornadoServer/%s" % tornado.version))
|
|
|
|
parts = [tornado.escape.utf8("HTTP/1.1 " + data["status"] + "\r\n")]
|
|
for key, value in headers:
|
|
parts.append(tornado.escape.utf8(key) + b": " + tornado.escape.utf8(value) + b"\r\n")
|
|
parts.append(b"\r\n")
|
|
parts.append(body)
|
|
request.write(b"".join(parts))
|
|
request.finish()
|
|
self._log(status_code, request)
|
|
|
|
@staticmethod
|
|
def environ(request, body=None):
|
|
"""
|
|
Converts a ``tornado.httputil.HTTPServerRequest`` to a WSGI environment.
|
|
|
|
An optional ``body`` to be used for populating ``wsgi.input`` can be supplied (either a string or a stream). If not
|
|
supplied, ``request.body`` will be wrapped into a ``io.BytesIO`` stream and used instead.
|
|
|
|
:param request: the ``tornado.httpserver.HTTPServerRequest`` to derive the WSGI environment from
|
|
:param body: an optional body to use as ``wsgi.input`` instead of ``request.body``, can be a string or a stream
|
|
"""
|
|
from tornado.wsgi import to_wsgi_str
|
|
import sys
|
|
import io
|
|
|
|
# determine the request_body to supply as wsgi.input
|
|
if body is not None:
|
|
if isinstance(body, (bytes, str)):
|
|
request_body = io.BytesIO(tornado.escape.utf8(body))
|
|
else:
|
|
request_body = body
|
|
else:
|
|
request_body = io.BytesIO(tornado.escape.utf8(request.body))
|
|
|
|
hostport = request.host.split(":")
|
|
if len(hostport) == 2:
|
|
host = hostport[0]
|
|
port = int(hostport[1])
|
|
else:
|
|
host = request.host
|
|
port = 443 if request.protocol == "https" else 80
|
|
environ = {
|
|
"REQUEST_METHOD": request.method,
|
|
"SCRIPT_NAME": "",
|
|
"PATH_INFO": to_wsgi_str(tornado.escape.url_unescape(
|
|
request.path, encoding=None, plus=False)),
|
|
"QUERY_STRING": request.query,
|
|
"REMOTE_ADDR": request.remote_ip,
|
|
"SERVER_NAME": host,
|
|
"SERVER_PORT": str(port),
|
|
"SERVER_PROTOCOL": request.version,
|
|
"wsgi.version": (1, 0),
|
|
"wsgi.url_scheme": request.protocol,
|
|
"wsgi.input": request_body,
|
|
"wsgi.errors": sys.stderr,
|
|
"wsgi.multithread": False,
|
|
"wsgi.multiprocess": True,
|
|
"wsgi.run_once": False,
|
|
}
|
|
if "Content-Type" in request.headers:
|
|
environ["CONTENT_TYPE"] = request.headers.pop("Content-Type")
|
|
if "Content-Length" in request.headers:
|
|
environ["CONTENT_LENGTH"] = request.headers.pop("Content-Length")
|
|
for key, value in request.headers.items():
|
|
environ["HTTP_" + key.replace("-", "_").upper()] = value
|
|
return environ
|
|
|
|
def _log(self, status_code, request):
|
|
access_log = logging.getLogger("tornado.access")
|
|
|
|
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 * request.request_time()
|
|
summary = request.method + " " + request.uri + " (" + \
|
|
request.remote_ip + ")"
|
|
log_method("%d %s %.2fms", status_code, summary, request_time)
|
|
|
|
|
|
#~~ customized HTTP1Connection implementation
|
|
|
|
|
|
class CustomHTTPServer(tornado.httpserver.HTTPServer):
|
|
"""
|
|
Custom implementation of ``tornado.httpserver.HTTPServer`` that allows defining max body sizes depending on path and
|
|
method.
|
|
|
|
The implementation is mostly taken from ``tornado.httpserver.HTTPServer``, the only difference is the creation
|
|
of a ``CustomHTTP1ConnectionParameters`` instance instead of ``tornado.http1connection.HTTP1ConnectionParameters``
|
|
which is supplied with the two new constructor arguments ``max_body_sizes`` and ``max_default_body_size`` and the
|
|
creation of a ``CustomHTTP1ServerConnection`` instead of a ``tornado.http1connection.HTTP1ServerConnection`` upon
|
|
connection by a client.
|
|
|
|
``max_body_sizes`` is expected to be an iterable containing tuples of the form (method, path regex, maximum body size),
|
|
with method and path regex having to match in order for maximum body size to take affect.
|
|
|
|
``default_max_body_size`` is the default maximum body size to apply if no specific one from ``max_body_sizes`` matches.
|
|
"""
|
|
|
|
def __init__(self, request_callback, no_keep_alive=False, io_loop=None,
|
|
xheaders=False, ssl_options=None, protocol=None,
|
|
decompress_request=False,
|
|
chunk_size=None, max_header_size=None,
|
|
idle_connection_timeout=None, body_timeout=None,
|
|
max_body_sizes=None, default_max_body_size=None, max_buffer_size=None):
|
|
self.request_callback = request_callback
|
|
self.no_keep_alive = no_keep_alive
|
|
self.xheaders = xheaders
|
|
self.protocol = protocol
|
|
self.conn_params = CustomHTTP1ConnectionParameters(
|
|
decompress=decompress_request,
|
|
chunk_size=chunk_size,
|
|
max_header_size=max_header_size,
|
|
header_timeout=idle_connection_timeout or 3600,
|
|
max_body_sizes=max_body_sizes,
|
|
default_max_body_size=default_max_body_size,
|
|
body_timeout=body_timeout)
|
|
tornado.tcpserver.TCPServer.__init__(self, io_loop=io_loop, ssl_options=ssl_options,
|
|
max_buffer_size=max_buffer_size,
|
|
read_chunk_size=chunk_size)
|
|
self._connections = set()
|
|
|
|
|
|
def handle_stream(self, stream, address):
|
|
context = tornado.httpserver._HTTPRequestContext(stream, address,
|
|
self.protocol)
|
|
conn = CustomHTTP1ServerConnection(
|
|
stream, self.conn_params, context)
|
|
self._connections.add(conn)
|
|
conn.start_serving(self)
|
|
|
|
|
|
class CustomHTTP1ServerConnection(tornado.http1connection.HTTP1ServerConnection):
|
|
"""
|
|
A custom implementation of ``tornado.http1connection.HTTP1ServerConnection`` which utilizes a ``CustomHTTP1Connection``
|
|
instead of a ``tornado.http1connection.HTTP1Connection`` in ``_server_request_loop``. The implementation logic is
|
|
otherwise the same as ``tornado.http1connection.HTTP1ServerConnection``.
|
|
"""
|
|
|
|
@tornado.gen.coroutine
|
|
def _server_request_loop(self, delegate):
|
|
try:
|
|
while True:
|
|
conn = CustomHTTP1Connection(self.stream, False,
|
|
self.params, self.context)
|
|
request_delegate = delegate.start_request(self, conn)
|
|
try:
|
|
ret = yield conn.read_response(request_delegate)
|
|
except (tornado.iostream.StreamClosedError,
|
|
tornado.iostream.UnsatisfiableReadError):
|
|
return
|
|
except tornado.http1connection._QuietException:
|
|
# This exception was already logged.
|
|
conn.close()
|
|
return
|
|
except Exception:
|
|
tornado.http1connection.gen_log.error("Uncaught exception", exc_info=True)
|
|
conn.close()
|
|
return
|
|
if not ret:
|
|
return
|
|
yield tornado.gen.moment
|
|
finally:
|
|
delegate.on_close(self)
|
|
|
|
|
|
class CustomHTTP1Connection(tornado.http1connection.HTTP1Connection):
|
|
"""
|
|
A custom implementation of ``tornado.http1connection.HTTP1Connection`` which upon checking the ``Content-Length`` of
|
|
the request against the configured maximum utilizes ``max_body_sizes`` and ``default_max_body_size`` as a fallback.
|
|
"""
|
|
|
|
def __init__(self, stream, is_client, params=None, context=None):
|
|
tornado.http1connection.HTTP1Connection.__init__(self, stream, is_client, params=params, context=context)
|
|
|
|
import re
|
|
self._max_body_sizes = map(lambda x: (x[0], re.compile(x[1]), x[2]), self.params.max_body_sizes or list())
|
|
self._default_max_body_size = self.params.default_max_body_size or self.stream.max_buffer_size
|
|
|
|
def _read_body(self, code, headers, delegate):
|
|
"""
|
|
Basically the same as ``tornado.http1connection.HTTP1Connection._read_body``, but determines the maximum
|
|
content length individually for the request (utilizing ``._get_max_content_length``).
|
|
|
|
If the individual max content length is 0 or smaller no content length is checked. If the content length of the
|
|
current request exceeds the individual max content length, the request processing is aborted and an
|
|
``HTTPInputError`` is raised.
|
|
"""
|
|
content_length = headers.get("Content-Length")
|
|
if "Content-Length" in headers:
|
|
if "," in headers["Content-Length"]:
|
|
# Proxies sometimes cause Content-Length headers to get
|
|
# duplicated. If all the values are identical then we can
|
|
# use them but if they differ it's an error.
|
|
pieces = re.split(r',\s*', headers["Content-Length"])
|
|
if any(i != pieces[0] for i in pieces):
|
|
raise tornado.httputil.HTTPInputError(
|
|
"Multiple unequal Content-Lengths: %r" %
|
|
headers["Content-Length"])
|
|
headers["Content-Length"] = pieces[0]
|
|
content_length = int(headers["Content-Length"])
|
|
|
|
content_length = int(content_length)
|
|
max_content_length = self._get_max_content_length(self._request_start_line.method, self._request_start_line.path)
|
|
if max_content_length is not None and 0 <= max_content_length < content_length:
|
|
raise tornado.httputil.HTTPInputError("Content-Length too long")
|
|
else:
|
|
content_length = None
|
|
|
|
if code == 204:
|
|
# This response code is not allowed to have a non-empty body,
|
|
# and has an implicit length of zero instead of read-until-close.
|
|
# http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.3
|
|
if ("Transfer-Encoding" in headers or
|
|
content_length not in (None, 0)):
|
|
raise tornado.httputil.HTTPInputError(
|
|
"Response with code %d should not have body" % code)
|
|
content_length = 0
|
|
|
|
if content_length is not None:
|
|
return self._read_fixed_body(content_length, delegate)
|
|
if headers.get("Transfer-Encoding") == "chunked":
|
|
return self._read_chunked_body(delegate)
|
|
if self.is_client:
|
|
return self._read_body_until_close(delegate)
|
|
return None
|
|
|
|
def _get_max_content_length(self, method, path):
|
|
"""
|
|
Gets the max content length for the given method and path. Checks whether method and path match against any
|
|
of the specific maximum content lengths supplied in ``max_body_sizes`` and returns that as the maximum content
|
|
length if available, otherwise returns ``default_max_body_size``.
|
|
|
|
:param method: method of the request to match against
|
|
:param path: path of the request to match against
|
|
:return: determine maximum content length to apply to this request, max return 0 for unlimited allowed content
|
|
length
|
|
"""
|
|
|
|
for m, p, s in self._max_body_sizes:
|
|
if method == m and p.match(path):
|
|
return s
|
|
return self._default_max_body_size
|
|
|
|
|
|
class CustomHTTP1ConnectionParameters(tornado.http1connection.HTTP1ConnectionParameters):
|
|
"""
|
|
An implementation of ``tornado.http1connection.HTTP1ConnectionParameters`` that adds two new parameters
|
|
``max_body_sizes`` and ``default_max_body_size``.
|
|
|
|
For a description of these please see the documentation of ``CustomHTTPServer`` above.
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
tornado.http1connection.HTTP1ConnectionParameters.__init__(self, args, kwargs)
|
|
self.max_body_sizes = kwargs["max_body_sizes"] if "max_body_sizes" in kwargs else list()
|
|
self.default_max_body_size = kwargs["default_max_body_size"] if "default_max_body_size" in kwargs else None
|
|
|
|
#~~ customized large response handler
|
|
|
|
|
|
class LargeResponseHandler(tornado.web.StaticFileHandler):
|
|
"""
|
|
Customized `tornado.web.StaticFileHandler <http://tornado.readthedocs.org/en/branch4.0/web.html#tornado.web.StaticFileHandler>`_
|
|
that allows delivery of the requested resource as attachment and access and request path validation through
|
|
optional callbacks. Note that access validation takes place before path validation.
|
|
|
|
Arguments:
|
|
path (str): The system path from which to serve files (this will be forwarded to the ``initialize`` method of
|
|
:class:``~tornado.web.StaticFileHandler``)
|
|
default_filename (str): The default filename to serve if none is explicitly specified and the request references
|
|
a subdirectory of the served path (this will be forwarded to the ``initialize`` method of
|
|
:class:``~tornado.web.StaticFileHandler`` as the ``default_filename`` keyword parameter). Defaults to ``None``.
|
|
as_attachment (bool): Whether to serve requested files with ``Content-Disposition: attachment`` header (``True``)
|
|
or not. Defaults to ``False``.
|
|
allow_client_caching (bool): Whether to allow the client to cache (by not setting any ``Cache-Control`` or
|
|
``Expires`` headers on the response) or not.
|
|
access_validation (function): Callback to call in the ``get`` method to validate access to the resource. Will
|
|
be called with ``self.request`` as parameter which contains the full tornado request object. Should raise
|
|
a ``tornado.web.HTTPError`` if access is not allowed in which case the request will not be further processed.
|
|
Defaults to ``None`` and hence no access validation being performed.
|
|
path_validation (function): Callback to call in the ``get`` method to validate the requested path. Will be called
|
|
with the requested path as parameter. Should raise a ``tornado.web.HTTPError`` (e.g. an 404) if the requested
|
|
path does not pass validation in which case the request will not be further processed.
|
|
Defaults to ``None`` and hence no path validation being performed.
|
|
etag_generator (function): Callback to call for generating the value of the ETag response header. Will be
|
|
called with the response handler as parameter. May return ``None`` to prevent the ETag response header
|
|
from being set. If not provided the last modified time of the file in question will be used as returned
|
|
by ``get_content_version``.
|
|
"""
|
|
|
|
def initialize(self, path, default_filename=None, as_attachment=False, allow_client_caching=True,
|
|
access_validation=None, path_validation=None, etag_generator=None,
|
|
mime_type_guesser=None):
|
|
tornado.web.StaticFileHandler.initialize(self, os.path.abspath(path), default_filename)
|
|
self._as_attachment = as_attachment
|
|
self._allow_client_caching = allow_client_caching
|
|
self._access_validation = access_validation
|
|
self._path_validation = path_validation
|
|
self._etag_generator = etag_generator
|
|
self._mime_type_guesser = mime_type_guesser
|
|
|
|
def get(self, path, include_body=True):
|
|
if self._access_validation is not None:
|
|
self._access_validation(self.request)
|
|
if self._path_validation is not None:
|
|
self._path_validation(path)
|
|
|
|
if "cookie" in self.request.arguments:
|
|
self.set_cookie(self.request.arguments["cookie"][0], "true", path="/")
|
|
|
|
result = tornado.web.StaticFileHandler.get(self, path, include_body=include_body)
|
|
return result
|
|
|
|
def set_extra_headers(self, path):
|
|
if self._as_attachment:
|
|
self.set_header("Content-Disposition", "attachment; filename=%s" % os.path.basename(path))
|
|
|
|
if not self._allow_client_caching:
|
|
self.set_header("Cache-Control", "max-age=0, must-revalidate, private")
|
|
self.set_header("Expires", "-1")
|
|
|
|
def compute_etag(self):
|
|
if self._etag_generator is not None:
|
|
return self._etag_generator(self)
|
|
else:
|
|
return self.get_content_version(self.absolute_path)
|
|
|
|
def get_content_type(self):
|
|
if self._mime_type_guesser is not None:
|
|
type = self._mime_type_guesser(self.absolute_path)
|
|
if type is not None:
|
|
return type
|
|
|
|
return tornado.web.StaticFileHandler.get_content_type(self)
|
|
|
|
@classmethod
|
|
def get_content_version(cls, abspath):
|
|
import os
|
|
import stat
|
|
return os.stat(abspath)[stat.ST_MTIME]
|
|
|
|
##~~ URL Forward Handler for forwarding requests to a preconfigured static URL
|
|
|
|
|
|
class UrlProxyHandler(tornado.web.RequestHandler):
|
|
"""
|
|
`tornado.web.RequestHandler <http://tornado.readthedocs.org/en/branch4.0/web.html#request-handlers>`_ that proxies
|
|
requests to a preconfigured url and returns the response. Allows delivery of the requested content as attachment
|
|
and access validation through an optional callback.
|
|
|
|
This will use `tornado.httpclient.AsyncHTTPClient <http://tornado.readthedocs.org/en/branch4.0/httpclient.html#tornado.httpclient.AsyncHTTPClient>`_
|
|
for making the request to the configured endpoint and return the body of the client response with the status code
|
|
from the client response and the following headers:
|
|
|
|
* ``Date``, ``Cache-Control``, ``Expires``, ``ETag``, ``Server``, ``Content-Type`` and ``Location`` will be copied over.
|
|
* If ``as_attachment`` is set to True, ``Content-Disposition`` will be set to ``attachment``. If ``basename`` is
|
|
set including the attachment's ``filename`` attribute will be set to the base name followed by the extension
|
|
guessed based on the MIME type from the ``Content-Type`` header of the response. If no extension can be guessed
|
|
no ``filename`` attribute will be set.
|
|
|
|
Arguments:
|
|
url (str): URL to forward any requests to. A 404 response will be returned if this is not set. Defaults to ``None``.
|
|
as_attachment (bool): Whether to serve files with ``Content-Disposition: attachment`` header (``True``)
|
|
or not. Defaults to ``False``.
|
|
basename (str): base name of file names to return as part of the attachment header, see above. Defaults to ``None``.
|
|
access_validation (function): Callback to call in the ``get`` method to validate access to the resource. Will
|
|
be called with ``self.request`` as parameter which contains the full tornado request object. Should raise
|
|
a ``tornado.web.HTTPError`` if access is not allowed in which case the request will not be further processed.
|
|
Defaults to ``None`` and hence no access validation being performed.
|
|
"""
|
|
|
|
def initialize(self, url=None, as_attachment=False, basename=None, access_validation=None):
|
|
tornado.web.RequestHandler.initialize(self)
|
|
self._url = url
|
|
self._as_attachment = as_attachment
|
|
self._basename = basename
|
|
self._access_validation = access_validation
|
|
|
|
@tornado.web.asynchronous
|
|
def get(self, *args, **kwargs):
|
|
if self._access_validation is not None:
|
|
self._access_validation(self.request)
|
|
|
|
if self._url is None:
|
|
raise tornado.web.HTTPError(404)
|
|
|
|
client = tornado.httpclient.AsyncHTTPClient()
|
|
r = tornado.httpclient.HTTPRequest(url=self._url, method=self.request.method, body=self.request.body, headers=self.request.headers, follow_redirects=False, allow_nonstandard_methods=True)
|
|
|
|
try:
|
|
return client.fetch(r, self.handle_response)
|
|
except tornado.web.HTTPError as e:
|
|
if hasattr(e, "response") and e.response:
|
|
self.handle_response(e.response)
|
|
else:
|
|
raise tornado.web.HTTPError(500)
|
|
|
|
def handle_response(self, response):
|
|
if response.error and not isinstance(response.error, tornado.web.HTTPError):
|
|
raise tornado.web.HTTPError(500)
|
|
|
|
filename = None
|
|
|
|
self.set_status(response.code)
|
|
for name in ("Date", "Cache-Control", "Server", "Content-Type", "Location", "Expires", "ETag"):
|
|
value = response.headers.get(name)
|
|
if value:
|
|
self.set_header(name, value)
|
|
|
|
if name == "Content-Type":
|
|
filename = self.get_filename(value)
|
|
|
|
if self._as_attachment:
|
|
if filename is not None:
|
|
self.set_header("Content-Disposition", "attachment; filename=%s" % filename)
|
|
else:
|
|
self.set_header("Content-Disposition", "attachment")
|
|
|
|
if response.body:
|
|
self.write(response.body)
|
|
self.finish()
|
|
|
|
def get_filename(self, content_type):
|
|
if not self._basename:
|
|
return None
|
|
|
|
typeValue = map(str.strip, content_type.split(";"))
|
|
if len(typeValue) == 0:
|
|
return None
|
|
|
|
extension = mimetypes.guess_extension(typeValue[0])
|
|
if not extension:
|
|
return None
|
|
|
|
return "%s%s" % (self._basename, extension)
|
|
|
|
|
|
class StaticDataHandler(tornado.web.RequestHandler):
|
|
def initialize(self, data="", content_type="text/plain"):
|
|
self.data = data
|
|
self.content_type = content_type
|
|
|
|
def get(self, *args, **kwargs):
|
|
self.set_status(200)
|
|
self.set_header("Content-Type", self.content_type)
|
|
self.write(self.data)
|
|
self.flush()
|
|
self.finish()
|
|
|
|
|
|
#~~ Factory method for creating Flask access validation wrappers from the Tornado request context
|
|
|
|
|
|
def access_validation_factory(app, login_manager, validator):
|
|
"""
|
|
Creates an access validation wrapper using the supplied validator.
|
|
|
|
:param validator: the access validator to use inside the validation wrapper
|
|
:return: an access validator taking a request as parameter and performing the request validation
|
|
"""
|
|
def f(request):
|
|
"""
|
|
Creates a custom wsgi and Flask request context in order to be able to process user information
|
|
stored in the current session.
|
|
|
|
:param request: The Tornado request for which to create the environment and context
|
|
"""
|
|
import flask
|
|
|
|
wsgi_environ = WsgiInputContainer.environ(request)
|
|
with app.request_context(wsgi_environ):
|
|
app.session_interface.open_session(app, flask.request)
|
|
login_manager.reload_user()
|
|
validator(flask.request)
|
|
return f
|
|
|
|
def path_validation_factory(path_filter, status_code=404):
|
|
"""
|
|
Creates a request path validation wrapper returning the defined status code if the supplied path_filter returns False.
|
|
|
|
:param path_filter: the path filter to use on the requested path, should return False for requests that should
|
|
be responded with the provided error code.
|
|
:return: a request path validator taking a request path as parameter and performing the request validation
|
|
"""
|
|
def f(path):
|
|
if not path_filter(path):
|
|
raise tornado.web.HTTPError(status_code)
|
|
return f
|