MrDraw/src/octoprint/server/util.py

396 lines
12 KiB
Python

# coding=utf-8
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
from flask.ext.principal import identity_changed, Identity
from tornado.web import StaticFileHandler, HTTPError
from flask import url_for, make_response, request, current_app
from flask.ext.login import login_required, login_user, current_user
from werkzeug.utils import redirect
from sockjs.tornado import SockJSConnection
import datetime
import stat
import mimetypes
import email
import time
import os
import threading
import logging
from functools import wraps
from octoprint.settings import settings
import octoprint.timelapse
import octoprint.server
from octoprint.users import ApiUser
from octoprint.events import Events
def restricted_access(func, apiEnabled=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.
"""
@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 make_response("OctoPrint isn't setup yet", 403)
# if API is globally enabled, enabled for this request and an api key is provided, try to use that
apikey = _getApiKey(request)
if settings().get(["api", "enabled"]) and apiEnabled and apikey is not None:
if apikey == settings().get(["api", "key"]):
# master key was used
user = ApiUser()
else:
# user key might have been used
user = octoprint.server.userManager.findUser(apikey=apikey)
if user is None:
make_response("Invalid API key", 401)
if login_user(user, remember=False):
identity_changed.send(current_app._get_current_object(), identity=Identity(user.get_id()))
return func(*args, **kwargs)
# call regular login_required decorator
return login_required(func)(*args, **kwargs)
return decorated_view
def api_access(func):
@wraps(func)
def decorated_view(*args, **kwargs):
if not settings().get(["api", "enabled"]):
make_response("API disabled", 401)
apikey = _getApiKey(request)
if apikey is None:
make_response("No API key provided", 401)
if apikey != settings().get(["api", "key"]):
make_response("Invalid API key", 403)
return func(*args, **kwargs)
return decorated_view
def _getUserForApiKey(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 _getApiKey(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
#~~ Printer state
class PrinterStateConnection(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):
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)
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})
#~~ customized large response handler
class LargeResponseHandler(StaticFileHandler):
CHUNK_SIZE = 16 * 1024
def initialize(self, path, default_filename=None, as_attachment=False, access_validation=None):
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 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 HTTPError(404)
if not os.path.isfile(abspath):
raise 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")
#~~ admin access validator 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
"""
apikey = _getApiKey(request)
if settings().get(["api", "enabled"]) and apikey is not None:
user = _getUserForApiKey(apikey)
else:
user = current_user
if user is None or not user.is_authenticated() or not user.is_admin():
raise HTTPError(403)
#~~ 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
"""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
script_name = environ.get('HTTP_X_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('HTTP_X_SCHEME', '')
if not scheme:
scheme = settings().get(["server", "scheme"])
if scheme:
environ['wsgi.url_scheme'] = scheme
host = environ.get('HTTP_X_FORWARDED_HOST', '')
if not host:
host = settings().get(["server", "host"])
if host:
environ['HTTP_HOST'] = host
return self.app(environ, start_response)
def redirectToTornado(request, target):
requestUrl = request.url
appBaseUrl = requestUrl[:requestUrl.find(url_for("index") + "api")]
redirectUrl = appBaseUrl + target
if "?" in requestUrl:
fragment = requestUrl[requestUrl.rfind("?"):]
redirectUrl += fragment
return redirect(redirectUrl)