Finalizing upload streaming support
Major refactoring of octoprint.server.util (divided into smaller submodules), extended Tornado to allow for request-specific max content lengths, introduced settings parameters to configure maximum upload size, maximum request body size and file suffixes See #455
This commit is contained in:
parent
e9ca09da87
commit
b4af85f405
18 changed files with 1422 additions and 1507 deletions
|
|
@ -1,13 +1,13 @@
|
|||
# coding=utf-8
|
||||
import uuid
|
||||
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 flask
|
||||
import tornado.wsgi
|
||||
import uuid
|
||||
from sockjs.tornado import SockJSRouter
|
||||
from flask import Flask, render_template, send_from_directory, make_response
|
||||
from flask import Flask, render_template, send_from_directory
|
||||
from flask.ext.login import LoginManager
|
||||
from flask.ext.principal import Principal, Permission, RoleNeed, identity_loaded, UserNeed
|
||||
from watchdog.observers import Observer
|
||||
|
|
@ -33,18 +33,16 @@ admin_permission = Permission(RoleNeed("admin"))
|
|||
user_permission = Permission(RoleNeed("user"))
|
||||
|
||||
# only import the octoprint stuff down here, as it might depend on things defined above to be initialized already
|
||||
from octoprint.server.util import LargeResponseHandler, ReverseProxied, restricted_access, PrinterStateConnection, admin_validator, \
|
||||
UrlForwardHandler, user_validator, GcodeWatchdogHandler, UploadCleanupWatchdogHandler, \
|
||||
access_validation_factory, WsgiInputContainer, StreamingFallbackHandler, PrintableFilesUploadHandler, \
|
||||
UploadStorageFallbackHandler
|
||||
from octoprint.printer import Printer, getConnectionOptions
|
||||
from octoprint.settings import settings
|
||||
import octoprint.gcodefiles as gcodefiles
|
||||
import octoprint.util as util
|
||||
import octoprint.users as users
|
||||
import octoprint.events as events
|
||||
import octoprint.timelapse
|
||||
import octoprint._version
|
||||
import octoprint.util
|
||||
|
||||
from . import util
|
||||
|
||||
|
||||
UI_API_KEY = ''.join('%02X' % ord(z) for z in uuid.uuid4().bytes)
|
||||
|
|
@ -121,10 +119,8 @@ class Server():
|
|||
global loginManager
|
||||
global debug
|
||||
|
||||
from tornado.wsgi import WSGIContainer
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.web import Application, FallbackHandler
|
||||
from tornado.web import Application
|
||||
|
||||
debug = self._debug
|
||||
|
||||
|
|
@ -152,12 +148,12 @@ class Server():
|
|||
if settings().getBoolean(["accessControl", "enabled"]):
|
||||
userManagerName = settings().get(["accessControl", "userManager"])
|
||||
try:
|
||||
clazz = util.getClass(userManagerName)
|
||||
clazz = octoprint.util.getClass(userManagerName)
|
||||
userManager = clazz()
|
||||
except AttributeError, e:
|
||||
logger.exception("Could not instantiate user manager %s, will run with accessControl disabled!" % userManagerName)
|
||||
|
||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||
app.wsgi_app = util.ReverseProxied(app.wsgi_app)
|
||||
|
||||
app.secret_key = "k3PuVYgtxNm8DXKKTw2nWmFQQun9qceV"
|
||||
loginManager = LoginManager()
|
||||
|
|
@ -182,14 +178,18 @@ class Server():
|
|||
|
||||
self._router = SockJSRouter(self._createSocketConnection, "/sockjs")
|
||||
|
||||
upload_suffixes = dict(name=settings().get(["server", "uploads", "nameSuffix"]), path=settings().get(["server", "uploads", "pathSuffix"]))
|
||||
self._tornado_app = Application(self._router.urls + [
|
||||
(r"/downloads/timelapse/([^/]*\.mpg)", LargeResponseHandler, dict(path=settings().getBaseFolder("timelapse"), as_attachment=True)),
|
||||
(r"/downloads/files/local/([^/]*\.(gco|gcode))", LargeResponseHandler, dict(path=settings().getBaseFolder("uploads"), as_attachment=True)),
|
||||
(r"/downloads/logs/([^/]*)", LargeResponseHandler, dict(path=settings().getBaseFolder("logs"), as_attachment=True, access_validation=access_validation_factory(app, loginManager, admin_validator))),
|
||||
(r"/downloads/camera/current", UrlForwardHandler, dict(url=settings().get(["webcam", "snapshot"]), as_attachment=True, access_validation=access_validation_factory(app, loginManager, user_validator))),
|
||||
(r".*", UploadStorageFallbackHandler, dict(fallback=WsgiInputContainer(app.wsgi_app), file_prefix="octoprint-file-upload-", file_suffix=".tmp"))
|
||||
(r"/downloads/timelapse/([^/]*\.mpg)", util.tornado.LargeResponseHandler, dict(path=settings().getBaseFolder("timelapse"), as_attachment=True)),
|
||||
(r"/downloads/files/local/([^/]*\.(gco|gcode))", util.tornado.LargeResponseHandler, dict(path=settings().getBaseFolder("uploads"), as_attachment=True)),
|
||||
(r"/downloads/logs/([^/]*)", util.tornado.LargeResponseHandler, dict(path=settings().getBaseFolder("logs"), as_attachment=True, access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.admin_validator))),
|
||||
(r"/downloads/camera/current", util.tornado.UrlForwardHandler, dict(url=settings().get(["webcam", "snapshot"]), as_attachment=True, access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.user_validator))),
|
||||
(r".*", util.tornado.UploadStorageFallbackHandler, dict(fallback=util.tornado.WsgiInputContainer(app.wsgi_app), file_prefix="octoprint-file-upload-", file_suffix=".tmp", suffixes=upload_suffixes))
|
||||
])
|
||||
self._server = HTTPServer(self._tornado_app, max_body_size=1*1024*1024*1024)
|
||||
max_body_sizes = [
|
||||
("POST", r"/api/files/([^/]*)", settings().getInt(["server", "uploads", "maxSize"]))
|
||||
]
|
||||
self._server = util.tornado.CustomHTTPServer(self._tornado_app, max_body_sizes=max_body_sizes, default_max_body_size=10*1024)
|
||||
self._server.listen(self._port, address=self._host)
|
||||
|
||||
eventManager.fire(events.Events.STARTUP)
|
||||
|
|
@ -201,8 +201,8 @@ class Server():
|
|||
|
||||
# start up watchdogs
|
||||
observer = Observer()
|
||||
observer.schedule(GcodeWatchdogHandler(gcodeManager, printer), settings().getBaseFolder("watched"))
|
||||
observer.schedule(UploadCleanupWatchdogHandler(gcodeManager), settings().getBaseFolder("uploads"))
|
||||
observer.schedule(util.watchdog.GcodeWatchdogHandler(gcodeManager, printer), settings().getBaseFolder("watched"))
|
||||
observer.schedule(util.watchdog.UploadCleanupWatchdogHandler(gcodeManager), settings().getBaseFolder("uploads"))
|
||||
observer.start()
|
||||
|
||||
try:
|
||||
|
|
@ -218,7 +218,7 @@ class Server():
|
|||
|
||||
def _createSocketConnection(self, session):
|
||||
global printer, gcodeManager, userManager, eventManager
|
||||
return PrinterStateConnection(printer, gcodeManager, userManager, eventManager, session)
|
||||
return util.sockjs.PrinterStateConnection(printer, gcodeManager, userManager, eventManager, session)
|
||||
|
||||
def _checkForRoot(self):
|
||||
if "geteuid" in dir(os) and os.geteuid() == 0:
|
||||
|
|
@ -283,7 +283,7 @@ class Server():
|
|||
with open(logConf, "r") as f:
|
||||
configFromFile = yaml.safe_load(f)
|
||||
|
||||
config = util.dict_merge(defaultConfig, configFromFile)
|
||||
config = octoprint.util.dict_merge(defaultConfig, configFromFile)
|
||||
logging.config.dictConfig(config)
|
||||
|
||||
if settings().getBoolean(["serial", "log"]):
|
||||
|
|
@ -292,5 +292,5 @@ class Server():
|
|||
logging.getLogger("SERIAL").debug("Enabling serial logging")
|
||||
|
||||
if __name__ == "__main__":
|
||||
octoprint = Server()
|
||||
octoprint.run()
|
||||
server = Server()
|
||||
server.run()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# coding=utf-8
|
||||
from octoprint.server.util import getApiKey, getUserForApiKey
|
||||
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 netaddr
|
||||
|
|
@ -15,8 +16,11 @@ from flask.ext.principal import Identity, identity_changed, AnonymousIdentity
|
|||
import octoprint.util as util
|
||||
import octoprint.users
|
||||
import octoprint.server
|
||||
from octoprint.server import restricted_access, admin_permission, NO_CONTENT, UI_API_KEY
|
||||
from octoprint.server import admin_permission, NO_CONTENT, UI_API_KEY
|
||||
from octoprint.settings import settings as s, valid_boolean_trues
|
||||
from octoprint.server.util import get_api_key, get_user_for_apikey
|
||||
from octoprint.server.util.flask import restricted_access
|
||||
|
||||
|
||||
#~~ init api blueprint, including sub modules
|
||||
|
||||
|
|
@ -67,7 +71,7 @@ def beforeApiRequests():
|
|||
if request.method == 'OPTIONS' and s().getBoolean(["api", "allowCrossOrigin"]):
|
||||
return optionsAllowOrigin(request)
|
||||
|
||||
apikey = getApiKey(request)
|
||||
apikey = get_api_key(request)
|
||||
if apikey is None:
|
||||
# no api key => 401
|
||||
return make_response("No API key provided", 401)
|
||||
|
|
@ -84,7 +88,7 @@ def beforeApiRequests():
|
|||
# global api key => continue regular request processing
|
||||
return
|
||||
|
||||
user = getUserForApiKey(apikey)
|
||||
user = get_user_for_apikey(apikey)
|
||||
if user is not None:
|
||||
# user specific api key => continue regular request processing
|
||||
return
|
||||
|
|
@ -143,7 +147,7 @@ def apiPrinterState():
|
|||
def apiVersion():
|
||||
return jsonify({
|
||||
"server": octoprint.server.VERSION,
|
||||
"api": octoprint.server.api.VERSION
|
||||
"api": VERSION
|
||||
})
|
||||
|
||||
#~~ system control
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
# 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"
|
||||
|
||||
from flask import request, jsonify, make_response
|
||||
|
||||
from octoprint.settings import settings
|
||||
from octoprint.printer import getConnectionOptions
|
||||
from octoprint.server import printer, restricted_access, NO_CONTENT
|
||||
from octoprint.server import printer, NO_CONTENT
|
||||
from octoprint.server.api import api
|
||||
from octoprint.server.util.flask import restricted_access
|
||||
import octoprint.util as util
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
# coding=utf-8
|
||||
from octoprint.events import Events
|
||||
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"
|
||||
|
||||
from flask import request, jsonify, make_response, url_for, redirect
|
||||
from flask import request, jsonify, make_response, url_for
|
||||
|
||||
import octoprint.gcodefiles as gcodefiles
|
||||
import octoprint.util as util
|
||||
from octoprint.filemanager.destinations import FileDestinations
|
||||
from octoprint.settings import settings, valid_boolean_trues
|
||||
from octoprint.server import printer, gcodeManager, eventManager, restricted_access, NO_CONTENT
|
||||
from octoprint.server import printer, gcodeManager, eventManager, NO_CONTENT
|
||||
from octoprint.server.util.flask import restricted_access
|
||||
from octoprint.server.api import api
|
||||
from octoprint.events import Events
|
||||
|
||||
|
||||
#~~ GCODE file handling
|
||||
|
|
@ -89,13 +92,16 @@ def uploadGcodeFile(target):
|
|||
if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]:
|
||||
return make_response("Unknown target: %s" % target, 404)
|
||||
|
||||
if "file.name" in request.values and "file.path" in request.values:
|
||||
input_name = "file"
|
||||
input_upload_name = input_name + "." + settings().get(["server", "uploads", "nameSuffix"])
|
||||
input_upload_path = input_name + "." + settings().get(["server", "uploads", "pathSuffix"])
|
||||
if input_upload_name in request.values and input_upload_path in request.values:
|
||||
import shutil
|
||||
upload = Object()
|
||||
upload.filename = request.values["file.name"]
|
||||
upload.save = lambda new_path: shutil.move(request.values["file.path"], new_path)
|
||||
elif "file" in request.files:
|
||||
upload = request.files["file"]
|
||||
upload = util.Object()
|
||||
upload.filename = request.values[input_upload_name]
|
||||
upload.save = lambda new_path: shutil.move(request.values[input_upload_path], new_path)
|
||||
elif input_name in request.files:
|
||||
upload = request.files[input_name]
|
||||
else:
|
||||
return make_response("No file included", 400)
|
||||
|
||||
|
|
@ -280,6 +286,3 @@ def deleteGcodeFile(filename, target):
|
|||
|
||||
return NO_CONTENT
|
||||
|
||||
|
||||
class Object(object):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
# 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"
|
||||
|
||||
from flask import request, make_response, jsonify
|
||||
|
||||
from octoprint.server import printer, restricted_access, NO_CONTENT
|
||||
from octoprint.server import printer, NO_CONTENT
|
||||
from octoprint.server.util.flask import restricted_access
|
||||
from octoprint.server.api import api
|
||||
import octoprint.util as util
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
__author__ = "Marc Hannappel Salandora"
|
||||
__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 os
|
||||
|
||||
|
|
@ -9,8 +12,8 @@ from werkzeug.utils import secure_filename
|
|||
|
||||
from octoprint.settings import settings
|
||||
|
||||
from octoprint.server import restricted_access, NO_CONTENT, admin_permission
|
||||
from octoprint.server.util import redirectToTornado
|
||||
from octoprint.server import NO_CONTENT, admin_permission
|
||||
from octoprint.server.util.flask import redirect_to_tornado, restricted_access
|
||||
from octoprint.server.api import api
|
||||
from octoprint.util import getFreeBytes
|
||||
|
||||
|
|
@ -27,7 +30,7 @@ def getLogFiles():
|
|||
@restricted_access
|
||||
@admin_permission.require(403)
|
||||
def downloadLog(filename):
|
||||
return redirectToTornado(request, url_for("index") + "downloads/logs/" + filename)
|
||||
return redirect_to_tornado(request, url_for("index") + "downloads/logs/" + filename)
|
||||
|
||||
|
||||
@api.route("/logs/<path:filename>", methods=["DELETE"])
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
# 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"
|
||||
|
||||
from flask import request, jsonify, make_response
|
||||
from flask import request, jsonify, make_response, Response
|
||||
import re
|
||||
|
||||
from octoprint.settings import settings, valid_boolean_trues
|
||||
from octoprint.server import printer, restricted_access, NO_CONTENT
|
||||
from octoprint.server import printer, NO_CONTENT
|
||||
from octoprint.server.api import api
|
||||
from octoprint.server.util.flask import restricted_access
|
||||
import octoprint.util as util
|
||||
|
||||
#~~ Printer
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -9,8 +12,9 @@ from flask import request, jsonify
|
|||
from octoprint.settings import settings
|
||||
from octoprint.printer import getConnectionOptions
|
||||
|
||||
from octoprint.server import restricted_access, admin_permission
|
||||
from octoprint.server import admin_permission
|
||||
from octoprint.server.api import api
|
||||
from octoprint.server.util.flask import restricted_access
|
||||
|
||||
|
||||
#~~ settings
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
# 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 os
|
||||
|
||||
|
|
@ -11,8 +14,8 @@ import octoprint.timelapse
|
|||
import octoprint.util as util
|
||||
from octoprint.settings import settings, valid_boolean_trues
|
||||
|
||||
from octoprint.server import restricted_access, admin_permission
|
||||
from octoprint.server.util import redirectToTornado
|
||||
from octoprint.server import admin_permission
|
||||
from octoprint.server.util.flask import redirect_to_tornado, restricted_access
|
||||
from octoprint.server.api import api
|
||||
|
||||
|
||||
|
|
@ -46,7 +49,7 @@ def getTimelapseData():
|
|||
|
||||
@api.route("/timelapse/<filename>", methods=["GET"])
|
||||
def downloadTimelapse(filename):
|
||||
return redirectToTornado(request, url_for("index") + "downloads/timelapse/" + filename)
|
||||
return redirect_to_tornado(request, url_for("index") + "downloads/timelapse/" + filename)
|
||||
|
||||
|
||||
@api.route("/timelapse/<filename>", methods=["DELETE"])
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
# 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"
|
||||
|
||||
from flask import request, jsonify, abort, make_response
|
||||
from flask.ext.login import current_user
|
||||
|
||||
import octoprint.users as users
|
||||
|
||||
from octoprint.server import restricted_access, SUCCESS, admin_permission, userManager
|
||||
from octoprint.server import SUCCESS, admin_permission, userManager
|
||||
from octoprint.server.api import api
|
||||
from octoprint.server.util.flask import restricted_access
|
||||
|
||||
|
||||
#~~ user settings
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
100
src/octoprint/server/util/__init__.py
Normal file
100
src/octoprint/server/util/__init__.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# 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"
|
||||
|
||||
from octoprint.settings import settings
|
||||
import octoprint.timelapse
|
||||
import octoprint.server
|
||||
from octoprint.users import ApiUser
|
||||
|
||||
from . import flask
|
||||
from . import sockjs
|
||||
from . import tornado
|
||||
from . import watchdog
|
||||
|
||||
|
||||
def get_user_for_apikey(apikey):
|
||||
if settings().get(["api", "enabled"]) and apikey is not None:
|
||||
if apikey == settings().get(["api", "key"]):
|
||||
# master key was used
|
||||
return ApiUser()
|
||||
else:
|
||||
# user key might have been used
|
||||
return octoprint.server.userManager.findUser(apikey=apikey)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_api_key(request):
|
||||
# Check Flask GET/POST arguments
|
||||
if hasattr(request, "values") and "apikey" in request.values:
|
||||
return request.values["apikey"]
|
||||
|
||||
# Check Tornado GET/POST arguments
|
||||
if hasattr(request, "arguments") and "apikey" in request.arguments \
|
||||
and len(request.arguments["apikey"]) > 0 and len(request.arguments["apikey"].strip()) > 0:
|
||||
return request.arguments["apikey"]
|
||||
|
||||
# Check Tornado and Flask headers
|
||||
if "X-Api-Key" in request.headers.keys():
|
||||
return request.headers.get("X-Api-Key")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
#~~ reverse proxy compatible WSGI middleware
|
||||
|
||||
|
||||
class ReverseProxied(object):
|
||||
"""
|
||||
Wrap the application in this middleware and configure the
|
||||
front-end server to add these headers, to let you quietly bind
|
||||
this to a URL other than / and to an HTTP scheme that is
|
||||
different than what is used locally.
|
||||
|
||||
In nginx:
|
||||
location /myprefix {
|
||||
proxy_pass http://192.168.0.1:5001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Scheme $scheme;
|
||||
proxy_set_header X-Script-Name /myprefix;
|
||||
}
|
||||
|
||||
Alternatively define prefix and scheme via config.yaml:
|
||||
server:
|
||||
baseUrl: /myprefix
|
||||
scheme: http
|
||||
|
||||
:param app: the WSGI application
|
||||
:param header_script_name: the HTTP header in the wsgi environment from which to determine the prefix
|
||||
:param header_scheme: the HTTP header in the wsgi environment from which to determine the scheme
|
||||
"""
|
||||
|
||||
def __init__(self, app, header_script_name="HTTP_X_SCRIPT_NAME", header_scheme="HTTP_X_SCHEME"):
|
||||
self.app = app
|
||||
self._header_script_name = header_script_name
|
||||
self._header_scheme = header_scheme
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
script_name = environ.get(self._header_script_name, '')
|
||||
if not script_name:
|
||||
script_name = settings().get(["server", "baseUrl"])
|
||||
|
||||
if script_name:
|
||||
environ['SCRIPT_NAME'] = script_name
|
||||
path_info = environ['PATH_INFO']
|
||||
if path_info.startswith(script_name):
|
||||
environ['PATH_INFO'] = path_info[len(script_name):]
|
||||
|
||||
scheme = environ.get(self._header_scheme, '')
|
||||
if not scheme:
|
||||
scheme = settings().get(["server", "scheme"])
|
||||
|
||||
if scheme:
|
||||
environ['wsgi.url_scheme'] = scheme
|
||||
return self.app(environ, start_response)
|
||||
|
||||
154
src/octoprint/server/util/flask.py
Normal file
154
src/octoprint/server/util/flask.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# 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 tornado.web
|
||||
import flask
|
||||
import flask.ext.login
|
||||
import flask.ext.principal
|
||||
import functools
|
||||
|
||||
from octoprint.settings import settings
|
||||
import octoprint.server
|
||||
import octoprint.users
|
||||
|
||||
|
||||
#~~ access validators for use with tornado
|
||||
|
||||
|
||||
def admin_validator(request):
|
||||
"""
|
||||
Validates that the given request is made by an admin user, identified either by API key or existing Flask
|
||||
session.
|
||||
|
||||
Must be executed in an existing Flask request context!
|
||||
|
||||
:param request: The Flask request object
|
||||
"""
|
||||
|
||||
user = _get_flask_user_from_request(request)
|
||||
if user is None or not user.is_authenticated() or not user.is_admin():
|
||||
raise tornado.web.HTTPError(403)
|
||||
|
||||
|
||||
def user_validator(request):
|
||||
"""
|
||||
Validates that the given request is made by an authenticated user, identified either by API key or existing Flask
|
||||
session.
|
||||
|
||||
Must be executed in an existing Flask request context!
|
||||
|
||||
:param request: The Flask request object
|
||||
"""
|
||||
|
||||
user = _get_flask_user_from_request(request)
|
||||
if user is None or not user.is_authenticated():
|
||||
raise tornado.web.HTTPError(403)
|
||||
|
||||
|
||||
def _get_flask_user_from_request(request):
|
||||
"""
|
||||
Retrieves the current flask user from the request context. Uses API key if available, otherwise the current
|
||||
user session if available.
|
||||
|
||||
:param request: flask request from which to retrieve the current user
|
||||
:return: the user or None if no user could be determined
|
||||
"""
|
||||
import octoprint.server.util
|
||||
import flask.ext.login
|
||||
from octoprint.settings import settings
|
||||
|
||||
apikey = octoprint.server.util.get_api_key(request)
|
||||
if settings().get(["api", "enabled"]) and apikey is not None:
|
||||
user = octoprint.server.util.get_user_for_apikey(apikey)
|
||||
else:
|
||||
user = flask.ext.login.current_user
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def redirect_to_tornado(request, target, code=302):
|
||||
"""
|
||||
Redirects from flask to tornado, flask request context must exist.
|
||||
|
||||
:param request:
|
||||
:param target:
|
||||
:param code:
|
||||
:return:
|
||||
"""
|
||||
|
||||
import flask
|
||||
|
||||
requestUrl = request.url
|
||||
appBaseUrl = requestUrl[:requestUrl.find(flask.url_for("index") + "api")]
|
||||
|
||||
redirectUrl = appBaseUrl + target
|
||||
if "?" in requestUrl:
|
||||
fragment = requestUrl[requestUrl.rfind("?"):]
|
||||
redirectUrl += fragment
|
||||
return flask.redirect(redirectUrl, code=code)
|
||||
|
||||
|
||||
def restricted_access(func, api_enabled=True):
|
||||
"""
|
||||
If you decorate a view with this, it will ensure that first setup has been
|
||||
done for OctoPrint's Access Control plus that any conditions of the
|
||||
login_required decorator are met. It also allows to login using the masterkey or any
|
||||
of the user's apikeys if API access is enabled globally and for the decorated view.
|
||||
|
||||
If OctoPrint's Access Control has not been setup yet (indicated by the "firstRun"
|
||||
flag from the settings being set to True and the userManager not indicating
|
||||
that it's user database has been customized from default), the decorator
|
||||
will cause a HTTP 403 status code to be returned by the decorated resource.
|
||||
|
||||
If an API key is provided and it matches a known key, the user will be logged in and
|
||||
the view will be called directly. If the provided key doesn't match any known key,
|
||||
a HTTP 403 status code will be returned by the decorated resource.
|
||||
|
||||
Otherwise the result of calling login_required will be returned.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
def decorated_view(*args, **kwargs):
|
||||
# if OctoPrint hasn't been set up yet, abort
|
||||
if settings().getBoolean(["server", "firstRun"]) and (octoprint.server.userManager is None or not octoprint.server.userManager.hasBeenCustomized()):
|
||||
return flask.make_response("OctoPrint isn't setup yet", 403)
|
||||
|
||||
# if API is globally enabled, enabled for this request and an api key is provided that is not the current UI API key, try to use that
|
||||
apikey = octoprint.server.util.get_api_key(flask.request)
|
||||
if settings().get(["api", "enabled"]) and api_enabled and apikey is not None and apikey != octoprint.server.UI_API_KEY:
|
||||
if apikey == settings().get(["api", "key"]):
|
||||
# master key was used
|
||||
user = octoprint.users.ApiUser()
|
||||
else:
|
||||
# user key might have been used
|
||||
user = octoprint.server.userManager.findUser(apikey=apikey)
|
||||
|
||||
if user is None:
|
||||
return flask.make_response("Invalid API key", 401)
|
||||
if flask.ext.login.login_user(user, remember=False):
|
||||
flask.ext.principal.identity_changed.send(flask.current_app._get_current_object(), identity=flask.ext.principal.Identity(user.get_id()))
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# call regular login_required decorator
|
||||
return flask.ext.login.login_required(func)(*args, **kwargs)
|
||||
return decorated_view
|
||||
|
||||
|
||||
def api_access(func):
|
||||
@functools.wraps(func)
|
||||
def decorated_view(*args, **kwargs):
|
||||
if not settings().get(["api", "enabled"]):
|
||||
flask.make_response("API disabled", 401)
|
||||
apikey = octoprint.server.util.get_api_key(flask.request)
|
||||
if apikey is None:
|
||||
flask.make_response("No API key provided", 401)
|
||||
if apikey != settings().get(["api", "key"]):
|
||||
flask.make_response("Invalid API key", 403)
|
||||
return func(*args, **kwargs)
|
||||
return decorated_view
|
||||
|
||||
|
||||
|
||||
124
src/octoprint/server/util/sockjs.py
Normal file
124
src/octoprint/server/util/sockjs.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# 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 threading
|
||||
import sockjs.tornado
|
||||
|
||||
import octoprint.timelapse
|
||||
import octoprint.server
|
||||
from octoprint.events import Events
|
||||
|
||||
|
||||
class PrinterStateConnection(sockjs.tornado.SockJSConnection):
|
||||
EVENTS = [Events.UPDATED_FILES, Events.METADATA_ANALYSIS_FINISHED, Events.MOVIE_RENDERING, Events.MOVIE_DONE,
|
||||
Events.MOVIE_FAILED, Events.SLICING_STARTED, Events.SLICING_DONE, Events.SLICING_FAILED,
|
||||
Events.TRANSFER_STARTED, Events.TRANSFER_DONE]
|
||||
|
||||
def __init__(self, printer, gcodeManager, userManager, eventManager, session):
|
||||
sockjs.tornado.SockJSConnection.__init__(self, session)
|
||||
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
self._temperatureBacklog = []
|
||||
self._temperatureBacklogMutex = threading.Lock()
|
||||
self._logBacklog = []
|
||||
self._logBacklogMutex = threading.Lock()
|
||||
self._messageBacklog = []
|
||||
self._messageBacklogMutex = threading.Lock()
|
||||
|
||||
self._printer = printer
|
||||
self._gcodeManager = gcodeManager
|
||||
self._userManager = userManager
|
||||
self._eventManager = eventManager
|
||||
|
||||
def _getRemoteAddress(self, info):
|
||||
forwardedFor = info.headers.get("X-Forwarded-For")
|
||||
if forwardedFor is not None:
|
||||
return forwardedFor.split(",")[0]
|
||||
return info.ip
|
||||
|
||||
def on_open(self, info):
|
||||
remoteAddress = self._getRemoteAddress(info)
|
||||
self._logger.info("New connection from client: %s" % remoteAddress)
|
||||
|
||||
# connected => update the API key, might be necessary if the client was left open while the server restarted
|
||||
self._emit("connected", {"apikey": octoprint.server.UI_API_KEY, "version": octoprint.server.VERSION})
|
||||
|
||||
self._printer.registerCallback(self)
|
||||
self._gcodeManager.registerCallback(self)
|
||||
octoprint.timelapse.registerCallback(self)
|
||||
|
||||
self._eventManager.fire(Events.CLIENT_OPENED, {"remoteAddress": remoteAddress})
|
||||
for event in PrinterStateConnection.EVENTS:
|
||||
self._eventManager.subscribe(event, self._onEvent)
|
||||
|
||||
octoprint.timelapse.notifyCallbacks(octoprint.timelapse.current)
|
||||
|
||||
def on_close(self):
|
||||
self._logger.info("Client connection closed")
|
||||
self._printer.unregisterCallback(self)
|
||||
self._gcodeManager.unregisterCallback(self)
|
||||
octoprint.timelapse.unregisterCallback(self)
|
||||
|
||||
self._eventManager.fire(Events.CLIENT_CLOSED)
|
||||
for event in PrinterStateConnection.EVENTS:
|
||||
self._eventManager.unsubscribe(event, self._onEvent)
|
||||
|
||||
def on_message(self, message):
|
||||
pass
|
||||
|
||||
def sendCurrentData(self, data):
|
||||
# add current temperature, log and message backlogs to sent data
|
||||
with self._temperatureBacklogMutex:
|
||||
temperatures = self._temperatureBacklog
|
||||
self._temperatureBacklog = []
|
||||
|
||||
with self._logBacklogMutex:
|
||||
logs = self._logBacklog
|
||||
self._logBacklog = []
|
||||
|
||||
with self._messageBacklogMutex:
|
||||
messages = self._messageBacklog
|
||||
self._messageBacklog = []
|
||||
|
||||
data.update({
|
||||
"temps": temperatures,
|
||||
"logs": logs,
|
||||
"messages": messages
|
||||
})
|
||||
self._emit("current", data)
|
||||
|
||||
def sendHistoryData(self, data):
|
||||
self._emit("history", data)
|
||||
|
||||
def sendEvent(self, type, payload=None):
|
||||
self._emit("event", {"type": type, "payload": payload})
|
||||
|
||||
def sendFeedbackCommandOutput(self, name, output):
|
||||
self._emit("feedbackCommandOutput", {"name": name, "output": output})
|
||||
|
||||
def sendTimelapseConfig(self, timelapseConfig):
|
||||
self._emit("timelapse", timelapseConfig)
|
||||
|
||||
def addLog(self, data):
|
||||
with self._logBacklogMutex:
|
||||
self._logBacklog.append(data)
|
||||
|
||||
def addMessage(self, data):
|
||||
with self._messageBacklogMutex:
|
||||
self._messageBacklog.append(data)
|
||||
|
||||
def addTemperature(self, data):
|
||||
with self._temperatureBacklogMutex:
|
||||
self._temperatureBacklog.append(data)
|
||||
|
||||
def _onEvent(self, event, payload):
|
||||
self.sendEvent(event, payload)
|
||||
|
||||
def _emit(self, type, payload):
|
||||
self.send({type: payload})
|
||||
853
src/octoprint/server/util/tornado.py
Normal file
853
src/octoprint/server/util/tornado.py
Normal file
|
|
@ -0,0 +1,853 @@
|
|||
# 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 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 octoprint.util
|
||||
|
||||
|
||||
#~~ 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:
|
||||
|
||||
------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:
|
||||
|
||||
------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.
|
||||
"""
|
||||
|
||||
# the request methods that may contain a request body
|
||||
BODY_METHODS = ("POST", "PATCH", "PUT")
|
||||
|
||||
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
|
||||
`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 `self._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
|
||||
data, self._buffer = data[0:delimiter_loc], 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:
|
||||
if self._current_part:
|
||||
self._on_data(self._current_part, data)
|
||||
|
||||
if end_of_header >= 0:
|
||||
self._on_header(self._buffer[delimiter_len+2:end_of_header])
|
||||
self._buffer = self._buffer[end_of_header + 4:]
|
||||
|
||||
if delimiter_loc != -1 and self._buffer[delimiter_len:delimiter_len+2] == "--":
|
||||
# we saw the last boundary and are at the end of our request
|
||||
if self._current_part:
|
||||
self._on_close(self._current_part)
|
||||
self._current_part = None
|
||||
self._buffer = b""
|
||||
self._on_finish()
|
||||
|
||||
def _on_header(self, header):
|
||||
"""
|
||||
Called for a new multipart header, takes care of parsing the header and calling `self._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_close(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(disp_params["name"], header.get("Content-Type", None), filename=disp_params["filename"] if "filename" in disp_params else None)
|
||||
|
||||
def _on_part(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!)
|
||||
|
||||
Structure of `data` parts:
|
||||
|
||||
* `name`: name of the part
|
||||
* `content_type`: content type of the part
|
||||
* `data`: bytes of the part (initialized to "")
|
||||
|
||||
: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_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_close(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_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
|
||||
:return:
|
||||
"""
|
||||
|
||||
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
|
||||
fields = dict((self._suffixes[key], value) for (key, value) in dict(name=part["filename"], path=part["path"], size=str(os.stat(part["path"]).st_size), content_type=part["content_type"]).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 += p + b"\r\n"
|
||||
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
|
||||
self._new_body += b"--%s--\r\n" % self._multipart_boundary
|
||||
|
||||
def _handle_method(self, *args, **kwargs):
|
||||
"""
|
||||
Handler for any request method, 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)
|
||||
finally:
|
||||
# make sure the temporary files are removed again
|
||||
for f in self._files:
|
||||
octoprint.util.silentRemove(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:
|
||||
|
||||
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 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 dict())
|
||||
self._default_max_body_size = self.params.default_max_body_size or self.stream.max_buffer_size
|
||||
|
||||
def _read_body(self, 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:
|
||||
content_length = int(content_length)
|
||||
max_content_length = self._get_max_content_length(self._request_start_line.method, self._request_start_line.path)
|
||||
if 0 <= max_content_length < content_length:
|
||||
raise tornado.httputil.HTTPInputError("Content-Length too long")
|
||||
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 od 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 to 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 dict()
|
||||
self.default_max_body_size = kwargs["default_max_body_size"] if "default_max_body_size" in kwargs else dict()
|
||||
|
||||
#~~ customized large response handler
|
||||
|
||||
|
||||
class LargeResponseHandler(tornado.web.StaticFileHandler):
|
||||
|
||||
CHUNK_SIZE = 16 * 1024
|
||||
|
||||
def initialize(self, path, default_filename=None, as_attachment=False, access_validation=None):
|
||||
tornado.web.StaticFileHandler.initialize(self, path, default_filename)
|
||||
self._as_attachment = as_attachment
|
||||
self._access_validation = access_validation
|
||||
|
||||
def get(self, path, include_body=True):
|
||||
if self._access_validation is not None:
|
||||
self._access_validation(self.request)
|
||||
|
||||
path = self.parse_url_path(path)
|
||||
abspath = os.path.abspath(os.path.join(self.root, path))
|
||||
# os.path.abspath strips a trailing /
|
||||
# it needs to be temporarily added back for requests to root/
|
||||
if not (abspath + os.path.sep).startswith(self.root):
|
||||
raise tornado.web.HTTPError(403, "%s is not in root static directory", path)
|
||||
if os.path.isdir(abspath) and self.default_filename is not None:
|
||||
# need to look at the request.path here for when path is empty
|
||||
# but there is some prefix to the path that was already
|
||||
# trimmed by the routing
|
||||
if not self.request.path.endswith("/"):
|
||||
self.redirect(self.request.path + "/")
|
||||
return
|
||||
abspath = os.path.join(abspath, self.default_filename)
|
||||
if not os.path.exists(abspath):
|
||||
raise tornado.web.HTTPError(404)
|
||||
if not os.path.isfile(abspath):
|
||||
raise tornado.web.HTTPError(403, "%s is not a file", path)
|
||||
|
||||
stat_result = os.stat(abspath)
|
||||
modified = datetime.datetime.fromtimestamp(stat_result[stat.ST_MTIME])
|
||||
|
||||
self.set_header("Last-Modified", modified)
|
||||
|
||||
mime_type, encoding = mimetypes.guess_type(abspath)
|
||||
if mime_type:
|
||||
self.set_header("Content-Type", mime_type)
|
||||
|
||||
cache_time = self.get_cache_time(path, modified, mime_type)
|
||||
|
||||
if cache_time > 0:
|
||||
self.set_header("Expires", datetime.datetime.utcnow() +
|
||||
datetime.timedelta(seconds=cache_time))
|
||||
self.set_header("Cache-Control", "max-age=" + str(cache_time))
|
||||
|
||||
self.set_extra_headers(path)
|
||||
|
||||
# Check the If-Modified-Since, and don't send the result if the
|
||||
# content has not been modified
|
||||
ims_value = self.request.headers.get("If-Modified-Since")
|
||||
if ims_value is not None:
|
||||
date_tuple = email.utils.parsedate(ims_value)
|
||||
if_since = datetime.datetime.fromtimestamp(time.mktime(date_tuple))
|
||||
if if_since >= modified:
|
||||
self.set_status(304)
|
||||
return
|
||||
|
||||
if not include_body:
|
||||
assert self.request.method == "HEAD"
|
||||
self.set_header("Content-Length", stat_result[stat.ST_SIZE])
|
||||
else:
|
||||
with open(abspath, "rb") as file:
|
||||
while True:
|
||||
data = file.read(LargeResponseHandler.CHUNK_SIZE)
|
||||
if not data:
|
||||
break
|
||||
self.write(data)
|
||||
self.flush()
|
||||
|
||||
def set_extra_headers(self, path):
|
||||
if self._as_attachment:
|
||||
self.set_header("Content-Disposition", "attachment")
|
||||
|
||||
|
||||
##~~ URL Forward Handler for forwarding requests to a preconfigured static URL
|
||||
|
||||
|
||||
class UrlForwardHandler(tornado.web.RequestHandler):
|
||||
|
||||
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"):
|
||||
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)
|
||||
|
||||
|
||||
#~~ 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 validation wrapper 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
|
||||
87
src/octoprint/server/util/watchdog.py
Normal file
87
src/octoprint/server/util/watchdog.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# 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 watchdog.events
|
||||
|
||||
import octoprint.gcodefiles
|
||||
import octoprint.util
|
||||
from octoprint.settings import settings
|
||||
|
||||
|
||||
class UploadCleanupWatchdogHandler(watchdog.events.PatternMatchingEventHandler):
|
||||
"""
|
||||
Takes care of automatically deleting metadata entries for files that get deleted from the uploads folder
|
||||
"""
|
||||
|
||||
patterns = map(lambda x: "*.%s" % x, octoprint.gcodefiles.GCODE_EXTENSIONS)
|
||||
|
||||
def __init__(self, gcode_manager):
|
||||
watchdog.events.PatternMatchingEventHandler.__init__(self)
|
||||
self._gcode_manager = gcode_manager
|
||||
|
||||
def on_deleted(self, event):
|
||||
filename = self._gcode_manager._getBasicFilename(event.src_path)
|
||||
if not filename:
|
||||
return
|
||||
|
||||
self._gcode_manager.removeFileFromMetadata(filename)
|
||||
|
||||
|
||||
class GcodeWatchdogHandler(watchdog.events.PatternMatchingEventHandler):
|
||||
"""
|
||||
Takes care of automatically "uploading" files that get added to the watched folder.
|
||||
"""
|
||||
|
||||
patterns = map(lambda x: "*.%s" % x, octoprint.gcodefiles.SUPPORTED_EXTENSIONS)
|
||||
|
||||
def __init__(self, gcodeManager, printer):
|
||||
watchdog.events.PatternMatchingEventHandler.__init__(self)
|
||||
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
self._gcodeManager = gcodeManager
|
||||
self._printer = printer
|
||||
|
||||
def _upload(self, path):
|
||||
class WatchdogFileWrapper(object):
|
||||
|
||||
def __init__(self, path):
|
||||
self._path = path
|
||||
self.filename = os.path.basename(self._path)
|
||||
|
||||
def save(self, target):
|
||||
octoprint.util.safeRename(self._path, target)
|
||||
|
||||
fileWrapper = WatchdogFileWrapper(path)
|
||||
|
||||
# determine current job
|
||||
currentFilename = None
|
||||
currentOrigin = None
|
||||
currentJob = self._printer.getCurrentJob()
|
||||
if currentJob is not None and "file" in currentJob.keys():
|
||||
currentJobFile = currentJob["file"]
|
||||
if "name" in currentJobFile.keys() and "origin" in currentJobFile.keys():
|
||||
currentFilename = currentJobFile["name"]
|
||||
currentOrigin = currentJobFile["origin"]
|
||||
|
||||
# determine future filename of file to be uploaded, abort if it can't be uploaded
|
||||
futureFilename = self._gcodeManager.getFutureFilename(fileWrapper)
|
||||
if futureFilename is None or (not settings().getBoolean(["cura", "enabled"]) and not octoprint.gcodefiles.isGcodeFileName(futureFilename)):
|
||||
self._logger.warn("Could not add %s: Invalid file" % fileWrapper.filename)
|
||||
return
|
||||
|
||||
# prohibit overwriting currently selected file while it's being printed
|
||||
if futureFilename == currentFilename and not currentOrigin == octoprint.gcodefiles.FileDestinations.SDCARD and self._printer.isPrinting() or self._printer.isPaused():
|
||||
self._logger.warn("Could not add %s: Trying to overwrite file that is currently being printed" % fileWrapper.filename)
|
||||
return
|
||||
|
||||
self._gcodeManager.addFile(fileWrapper, octoprint.gcodefiles.FileDestinations.LOCAL)
|
||||
|
||||
def on_created(self, event):
|
||||
self._upload(event.src_path)
|
||||
|
|
@ -42,7 +42,13 @@ default_settings = {
|
|||
"port": 5000,
|
||||
"firstRun": True,
|
||||
"baseUrl": "",
|
||||
"scheme": ""
|
||||
"scheme": "",
|
||||
"uploads": {
|
||||
"maxSize": 1 * 1024 * 1024 * 1024, # 1GB
|
||||
"nameSuffix": ".name",
|
||||
"pathSuffix": ".path"
|
||||
},
|
||||
"maxSize": 100 * 1024, # 100 KB
|
||||
},
|
||||
"webcam": {
|
||||
"stream": None,
|
||||
|
|
@ -375,7 +381,11 @@ class Settings(object):
|
|||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return value.lower() in valid_boolean_trues
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
if isinstance(value, (str, unicode)):
|
||||
return value.lower() in valid_boolean_trues
|
||||
return value is not None
|
||||
|
||||
def getBaseFolder(self, type):
|
||||
if type not in default_settings["folder"].keys():
|
||||
|
|
|
|||
|
|
@ -248,3 +248,7 @@ def dict_merge(a, b):
|
|||
else:
|
||||
result[k] = deepcopy(v)
|
||||
return result
|
||||
|
||||
|
||||
class Object(object):
|
||||
pass
|
||||
|
|
|
|||
Loading…
Reference in a new issue