From 2c4efe9d0e8776b03b244e26f77a8a9159fd28a3 Mon Sep 17 00:00:00 2001 From: TheophileDiot Date: Tue, 12 Jul 2022 15:35:26 +0200 Subject: [PATCH] Add Plugin Pages feature --- docs/plugins.md | 59 ++++ ui/main.py | 355 ++++++++++++++++++------ ui/src/Config.py | 60 ++-- ui/src/Instances.py | 8 +- ui/static/css/configs&plugins&cache.css | 38 +++ ui/templates/loading.html | 19 +- ui/templates/plugins.html | 51 ++++ 7 files changed, 477 insertions(+), 113 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 89be842af..312237bbc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -369,3 +369,62 @@ end ### Jobs BunkerWeb uses an internal job scheduler for periodic tasks like renewing certificates with certbot, downloading blacklists, downloading MMDB files, ... You can add tasks of your choice by putting them inside a subfolder named **jobs** and listing them in the **plugin.json** metadata file. Don't forget to add the execution permissions for everyone to avoid any problems when a user is cloning and installing your plugin. + +### Plugin page + +Plugin pages are used to display information about your plugin. You can create a page by creating a subfolder named **ui** next to the file **plugin.json** and putting a **template.html** file inside it. The template file will be used to display the page. + +A plugin page can have a form that is used to submit data to the plugin. To get the values of the form, you need to put a **actions.py** file in the **ui** folder. Inside the file, **you must define a function that has the same name as the plugin**. This function will be called when the form is submitted. You can then use the **request** object (from the library flask) to get the values of the form. The form's action must finish with **/plugins/<*plugin_id*>**. + +!!! info "Template variables" + + Your template file can use template variables to display the content of your plugin. Like *Jinja2*, the template variables can be accessed by using the `{{` and `}}` delimiters. To use template variables, your custom function must return a dictionary with the template variables. The dictionary keys are the template variables names and the values are the values to display. Example : + ```json + { + "foo": "bar" + } + ``` + ```html + + +

{{ foo }}

+ + + ``` + Will display : `bar` + +If you want to submit your form through a POST request, you need to add the following line to your form : + +```html + +``` + +Otherwise, the form will not be submitted because of the CSRF token protection. + +!!! tip "Plugins pages" + + Plugins pages are displayed in the **Plugins** section of the Web UI. + +For example, I have a plugin called **myplugin** and I want to create a custom page. I just have to create a subfolder called **ui** and put a **template.html** file inside it. I want my plugin to display a form that will submit the data to the plugin. I can then use the **request** object (from the library flask) to get the values of the form. For that I create a **actions.py** file in the same **ui** folder as my **template.html** file. I define a function called **myplugin** that returns a dictionary with the template variables I want to display. + +```html + + +

{{ foo }}

+
+ + + +
+ + +``` + +```python +from flask import request + +def myplugin(): + return { + "foo": request.form["foo"] + } +``` \ No newline at end of file diff --git a/ui/main.py b/ui/main.py index a3c554274..5ca8ad70b 100755 --- a/ui/main.py +++ b/ui/main.py @@ -1,6 +1,11 @@ +from email import message import os from shutil import rmtree, copytree, chown from logging import getLogger, INFO, ERROR, StreamHandler, Formatter +from traceback import format_exc +from typing import Optional +from jinja2 import Template +from threading import Thread from flask import ( Flask, flash, @@ -12,15 +17,16 @@ from flask import ( url_for, ) from flask_login import LoginManager, login_required, login_user, logout_user -from flask_wtf.csrf import CSRFProtect, CSRFError +from flask_wtf.csrf import CSRFProtect, CSRFError, generate_csrf from json import JSONDecodeError, load as json_load from bs4 import BeautifulSoup from datetime import datetime, timezone from dateutil.parser import parse as dateutil_parse from requests import get from requests.utils import default_headers -from sys import path as sys_path, exit as sys_exit +from sys import path as sys_path, exit as sys_exit, modules as sys_modules from copy import deepcopy +from re import match as re_match from docker import DockerClient from docker.errors import ( NotFound as docker_NotFound, @@ -144,6 +150,9 @@ try: WTF_CSRF_SSL_STRICT=False, USER=user, SEND_FILE_MAX_AGE_DEFAULT=86400, + PLUGIN_ARGS=None, + RELOADING=False, + TO_FLASH=[], ) except FileNotFoundError as e: logger.error(repr(e), e.filename) @@ -161,6 +170,50 @@ app.jinja_env.globals.update(gen_folders_tree_html=gen_folders_tree_html) app.jinja_env.globals.update(check_settings=check_settings) +def manage_bunkerweb(method: str, operation: str = "reloads", *args): + # Do the operation + if method == "services": + if operation == "new": + operation, error = app.config["CONFIG"].new_service(args[0]) + elif operation == "edit": + operation = app.config["CONFIG"].edit_service(args[1], args[0]) + elif operation == "delete": + operation, error = app.config["CONFIG"].delete_service(args[2]) + + if error: + app.config["TO_FLASH"].append({"content": operation, "type": "error"}) + else: + app.config["TO_FLASH"].append({"content": operation, "type": "success"}) + if method == "global_config": + operation = app.config["CONFIG"].edit_global_conf(args[0]) + app.config["TO_FLASH"].append({"content": operation, "type": "success"}) + elif method == "plugins": + app.config["CONFIG"].reload_config() + + if operation == "reload": + operation = app.config["INSTANCES"].reload_instance(args[0]) + elif operation == "start": + operation = app.config["INSTANCES"].start_instance(args[0]) + elif operation == "stop": + operation = app.config["INSTANCES"].stop_instance(args[0]) + elif operation == "restart": + operation = app.config["INSTANCES"].restart_instance(args[0]) + else: + operation = app.config["INSTANCES"].reload_instances() + + if isinstance(operation, list): + for op in operation: + app.config["TO_FLASH"].append( + {"content": f"Reload failed for the instance {op}", "type": "error"} + ) + elif operation.startswith("Can't"): + app.config["TO_FLASH"].append({"content": operation, "type": "error"}) + else: + app.config["TO_FLASH"].append({"content": operation, "type": "success"}) + + app.config["RELOADING"] = False + + @login_manager.user_loader def load_user(user_id): return User(user_id, vars["ADMIN_PASSWORD"]) @@ -172,7 +225,7 @@ csrf.init_app(app) @app.errorhandler(CSRFError) -def handle_csrf_error(e): +def handle_csrf_error(_): """ It takes a CSRFError exception as an argument, and returns a Flask response @@ -192,8 +245,13 @@ def index(): @app.route("/loading") @login_required def loading(): - next_url = request.values.get("next") - return render_template("loading.html", next=next_url) + next_url: str = request.values.get("next", None) or url_for("home") + message: Optional[str] = request.values.get("message", None) + return render_template( + "loading.html", + message=message if message is not None else "Loading", + next=next_url, + ) @app.route("/home") @@ -291,30 +349,25 @@ def instances(): flash("Missing INSTANCE_ID parameter.", "error") return redirect(url_for("loading", next=url_for("instances"))) - # Do the operation - if request.form["operation"] == "reload": - operation = app.config["INSTANCES"].reload_instance( - request.form["INSTANCE_ID"] - ) - elif request.form["operation"] == "start": - operation = app.config["INSTANCES"].start_instance( - request.form["INSTANCE_ID"] - ) - elif request.form["operation"] == "stop": - operation = app.config["INSTANCES"].stop_instance( - request.form["INSTANCE_ID"] - ) - elif request.form["operation"] == "restart": - operation = app.config["INSTANCES"].restart_instance( - request.form["INSTANCE_ID"] - ) + app.config["RELOADING"] = True + Thread( + target=manage_bunkerweb, + name="Reloading instances", + args=("instances", request.form["operation"], request.form["INSTANCE_ID"]), + ).start() - if operation.startswith("Can't"): - flash(operation, "error") - else: - flash(operation) - - return redirect(url_for("loading", next=url_for("instances"))) + return redirect( + url_for( + "loading", + next=url_for("instances"), + message=( + f"{request.form['operation'].title()}ing" + if request.form["operation"] is not "stop" + else "Stopping" + ) + + " instance", + ) + ) # Display instances instances = app.config["INSTANCES"].get_instances() @@ -394,33 +447,32 @@ def services(): error = 0 - # Do the operation + # Reload instances + app.config["RELOADING"] = True + Thread( + target=manage_bunkerweb, + name="Reloading instances", + args=( + "services", + request.form["operation"], + variables, + request.form.get("OLD_SERVER_NAME", None), + request.form.get("SERVER_NAME", None), + ), + ).start() + + message = "" + if request.form["operation"] == "new": - operation, error = app.config["CONFIG"].new_service(variables) + message = f"Creating service {variables['SERVER_NAME'].split(' ')[0]}" elif request.form["operation"] == "edit": - operation = app.config["CONFIG"].edit_service( - request.form["OLD_SERVER_NAME"], variables + message = ( + f"Saving configuration for service {request.form['OLD_SERVER_NAME']}" ) elif request.form["operation"] == "delete": - operation, error = app.config["CONFIG"].delete_service( - request.form["SERVER_NAME"] - ) + message = f"Deleting service {request.form['SERVER_NAME']}" - if error: - flash(operation, "error") - return redirect(url_for("loading", next=url_for("services"))) - - flash(operation) - - # Reload instances - _reloads = app.config["INSTANCES"].reload_instances() - if not _reloads: - for _reload in _reloads: - flash(f"Reload failed for the instance {_reload}", "error") - else: - flash("Successfully reloaded instances") - - return redirect(url_for("loading", next=url_for("services"))) + return redirect(url_for("loading", next=url_for("services"), message=message)) # Display services services = app.config["CONFIG"].get_services() @@ -461,26 +513,25 @@ def global_config(): if error: return redirect(url_for("loading", next=url_for("global_config"))) - error = 0 - - # Do the operation - operation = app.config["CONFIG"].edit_global_conf(variables) - - if error: - flash(operation, "error") - return redirect(url_for("loading", next=url_for("global_config"))) - - flash(operation) - # Reload instances - _reloads = app.config["INSTANCES"].reload_instances() - if not _reloads: - for _reload in _reloads: - flash(f"Reload failed for the instance {_reload}", "error") - else: - flash("Successfully reloaded instances") + app.config["RELOADING"] = True + Thread( + target=manage_bunkerweb, + name="Reloading instances", + args=( + "global_config", + "reloads", + variables, + ), + ).start() - return redirect(url_for("loading", next=url_for("global_config"))) + return redirect( + url_for( + "loading", + next=url_for("global_config"), + message="Saving global configuration", + ) + ) # Display services services = app.config["CONFIG"].get_services() @@ -558,12 +609,12 @@ def configs(): flash(operation) # Reload instances - _reloads = app.config["INSTANCES"].reload_instances() - if not _reloads: - for _reload in _reloads: - flash(f"Reload failed for the instance {_reload}", "error") - else: - flash("Successfully reloaded instances") + app.config["RELOADING"] = True + Thread( + target=manage_bunkerweb, + name="Reloading instances", + args=("configs",), + ).start() return redirect(url_for("loading", next=url_for("configs"))) @@ -868,24 +919,24 @@ def plugins(): error = 0 + # Fix permissions for plugins folders for root, dirs, files in os.walk("/opt/bunkerweb/plugins", topdown=False): for name in files + dirs: chown(os.path.join(root, name), "nginx", "nginx") os.chmod(os.path.join(root, name), 0o770) - app.config["CONFIG"].reload_config() - if operation: flash(operation) # Reload instances - _reloads = app.config["INSTANCES"].reload_instances() - if not _reloads: - for _reload in _reloads: - flash(f"Reload failed for the instance {_reload}", "error") - else: - flash("Successfully reloaded instances") + app.config["RELOADING"] = True + Thread( + target=manage_bunkerweb, + name="Reloading instances", + args=("plugins",), + ).start() + # Remove tmp folder if os.path.exists("/opt/bunkerweb/tmp/ui"): try: rmtree("/opt/bunkerweb/tmp/ui") @@ -893,8 +944,11 @@ def plugins(): pass app.config["CONFIG"].reload_plugins() - return redirect(url_for("loading", next=url_for("plugins"))) + return redirect( + url_for("loading", next=url_for("plugins"), message="Reloading plugins") + ) + # Initialize plugins tree plugins = [ { "name": "plugins", @@ -918,8 +972,47 @@ def plugins(): ], } ] + # Populate plugins tree + plugins_pages = app.config["CONFIG"].get_plugins_pages() - return render_template("plugins.html", folders=plugins) + pages = [] + active = True + for page in plugins_pages: + with open( + f"/opt/bunkerweb/" + + ( + "plugins" + if os.path.exists( + f"/opt/bunkerweb/plugins/{page.lower()}/ui/template.html" + ) + else "core" + ) + + f"/{page.lower()}/ui/template.html", + "r", + ) as f: + # Convert the file content to a jinja2 template + template = Template(f.read()) + + pages.append( + { + "id": page.lower().replace(" ", "-"), + "name": page, + # Render the template with the plugin's data if it corresponds to the last submitted form else with the default data + "content": template.render(csrf_token=generate_csrf) + if app.config["PLUGIN_ARGS"] is None + or app.config["PLUGIN_ARGS"]["plugin"] != page.lower() + else template.render( + csrf_token=generate_csrf, **app.config["PLUGIN_ARGS"]["args"] + ), + # Only the first plugin page is active + "active": active, + } + ) + active = False + + app.config["PLUGIN_ARGS"] = None + + return render_template("plugins.html", folders=plugins, pages=pages) @app.route("/plugins/upload", methods=["POST"]) @@ -944,6 +1037,85 @@ def upload_plugin(): return {"status": "ok"}, 201 +@app.route("/plugins/", methods=["GET", "POST"]) +@login_required +def custom_plugin(plugin): + if not re_match(r"^[a-zA-Z0-9_-]{1,64}$", plugin): + flash( + f"Invalid plugin id, {plugin} (must be between 1 and 64 characters, only letters, numbers, underscores and hyphens)", + "error", + ) + return redirect(url_for("loading", next=url_for("plugins"))) + + if not os.path.exists( + f"/opt/bunkerweb/plugins/{plugin}/ui/actions.py" + ) and not os.path.exists(f"/opt/bunkerweb/core/{plugin}/ui/actions.py"): + flash( + f"The actions.py file for the plugin {plugin} does not exist", + "error", + ) + return redirect(url_for("loading", next=url_for("plugins"))) + + # Add the custom plugin to sys.path + sys_path.append( + f"/opt/bunkerweb/" + + ( + "plugins" + if os.path.exists(f"/opt/bunkerweb/plugins/{plugin}/ui/actions.py") + else "core" + ) + + f"/{plugin}/ui/" + ) + try: + # Try to import the custom plugin + import actions + except: + flash( + f"An error occurred while importing the plugin {plugin}:
{format_exc()}", + "error", + ) + return redirect(url_for("loading", next=url_for("plugins"))) + + error = False + res = None + + try: + # Try to get the custom plugin custom function and call it + method = getattr(actions, plugin) + res = method() + except AttributeError: + flash( + f"The plugin {plugin} does not have a {plugin} method", + "error", + ) + error = True + return redirect(url_for("loading", next=url_for("plugins"))) + except: + flash( + f"An error occurred while executing the plugin {plugin}:
{format_exc()}", + "error", + ) + error = True + finally: + # Remove the custom plugin from the shared library + sys_path.pop() + del sys_modules["actions"] + del actions + + if ( + request.method != "POST" + or error is True + or res is None + or isinstance(res, dict) is False + ): + return redirect(url_for("loading", next=url_for("plugins"))) + + app.config["PLUGIN_ARGS"] = {"plugin": plugin, "args": res} + + flash(f"Your action {plugin} has been executed") + return redirect(url_for("loading", next=url_for("plugins"))) + + @app.route("/cache", methods=["GET"]) @login_required def cache(): @@ -1212,6 +1384,21 @@ def login(): return render_template("login.html") +@app.route("/check_reloading") +@login_required +def check_reloading(): + if app.config["RELOADING"] is False: + for f in app.config["TO_FLASH"]: + if f["type"] == "error": + flash(f["content"], "error") + else: + flash(f["content"]) + + app.config["TO_FLASH"].clear() + + return jsonify({"reloading": app.config["RELOADING"]}) + + @app.route("/logout") @login_required def logout(): diff --git a/ui/src/Config.py b/ui/src/Config.py index 089f61483..af92b28d6 100644 --- a/ui/src/Config.py +++ b/ui/src/Config.py @@ -1,8 +1,8 @@ from copy import deepcopy +from os import listdir from flask import flash -from operator import xor from os.path import isfile -from typing import Tuple +from typing import List, Tuple from json import load as json_load from uuid import uuid4 from glob import iglob @@ -13,35 +13,48 @@ from subprocess import run, DEVNULL, STDOUT class Config: def __init__(self): with open("/opt/bunkerweb/settings.json", "r") as f: - self.__settings = json_load(f) + self.__settings: dict = json_load(f) - self.__plugins = [] - for filename in iglob("/opt/bunkerweb/core/**/plugin.json"): - with open(filename, "r") as f: - self.__plugins.append(json_load(f)) - - for filename in iglob("/opt/bunkerweb/plugins/**/plugin.json"): - with open(filename, "r") as f: - self.__plugins.append(json_load(f)) - - self.__plugins.sort(key=lambda plugin: plugin.get("name")) - self.__plugins_settings = { - **{k: v for x in self.__plugins for k, v in x["settings"].items()}, - **self.__settings, - } + self.reload_plugins() def reload_plugins(self) -> None: - self.__plugins.clear() + self.__plugins = [] + self.__plugins_pages = [] - for filename in iglob("/opt/bunkerweb/core/**/plugin.json"): - with open(filename, "r") as f: - self.__plugins.append(json_load(f)) + for foldername in iglob("/opt/bunkerweb/plugins/*"): + content = listdir(foldername) + if "plugin.json" not in content: + continue + + with open(f"{foldername}/plugin.json", "r") as f: + plugin = json_load(f) + + self.__plugins.append(plugin) + + if "ui" in content: + if "template.html" in listdir(f"{foldername}/ui"): + self.__plugins_pages.append(plugin["name"]) + + for foldername in iglob("/opt/bunkerweb/core/*"): + content = listdir(foldername) + if "plugin.json" not in content: + continue + + with open(f"{foldername}/plugin.json", "r") as f: + plugin = json_load(f) + + self.__plugins.append(plugin) + + if "ui" in content: + if "template.html" in listdir(f"{foldername}/ui"): + self.__plugins_pages.append(plugin["name"]) for filename in iglob("/opt/bunkerweb/plugins/**/plugin.json"): with open(filename, "r") as f: self.__plugins.append(json_load(f)) self.__plugins.sort(key=lambda plugin: plugin.get("name")) + self.__plugins_pages.sort() self.__plugins_settings = { **{k: v for x in self.__plugins for k, v in x["settings"].items()}, **self.__settings, @@ -148,9 +161,12 @@ class Config: def get_plugins_settings(self) -> dict: return self.__plugins_settings - def get_plugins(self) -> dict: + def get_plugins(self) -> List[dict]: return self.__plugins + def get_plugins_pages(self) -> List[str]: + return self.__plugins_pages + def get_settings(self) -> dict: return self.__settings diff --git a/ui/src/Instances.py b/ui/src/Instances.py index 239a142e7..eb5da9179 100644 --- a/ui/src/Instances.py +++ b/ui/src/Instances.py @@ -1,5 +1,5 @@ import os -from typing import Any +from typing import Any, Union from subprocess import run from api.API import API @@ -126,17 +126,17 @@ class Instances: return instances - def reload_instances(self) -> list[str]: + def reload_instances(self) -> Union[list[str], str]: not_reloaded: list[str] = [] for instance in self.get_instances(): if instance.health is False: not_reloaded.append(instance.name) continue - if self.reload_instance(None, instance).startswith("Can't reload"): + if self.reload_instance(instance=instance).startswith("Can't reload"): not_reloaded.append(instance.name) - return not_reloaded + return not_reloaded or "Successfully reloaded instances" def reload_instance(self, id: int = None, instance: Instance = None) -> str: if instance is None: diff --git a/ui/static/css/configs&plugins&cache.css b/ui/static/css/configs&plugins&cache.css index 8775f2d4b..e5aab4089 100644 --- a/ui/static/css/configs&plugins&cache.css +++ b/ui/static/css/configs&plugins&cache.css @@ -287,3 +287,41 @@ background: inherit; color: inherit; } + +.plugins-pages .row .col-form-label, +.plugins-pages .row .col-8 { + background-color: transparent; +} + +.nav-pills .nav-link.active, +.nav-pills .nav-link:hover { + background-color: rgba(64, 187, 107, 0.5) !important; + background-clip: border-box !important; +} + +.plugins-pages .nav-link span { + color: #000 !important; +} + +.plugins-pages aside { + background: none !important; +} + +[data-theme="dark"] .plugins-pages, +[data-theme="dark"] .wrapper { + background-color: #222 !important; +} + +[data-theme="dark"] .plugins-pages .bg-light { + background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; + border-color: #111 !important; + border: 1px solid #343636 !important; +} + +[data-theme="dark"] .plugins-pages .nav-link span { + color: #fff !important; +} + +[data-theme="dark"] .nav-pills .nav-link.active { + background-color: #535353 !important; +} diff --git a/ui/templates/loading.html b/ui/templates/loading.html index 9d1b6ba15..40a039a40 100644 --- a/ui/templates/loading.html +++ b/ui/templates/loading.html @@ -3,13 +3,13 @@ - Loading... + {{ message }}...
-

Loading...

+

{{ message }}...

@@ -23,6 +23,19 @@ \ No newline at end of file diff --git a/ui/templates/plugins.html b/ui/templates/plugins.html index 3ad75f13c..4d5a0ec88 100644 --- a/ui/templates/plugins.html +++ b/ui/templates/plugins.html @@ -38,6 +38,57 @@
+{% if pages %} +
+
+
+ +
+
+
+ {% for page in pages %} +
+ {{ page["content"]|safe }} +
+ {% endfor %} +
+
+
+
+
+
+{% endif %}