# coding=utf-8 from __future__ import absolute_import, division, print_function __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" import os import datetime from collections import defaultdict from flask import request, g, url_for, make_response, render_template, send_from_directory, redirect, abort import octoprint.plugin from octoprint.server import app, userManager, pluginManager, gettext, \ debug, LOCALES, VERSION, DISPLAY_VERSION, UI_API_KEY, BRANCH, preemptiveCache, \ NOT_MODIFIED from octoprint.settings import settings from octoprint.filemanager import get_all_extensions import re import base64 from . import util import logging _logger = logging.getLogger(__name__) _templates = None _plugin_names = None _plugin_vars = None _valid_id_re = re.compile("[a-z_]+") _valid_div_re = re.compile("[a-zA-Z_-]+") def _preemptive_unless(base_url=None, additional_unless=None): if base_url is None: base_url = request.url_root disabled_for_root = not settings().getBoolean(["devel", "cache", "preemptive"]) \ or base_url in settings().get(["server", "preemptiveCache", "exceptions"]) \ or not (base_url.startswith("http://") or base_url.startswith("https://")) recording_disabled = request.headers.get("X-Preemptive-Record", "yes") == "no" if callable(additional_unless): return recording_disabled or disabled_for_root or additional_unless() else: return recording_disabled or disabled_for_root def _preemptive_data(key, path=None, base_url=None, data=None, additional_request_data=None): if path is None: path = request.path if base_url is None: base_url = request.url_root d = dict(path=path, base_url=base_url, query_string="l10n={}".format(g.locale.language if g.locale else "en")) if key != "_default": d["plugin"] = key # add data if we have any if data is not None: try: if callable(data): data = data() if data: if "query_string" in data: data["query_string"] = "l10n={}&{}".format(g.locale.language, data["query_string"]) d.update(data) except: _logger.exception("Error collecting data for preemptive cache from plugin {}".format(key)) # add additional request data if we have any if callable(additional_request_data): try: ard = additional_request_data() if ard: d.update(dict( _additional_request_data=ard )) except: _logger.exception("Error retrieving additional data for preemptive cache from plugin {}".format(key)) return d def _cache_key(ui, url=None, locale=None, additional_key_data=None): if url is None: url = request.base_url if locale is None: locale = g.locale.language if g.locale else "en" k = "ui:{}:{}:{}".format(ui, url, locale) if callable(additional_key_data): try: ak = additional_key_data() if ak: # we have some additional key components, let's attach them if not isinstance(ak, (list, tuple)): ak = [ak] k = "{}:{}".format(k, ":".join(ak)) except: _logger.exception("Error while trying to retrieve additional cache key parts for ui {}".format(ui)) return k @app.route("/cached.gif") def in_cache(): url = request.base_url.replace("/cached.gif", "/") path = request.path.replace("/cached.gif", "/") base_url = request.url_root # select view from plugins and fall back on default view if no plugin will handle it ui_plugins = pluginManager.get_implementations(octoprint.plugin.UiPlugin, sorting_context="UiPlugin.on_ui_render") for plugin in ui_plugins: if plugin.will_handle_ui(request): ui = plugin._identifier key = _cache_key(plugin._identifier, url=url, additional_key_data=plugin.get_ui_additional_key_data_for_cache) unless = _preemptive_unless(url, additional_unless=plugin.get_ui_additional_unless) data = _preemptive_data(plugin._identifier, path=path, base_url=base_url, data=plugin.get_ui_data_for_preemptive_caching, additional_request_data=plugin.get_ui_additional_request_data_for_preemptive_caching) break else: ui = "_default" key = _cache_key("_default", url=url) unless = _preemptive_unless(url) data = _preemptive_data("_default", path=path, base_url=base_url) response = make_response(bytes(base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"))) response.headers["Content-Type"] = "image/gif" if unless or not preemptiveCache.has_record(data, root=path): _logger.info("Preemptive cache not active for path {}, ui {} and data {!r}, signaling as cached".format(path, ui, data)) return response elif util.flask.is_in_cache(key): _logger.info("Found path {} in cache (key: {}), signaling as cached".format(path, key)) return response else: _logger.debug("Path {} not yet cached (key: {}), signaling as missing".format(path, key)) return abort(404) @app.route("/") def index(): global _templates, _plugin_names, _plugin_vars preemptive_cache_enabled = settings().getBoolean(["devel", "cache", "preemptive"]) # helper to check if wizards are active def wizard_active(templates): return templates is not None and bool(templates["wizard"]["order"]) # we force a refresh if the client forces one or if we have wizards cached force_refresh = util.flask.cache_check_headers() or "_refresh" in request.values or wizard_active(_templates) # if we need to refresh our template cache or it's not yet set, process it if force_refresh or _templates is None or _plugin_names is None or _plugin_vars is None: _templates, _plugin_names, _plugin_vars = _process_templates() now = datetime.datetime.utcnow() render_kwargs = _get_render_kwargs(_templates, _plugin_names, _plugin_vars, now) def get_preemptively_cached_view(key, view, data=None, additional_request_data=None, additional_unless=None): if (data is None and additional_request_data is None) or g.locale is None: return view d = _preemptive_data(key, data=data, additional_request_data=additional_request_data) def unless(): return _preemptive_unless(base_url=request.url_root, additional_unless=additional_unless) # finally decorate our view return util.flask.preemptively_cached(cache=preemptiveCache, data=d, unless=unless)(view) def get_cached_view(key, view, additional_key_data=None, additional_files=None, custom_files=None, custom_etag=None, custom_lastmodified=None): def cache_key(): return _cache_key(key, additional_key_data=additional_key_data) def check_etag_and_lastmodified(): files = collect_files() lastmodified = compute_lastmodified(files) lastmodified_ok = util.flask.check_lastmodified(lastmodified) etag_ok = util.flask.check_etag(compute_etag(files=files, lastmodified=lastmodified, additional=cache_key())) return lastmodified_ok and etag_ok def validate_cache(cached): etag_different = compute_etag(additional=cache_key()) != cached.get_etag()[0] return force_refresh or etag_different def collect_files(): if callable(custom_files): try: files = custom_files() if files: return files except: _logger.exception("Error while trying to retrieve tracked files for plugin {}".format(key)) templates = _get_all_templates() assets = _get_all_assets() translations = _get_all_translationfiles(g.locale.language if g.locale else "en", "messages") files = templates + assets + translations if callable(additional_files): try: af = additional_files() if af: files += af except: _logger.exception("Error while trying to retrieve additional tracked files for plugin {}".format(key)) return sorted(set(files)) def compute_lastmodified(files=None): if callable(custom_lastmodified): try: lastmodified = custom_lastmodified() if lastmodified: return lastmodified except: _logger.exception("Error while trying to retrieve custom LastModified value for plugin {}".format(key)) if files is None: files = collect_files() return _compute_date(files) def compute_etag(files=None, lastmodified=None, additional=None): if callable(custom_etag): try: etag = custom_etag() if etag: return etag except: _logger.exception("Error while trying to retrieve custom ETag value for plugin {}".format(key)) if files is None: files = collect_files() if lastmodified is None: lastmodified = compute_lastmodified(files) if lastmodified and not isinstance(lastmodified, basestring): from werkzeug.http import http_date lastmodified = http_date(lastmodified) if additional is None: additional = [] import hashlib hash = hashlib.sha1() hash.update(octoprint.__version__) hash.update(octoprint.server.UI_API_KEY) hash.update(",".join(sorted(files))) if lastmodified: hash.update(lastmodified) for add in additional: hash.update(add) return hash.hexdigest() decorated_view = view decorated_view = util.flask.lastmodified(lambda _: compute_lastmodified())(decorated_view) decorated_view = util.flask.etagged(lambda _: compute_etag(additional=cache_key()))(decorated_view) decorated_view = util.flask.cached(timeout=-1, refreshif=validate_cache, key=cache_key, unless_response=util.flask.cache_check_response_headers)(decorated_view) decorated_view = util.flask.conditional(check_etag_and_lastmodified, NOT_MODIFIED)(decorated_view) return decorated_view def plugin_view(p): cached = get_cached_view(p._identifier, p.on_ui_render, additional_key_data=p.get_ui_additional_key_data_for_cache, additional_files=p.get_ui_additional_tracked_files, custom_files=p.get_ui_custom_tracked_files, custom_etag=p.get_ui_custom_etag, custom_lastmodified=p.get_ui_custom_lastmodified) if preemptive_cache_enabled and p.get_ui_preemptive_caching_enabled(): view = get_preemptively_cached_view(p._identifier, cached, p.get_ui_data_for_preemptive_caching, p.get_ui_additional_request_data_for_preemptive_caching, p.get_ui_additional_unless) else: view = cached return view(now, request, render_kwargs) def default_view(): wizard = wizard_active(_templates) enable_accesscontrol = userManager.enabled accesscontrol_active = enable_accesscontrol and userManager.hasBeenCustomized() render_kwargs.update(dict( webcamStream=settings().get(["webcam", "stream"]), enableTemperatureGraph=settings().get(["feature", "temperatureGraph"]), enableAccessControl=enable_accesscontrol, accessControlActive=accesscontrol_active, enableSdSupport=settings().get(["feature", "sdSupport"]), gcodeMobileThreshold=settings().get(["gcodeViewer", "mobileSizeThreshold"]), gcodeThreshold=settings().get(["gcodeViewer", "sizeThreshold"]), wizard=wizard, now=now, )) # no plugin took an interest, we'll use the default UI def make_default_ui(): r = make_response(render_template("index.jinja2", **render_kwargs)) if wizard: # if we have active wizard dialogs, set non caching headers r = util.flask.add_non_caching_response_headers(r) return r cached = get_cached_view("_default", make_default_ui) preemptively_cached = get_preemptively_cached_view("_default", cached, dict(), dict()) return preemptively_cached() response = None forced_view = request.headers.get("X-Force-View", None) if forced_view: # we have view forced by the preemptive cache _logger.debug("Forcing rendering of view {}".format(forced_view)) response = None if forced_view != "_default": plugin = pluginManager.get_plugin_info(forced_view, require_enabled=True) if plugin is not None and isinstance(plugin.implementation, octoprint.plugin.UiPlugin): response = plugin_view(plugin.implementation) if response is None: return default_view() else: # select view from plugins and fall back on default view if no plugin will handle it ui_plugins = pluginManager.get_implementations(octoprint.plugin.UiPlugin, sorting_context="UiPlugin.on_ui_render") for plugin in ui_plugins: if plugin.will_handle_ui(request): # plugin claims responsibility, let it render the UI response = plugin_view(plugin) if response is not None: break else: _logger.warn("UiPlugin {} returned an empty response".format(plugin._identifier)) else: response = default_view() return response def _get_render_kwargs(templates, plugin_names, plugin_vars, now): #~~ a bunch of settings first_run = settings().getBoolean(["server", "firstRun"]) locales = dict((l.language, dict(language=l.language, display=l.display_name, english=l.english_name)) for l in LOCALES) extensions = map(lambda ext: ".{}".format(ext), get_all_extensions()) #~~ prepare full set of template vars for rendering render_kwargs = dict( debug=debug, firstRun=first_run, version=dict(number=VERSION, display=DISPLAY_VERSION, branch=BRANCH), uiApiKey=UI_API_KEY, templates=templates, pluginNames=plugin_names, locales=locales, supportedExtensions=extensions ) render_kwargs.update(plugin_vars) return render_kwargs def _process_templates(): enable_accesscontrol = userManager.enabled first_run = settings().getBoolean(["server", "firstRun"]) enable_gcodeviewer = settings().getBoolean(["gcodeViewer", "enabled"]) enable_timelapse = (settings().get(["webcam", "snapshot"]) and settings().get(["webcam", "ffmpeg"])) enable_systemmenu = settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None preferred_stylesheet = settings().get(["devel", "stylesheet"]) ##~~ prepare templates templates = defaultdict(lambda: dict(order=[], entries=dict())) # rules for transforming template configs to template entries template_rules = dict( navbar=dict(div=lambda x: "navbar_plugin_" + x, template=lambda x: x + "_navbar.jinja2", to_entry=lambda data: data), sidebar=dict(div=lambda x: "sidebar_plugin_" + x, template=lambda x: x + "_sidebar.jinja2", to_entry=lambda data: (data["name"], data)), tab=dict(div=lambda x: "tab_plugin_" + x, template=lambda x: x + "_tab.jinja2", to_entry=lambda data: (data["name"], data)), settings=dict(div=lambda x: "settings_plugin_" + x, template=lambda x: x + "_settings.jinja2", to_entry=lambda data: (data["name"], data)), usersettings=dict(div=lambda x: "usersettings_plugin_" + x, template=lambda x: x + "_usersettings.jinja2", to_entry=lambda data: (data["name"], data)), wizard=dict(div=lambda x: "wizard_plugin_" + x, template=lambda x: x + "_wizard.jinja2", to_entry=lambda data: (data["name"], data)), about=dict(div=lambda x: "about_plugin_" + x, template=lambda x: x + "_about.jinja2", to_entry=lambda data: (data["name"], data)), generic=dict(template=lambda x: x + ".jinja2", to_entry=lambda data: data) ) # sorting orders template_sorting = dict( navbar=dict(add="prepend", key=None), sidebar=dict(add="append", key="name"), tab=dict(add="append", key="name"), settings=dict(add="custom_append", key="name", custom_add_entries=lambda missing: dict(section_plugins=(gettext("Plugins"), None)), custom_add_order=lambda missing: ["section_plugins"] + missing), usersettings=dict(add="append", key="name"), wizard=dict(add="append", key="name", key_extractor=lambda d, k: "0:{}".format(d[0]) if "mandatory" in d[1] and d[1]["mandatory"] else "1:{}".format(d[0])), about=dict(add="append", key="name"), generic=dict(add="append", key=None) ) hooks = pluginManager.get_hooks("octoprint.ui.web.templatetypes") for name, hook in hooks.items(): try: result = hook(dict(template_sorting), dict(template_rules)) except: _logger.exception("Error while retrieving custom template type definitions from plugin {name}".format(**locals())) else: if not isinstance(result, list): continue for entry in result: if not isinstance(entry, tuple) or not len(entry) == 3: continue key, order, rule = entry # order defaults if "add" not in order: order["add"] = "prepend" if "key" not in order: order["key"] = "name" # rule defaults if "div" not in rule: # default div name: __plugin_ div = "{name}_{key}_plugin_".format(**locals()) rule["div"] = lambda x: div + x if "template" not in rule: # default template name: _plugin__