diff --git a/octoprint/server.py b/octoprint/server.py index f620266..3698506 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -2,9 +2,11 @@ __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' -from flask import Flask, request, render_template, jsonify, send_from_directory, abort, url_for from werkzeug.utils import secure_filename import tornadio2 +from flask import Flask, request, render_template, jsonify, send_from_directory, url_for, current_app, session, abort +from flask.ext.login import LoginManager, login_user, logout_user, login_required, current_user +from flask.ext.principal import Principal, Permission, RoleNeed, Identity, identity_changed, AnonymousIdentity, identity_loaded, UserNeed import os import threading @@ -12,23 +14,30 @@ import logging, logging.config import subprocess from octoprint.printer import Printer, getConnectionOptions -from octoprint.settings import settings +from octoprint.settings import settings, valid_boolean_trues import octoprint.timelapse as timelapse import octoprint.gcodefiles as gcodefiles import octoprint.util as util +import octoprint.users as users SUCCESS = {} BASEURL = "/ajax/" + app = Flask("octoprint") # Only instantiated by the Server().run() method # In order that threads don't start too early when running as a Daemon printer = None gcodeManager = None +userManager = None + +principals = Principal(app) +admin_permission = Permission(RoleNeed("admin")) +user_permission = Permission(RoleNeed("user")) #~~ Printer state class PrinterStateConnection(tornadio2.SocketConnection): - def __init__(self, session, endpoint=None): + def __init__(self, printer, gcodeManager, userManager, session, endpoint=None): tornadio2.SocketConnection.__init__(self, session, endpoint) self._logger = logging.getLogger(__name__) @@ -40,6 +49,10 @@ class PrinterStateConnection(tornadio2.SocketConnection): self._messageBacklog = [] self._messageBacklogMutex = threading.Lock() + self._printer = printer + self._gcodeManager = gcodeManager + self._userManager = userManager + def on_open(self, info): self._logger.info("New connection from client") # Use of global here is smelly @@ -99,40 +112,43 @@ class PrinterStateConnection(tornadio2.SocketConnection): @app.route("/") def index(): return render_template( - "index.html", + "index.jinja2", + ajaxBaseUrl=BASEURL, webcamStream=settings().get(["webcam", "stream"]), enableTimelapse=(settings().get(["webcam", "snapshot"]) is not None and settings().get(["webcam", "ffmpeg"]) is not None), enableGCodeVisualizer=settings().get(["feature", "gCodeVisualizer"]), - enableSystemMenu=settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None and len(settings().get(["system", "actions"])) > 0 + enableSystemMenu=settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None and len(settings().get(["system", "actions"])) > 0, + enableAccessControl=userManager is not None ) #~~ Printer control -@app.route(BASEURL + "control/connectionOptions", methods=["GET"]) +@app.route(BASEURL + "control/connection/options", methods=["GET"]) def connectionOptions(): return jsonify(getConnectionOptions()) -@app.route(BASEURL + "control/connect", methods=["POST"]) +@app.route(BASEURL + "control/connection", methods=["POST"]) +@login_required def connect(): - port = None - baudrate = None - if "port" in request.values.keys(): - port = request.values["port"] - if "baudrate" in request.values.keys(): - baudrate = request.values["baudrate"] - if "save" in request.values.keys(): - settings().set(["serial", "port"], port) - settings().setInt(["serial", "baudrate"], baudrate) - settings().save() - printer.connect(port=port, baudrate=baudrate) - return jsonify(state="Connecting") + if "command" in request.values.keys() and request.values["command"] == "connect": + port = None + baudrate = None + if "port" in request.values.keys(): + port = request.values["port"] + if "baudrate" in request.values.keys(): + baudrate = request.values["baudrate"] + if "save" in request.values.keys(): + settings().set(["serial", "port"], port) + settings().setInt(["serial", "baudrate"], baudrate) + settings().save() + printer.connect(port=port, baudrate=baudrate) + elif "command" in request.values.keys() and request.values["command"] == "disconnect": + printer.disconnect() -@app.route(BASEURL + "control/disconnect", methods=["POST"]) -def disconnect(): - printer.disconnect() - return jsonify(state="Offline") + return jsonify(SUCCESS) @app.route(BASEURL + "control/command", methods=["POST"]) +@login_required def printerCommand(): if "application/json" in request.headers["Content-Type"]: data = request.json @@ -155,32 +171,27 @@ def printerCommand(): return jsonify(SUCCESS) -@app.route(BASEURL + "control/print", methods=["POST"]) -def printGcode(): - printer.startPrint() - return jsonify(SUCCESS) - -@app.route(BASEURL + "control/pause", methods=["POST"]) -def pausePrint(): - printer.togglePausePrint() - return jsonify(SUCCESS) - -@app.route(BASEURL + "control/cancel", methods=["POST"]) -def cancelPrint(): - printer.cancelPrint() +@app.route(BASEURL + "control/job", methods=["POST"]) +@login_required +def printJobControl(): + if "command" in request.values.keys(): + if request.values["command"] == "start": + printer.startPrint() + elif request.values["command"] == "pause": + printer.togglePausePrint() + elif request.values["command"] == "cancel": + printer.cancelPrint() return jsonify(SUCCESS) @app.route(BASEURL + "control/temperature", methods=["POST"]) +@login_required def setTargetTemperature(): - if not printer.isOperational(): - return jsonify(SUCCESS) - - elif request.values.has_key("temp"): - # set target temperature + if "temp" in request.values.keys(): + # set target temperature temp = request.values["temp"] printer.command("M104 S" + temp) - elif request.values.has_key("bedTemp"): + if "bedTemp" in request.values.keys(): # set target bed temperature bedTemp = request.values["bedTemp"] printer.command("M140 S" + bedTemp) @@ -188,6 +199,7 @@ def setTargetTemperature(): return jsonify(SUCCESS) @app.route(BASEURL + "control/jog", methods=["POST"]) +@login_required def jog(): if not printer.isOperational() or printer.isPrinting(): # do not jog when a print job is running or we don't have a connection @@ -224,6 +236,7 @@ def getSpeedValues(): return jsonify(feedrate=printer.feedrateState()) @app.route(BASEURL + "control/speed", methods=["POST"]) +@login_required def speed(): if not printer.isOperational(): return jsonify(SUCCESS) @@ -251,6 +264,7 @@ def readGcodeFile(filename): return send_from_directory(settings().getBaseFolder("uploads"), filename, as_attachment=True) @app.route(BASEURL + "gcodefiles/upload", methods=["POST"]) +@login_required def uploadGcodeFile(): filename = None if "gcode_file" in request.files.keys(): @@ -259,10 +273,11 @@ def uploadGcodeFile(): return jsonify(files=gcodeManager.getAllFileData(), filename=filename) @app.route(BASEURL + "gcodefiles/load", methods=["POST"]) +@login_required def loadGcodeFile(): if "filename" in request.values.keys(): printAfterLoading = False - if "print" in request.values.keys() and request.values["print"]: + if "print" in request.values.keys() and request.values["print"] in valid_boolean_trues: printAfterLoading = True filename = gcodeManager.getAbsolutePath(request.values["filename"]) if filename is not None: @@ -270,6 +285,7 @@ def loadGcodeFile(): return jsonify(SUCCESS) @app.route(BASEURL + "gcodefiles/delete", methods=["POST"]) +@login_required def deleteGcodeFile(): if "filename" in request.values.keys(): filename = request.values["filename"] @@ -308,6 +324,7 @@ def downloadTimelapse(filename): return send_from_directory(settings().getBaseFolder("timelapse"), filename, as_attachment=True) @app.route(BASEURL + "timelapse/", methods=["DELETE"]) +@login_required def deleteTimelapse(filename): if util.isAllowedFile(filename, set(["mpg"])): secure = os.path.join(settings().getBaseFolder("timelapse"), secure_filename(filename)) @@ -315,7 +332,8 @@ def deleteTimelapse(filename): os.remove(secure) return getTimelapseData() -@app.route(BASEURL + "timelapse/config", methods=["POST"]) +@app.route(BASEURL + "timelapse", methods=["POST"]) +@login_required def setTimelapseConfig(): if request.values.has_key("type"): type = request.values["type"] @@ -381,6 +399,8 @@ def getSettings(): }) @app.route(BASEURL + "settings", methods=["POST"]) +@login_required +@admin_permission.require(403) def setSettings(): if "application/json" in request.headers["Content-Type"]: data = request.json @@ -425,9 +445,117 @@ def setSettings(): return getSettings() +#~~ user settings + +@app.route(BASEURL + "users", methods=["GET"]) +@login_required +@admin_permission.require(403) +def getUsers(): + if userManager is None: + return jsonify(SUCCESS) + + return jsonify({"users": userManager.getAllUsers()}) + +@app.route(BASEURL + "users", methods=["POST"]) +@login_required +@admin_permission.require(403) +def addUser(): + if userManager is None: + return jsonify(SUCCESS) + + if "application/json" in request.headers["Content-Type"]: + data = request.json + + name = data["name"] + password = data["password"] + active = data["active"] + + roles = ["user"] + if "admin" in data.keys() and data["admin"]: + roles.append("admin") + + try: + userManager.addUser(name, password, active, roles) + except users.UserAlreadyExists: + abort(409) + return getUsers() + +@app.route(BASEURL + "users/", methods=["GET"]) +@login_required +def getUser(username): + if userManager is None: + return jsonify(SUCCESS) + + if current_user is not None and not current_user.is_anonymous() and (current_user.get_name() == username or current_user.is_admin()): + user = userManager.findUser(username) + if user is not None: + return jsonify(user.asDict()) + else: + abort(404) + else: + abort(403) + +@app.route(BASEURL + "users/", methods=["PUT"]) +@login_required +@admin_permission.require(403) +def updateUser(username): + if userManager is None: + return jsonify(SUCCESS) + + user = userManager.findUser(username) + if user is not None: + if "application/json" in request.headers["Content-Type"]: + data = request.json + + # change roles + roles = ["user"] + if "admin" in data.keys() and data["admin"]: + roles.append("admin") + userManager.changeUserRoles(username, roles) + + # change activation + if "active" in data.keys(): + userManager.changeUserActivation(username, data["active"]) + return getUsers() + else: + abort(404) + +@app.route(BASEURL + "users/", methods=["DELETE"]) +@login_required +@admin_permission.require(http_exception=403) +def removeUser(username): + if userManager is None: + return jsonify(SUCCESS) + + try: + userManager.removeUser(username) + return getUsers() + except users.UnknownUser: + abort(404) + +@app.route(BASEURL + "users//password", methods=["PUT"]) +@login_required +def changePasswordForUser(username): + if userManager is None: + return jsonify(SUCCESS) + + if current_user is not None and not current_user.is_anonymous() and (current_user.get_name() == username or current_user.is_admin()): + if "application/json" in request.headers["Content-Type"]: + data = request.json + if "password" in data.keys() and data["password"]: + try: + userManager.changeUserPassword(username, data["password"]) + except users.UnknownUser: + return app.make_response(("Unknown user: %s" % username, 404, [])) + return jsonify(SUCCESS) + else: + return app.make_response(("Forbidden", 403, [])) + #~~ system control @app.route(BASEURL + "system", methods=["POST"]) +@login_required +@admin_permission.require(403) def performSystemAction(): logger = logging.getLogger(__name__) if request.values.has_key("action"): @@ -446,6 +574,61 @@ def performSystemAction(): return app.make_response(("Command failed: %r" % ex, 500, [])) return jsonify(SUCCESS) +#~~ Login/user handling + +@app.route(BASEURL + "login", methods=["POST"]) +def login(): + if userManager is not None and "user" in request.values.keys() and "pass" in request.values.keys(): + username = request.values["user"] + password = request.values["pass"] + + if "remember" in request.values.keys() and request.values["remember"]: + remember = True + else: + remember = False + + user = userManager.findUser(username) + if user is not None: + if user.check_password(users.UserManager.createPasswordHash(password)): + login_user(user, remember=remember) + identity_changed.send(current_app._get_current_object(), identity=Identity(user.get_id())) + return jsonify(user.asDict()) + return app.make_response(("User unknown or password incorrect", 401, [])) + elif "passive" in request.values.keys(): + user = current_user + if user is not None and not user.is_anonymous(): + return jsonify(user.asDict()) + return jsonify(SUCCESS) + +@app.route(BASEURL + "logout", methods=["POST"]) +@login_required +def logout(): + # Remove session keys set by Flask-Principal + for key in ('identity.name', 'identity.auth_type'): + del session[key] + identity_changed.send(current_app._get_current_object(), identity=AnonymousIdentity()) + + logout_user() + + return jsonify(SUCCESS) + +@identity_loaded.connect_via(app) +def on_identity_loaded(sender, identity): + user = load_user(identity.id) + if user is None: + return + + identity.provides.add(UserNeed(user.get_name())) + if user.is_user(): + identity.provides.add(RoleNeed("user")) + if user.is_admin(): + identity.provides.add(RoleNeed("admin")) + +def load_user(id): + if userManager is not None: + return userManager.findUser(id) + return users.DummyUser() + #~~ startup code class Server(): def __init__(self, configfile=None, basedir=None, host="0.0.0.0", port=5000, debug=False): @@ -459,6 +642,7 @@ class Server(): # Global as I can't work out a way to get it into PrinterStateConnection global printer global gcodeManager + global userManager from tornado.wsgi import WSGIContainer from tornado.httpserver import HTTPServer @@ -470,19 +654,37 @@ class Server(): # then initialize logging self._initLogging(self._debug) + logger = logging.getLogger(__name__) gcodeManager = gcodefiles.GcodeManager() printer = Printer(gcodeManager) + if settings().getBoolean(["accessControl", "enabled"]): + userManagerName = settings().get(["accessControl", "userManager"]) + try: + clazz = util.getClass(userManagerName) + userManager = clazz() + except AttributeError, e: + logger.exception("Could not instantiate user manager %s, will run with accessControl disabled!" % userManagerName) + + app.secret_key = "k3PuVYgtxNm8DXKKTw2nWmFQQun9qceV" + login_manager = LoginManager() + login_manager.session_protection = "strong" + login_manager.user_callback = load_user + if userManager is None: + login_manager.anonymous_user = users.DummyUser + principals.identity_loaders.appendleft(users.dummy_identity_loader) + login_manager.init_app(app) + if self._host is None: self._host = settings().get(["server", "host"]) if self._port is None: self._port = settings().getInt(["server", "port"]) - logging.getLogger(__name__).info("Listening on http://%s:%d" % (self._host, self._port)) + logger.info("Listening on http://%s:%d" % (self._host, self._port)) app.debug = self._debug - self._router = tornadio2.TornadioRouter(PrinterStateConnection) + self._router = tornadio2.TornadioRouter(self._createSocketConnection) self._tornado_app = Application(self._router.urls + [ (".*", FallbackHandler, {"fallback": WSGIContainer(app)}) @@ -491,11 +693,15 @@ class Server(): self._server.listen(self._port, address=self._host) IOLoop.instance().start() + def _createSocketConnection(self, session, endpoint=None): + global printer, gcodeManager, userManager + return PrinterStateConnection(printer, gcodeManager, userManager, session, endpoint) + def _initSettings(self, configfile, basedir): s = settings(init=True, basedir=basedir, configfile=configfile) def _initLogging(self, debug): - self._config = { + config = { "version": 1, "formatters": { "simple": { @@ -534,13 +740,13 @@ class Server(): } if debug: - self._config["loggers"]["SERIAL"] = { + config["loggers"]["SERIAL"] = { "level": "DEBUG", "handlers": ["serialFile"], "propagate": False } - logging.config.dictConfig(self._config) + logging.config.dictConfig(config) if __name__ == "__main__": octoprint = Server() diff --git a/octoprint/settings.py b/octoprint/settings.py index 10e99b4..ed1c788 100644 --- a/octoprint/settings.py +++ b/octoprint/settings.py @@ -72,6 +72,11 @@ default_settings = { "controls": [], "system": { "actions": [] + }, + "accessControl": { + "enabled": False, + "userManager": "octoprint.users.FilebasedUserManager", + "userfile": None } } diff --git a/octoprint/static/css/bootstrap-modal.css b/octoprint/static/css/bootstrap-modal.css new file mode 100644 index 0000000..76e3be2 --- /dev/null +++ b/octoprint/static/css/bootstrap-modal.css @@ -0,0 +1,214 @@ +/*! + * Bootstrap Modal + * + * Copyright Jordan Schroter + * Licensed under the Apache License v2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + */ + +.modal-open { + overflow: hidden; +} + + +/* add a scroll bar to stop page from jerking around */ +.modal-open.page-overflow .page-container, +.modal-open.page-overflow .page-container .navbar-fixed-top, +.modal-open.page-overflow .page-container .navbar-fixed-bottom, +.modal-open.page-overflow .modal-scrollable { + overflow-y: scroll; +} + +@media (max-width: 979px) { + .modal-open.page-overflow .page-container .navbar-fixed-top, + .modal-open.page-overflow .page-container .navbar-fixed-bottom { + overflow-y: visible; + } +} + + +.modal-scrollable { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow: auto; +} + +.modal { + outline: none; + position: absolute; + margin-top: 0; + top: 50%; + overflow: visible; /* allow content to popup out (i.e tooltips) */ +} + +.modal.fade { + top: -100%; + -webkit-transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out; + -moz-transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out; + -o-transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out; + transition: opacity 0.3s linear, top 0.3s ease-out, bottom 0.3s ease-out, margin-top 0.3s ease-out; +} + +.modal.fade.in { + top: 50%; +} + +.modal-body { + max-height: none; + overflow: visible; +} + +.modal.modal-absolute { + position: absolute; + z-index: 950; +} + +.modal .loading-mask { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: #fff; + border-radius: 6px; +} + +.modal-backdrop.modal-absolute{ + position: absolute; + z-index: 940; +} + +.modal-backdrop, +.modal-backdrop.fade.in{ + opacity: 0.7; + filter: alpha(opacity=70); + background: #fff; +} + +.modal.container { + width: 940px; + margin-left: -470px; +} + +/* Modal Overflow */ + +.modal-overflow.modal { + top: 1%; +} + +.modal-overflow.modal.fade { + top: -100%; +} + +.modal-overflow.modal.fade.in { + top: 1%; +} + +.modal-overflow .modal-body { + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Responsive */ + +@media (min-width: 1200px) { + .modal.container { + width: 1170px; + margin-left: -585px; + } +} + +@media (max-width: 979px) { + .modal, + .modal.container, + .modal.modal-overflow { + top: 1%; + right: 1%; + left: 1%; + bottom: auto; + width: auto !important; + height: auto !important; + margin: 0 !important; + padding: 0 !important; + } + + .modal.fade.in, + .modal.container.fade.in, + .modal.modal-overflow.fade.in { + top: 1%; + bottom: auto; + } + + .modal-body, + .modal-overflow .modal-body { + position: static; + margin: 0; + height: auto !important; + max-height: none !important; + overflow: visible !important; + } + + .modal-footer, + .modal-overflow .modal-footer { + position: static; + } +} + +.loading-spinner { + position: absolute; + top: 50%; + left: 50%; + margin: -12px 0 0 -12px; +} + +/* +Animate.css - http://daneden.me/animate +Licensed under the ☺ license (http://licence.visualidiot.com/) + +Copyright (c) 2012 Dan Eden*/ + +.animated { + -webkit-animation-duration: 1s; + -moz-animation-duration: 1s; + -o-animation-duration: 1s; + animation-duration: 1s; + -webkit-animation-fill-mode: both; + -moz-animation-fill-mode: both; + -o-animation-fill-mode: both; + animation-fill-mode: both; +} + +@-webkit-keyframes shake { + 0%, 100% {-webkit-transform: translateX(0);} + 10%, 30%, 50%, 70%, 90% {-webkit-transform: translateX(-10px);} + 20%, 40%, 60%, 80% {-webkit-transform: translateX(10px);} +} + +@-moz-keyframes shake { + 0%, 100% {-moz-transform: translateX(0);} + 10%, 30%, 50%, 70%, 90% {-moz-transform: translateX(-10px);} + 20%, 40%, 60%, 80% {-moz-transform: translateX(10px);} +} + +@-o-keyframes shake { + 0%, 100% {-o-transform: translateX(0);} + 10%, 30%, 50%, 70%, 90% {-o-transform: translateX(-10px);} + 20%, 40%, 60%, 80% {-o-transform: translateX(10px);} +} + +@keyframes shake { + 0%, 100% {transform: translateX(0);} + 10%, 30%, 50%, 70%, 90% {transform: translateX(-10px);} + 20%, 40%, 60%, 80% {transform: translateX(10px);} +} + +.shake { + -webkit-animation-name: shake; + -moz-animation-name: shake; + -o-animation-name: shake; + animation-name: shake; +} diff --git a/octoprint/static/css/octoprint.less b/octoprint/static/css/octoprint.less index b66ef6f..2f8c258 100644 --- a/octoprint/static/css/octoprint.less +++ b/octoprint/static/css/octoprint.less @@ -113,7 +113,20 @@ body { .accordion-heading { .settings-trigger { float: right; - padding: 0px 15px; + + .dropdown-toggle { + display: inline-block; + padding: 8px 15px; + font-size: 14px; + line-height: 20px; + color: #000; + text-decoration: none; + background: none; + + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + } } a.accordion-toggle { @@ -181,6 +194,42 @@ table { &.timelapse_files_action { text-align: center; width: 45px; + + a { + text-decoration: none; + color: #000; + + &.disabled { + color: #ccc; + cursor: default; + } + } + } + + // user settings + &.settings_users_name { + text-overflow: ellipsis; + text-align: left; + } + + &.settings_users_active, &.settings_users_admin { + text-align: center; + width: 55px; + } + + &.settings_users_actions { + text-align: center; + width: 60px; + + a { + text-decoration: none; + color: #000; + + &.disabled { + color: #ccc; + cursor: default; + } + } } } } @@ -274,9 +323,9 @@ ul.dropdown-menu li a { } } -/** Controls */ +/** Control tab */ -#controls { +#control { overflow: hidden; .jog-panel { @@ -324,7 +373,6 @@ ul.dropdown-menu li a { /** Settings dialog */ #settings_dialog { - width: 650px; } /** Footer */ diff --git a/octoprint/static/gcodeviewer/js/ui.js b/octoprint/static/gcodeviewer/js/ui.js index f780c81..4a6e32d 100644 --- a/octoprint/static/gcodeviewer/js/ui.js +++ b/octoprint/static/gcodeviewer/js/ui.js @@ -239,7 +239,7 @@ GCODE.ui = (function(){ }); if(!Modernizr.canvas)fatal.push("
  • Your browser doesn't seem to support HTML5 Canvas, this application won't work without it.
  • "); - if(!Modernizr.filereader)fatal.push("
  • Your browser doesn't seem to support HTML5 File API, this application won't work without it.
  • "); + //if(!Modernizr.filereader)fatal.push("
  • Your browser doesn't seem to support HTML5 File API, this application won't work without it.
  • "); if(!Modernizr.webworkers)fatal.push("
  • Your browser doesn't seem to support HTML5 Web Workers, this application won't work without it.
  • "); if(!Modernizr.svg)fatal.push("
  • Your browser doesn't seem to support HTML5 SVG, this application won't work without it.
  • "); @@ -249,7 +249,7 @@ GCODE.ui = (function(){ return false; } - if(!Modernizr.webgl){ + if(!Modernizr.webgl && GCODE.renderer3d){ warnings.push("
  • Your browser doesn't seem to support HTML5 Web GL, 3d mode is not recommended, going to be SLOW!
  • "); GCODE.renderer3d.setOption({rendererType: "canvas"}); } diff --git a/octoprint/static/js/bootstrap/bootstrap-modal.js b/octoprint/static/js/bootstrap/bootstrap-modal.js new file mode 100644 index 0000000..c125bd5 --- /dev/null +++ b/octoprint/static/js/bootstrap/bootstrap-modal.js @@ -0,0 +1,374 @@ +/* =========================================================== + * bootstrap-modal.js v2.1 + * =========================================================== + * Copyright 2012 Jordan Schroter + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + + +!function ($) { + + "use strict"; // jshint ;_; + + /* MODAL CLASS DEFINITION + * ====================== */ + + var Modal = function (element, options) { + this.init(element, options); + }; + + Modal.prototype = { + + constructor: Modal, + + init: function (element, options) { + this.options = options; + + this.$element = $(element) + .delegate('[data-dismiss="modal"]', 'click.dismiss.modal', $.proxy(this.hide, this)); + + this.options.remote && this.$element.find('.modal-body').load(this.options.remote); + + var manager = typeof this.options.manager === 'function' ? + this.options.manager.call(this) : this.options.manager; + + manager = manager.appendModal ? + manager : $(manager).modalmanager().data('modalmanager'); + + manager.appendModal(this); + }, + + toggle: function () { + return this[!this.isShown ? 'show' : 'hide'](); + }, + + show: function () { + var e = $.Event('show'); + + if (this.isShown) return; + + this.$element.trigger(e); + + if (e.isDefaultPrevented()) return; + + this.escape(); + + this.tab(); + + this.options.loading && this.loading(); + }, + + hide: function (e) { + e && e.preventDefault(); + + e = $.Event('hide'); + + this.$element.trigger(e); + + if (!this.isShown || e.isDefaultPrevented()) return (this.isShown = false); + + this.isShown = false; + + this.escape(); + + this.tab(); + + this.isLoading && this.loading(); + + $(document).off('focusin.modal'); + + this.$element + .removeClass('in') + .removeClass('animated') + .removeClass(this.options.attentionAnimation) + .removeClass('modal-overflow') + .attr('aria-hidden', true); + + $.support.transition && this.$element.hasClass('fade') ? + this.hideWithTransition() : + this.hideModal(); + }, + + layout: function () { + var prop = this.options.height ? 'height' : 'max-height', + value = this.options.height || this.options.maxHeight; + + if (this.options.width){ + this.$element.css('width', this.options.width); + + var that = this; + this.$element.css('margin-left', function () { + if (/%/ig.test(that.options.width)){ + return -(parseInt(that.options.width) / 2) + '%'; + } else { + return -($(this).width() / 2) + 'px'; + } + }); + } else { + this.$element.css('width', ''); + this.$element.css('margin-left', ''); + } + + this.$element.find('.modal-body') + .css('overflow', '') + .css(prop, ''); + + var modalOverflow = $(window).height() - 10 < this.$element.height(); + + if (value){ + this.$element.find('.modal-body') + .css('overflow', 'auto') + .css(prop, value); + } + + if (modalOverflow || this.options.modalOverflow) { + this.$element + .css('margin-top', 0) + .addClass('modal-overflow'); + } else { + this.$element + .css('margin-top', 0 - this.$element.height() / 2) + .removeClass('modal-overflow'); + } + }, + + tab: function () { + var that = this; + + if (this.isShown && this.options.consumeTab) { + this.$element.on('keydown.tabindex.modal', '[data-tabindex]', function (e) { + if (e.keyCode && e.keyCode == 9){ + var $next = $(this), + $rollover = $(this); + + that.$element.find('[data-tabindex]:enabled:not([readonly])').each(function (e) { + if (!e.shiftKey){ + $next = $next.data('tabindex') < $(this).data('tabindex') ? + $next = $(this) : + $rollover = $(this); + } else { + $next = $next.data('tabindex') > $(this).data('tabindex') ? + $next = $(this) : + $rollover = $(this); + } + }); + + $next[0] !== $(this)[0] ? + $next.focus() : $rollover.focus(); + + e.preventDefault(); + } + }); + } else if (!this.isShown) { + this.$element.off('keydown.tabindex.modal'); + } + }, + + escape: function () { + var that = this; + if (this.isShown && this.options.keyboard) { + if (!this.$element.attr('tabindex')) this.$element.attr('tabindex', -1); + + this.$element.on('keyup.dismiss.modal', function (e) { + e.which == 27 && that.hide(); + }); + } else if (!this.isShown) { + this.$element.off('keyup.dismiss.modal') + } + }, + + hideWithTransition: function () { + var that = this + , timeout = setTimeout(function () { + that.$element.off($.support.transition.end); + that.hideModal(); + }, 500); + + this.$element.one($.support.transition.end, function () { + clearTimeout(timeout); + that.hideModal(); + }); + }, + + hideModal: function () { + this.$element + .hide() + .trigger('hidden'); + + var prop = this.options.height ? 'height' : 'max-height'; + var value = this.options.height || this.options.maxHeight; + + if (value){ + this.$element.find('.modal-body') + .css('overflow', '') + .css(prop, ''); + } + + }, + + removeLoading: function () { + this.$loading.remove(); + this.$loading = null; + this.isLoading = false; + }, + + loading: function (callback) { + callback = callback || function () {}; + + var animate = this.$element.hasClass('fade') ? 'fade' : ''; + + if (!this.isLoading) { + var doAnimate = $.support.transition && animate; + + this.$loading = $('
    ') + .append(this.options.spinner) + .appendTo(this.$element); + + if (doAnimate) this.$loading[0].offsetWidth; // force reflow + + this.$loading.addClass('in'); + + this.isLoading = true; + + doAnimate ? + this.$loading.one($.support.transition.end, callback) : + callback(); + + } else if (this.isLoading && this.$loading) { + this.$loading.removeClass('in'); + + var that = this; + $.support.transition && this.$element.hasClass('fade')? + this.$loading.one($.support.transition.end, function () { that.removeLoading() }) : + that.removeLoading(); + + } else if (callback) { + callback(this.isLoading); + } + }, + + focus: function () { + var $focusElem = this.$element.find(this.options.focusOn); + + $focusElem = $focusElem.length ? $focusElem : this.$element; + + $focusElem.focus(); + }, + + attention: function (){ + // NOTE: transitionEnd with keyframes causes odd behaviour + + if (this.options.attentionAnimation){ + this.$element + .removeClass('animated') + .removeClass(this.options.attentionAnimation); + + var that = this; + + setTimeout(function () { + that.$element + .addClass('animated') + .addClass(that.options.attentionAnimation); + }, 0); + } + + + this.focus(); + }, + + + destroy: function () { + var e = $.Event('destroy'); + this.$element.trigger(e); + if (e.isDefaultPrevented()) return; + + this.teardown(); + }, + + teardown: function () { + if (!this.$parent.length){ + this.$element.remove(); + this.$element = null; + return; + } + + if (this.$parent !== this.$element.parent()){ + this.$element.appendTo(this.$parent); + } + + this.$element.off('.modal'); + this.$element.removeData('modal'); + this.$element + .removeClass('in') + .attr('aria-hidden', true); + } + }; + + + /* MODAL PLUGIN DEFINITION + * ======================= */ + + $.fn.modal = function (option, args) { + return this.each(function () { + var $this = $(this), + data = $this.data('modal'), + options = $.extend({}, $.fn.modal.defaults, $this.data(), typeof option == 'object' && option); + + if (!data) $this.data('modal', (data = new Modal(this, options))); + if (typeof option == 'string') data[option].apply(data, [].concat(args)); + else if (options.show) data.show() + }) + }; + + $.fn.modal.defaults = { + keyboard: true, + backdrop: true, + loading: false, + show: true, + width: null, + height: null, + maxHeight: null, + modalOverflow: false, + consumeTab: true, + focusOn: null, + replace: false, + resize: false, + attentionAnimation: 'shake', + manager: 'body', + spinner: '
    ' + }; + + $.fn.modal.Constructor = Modal; + + + /* MODAL DATA-API + * ============== */ + + $(function () { + $(document).off('click.modal').on('click.modal.data-api', '[data-toggle="modal"]', function ( e ) { + var $this = $(this), + href = $this.attr('href'), + $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))), //strip for ie7 + option = $target.data('modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()); + + e.preventDefault(); + $target + .modal(option) + .one('hide', function () { + $this.focus(); + }) + }); + }); + +}(window.jQuery); diff --git a/octoprint/static/js/bootstrap/bootstrap-modalmanager.js b/octoprint/static/js/bootstrap/bootstrap-modalmanager.js new file mode 100644 index 0000000..1982975 --- /dev/null +++ b/octoprint/static/js/bootstrap/bootstrap-modalmanager.js @@ -0,0 +1,412 @@ +/* =========================================================== + * bootstrap-modalmanager.js v2.1 + * =========================================================== + * Copyright 2012 Jordan Schroter. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + +!function ($) { + + "use strict"; // jshint ;_; + + /* MODAL MANAGER CLASS DEFINITION + * ====================== */ + + var ModalManager = function (element, options) { + this.init(element, options); + }; + + ModalManager.prototype = { + + constructor: ModalManager, + + init: function (element, options) { + this.$element = $(element); + this.options = $.extend({}, $.fn.modalmanager.defaults, this.$element.data(), typeof options == 'object' && options); + this.stack = []; + this.backdropCount = 0; + + if (this.options.resize) { + var resizeTimeout, + that = this; + + $(window).on('resize.modal', function(){ + resizeTimeout && clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(function(){ + for (var i = 0; i < that.stack.length; i++){ + that.stack[i].isShown && that.stack[i].layout(); + } + }, 10); + }); + } + }, + + createModal: function (element, options) { + $(element).modal($.extend({ manager: this }, options)); + }, + + appendModal: function (modal) { + this.stack.push(modal); + + var that = this; + + modal.$element.on('show.modalmanager', targetIsSelf(function (e) { + + var showModal = function(){ + modal.isShown = true; + + var transition = $.support.transition && modal.$element.hasClass('fade'); + + that.$element + .toggleClass('modal-open', that.hasOpenModal()) + .toggleClass('page-overflow', $(window).height() < that.$element.height()); + + modal.$parent = modal.$element.parent(); + + modal.$container = that.createContainer(modal); + + modal.$element.appendTo(modal.$container); + + that.backdrop(modal, function () { + + modal.$element.show(); + + if (transition) { + //modal.$element[0].style.display = 'run-in'; + modal.$element[0].offsetWidth; + //modal.$element.one($.support.transition.end, function () { modal.$element[0].style.display = 'block' }); + } + + modal.layout(); + + modal.$element + .addClass('in') + .attr('aria-hidden', false); + + var complete = function () { + that.setFocus(); + modal.$element.trigger('shown'); + }; + + transition ? + modal.$element.one($.support.transition.end, complete) : + complete(); + }); + }; + + modal.options.replace ? + that.replace(showModal) : + showModal(); + })); + + modal.$element.on('hidden.modalmanager', targetIsSelf(function (e) { + + that.backdrop(modal); + + if (modal.$backdrop){ + $.support.transition && modal.$element.hasClass('fade') ? + modal.$backdrop.one($.support.transition.end, function () { that.destroyModal(modal) }) : + that.destroyModal(modal); + } else { + that.destroyModal(modal); + } + + })); + + modal.$element.on('destroy.modalmanager', targetIsSelf(function (e) { + that.removeModal(modal); + })); + + }, + + destroyModal: function (modal) { + + modal.destroy(); + + var hasOpenModal = this.hasOpenModal(); + + this.$element.toggleClass('modal-open', hasOpenModal); + + if (!hasOpenModal){ + this.$element.removeClass('page-overflow'); + } + + this.removeContainer(modal); + + this.setFocus(); + }, + + hasOpenModal: function () { + for (var i = 0; i < this.stack.length; i++){ + if (this.stack[i].isShown) return true; + } + + return false; + }, + + setFocus: function () { + var topModal; + + for (var i = 0; i < this.stack.length; i++){ + if (this.stack[i].isShown) topModal = this.stack[i]; + } + + if (!topModal) return; + + topModal.focus(); + + }, + + removeModal: function (modal) { + modal.$element.off('.modalmanager'); + if (modal.$backdrop) this.removeBackdrop.call(modal); + this.stack.splice(this.getIndexOfModal(modal), 1); + }, + + getModalAt: function (index) { + return this.stack[index]; + }, + + getIndexOfModal: function (modal) { + for (var i = 0; i < this.stack.length; i++){ + if (modal === this.stack[i]) return i; + } + }, + + replace: function (callback) { + var topModal; + + for (var i = 0; i < this.stack.length; i++){ + if (this.stack[i].isShown) topModal = this.stack[i]; + } + + if (topModal) { + this.$backdropHandle = topModal.$backdrop; + topModal.$backdrop = null; + + callback && topModal.$element.one('hidden', + targetIsSelf( $.proxy(callback, this) )); + + topModal.hide(); + } else if (callback) { + callback(); + } + }, + + removeBackdrop: function (modal) { + modal.$backdrop.remove(); + modal.$backdrop = null; + }, + + createBackdrop: function (animate) { + var $backdrop; + + if (!this.$backdropHandle) { + $backdrop = $('