Made a lot of improvements in web UI + have a working configuration save for services and global config

This commit is contained in:
Théophile Diot 2024-09-06 16:09:07 +02:00
parent 3e56a2c522
commit 02a8af2aa7
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
24 changed files with 976 additions and 422 deletions

View file

@ -10,5 +10,5 @@ from app.models.ui_database import UIDatabase
DB = UIDatabase(getLogger("UI"), log=False)
DATA = UIData(Path(sep, "var", "tmp", "bunkerweb").joinpath("ui_data.json"))
BW_CONFIG = Config(DB)
BW_CONFIG = Config(DB, data=DATA)
BW_INSTANCES_UTILS = InstancesUtils(DB)

View file

@ -9,11 +9,14 @@ from pathlib import Path
from re import error as RegexError, search as re_search
from typing import List, Literal, Optional, Set, Tuple, Union
from app.utils import get_blacklisted_settings
class Config:
def __init__(self, db) -> None:
def __init__(self, db, data) -> None:
self.__settings = json_loads(Path(sep, "usr", "share", "bunkerweb", "settings.json").read_text(encoding="utf-8"))
self.__db = db
self.__data = data
def __gen_conf(
self, global_conf: dict, services_conf: list[dict], *, check_changes: bool = True, changed_service: Optional[str] = None
@ -104,7 +107,7 @@ class Config:
"""
return self.__db.get_services_settings(methods=methods, with_drafts=with_drafts)
def check_variables(self, variables: dict, config: dict) -> dict:
def check_variables(self, variables: dict, config: dict, *, global_config: bool = False, threaded: bool = False) -> dict:
"""Testify that the variables passed are valid
Parameters
@ -117,32 +120,41 @@ class Config:
int
Return the error code
"""
self.__data.load_from_file()
plugins_settings = self.get_plugins_settings()
for k, v in variables.copy().items():
check = False
if k.endswith("SCHEMA"):
variables.pop(k)
continue
if k in plugins_settings:
setting = k
else:
setting = k[0 : k.rfind("_")] # noqa: E203
if setting not in plugins_settings or "multiple" not in plugins_settings[setting]:
flash(f"Variable {k} is not valid.", "error")
content = f"Variable {k} is not valid."
if threaded:
self.__data["TO_FLASH"].append({"content": content, "type": "error"})
else:
flash(content, "error")
variables.pop(k)
continue
if setting in ("AUTOCONF_MODE", "SWARM_MODE", "KUBERNETES_MODE", "IS_LOADING", "IS_DRAFT"):
flash(f"Variable {k} is not editable, ignoring it", "error")
if setting in get_blacklisted_settings(global_config):
message = f"Variable {k} is not editable, ignoring it"
if threaded:
self.__data["TO_FLASH"].append({"content": message, "type": "error"})
else:
flash(message, "error")
variables.pop(k)
continue
elif setting not in config and plugins_settings[setting]["default"] == v:
variables.pop(k)
continue
elif config[setting]["method"] not in ("default", "ui"):
flash(f"Variable {k} is not editable as is it managed by the {config[setting]['method']}, ignoring it", "error")
message = f"Variable {k} is not editable as is it managed by the {config[setting]['method']}, ignoring it"
if threaded:
self.__data["TO_FLASH"].append({"content": message, "type": "error"})
else:
flash(message, "error")
variables.pop(k)
continue
@ -150,14 +162,33 @@ class Config:
if re_search(plugins_settings[setting]["regex"], v):
check = True
except RegexError as e:
flash(f"Invalid regex for setting {setting} : {plugins_settings[setting]['regex']}, ignoring regex check:{e}", "error")
message = f"Invalid regex for setting {setting} : {plugins_settings[setting]['regex']}, ignoring regex check:{e}"
if threaded:
self.__data["TO_FLASH"].append({"content": message, "type": "error"})
else:
flash(message, "error")
variables.pop(k)
continue
if not check:
flash(f"Variable {k} is not valid.", "error")
message = f"Variable {k} is not valid."
if threaded:
self.__data["TO_FLASH"].append({"content": message, "type": "error"})
else:
flash(message, "error")
variables.pop(k)
for k in config:
if k in plugins_settings:
continue
setting = k[0 : k.rfind("_")] # noqa: E203
if setting not in plugins_settings or "multiple" not in plugins_settings[setting]:
continue
if k not in variables:
variables[k] = plugins_settings[setting]["default"]
return variables
def new_service(self, variables: dict, is_draft: bool = False) -> Tuple[str, int]:

View file

@ -95,10 +95,12 @@ class Totp:
def get_last_counter(self, user: Users) -> Optional[int]:
"""Fetch stored last_counter from cache."""
DATA.load_from_file()
return DATA.get("totp_last_counter", {}).get(user.get_id())
def set_last_counter(self, user: Users, tmatch: TotpMatch) -> None:
"""Cache last_counter."""
DATA.load_from_file()
if "totp_last_counter" not in DATA:
DATA["totp_last_counter"] = {}
DATA["totp_last_counter"][user.get_id()] = tmatch.counter

View file

@ -5,7 +5,7 @@ from bs4 import BeautifulSoup
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from app.dependencies import BW_CONFIG, DATA, DB
from app.dependencies import BW_CONFIG, DATA, DB # TODO: remember about DATA.load_from_file()
from app.utils import LOGGER, PLUGIN_NAME_RX, path_to_dict
from app.routes.utils import handle_error, verify_data_in_form

View file

@ -1,6 +1,7 @@
from contextlib import suppress
from threading import Thread
from time import time
from typing import Dict
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
@ -19,62 +20,66 @@ def global_config_page():
if request.method == "POST":
if DB.readonly:
return handle_error("Database is in read-only mode", "global_config")
DATA.load_from_file()
# Check variables
variables = request.form.to_dict().copy()
del variables["csrf_token"]
# Edit check fields and remove already existing ones
config = DB.get_config(methods=True, with_drafts=True)
services = config["SERVER_NAME"]["value"].split(" ")
for variable, value in variables.copy().items():
setting = config.get(variable, {"value": None, "global": True})
if setting["global"] and value == setting["value"]:
del variables[variable]
continue
variables = BW_CONFIG.check_variables(variables, config)
if not variables:
return handle_error("The global configuration was not edited because no values were changed.", "global_config", True)
for variable, value in variables.copy().items():
for service in services:
setting = config.get(f"{service}_{variable}", None)
if setting and setting["global"] and (setting["value"] != value or setting["value"] == config.get(variable, {"value": None})["value"]):
variables[f"{service}_{variable}"] = value
db_metadata = DB.get_metadata()
def update_global_config(threaded: bool = False):
def update_global_config(variables: Dict[str, str], threaded: bool = False):
wait_applying()
# Edit check fields and remove already existing ones
config = DB.get_config(methods=True, with_drafts=True)
services = config["SERVER_NAME"]["value"].split(" ")
for variable, value in variables.copy().items():
setting = config.get(variable, {"value": None, "global": True})
if setting["global"] and value == setting["value"]:
del variables[variable]
continue
variables = BW_CONFIG.check_variables(variables, config, global_config=True, threaded=threaded)
if not variables:
content = "The global configuration was not edited because no values were changed."
if threaded:
DATA["TO_FLASH"].append({"content": content, "type": "warning"})
else:
flash(content, "warning")
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
if "PRO_LICENSE_KEY" in variables:
DATA["PRO_LOADING"] = True
for variable, value in variables.copy().items():
for service in services:
setting = config.get(f"{service}_{variable}", None)
if setting and setting["global"] and (setting["value"] != value or setting["value"] == config.get(variable, {"value": None})["value"]):
variables[f"{service}_{variable}"] = value
with suppress(BaseException):
if config["PRO_LICENSE_KEY"]["value"] != variables["PRO_LICENSE_KEY"]:
if threaded:
DATA["TO_FLASH"].append({"content": "Checking license key to upgrade.", "type": "success"})
else:
flash("Checking license key to upgrade.", "success")
manage_bunkerweb("global_config", variables, threaded=threaded)
if "PRO_LICENSE_KEY" in variables:
DATA["PRO_LOADING"] = True
DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True})
Thread(target=update_global_config, args=(variables, True)).start()
if any(
v
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(target=update_global_config, args=(True,)).start()
else:
update_global_config()
DATA["CONFIG_CHANGED"] = True
with suppress(BaseException):
if config["PRO_LICENSE_KEY"]["value"] != variables["PRO_LICENSE_KEY"]:
flash("Checking license key to upgrade.", "success")
arguments = {}
if request.args.get("keywords"):
arguments["keywords"] = request.args["keywords"]
if request.args.get("type", "all") != "all":
arguments["type"] = request.args["type"]
return redirect(
url_for(
"loading",
next=url_for("global_config.global_config_page"),
next=url_for("global_config.global_config_page") + f"?{'&'.join([f'{k}={v}' for k, v in arguments.items()])}",
message="Saving global configuration",
)
)
@ -82,5 +87,4 @@ def global_config_page():
keywords = request.args.get("keywords", "")
search_type = request.args.get("type", "all")
global_config = DB.get_config(global_only=True, methods=True)
plugins = BW_CONFIG.get_plugins()
return render_template("global_config.html", config=global_config, plugins=plugins, keywords=keywords, type=search_type)
return render_template("global_config.html", config=global_config, keywords=keywords, type=search_type)

View file

@ -75,6 +75,7 @@ def instances_action(action: Literal["ping", "reload", "stop", "delete"]): # TO
instances = request.form["instances"].split(",")
if not instances:
return handle_error("No instances selected.", "instances", True)
DATA.load_from_file()
if action == "ping":
succeed = []

View file

@ -21,7 +21,7 @@ from werkzeug.utils import secure_filename
from common_utils import bytes_hash # type: ignore
from app.dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DATA, DB
from app.dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DATA, DB # TODO: remember about DATA.load_from_file()
from app.utils import LOGGER, PLUGIN_NAME_RX, TMP_DIR
from app.routes.utils import PLUGIN_ID_RX, PLUGIN_KEYS, error_message, handle_error, verify_data_in_form, wait_applying

View file

@ -1,9 +1,12 @@
from flask import Blueprint, Response, redirect, render_template, request, url_for
from threading import Thread
from time import time
from typing import Dict
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from app.dependencies import BW_CONFIG, DB
from app.dependencies import BW_CONFIG, DATA, DB
from app.routes.utils import get_service_data, handle_error, update_service
from app.routes.utils import handle_error, manage_bunkerweb, wait_applying
services = Blueprint("services", __name__)
@ -11,15 +14,15 @@ services = Blueprint("services", __name__)
@services.route("/services", methods=["GET", "POST"])
@login_required
def services_page():
if request.method == "POST":
if request.method == "POST": # TODO: Handle creation and deletion of services
if DB.readonly:
return handle_error("Database is in read-only mode", "services")
config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode = get_service_data("services")
# config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode = get_service_data("services")
message = update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged)
# message = update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged)
return redirect(url_for("loading", next=url_for("services.services_page"), message=message))
# return redirect(url_for("loading", next=url_for("services.services_page"), message=message))
return render_template("services.html") # TODO
@ -28,11 +31,157 @@ def services_page():
@login_required
def services_service_page(service: str):
services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ")
if service not in services:
return Response("Service not found", status=404)
service_exists = service in services
if request.method == "POST":
if DB.readonly:
return handle_error("Database is in read-only mode", "services")
DATA.load_from_file()
# Check variables
variables = request.form.to_dict().copy()
del variables["csrf_token"]
mode = request.args.get("mode", "easy")
is_draft = variables.get("IS_DRAFT", "no") == "yes"
def update_service(variables: Dict[str, str], is_draft: bool, threaded: bool = False): # TODO: handle easy and raw modes
wait_applying()
# Edit check fields and remove already existing ones
if service_exists:
config = DB.get_config(methods=True, with_drafts=True, service=service)
else:
config = DB.get_config(methods=True, with_drafts=True)
was_draft = config.get(f"{service}_IS_DRAFT", {"value": "no"})["value"] == "yes"
old_server_name = variables.pop("OLD_SERVER_NAME", "")
# Edit check fields and remove already existing ones
for variable, value in variables.copy().items():
if variable != "SERVER_NAME" and value == config.get(f"{service}_{variable}", {"value": None})["value"]:
del variables[variable]
variables = BW_CONFIG.check_variables(variables, config, threaded=threaded)
if was_draft == is_draft and not variables:
content = f"The service {service} was not edited because no values were changed."
if threaded:
DATA["TO_FLASH"].append({"content": content, "type": "warning"})
else:
flash(content, "warning")
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
if "SERVER_NAME" not in variables:
variables["SERVER_NAME"] = old_server_name
manage_bunkerweb(
"services",
variables,
old_server_name,
operation="edit" if service_exists else "new",
is_draft=is_draft,
was_draft=was_draft,
threaded=threaded,
)
DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True})
Thread(target=update_service, args=(variables, is_draft, True)).start()
arguments = {}
if mode != "easy":
arguments["mode"] = mode
if request.args.get("keywords"):
arguments["keywords"] = request.args["keywords"]
if request.args.get("type", "all") != "all":
arguments["type"] = request.args["type"]
return redirect(
url_for(
"loading",
next=url_for(
"services.services_service_page",
service=service,
)
+ f"?{'&'.join([f'{k}={v}' for k, v in arguments.items()])}",
message=f"Saving configuration for {'draft ' if is_draft else ''}service {service}",
)
)
services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ")
if not service_exists:
db_config = DB.get_config(global_only=True, methods=True)
return render_template("service_settings.html", config=db_config)
mode = request.args.get("mode", "easy")
keywords = request.args.get("keywords", "")
search_type = request.args.get("type", "all")
db_config = DB.get_config(methods=True, with_drafts=True, service=service)
plugins = BW_CONFIG.get_plugins()
return render_template("service_settings.html", config=db_config, plugins=plugins, mode=mode, keywords=keywords, type=search_type)
return render_template(
"service_settings.html",
config=db_config,
mode=mode,
keywords=keywords,
type=search_type,
)
# def update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged):
# if request.form["operation"] == "edit":
# if is_draft_unchanged and len(variables) == 1 and "SERVER_NAME" in variables and server_name == old_server_name:
# return handle_error("The service was not edited because no values were changed.", "services", True)
# if request.form["operation"] == "new" and not variables:
# return handle_error("The service was not created because all values had the default value.", "services", True)
# # Delete
# if request.form["operation"] == "delete":
# is_service = BW_CONFIG.check_variables({"SERVER_NAME": request.form["SERVER_NAME"]}, config)
# if not is_service:
# error_message(f"Error while deleting the service {request.form['SERVER_NAME']}")
# if config.get(f"{request.form['SERVER_NAME'].split(' ')[0]}_SERVER_NAME", {"method": "scheduler"})["method"] != "ui":
# return handle_error("The service cannot be deleted because it has not been created with the UI.", "services", True)
# db_metadata = DB.get_metadata()
# def update_services(threaded: bool = False):
# wait_applying()
# manage_bunkerweb(
# "services",
# variables,
# old_server_name,
# variables.get("SERVER_NAME", ""),
# operation=operation,
# is_draft=is_draft,
# was_draft=was_draft,
# threaded=threaded,
# )
# if any(
# v
# for k, v in db_metadata.items()
# if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
# ):
# DATA["RELOADING"] = True
# DATA["LAST_RELOAD"] = time()
# Thread(target=update_services, args=(True,)).start()
# else:
# update_services()
# DATA["CONFIG_CHANGED"] = True
# message = ""
# if request.form["operation"] == "new":
# message = f"Creating {'draft ' if is_draft else ''}service {variables.get('SERVER_NAME', '').split(' ')[0]}"
# elif request.form["operation"] == "edit":
# message = f"Saving configuration for {'draft ' if is_draft else ''}service {old_server_name.split(' ')[0]}"
# elif request.form["operation"] == "delete":
# message = f"Deleting {'draft ' if was_draft and is_draft else ''}service {request.form.get('SERVER_NAME', '').split(' ')[0]}"
# return message

View file

@ -6,7 +6,7 @@ from time import time
from flask import Blueprint, Response, flash, redirect, render_template, request, url_for
from app.dependencies import BW_CONFIG, DATA, DB
from app.dependencies import BW_CONFIG, DATA, DB # TODO: remember about DATA.load_from_file()
from app.utils import USER_PASSWORD_RX, gen_password_hash
from app.routes.utils import REVERSE_PROXY_PATH, handle_error, manage_bunkerweb

View file

@ -3,8 +3,7 @@ from copy import deepcopy
from datetime import datetime
from functools import wraps
from io import BytesIO
from threading import Thread
from time import sleep, time
from time import sleep
from typing import Any, Dict, Optional, Tuple, Union
from flask import Response, flash, redirect, request, session, url_for
@ -49,6 +48,7 @@ def wait_applying():
def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: bool = False, was_draft: bool = False, threaded: bool = False) -> int:
# Do the operation
error = 0
DATA.load_from_file()
if "TO_FLASH" not in DATA:
DATA["TO_FLASH"] = []
@ -311,66 +311,6 @@ def get_service_data(page_name: str):
return config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode
def update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged):
if request.form["operation"] == "edit":
if is_draft_unchanged and len(variables) == 1 and "SERVER_NAME" in variables and server_name == old_server_name:
return handle_error("The service was not edited because no values were changed.", "services", True)
if request.form["operation"] == "new" and not variables:
return handle_error("The service was not created because all values had the default value.", "services", True)
# Delete
if request.form["operation"] == "delete":
is_service = BW_CONFIG.check_variables({"SERVER_NAME": request.form["SERVER_NAME"]}, config)
if not is_service:
error_message(f"Error while deleting the service {request.form['SERVER_NAME']}")
if config.get(f"{request.form['SERVER_NAME'].split(' ')[0]}_SERVER_NAME", {"method": "scheduler"})["method"] != "ui":
return handle_error("The service cannot be deleted because it has not been created with the UI.", "services", True)
db_metadata = DB.get_metadata()
def update_services(threaded: bool = False):
wait_applying()
manage_bunkerweb(
"services",
variables,
old_server_name,
variables.get("SERVER_NAME", ""),
operation=operation,
is_draft=is_draft,
was_draft=was_draft,
threaded=threaded,
)
if any(
v
for k, v in db_metadata.items()
if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
):
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(target=update_services, args=(True,)).start()
else:
update_services()
DATA["CONFIG_CHANGED"] = True
message = ""
if request.form["operation"] == "new":
message = f"Creating {'draft ' if is_draft else ''}service {variables.get('SERVER_NAME', '').split(' ')[0]}"
elif request.form["operation"] == "edit":
message = f"Saving configuration for {'draft ' if is_draft else ''}service {old_server_name.split(' ')[0]}"
elif request.form["operation"] == "delete":
message = f"Deleting {'draft ' if was_draft and is_draft else ''}service {request.form.get('SERVER_NAME', '').split(' ')[0]}"
return message
def cors_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):

View file

@ -290,6 +290,23 @@ td.highlight {
background-color: var(--bs-bw-green);
}
.btn-text-bw-green,
.btn-text-bw-green:hover {
color: var(--bs-bw-green) !important;
}
.btn-text-secondary,
.btn-text-secondary:hover {
color: var(--bs-secondary) !important;
}
.btn-text-danger,
.btn-text-danger.disabled,
.btn-text-danger:hover {
color: var(--bs-danger) !important;
border: none;
}
.col.form-floating > .form-control-sm,
.col-auto.form-floating > .form-select.form-select-sm {
height: calc(1.9em + 0.9rem + 1px);
@ -398,3 +415,17 @@ a.badge:hover {
.setting-checkbox-label {
font-size: calc(var(--bs-body-font-size) * 0.85);
}
.sticky-card {
position: sticky;
background-color: rgba(255, 255, 255, 0.88) !important;
backdrop-filter: saturate(200%) blur(6px);
top: 85px;
z-index: 1000;
}
#floating-modes-menu {
bottom: 2.1rem !important;
left: 1.5rem !important;
z-index: 1080;
}

View file

@ -154,7 +154,7 @@ $(document).ready(function () {
// Create a FormData object
const formData = new FormData();
formData.append("csrf_token", $("#csrf_token").val()); // Add the CSRF token
formData.append("csrf_token", $("#csrf_token").val()); // Add the CSRF token // TODO: find a way to ignore CSRF token
formData.append("instances", instances.join(",")); // Add the instances
// Send the form data using $.ajax
@ -224,21 +224,21 @@ $(document).ready(function () {
}
// Create a form element using jQuery and set its attributes
const $form = $("<form>", {
const form = $("<form>", {
method: "POST",
action: `${window.location.pathname}/${action}`,
class: "visually-hidden",
});
// Add CSRF token and instances as hidden inputs
$form.append(
form.append(
$("<input>", {
type: "hidden",
name: "csrf_token",
value: $("#csrf_token").val(),
}),
);
$form.append(
form.append(
$("<input>", {
type: "hidden",
name: "instances",
@ -247,7 +247,7 @@ $(document).ready(function () {
);
// Append the form to the body and submit it
$form.appendTo("body").submit();
form.appendTo("body").submit();
},
};

View file

@ -5,7 +5,7 @@ $(document).ready(() => {
let currentKeywords = "";
const pluginDropdownItems = $("#plugins-dropdown-menu li.nav-item");
const updateUrlParams = (params) => {
const updateUrlParams = (params, removeHash = false) => {
// Create a new URL based on the current location (this keeps both the search params and the hash)
const newUrl = new URL(window.location.href);
@ -20,7 +20,12 @@ $(document).ready(() => {
// Update the search params of the URL
newUrl.search = searchParams.toString();
// Push the updated URL (this keeps the hash and the merged search params)
// Optionally remove the hash from the URL
if (removeHash) {
newUrl.hash = "";
}
// Push the updated URL (this keeps or removes the hash and updates the search params)
history.pushState(params, document.title, newUrl.toString());
};
@ -88,7 +93,8 @@ $(document).ready(() => {
const params = {};
if (currentType !== "all") params.type = currentType;
if (currentMode !== "easy") params.mode = currentMode;
updateUrlParams(params);
// Call updateUrlParams with `removeHash = true` to remove the hash
updateUrlParams(params, true);
} else {
window.location.hash = currentPlugin;
}
@ -114,45 +120,6 @@ $(document).ready(() => {
$(this).removeClass("is-valid");
});
$(".show-multiple").on("click", function () {
const toggleText = $(this).text().trim() === "SHOW" ? "HIDE" : "SHOW";
$(this).html(
`<i class="bx bx-${
toggleText === "SHOW" ? "hide" : "show-alt"
} bx-sm"></i>&nbsp;${toggleText}`,
);
});
$(".add-multiple").on("click", function () {
const showButtonId = $(this).attr("id").replace("add", "show");
if ($(`#${showButtonId}`).text().trim() === "SHOW") {
$(`#${showButtonId}`).trigger("click");
}
});
$('div[id^="multiple-"]')
.filter(function () {
return !/^multiple-.*-\d+$/.test($(this).attr("id"));
})
.each(function () {
let defaultValues = true;
$(this)
.find("input, select")
.each(function () {
const type = $(this).attr("type");
const defaultVal = $(this).data("default");
const isChecked =
type === "checkbox" &&
$(this).prop("checked") === (defaultVal === "yes");
const isMatchingValue =
type !== "checkbox" && $(this).val() === defaultVal;
if (!isChecked && !isMatchingValue) defaultValues = false;
});
if (defaultValues) $(`#show-${$(this).attr("id")}`).trigger("click");
});
$("#plugin-type-select").on("change", function () {
currentType = $(this).val();
const params = currentType === "all" ? {} : { type: currentType };
@ -174,6 +141,317 @@ $(document).ready(() => {
}
});
$(document).on("click", ".show-multiple", function () {
const toggleText = $(this).text().trim() === "SHOW" ? "HIDE" : "SHOW";
$(this).html(
`<i class="bx bx-${
toggleText === "SHOW" ? "hide" : "show-alt"
} bx-sm"></i>&nbsp;${toggleText}`,
);
});
$(".add-multiple").on("click", function () {
const multipleId = $(this).attr("id").replace("add-", "");
const suffix = $(`#${multipleId}`).find(".multiple-container").length;
const cloneId = `${multipleId}-${suffix}`;
// Clone the first .multiple-container and reset input values
const multipleClone = $(`#${multipleId}`)
.find(".multiple-container")
.first()
.clone();
// Update the IDs and names of the cloned inputs/selects
multipleClone.find("input, select").each(function () {
const type = $(this).attr("type");
const defaultVal = $(this).data("default");
// Enable the inputs/selects and update values
$(this).attr("disabled", false);
const newId = $(this).attr("id").replace("-0", `-${suffix}`);
const newName = `${$(this).attr("name")}_${suffix}`;
$(this).attr("id", newId).attr("name", newName);
// Update the label for the input/select
const settingLabel = $(this).next("label");
settingLabel.attr("for", newId).text(`${settingLabel.text()}_${suffix}`);
// Update value to an empty string or default value
if ($(this).is("select")) {
$(this).val(defaultVal);
$(this)
.find("option")
.each(function () {
$(this).prop("selected", false);
});
} else if (type === "checkbox") {
$(this).prop("checked", false);
} else {
$(this).val("");
}
});
// Update the collapse section's ID and remove tooltips
multipleClone
.find(".multiple-collapse")
.attr("id", `${cloneId}`)
.find('[data-bs-toggle="tooltip"]:not(.badge)')
.each(function () {
$(this)
.removeAttr("data-bs-toggle")
.removeAttr("data-bs-placement")
.removeAttr("data-bs-original-title");
});
// Add the #suffix to h6
const multipleTitle = multipleClone.find("h6");
multipleTitle.text(`${multipleTitle.text()} #${suffix}`);
// Append the "REMOVE" button
multipleClone.find(".add-multiple").remove();
multipleClone.find(".show-multiple").before(
`<div>
<button id="remove-${cloneId}"
type="button"
class="btn btn-xs btn-text-danger rounded-pill remove-multiple p-0 pe-2">
<i class="bx bx-trash bx-sm"></i>&nbsp;REMOVE
</button>
</div>`,
);
// Append the cloned element to the container
$(`#${multipleId}`).append(multipleClone);
// Reinitialize Bootstrap tooltips for the newly added clone
multipleClone.find('[data-bs-toggle="tooltip"]').tooltip();
// Update the data-bs-target and aria-controls attributes of the show-multiple button
const showMultiple = multipleClone.find(".show-multiple");
showMultiple
.attr("data-bs-target", `#${cloneId}`)
.attr("aria-controls", cloneId);
if (showMultiple.text().trim() === "SHOW") showMultiple.trigger("click");
// Scroll to the newly added element
multipleClone.focus()[0].scrollIntoView({
behavior: "smooth",
block: "start",
});
});
$(document).on("click", ".remove-multiple", function () {
const multipleId = $(this).attr("id").replace("remove-", "");
const multiple = $(`#${multipleId}`);
// Check if any input/select is disabled, and exit early if so
let disabled = false;
multiple.find("input, select").each(function () {
if ($(this).prop("disabled")) {
disabled = true;
return false; // Exit the loop early
}
});
if (disabled) return;
const elementToRemove = multiple.parent();
// Ensure the element has the 'collapse' class
if (!elementToRemove.hasClass("collapse")) {
elementToRemove.addClass("collapse show");
}
// Initialize Bootstrap Collapse for the element
const bsCollapse = new bootstrap.Collapse(elementToRemove, {
toggle: false, // Ensure we only collapse, not toggle
});
// Start the collapsing animation and adjust padding
bsCollapse.hide();
elementToRemove.removeClass("pt-2 pb-2").addClass("pt-0 pb-0");
// Remove the element after collapse transition completes
elementToRemove.on("hidden.bs.collapse", function () {
setTimeout(() => {
$(this).remove(); // Remove the element after collapse
}, 60);
});
// Update all next elements' IDs and names
elementToRemove.nextAll().each(function () {
const nextId = $(this).find(".multiple-collapse").attr("id");
const nextSuffix = parseInt(
nextId.substring(nextId.lastIndexOf("-") + 1),
10,
);
const newSuffix = nextSuffix - 1;
const newId = nextId.replace(`-${nextSuffix}`, `-${newSuffix}`);
// Update the ID of the next element
$(this).find(".multiple-collapse").attr("id", newId);
const multipleTitle = $(this).find("h6");
multipleTitle.text(function () {
return $(this).text().replace(` #${nextSuffix}`, ` #${newSuffix}`);
});
// Update the input/select name and corresponding label
$(this)
.find("input, select")
.each(function () {
const newName = $(this)
.attr("name")
.replace(`_${nextSuffix}`, `_${newSuffix}`);
$(this).attr("name", newName);
// Find the associated label and update its 'for' attribute and text
const settingLabel = $(`label[for="${$(this).attr("id")}"]`);
if (settingLabel.length) {
settingLabel.attr("for", newId).text(function () {
return $(this).text().replace(`_${nextSuffix}`, `_${newSuffix}`);
});
}
});
// Update the data-bs-target and aria-controls of the show-multiple button
const showMultiple = $(this).find(".show-multiple");
showMultiple
.attr("data-bs-target", `#${newId}`)
.attr("aria-controls", newId);
const removeMultiple = $(this).find(".remove-multiple");
removeMultiple.attr("id", `remove-${newId}`);
});
});
$("#save-settings").on("click", function () {
const form = $("<form>", {
method: "POST",
action: window.location.href,
class: "visually-hidden",
});
form.append(
$("<input>", {
type: "hidden",
name: "csrf_token",
value: $("#csrf_token").val(),
}),
);
$("div[id^='navs-plugins-']")
.find("input, select")
.each(function () {
const settingName = $(this).attr("name");
const settingType = $(this).attr("type");
const originalValue = $(this).data("original");
var settingValue = $(this).val();
if ($(this).is("select")) {
settingValue = $(this).find("option:selected").val();
} else if (settingType === "checkbox") {
settingValue = $(this).prop("checked") ? "yes" : "no";
}
if (
$(this).attr("id") &&
!$(this).attr("id").startsWith("multiple-") &&
settingValue == originalValue
)
return;
form.append(
$("<input>", {
type: "hidden",
name: settingName,
value: settingValue,
}),
);
});
if (form.children().length < 2) {
alert("No changes detected.");
return;
}
form.appendTo("body").submit();
});
$('div[id^="multiple-"]')
.filter(function () {
return /^multiple-.*-\d+$/.test($(this).attr("id"));
})
.each(function () {
let defaultValues = true;
let disabled = false;
$(this)
.find("input, select")
.each(function () {
const type = $(this).attr("type");
const defaultVal = $(this).data("default");
if ($(this).prop("disabled")) {
disabled = true;
}
// Check for select element
if ($(this).is("select")) {
const selectedVal = $(this).find("option:selected").val();
if (selectedVal != defaultVal) {
defaultValues = false;
}
} else if (type === "checkbox") {
const isChecked =
$(this).prop("checked") === (defaultVal === "yes");
if (!isChecked) {
defaultValues = false;
}
} else {
const isMatchingValue = $(this).val() == defaultVal;
if (!isMatchingValue) {
defaultValues = false;
}
}
});
if (defaultValues) $(`#show-${$(this).attr("id")}`).trigger("click");
if (disabled && $(`#remove-${$(this).attr("id")}`).length) {
$(`#remove-${$(this).attr("id")}`).addClass("disabled");
$(`#remove-${$(this).attr("id")}`)
.parent()
.attr(
"title",
"Cannot remove because one or more settings are disabled",
);
new bootstrap.Tooltip(
$(`#remove-${$(this).attr("id")}`)
.parent()
.get(0),
{
placement: "top",
},
);
}
});
var hasExternalPlugins = false;
var hasProPlugins = false;
pluginDropdownItems.each(function () {
const type = $(this).data("type");
if (type === "external") {
hasExternalPlugins = true;
} else if (type === "pro") {
hasProPlugins = true;
}
});
if (!hasExternalPlugins && !hasProPlugins) {
$("#plugin-type-select").parent().remove();
} else if (!hasExternalPlugins) {
$("#plugin-type-select option[value='external']").remove();
} else if (!hasProPlugins) {
$("#plugin-type-select option[value='pro']").remove();
}
const hash = window.location.hash;
if (hash) {
const targetTab = $(

View file

@ -1,12 +1,8 @@
<nav class="d-flex align-items-center" aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<!-- prettier-ignore -->
{% set breadcrumbs_url = url_for(request.endpoint) %}
{% if current_endpoint != "services" and "services" in request.path %}
{% set breadcrumbs_url = url_for("services") + "/" + current_endpoint %}
{% endif %}
{% with breadcrumbs = breadcrumbs_url.split("/") %}
{% for breadcrumb in breadcrumbs[1:] %}
{% with breadcrumbs = request.path.split("/") %}
{% for breadcrumb in breadcrumbs %}
{% set breadcrumb_url = url_for(breadcrumb.replace('_', '-')) %}
{% if breadcrumb == current_endpoint %}
{% set breadcrumb_url = breadcrumbs_url %}

View file

@ -14,44 +14,103 @@
<div class="d-flex justify-content-between align-items-center mb-5">
{% include "breadcrumb.html" %}
{% if current_endpoint != "services" and "services" in request.path %}
<!-- Floating button to toggle the menu -->
<div class="position-fixed bottom-0 start-0 m-3 d-lg-none"
id="floating-modes-menu">
<button class="btn btn-sm btn-primary d-flex align-items-center justify-content-center rounded-pill me-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#service-modes-floating"
aria-controls="service-modes-floating"
aria-expanded="false"
aria-label="Toggle navigation">
<i class="bx bx-xs bx-menu"></i> Service Modes
</button>
<!-- Collapsible floating menu -->
<div class="collapse mt-2" id="service-modes-floating">
<ul id="service-modes-menu"
class="nav nav-pills flex-column bg-white p-2 rounded shadow-sm"
role="tablist">
<li class="nav-item mb-1" role="presentation">
<button type="button"
class="btn btn-sm rounded-pill nav-link d-flex align-items-center{% if mode == 'easy' %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-modes-easy"
aria-controls="navs-modes-easy"
{% if mode == 'easy' %}aria-selected="true"{% endif %}>
<i class="bx bx-customize bx-sm"></i>
&nbsp;
<span>Easy</span>
</button>
</li>
<li class="nav-item mb-1" role="presentation">
<button type="button"
class="btn btn-sm rounded-pill nav-link d-flex align-items-center{% if mode == 'advanced' %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-modes-advanced"
aria-controls="navs-modes-advanced"
{% if mode == 'advanced' %}aria-selected="true"{% endif %}>
<i class="bx bx-shield-quarter bx-sm"></i>
&nbsp;
<span>Advanced</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="btn btn-sm rounded-pill nav-link d-flex align-items-center{% if mode == 'raw' %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-modes-raw"
aria-controls="navs-modes-raw"
{% if mode == 'raw' %}aria-selected="true"{% endif %}>
<i class="bx bx-notepad bx-sm"></i>
&nbsp;
<span>Raw</span>
</button>
</li>
</ul>
</div>
</div>
<ul id="service-modes-menu"
class="nav nav-pills flex-column flex-md-row"
class="nav nav-pills flex-column flex-md-row d-none d-lg-flex"
role="tablist">
<li class="nav-item me-0 me-sm-3" role="presentation">
<button type="button"
class="rounded-pill nav-link d-flex align-items-center{% if mode == 'easy' %} active{% endif %}"
class="btn btn-sm rounded-pill nav-link d-flex align-items-center{% if mode == 'easy' %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-modes-easy"
aria-controls="navs-modes-easy"
{% if mode == 'easy' %}aria-selected="true"{% endif %}>
<i class="bx bx-customize bx-sm"></i>
<i class="bx bx-customize bx-xs"></i>
&nbsp;
<span class="d-none d-sm-inline">Easy</span>
</button>
</li>
<li class="nav-item me-0 me-sm-3" role="presentation">
<button type="button"
class="rounded-pill nav-link d-flex align-items-center{% if mode == 'advanced' %} active{% endif %}"
class="btn btn-sm rounded-pill nav-link d-flex align-items-center{% if mode == 'advanced' %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-modes-advanced"
aria-controls="navs-modes-advanced"
{% if mode == 'advanced' %}aria-selected="true"{% endif %}>
<i class="bx bx-shield-quarter bx-sm"></i>
<i class="bx bx-shield-quarter bx-xs"></i>
&nbsp;
<span class="d-none d-sm-inline">Advanced</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="rounded-pill nav-link d-flex align-items-center{% if mode == 'raw' %} active{% endif %}"
class="btn btn-sm rounded-pill nav-link d-flex align-items-center{% if mode == 'raw' %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-modes-raw"
aria-controls="navs-modes-raw"
{% if mode == 'raw' %}aria-selected="true"{% endif %}>
<i class="bx bx-notepad bx-sm"></i>
<i class="bx bx-notepad bx-xs"></i>
&nbsp;
<span class="d-none d-sm-inline">Raw</span>
</button>

View file

@ -15,8 +15,8 @@
<div class="toast-header">
<i class="d-block w-px-20 h-auto rounded me-2 tf-icons bx bx-bell"></i>
<span class="fw-medium me-auto">
{% if category == 'error' %}
Error
{% if category != 'message' %}
{{ category|capitalize }}
{% else %}
Success
{% endif %}

View file

@ -72,61 +72,59 @@
<i class="bx bx-chevron-right chevron-icon chevron-rotate"></i>
</button>
</li>
<div class="collapse show" id="pluginsCollapse">
<div class="collapse w-100 show" id="pluginsCollapse">
{% for plugin in plugins %}
{% with not_pro_pro_plugin = not is_pro_version and plugin['type'] == "pro" %}
{% if not_pro_pro_plugin or plugin['page'] %}
<li class="menu-item {% if current_endpoint == plugin['id'] %}active{% endif %}">
<a href="{% if not_pro_pro_plugin %}https://panel.bunkerweb.io/?utm_campaign=self&utm_source=ui#pro{% else %}{{ url_for("plugins") }}/{{ plugin['id'] }}{% endif %}"
class="menu-link"
{% if not_pro_pro_plugin %}target="_blank" rel="noopener"{% endif %}>
<i class="menu-icon tf-icons bx bx-puzzle"></i>
<div class="text-truncate{% if plugin['type'] == 'pro' %} text-primary shine shine-sm{% endif %}"
data-i18n="{{ plugin['name'] }}">{{ plugin['name'] }}</div>
{% if plugin['type'] != "pro" %}
<div class="badge rounded-pill bg-label-{% if plugin['type'] == 'core' %}secondary{% else %}primary{% endif %} text-uppercase fs-tiny ms-auto">
{{ plugin['type'].title() }}
</div>
{% else %}
<div class="badge badge-center rounded-pill text-uppercase fs-tiny ms-auto"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-html="true"
title="<i class='bx bx-diamond bx-xs'></i><span>Pro feature</span>">
<img src="{{ pro_diamond_url }}"
alt="Pro plugin"
width="18px"
height="15.5px">
</div>
{% endif %}
</a>
</li>
{% endif %}
{% endwith %}
{% endfor %}
</div>
<!-- / Plugins Pages -->
<!-- Misc -->
<li class="menu-header small text-uppercase">
<span class="menu-header-text">Misc</span>
</li>
<li class="menu-item">
<a href="https://panel.bunkerweb.io/order/support/?utm_campaign=self&utm_source=ui"
target="_blank"
class="menu-link">
<i class="menu-icon tf-icons bx bx-support"></i>
<div class="text-truncate" data-i18n="Support">Support</div>
</a>
</li>
<li class="menu-item">
<a href="https://docs.bunkerweb.io/latest/?utm_campaign=self&utm_source=ui"
target="_blank"
class="menu-link">
<i class="menu-icon tf-icons bx bx-file"></i>
<div class="text-truncate" data-i18n="Documentation">Documentation</div>
</a>
</li>
</ul>
{% endwith %}
<li class="menu-item{% if current_endpoint == plugin['id'] %} active{% endif %}"{% if not_pro_pro_plugin %}data-bs-toggle="tooltip" data-bs-placement="top" data-bs-html="true" title="<i class='bx bx-diamond bx-xs'></i><span>Pro feature</span>"
{% endif %}
>
<a href="{% if not_pro_pro_plugin %}https://panel.bunkerweb.io/?utm_campaign=self&utm_source=ui#pro{% else %}{{ url_for("plugins") }}/{{ plugin['id'] }}{% endif %}"
class="menu-link"
{% if not_pro_pro_plugin %}target="_blank" rel="noopener"{% endif %}>
<i class="menu-icon tf-icons bx bx-puzzle"></i>
<div class="text-truncate{% if plugin['type'] == 'pro' %} text-primary shine shine-sm{% endif %} pe-2"
data-i18n="{{ plugin['name'] }}">{{ plugin['name'] }}</div>
{% if plugin['type'] != "pro" %}
<div class="badge rounded-pill bg-label-{% if plugin['type'] == 'core' %}secondary{% else %}primary{% endif %} text-uppercase fs-tiny ms-auto">
{{ plugin['type'].title() }}
</div>
{% else %}
<div class="badge badge-center rounded-pill text-uppercase fs-tiny ms-auto">
<img src="{{ pro_diamond_url }}"
alt="Pro plugin"
width="18px"
height="15.5px">
</div>
{% endif %}
</a>
</li>
{% endif %}
{% endwith %}
{% endfor %}
</div>
<!-- / Plugins Pages -->
<!-- Misc -->
<li class="menu-header small text-uppercase">
<span class="menu-header-text">Misc</span>
</li>
<li class="menu-item">
<a href="https://panel.bunkerweb.io/order/support/?utm_campaign=self&utm_source=ui"
target="_blank"
class="menu-link">
<i class="menu-icon tf-icons bx bx-support"></i>
<div class="text-truncate" data-i18n="Support">Support</div>
</a>
</li>
<li class="menu-item">
<a href="https://docs.bunkerweb.io/latest/?utm_campaign=self&utm_source=ui"
target="_blank"
class="menu-link">
<i class="menu-icon tf-icons bx bx-file"></i>
<div class="text-truncate" data-i18n="Documentation">Documentation</div>
</a>
</li>
</ul>
{% endwith %}
</aside>
<!-- / Menu -->

View file

@ -1,13 +1,16 @@
<div class="form-check form-switch mt-1">
<input id="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}"
<input id="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}"
name="{{ setting }}"
class="form-check-input"
type="checkbox"
role="switch"
aria-labelledby="label-{{ plugin['id'] }}-{{ setting_data['id'] }}"
data-default="{{ setting_data['default'] }}"
aria-labelledby="label-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}"
data-original="{{ setting_value }}"
data-default="{{ setting_default }}"
{% if setting_value == "yes" %}checked{% endif %}
{% if disabled %}disabled{% endif %}>
<label class="form-check setting-checkbox-label d-flex align-items-center ps-0"
for="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}">{{ setting }}</label>
for="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}">
{{ setting }}
</label>
</div>

View file

@ -1,14 +1,17 @@
<div class="form-floating mt-1{% if setting_data['type'] == 'password' %} input-group input-group-merge form-password-toggle{% endif %}">
<input id="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}"
<input id="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}"
name="{{ setting }}"
type="{{ setting_data['type'] }}"
class="form-control plugin-setting"
aria-labelledby="label-{{ plugin['id'] }}-{{ setting_data['id'] }}"
aria-labelledby="label-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}"
pattern="{{ setting_data['regex'] }}"
value="{{ setting_value }}"
data-default="{{ setting_data['default'] }}"
data-original="{{ setting_value }}"
data-default="{{ setting_default }}"
{% if disabled %}disabled{% endif %}>
<label for="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}">{{ setting }}</label>
<label for="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}">
{{ setting }}
</label>
{% if setting_data['type'] == 'password' %}
<span class="input-group-text cursor-pointer"><i class="bx bx-hide"></i></span>
{% endif %}

View file

@ -1,5 +1,9 @@
{% set blacklisted_settings = get_blacklisted_settings(current_endpoint == "global-config") %}
<div class="card p-1 mb-4">
<div class="card p-1 mb-4 sticky-card">
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}">
<div class="d-flex flex-wrap justify-content-around align-items-center">
<div class="dropdown btn-group">
<button id="select-plugin"
@ -75,166 +79,198 @@
"title-class": " border-primary text-primary fw-bold shine"
}
} %}
<div class="card tab-content position-relative">
{% for plugin in plugins if get_filtered_settings(plugin["settings"], current_endpoint == "global-config") %}
<div id="navs-plugins-{{ plugin['id'] }}"
class="tab-pane fade{% if loop.index == 1 %} show active{% endif %}"
role="tabpanel"
aria-labelledby="navs-plugins-{{ plugin['id'] }}-tab"
data-type="{{ plugin['type'] }}">
<div class="card-header">
<h5 class="card-title d-inline border p-2{{ plugin_types[plugin['type']].get('title-class', '') }}">
{{ plugin["name"] }}&nbsp;&nbsp;v{{ plugin["version"] }}&nbsp;&nbsp;{{ plugin_types[plugin["type"]].get('icon', '<img src="' + pro_diamond_url + '"
<div class="card tab-content m-1 p-2 position-relative">
{% for plugin in plugins %}
{% set filtered_settings = get_filtered_settings(plugin["settings"], current_endpoint == "global-config") %}
{% if filtered_settings %}
<div id="navs-plugins-{{ plugin['id'] }}"
class="tab-pane fade{% if loop.index == 1 %} show active{% endif %}"
role="tabpanel"
aria-labelledby="navs-plugins-{{ plugin['id'] }}-tab"
data-type="{{ plugin['type'] }}">
<div class="card-header">
<h5 class="card-title d-inline border p-2{{ plugin_types[plugin['type']].get('title-class', '') }}">
{{ plugin["name"] }}&nbsp;&nbsp;v{{ plugin["version"] }}&nbsp;&nbsp;{{ plugin_types[plugin["type"]].get('icon', '<img src="' + pro_diamond_url + '"
alt="Pro plugin"
width="18px"
height="15.5px">') |safe }}
</h5>
<p class="card-subtitle text-muted mt-2">{{ plugin["description"] }}</p>
</div>
<div class="card-body row pb-0">
{% for setting, setting_data in get_filtered_settings(plugin["settings"], current_endpoint == "global-config").items() if not setting_data.get('multiple', false) and setting not in blacklisted_settings and (not service_endpoint or setting_data['context'] == "multisite") %}
{% set setting_config = config.get(setting, {}) %}
{% set setting_value = setting_config.get("value", setting_data["default"]) %}
{% set setting_method = setting_config.get("method", "default") %}
{% set setting_template = setting_config.get("template", "") %}
{% set disabled = setting_method not in ('ui', 'default') %}
<div class="col-12 col-sm-6 col-lg-4 pb-3"
{% if disabled %}data-bs-toggle="tooltip" data-bs-placement="top" title="Disabled by {{ setting_method }}"{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="label-{{ plugin['id'] }}-{{ setting_data['id'] }}"
for="setting-{{ plugin['id'] }}-{{ setting_data['id'] }}"
class="form-label fw-semibold text-truncate">
{{ setting_data["label"]|capitalize }}
</label>
<div class="d-flex align-items-center">
{% if current_endpoint == "global-config" and setting_data["context"] == "multisite" %}
<a role="badge"
href='https://docs.bunkerweb.io/latest/concepts/?utm_campaign=self&utm_source=ui#multisite-mode'
class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Multisite setting"
target="_blank"
rel="noopener">
<span class="bx bx-server bx-xs"></span>
</a>
{% endif %}
{% if setting_template %}
<span class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
</h5>
<p class="card-subtitle text-muted mt-2">{{ plugin["description"] }}</p>
</div>
<div class="card-body row pb-0">
{% for setting, setting_data in filtered_settings.items() if not setting_data.get('multiple', false) and setting not in blacklisted_settings and (not service_endpoint or setting_data['context'] == "multisite") %}
{% set setting_config = config.get(setting, {}) %}
{% set setting_value = setting_config.get("value", setting_data["default"]) %}
{% set setting_method = setting_config.get("method", "default") %}
{% set setting_default = setting_data.get("default", "") %}
{% set setting_template = setting_config.get("template", "") %}
{% set disabled = setting_method not in ('ui', 'default') %}
<div class="col-12 col-sm-6 col-lg-4 pb-3"
{% if disabled %}data-bs-toggle="tooltip" data-bs-placement="top" title="Disabled by {{ setting_method }}"{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="label-{{ plugin['id'] }}-{{ setting_data['id'] }}"
for="setting-{{ plugin['id'] }}-{{ setting_data['id'] }}"
class="form-label fw-semibold text-truncate">
{{ setting_data["label"]|capitalize }}
</label>
<div class="d-flex align-items-center">
{% if current_endpoint == "global-config" and setting_data["context"] == "multisite" %}
<a role="badge"
href='https://docs.bunkerweb.io/latest/concepts/?utm_campaign=self&utm_source=ui#multisite-mode'
class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Multisite setting"
target="_blank"
rel="noopener">
<span class="bx bx-server bx-xs"></span>
</a>
{% endif %}
{% if setting_template %}
<span class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="From template: {{ setting_template }}">
<span class="bx bx-spreadsheet bx-xs"></span>
</span>
{% endif %}
<span class="badge rounded-pill bg-secondary-subtle text-dark d-flex align-items-center justify-content-center p-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="From template: {{ setting_template }}">
<span class="bx bx-spreadsheet bx-xs"></span>
title="{{ setting_data['help']|capitalize }}">
<span class="bx bx-question-mark bx-xs"></span>
</span>
</div>
</div>
{% if setting_data["type"] == "select" %}
{% include "models/select_setting.html" %}
{% elif setting_data["type"] == "check" %}
{% include "models/checkbox_setting.html" %}
{% else %}
{% if setting == "SERVER_NAME" and current_endpoint != "global-config" %}
<input type="hidden" name="OLD_SERVER_NAME" value="{{ setting_value }}">
{% endif %}
<span class="badge rounded-pill bg-secondary-subtle text-dark d-flex align-items-center justify-content-center p-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{{ setting_data['help']|capitalize }}">
<span class="bx bx-question-mark bx-xs"></span>
</span>
</div>
</div>
{% if setting_data["type"] == "select" %}
{% include "models/select_setting.html" %}
{% elif setting_data["type"] == "check" %}
{% include "models/checkbox_setting.html" %}
{% else %}
{% include "models/input_setting.html" %}
{% endif %}
</div>
{% endfor %}
</div>
{% set multiples = get_multiples(get_filtered_settings(plugin["settings"], current_endpoint == "global-config")) %}
{% if multiples %}
{% set setting_id_prefix = "multiple-" %}
{% set multiple_multiples = multiples|length > 1 %}
<div class="card-body row card-body row border-primary border-top pt-3">
<h5 class="row card-title text-uppercase">Multiple settings</h5>
{% for multiple, settings in multiples.items() %}
{% set multiple_settings = settings|length > 1 %}
<div class="col-12{% if multiple_multiples %} col-md-6{% endif %}{% if multiples|length > 2 and not multiple_settings %} col-lg-4{% endif %} pb-3">
<div class="row">
<div class="ps-0 d-flex align-items-center justify-content-start">
<h6 class="mb-0">{{ multiple.replace('-', ' ') | capitalize }}</h6>
<button id="add-multiple-{{ plugin['id'] }}-{{ multiple }}"
type="button"
class="btn btn-xs btn-outline-bw-green rounded-pill add-multiple ms-2">
<i class="bx bx-plus-circle bx-sm"></i>&nbsp;ADD
</button>
<button id="show-multiple-{{ plugin['id'] }}-{{ multiple }}"
type="button"
class="btn btn-xs btn-outline-secondary rounded-pill show-multiple ms-2"
data-bs-toggle="collapse"
data-bs-target="#multiple-{{ plugin['id'] }}-{{ multiple }}"
aria-expanded="true"
aria-controls="multiple-{{ plugin['id'] }}-{{ multiple }}">
<i class="bx bx-show-alt bx-sm"></i>&nbsp;HIDE
</button>
</div>
<div id="multiple-{{ plugin['id'] }}-{{ multiple }}"
class="collapse show multiple-collapse">
<div class="row multiple-container mt-2 pt-2">
{% for setting, setting_data in settings.items() if setting not in blacklisted_settings %}
{% set setting_config = config.get(setting, {}) %}
{% set setting_value = setting_config.get("value", setting_data["default"]) %}
{% set setting_method = setting_config.get("method", "default") %}
{% set setting_template = setting_config.get("template", "") %}
{% set disabled = setting_method not in ('ui', 'default') %}
<div id="multiple-{{ plugin['id'] }}-{{ multiple }}-{{ loop.index - 1 }}"
class="col-12{% if multiple_multiples and multiple_settings %} col-md-6{% endif %}{% if not multiple_multiples %} col-lg-4{% endif %} ps-0 pb-2"
{% if disabled %}data-bs-toggle="tooltip" data-bs-placement="top" title="Disabled by {{ setting_method }}"{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="multiple-label-{{ plugin['id'] }}-{{ setting_data['id'] }}"
for="multiple-setting-{{ plugin['id'] }}-{{ setting_data['id'] }}"
class="form-label fw-semibold text-truncate">
{{ setting_data["label"]|capitalize }}
</label>
<div class="d-flex align-items-center">
{% if current_endpoint == "global-config" and setting_data["context"] == "multisite" %}
<a role="badge"
href='https://docs.bunkerweb.io/latest/concepts/?utm_campaign=self&utm_source=ui#multisite-mode'
class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Multisite setting"
target="_blank"
rel="noopener">
<span class="bx bx-server bx-xs"></span>
</a>
{% endif %}
{% if setting_template %}
<span class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="From template: {{ setting_template }}">
<span class="bx bx-spreadsheet bx-xs"></span>
</span>
{% endif %}
<span class="badge rounded-pill bg-secondary-subtle text-dark d-flex align-items-center justify-content-center p-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{{ setting_data['help']|capitalize }}">
<span class="bx bx-question-mark bx-xs"></span>
</span>
</div>
</div>
{% if setting_data["type"] == "select" %}
{% include "models/select_setting.html" %}
{% elif setting_data["type"] == "check" %}
{% include "models/checkbox_setting.html" %}
{% else %}
{% include "models/input_setting.html" %}
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% include "models/input_setting.html" %}
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% set plugin_multiples = get_multiples(filtered_settings, config) %}
{% if plugin_multiples %}
{% set setting_id_prefix = "multiple-" %}
{% set multiple_plugin_multiples = plugin_multiples|length > 1 %}
<div class="card-header pb-2 mt-6">
<h5 class="card-title d-inline border p-2{{ plugin_types[plugin['type']].get('title-class', '') }}">
Multiple settings
</h5>
<p class="card-subtitle text-muted mt-2">This is where you can configure multiple settings for this plugin.</p>
</div>
<div class="card-body row card-body pt-0">
{% for multiple, multiples in plugin_multiples.items() %}
{% set multiple_settings = settings|length > 1 %}
<div id="multiple-{{ plugin['id'] }}-{{ multiple }}"
class="col-12{% if multiple_plugin_multiples %} col-md-6{% endif %}">
<!-- TODO: Handle if multiple_plugin_multiples|length > 2 and not multiple_settings -> col-lg-4 via JS -->
{% for setting_suffix, settings in multiples.items() %}
<div class="row multiple-container pt-2 pb-2">
{% set setting_id_suffix = "-" + setting_suffix %}
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0 me-2">
{{ multiple.replace('-', ' ') | capitalize }}
{% if setting_suffix != "0" %}#{{ setting_suffix }}{% endif %}
</h6>
<div class="d-flex align-items-center">
{% if setting_suffix == "0" %}
<button id="add-multiple-{{ plugin['id'] }}-{{ multiple }}"
type="button"
class="btn btn-xs btn-text-bw-green rounded-pill add-multiple p-0 pe-2">
<i class="bx bx-plus-circle bx-sm"></i>&nbsp;ADD
</button>
{% else %}
<div>
<button id="remove-multiple-{{ plugin['id'] }}-{{ multiple }}-{{ setting_suffix }}"
type="button"
class="btn btn-xs btn-text-danger rounded-pill remove-multiple p-0 pe-2">
<i class="bx bx-trash bx-sm"></i>&nbsp;REMOVE
</button>
</div>
{% endif %}
<button id="show-multiple-{{ plugin['id'] }}-{{ multiple }}-{{ setting_suffix }}"
type="button"
class="btn btn-xs btn-text-secondary rounded-pill show-multiple p-0"
data-bs-toggle="collapse"
data-bs-target="#multiple-{{ plugin['id'] }}-{{ multiple }}-{{ setting_suffix }}"
aria-expanded="true"
aria-controls="multiple-{{ plugin['id'] }}-{{ multiple }}-{{ setting_suffix }}">
<i class="bx bx-show-alt bx-sm"></i>&nbsp;HIDE
</button>
</div>
</div>
<div id="multiple-{{ plugin['id'] }}-{{ multiple }}-{{ setting_suffix }}"
class="collapse show multiple-collapse pt-0">
<div class="row mt-2 pt-2">
{% for setting, setting_data in settings.items() if setting not in blacklisted_settings %}
{% set setting_config = config.get(setting, {}) %}
{% set setting_value = setting_config.get("value", setting_data["default"]) %}
{% set setting_method = setting_config.get("method", "default") %}
{% set setting_default = setting_data.get("default", "") %}
{% set setting_template = setting_config.get("template", "") %}
{% set disabled = setting_method not in ('ui', 'default') %}
<div class="col-12{% if multiple_settings %} col-md-6{% endif %}{% if settings|length > 2 and not multiple_multiples %} col-lg-4{% endif %} pb-2"
{% if disabled %}data-bs-toggle="tooltip" data-bs-placement="top" title="Disabled by {{ setting_method }}"{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="multiple-label-{{ plugin['id'] }}-{{ setting_data['id'] }}"
for="multiple-setting-{{ plugin['id'] }}-{{ setting_data['id'] }}"
class="form-label fw-semibold text-truncate">
{{ setting_data["label"]|capitalize }}
</label>
<div class="d-flex align-items-center">
{% if current_endpoint == "global-config" and setting_data["context"] == "multisite" %}
<a role="badge"
href='https://docs.bunkerweb.io/latest/concepts/?utm_campaign=self&utm_source=ui#multisite-mode'
class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Multisite setting"
target="_blank"
rel="noopener">
<span class="bx bx-server bx-xs"></span>
</a>
{% endif %}
{% if setting_template %}
<span class="badge badge-center rounded-pill bg-secondary d-flex align-items-center justify-content-center p-1 me-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="From template: {{ setting_template }}">
<span class="bx bx-spreadsheet bx-xs"></span>
</span>
{% endif %}
<span class="badge rounded-pill bg-secondary-subtle text-dark d-flex align-items-center justify-content-center p-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{{ setting_data['help']|capitalize }}">
<span class="bx bx-question-mark bx-xs"></span>
</span>
</div>
</div>
{% if setting_data["type"] == "select" %}
{% include "models/select_setting.html" %}
{% elif setting_data["type"] == "check" %}
{% include "models/checkbox_setting.html" %}
{% else %}
{% include "models/input_setting.html" %}
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>

View file

@ -1,14 +1,17 @@
<div class="form-floating mt-1">
<select id="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}"
<select id="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}"
name="{{ setting }}"
class="form-select"
aria-labelledby="label-{{ plugin['id'] }}-{{ setting_data['id'] }}"
data-default="{{ setting_data['default'] }}"
aria-labelledby="label-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}"
data-original="{{ setting_value }}"
data-default="{{ setting_default }}"
{% if disabled %}disabled{% endif %}>
{% for option in setting_data["select"] %}
<option value="{{ option }}"
{% if setting_value == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
<label for="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}">{{ setting }}</label>
<label for="{{ setting_id_prefix }}setting-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}">
{{ setting }}
</label>
</div>

View file

@ -43,15 +43,36 @@ def handle_stop(signum, frame):
stop(0, False)
def get_multiples(settings: dict) -> Dict[str, dict]:
multiples = {}
def get_multiples(settings: dict, config: dict) -> Dict[str, Dict[str, Dict[str, dict]]]:
plugin_multiples = {}
for setting, data in settings.items():
multiple = data.get("multiple")
if multiple:
if multiple not in multiples:
multiples[multiple] = {}
multiples[multiple].update({setting: data | {"setting_no_suffix": setting.rsplit("_", 1)[0] if match(r".+_\d+$", setting) else setting}})
return multiples
data = data | {"setting_no_suffix": setting}
if multiple not in plugin_multiples:
plugin_multiples[multiple] = {}
if "0" not in plugin_multiples[multiple]:
plugin_multiples[multiple]["0"] = {}
plugin_multiples[multiple]["0"].update({setting: data})
for config_setting in config:
setting_match = match(setting + r"_(?P<suffix>\d+)$", config_setting)
if setting_match:
suffix = setting_match.group("suffix")
if suffix == "0":
continue
if suffix not in plugin_multiples[multiple]:
plugin_multiples[multiple][suffix] = {}
plugin_multiples[multiple][suffix].update({config_setting: data})
# Sort the multiples and their settings
for multiple, multiples in plugin_multiples.items():
plugin_multiples[multiple] = dict(sorted(multiples.items()))
return plugin_multiples
def get_filtered_settings(settings: dict, global_config: bool = False) -> Dict[str, dict]:

View file

@ -29,7 +29,6 @@ from app.routes.global_config import global_config
from app.routes.home import home
from app.routes.instances import instances
from app.routes.jobs import jobs
from app.routes.modes import modes
from app.routes.login import login
from app.routes.logout import logout, logout_page
from app.routes.logs import logs
@ -406,8 +405,8 @@ def check_reloading():
DATA["RELOADING"] = False
for f in DATA.get("TO_FLASH", []):
if f["type"] == "error":
flash(f["content"], "error")
if f["type"] != "success":
flash(f["content"], f["type"])
else:
flash(f["content"])
@ -419,6 +418,6 @@ def check_reloading():
return jsonify({"reloading": DATA.get("RELOADING", False)})
BLUEPRINTS = (bans, cache, configs, global_config, home, instances, jobs, modes, login, logout, logs, plugins, profile, reports, services, setup, totp)
BLUEPRINTS = (bans, cache, configs, global_config, home, instances, jobs, login, logout, logs, plugins, profile, reports, services, setup, totp)
for blueprint in BLUEPRINTS:
app.register_blueprint(blueprint)