diff --git a/src/common/core/ui/confs/default-server-http/ui.conf b/src/common/core/ui/confs/default-server-http/ui.conf index 1c619b9e5..8244cd369 100644 --- a/src/common/core/ui/confs/default-server-http/ui.conf +++ b/src/common/core/ui/confs/default-server-http/ui.conf @@ -4,6 +4,13 @@ access_by_lua_block { local scheme = ngx_var.scheme local http_host = ngx_var.http_host local request_uri = ngx_var.request_uri + local x_requested_with = ngx.req.get_headers()["X-Requested-With"] + + -- Bypass redirect for AJAX requests + if x_requested_with and x_requested_with:lower() == "xmlhttprequest" then + return + end + if scheme == "http" and http_host ~= nil and http_host ~= "" and request_uri and request_uri ~= "" then return ngx.redirect("https://" .. http_host .. request_uri, ngx.HTTP_MOVED_PERMANENTLY) end @@ -88,6 +95,53 @@ location /setup { } } +location /setup/loading { + etag off; + add_header Last-Modified ""; + proxy_pass $backendui; + + # Proxy headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Prefix "/"; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Timeouts + proxy_connect_timeout 60s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + + # Buffering + proxy_buffering on; + + # Response body modifications + header_filter_by_lua_block { + ngx.header.content_length = nil + } + body_filter_by_lua_block { + local str = ngx.arg[1] + local patterns = { + ["/css"] = "/setup/css", + ["/fonts"] = "/setup/fonts", + ["/img"] = "/setup/img", + ["/js"] = "/setup/js", + ["/libs"] = "/setup/libs", + } + + for pattern, replacement in pairs(patterns) do + str = ngx.re.gsub(str, pattern, replacement, "ijo") + end + ngx.arg[1] = str + } +} + location /setup/check { etag off; add_header Last-Modified ""; diff --git a/src/ui/app/routes/setup.py b/src/ui/app/routes/setup.py index 804562344..64329f305 100644 --- a/src/ui/app/routes/setup.py +++ b/src/ui/app/routes/setup.py @@ -1,10 +1,11 @@ +from datetime import datetime, timedelta from itertools import chain from os import environ, getenv # from secrets import choice # from string import ascii_letters, digits -from threading import Thread -from time import time +from re import escape, match +from time import sleep, time from flask import Blueprint, Response, flash, redirect, render_template, request, url_for from flask_login import current_user @@ -14,7 +15,7 @@ from flask_login import current_user from app.dependencies import BW_CONFIG, DATA, DB from app.utils import LOGGER, USER_PASSWORD_RX, gen_password_hash -from app.routes.utils import REVERSE_PROXY_PATH, handle_error, manage_bunkerweb +from app.routes.utils import REVERSE_PROXY_PATH, handle_error setup = Blueprint("setup", __name__) @@ -194,17 +195,17 @@ def setup_page(): LOGGER.debug(f"Creating new service with base_config: {base_config} and config: {config}") - operation, error = BW_CONFIG.new_service(base_config, override_method="wizard") + operation, error = BW_CONFIG.new_service(base_config, override_method="wizard", check_changes=False) if error: return handle_error(f"Couldn't create the new service: {operation}", "setup", False, "error") - # deepcode ignore MissingAPI: We don't need to check to wait for the thread to finish - Thread( - target=manage_bunkerweb, - name="Reloading instances", - args=("services", config | base_config, request.form["server_name"], request.form["server_name"]), - kwargs={"operation": "edit", "threaded": True}, - ).start() + operation, error = BW_CONFIG.edit_service(request.form["server_name"], config | base_config, check_changes=False) + if error: + return handle_error(f"Couldn't edit the new service: {operation}", "setup", False, "error") + + err = DB.checked_changes(["config", "custom_configs"], plugins_changes="all", value=True) + if err: + LOGGER.error(f"Error while applying changes to the database: {err}, you may need to reload the application") return Response(status=200) @@ -243,13 +244,30 @@ def setup_loading(): if current_user.is_authenticated: return redirect(url_for("home.home_page")) - if DB.get_ui_user(): - db_config = BW_CONFIG.get_config(methods=False, filtered_settings=("SERVER_NAME", "USE_UI", "REVERSE_PROXY_URL")) - for server_name in db_config["SERVER_NAME"].split(" "): - if server_name and db_config.get(f"{server_name}_USE_UI", db_config.get("USE_UI", "no")) == "yes": + DATA.load_from_file() + + db_config = DB.get_config(filtered_settings=("SERVER_NAME", "USE_UI", "REVERSE_PROXY_URL")) + ui_service = {} + ui_admin = DB.get_ui_user() + admin_old_enough = ui_admin and ui_admin.creation_date < datetime.now() - timedelta(minutes=5) + + for server_name in db_config["SERVER_NAME"].split(" "): + if server_name and db_config.get(f"{server_name}_USE_UI", "no") == "yes": + if admin_old_enough: return redirect(url_for("login.login_page"), 301) + ui_service = {"server_name": server_name, "url": db_config.get(f"{server_name}_REVERSE_PROXY_URL", "/")} + break + + if not ui_service: + sleep(1) + return redirect(url_for("setup.setup_loading")) target_endpoint = request.args.get("target_endpoint", "") + if target_endpoint and not match( + rf"^https://{escape(ui_service['server_name'])}{escape(ui_service['url'])}/check$".replace("//check", "/check"), target_endpoint + ): + return Response(status=400) + return render_template( "loading.html", message="Setting up Web UI...", diff --git a/src/ui/app/static/js/pages/setup.js b/src/ui/app/static/js/pages/setup.js index 12d02b6a8..f41afb077 100644 --- a/src/ui/app/static/js/pages/setup.js +++ b/src/ui/app/static/js/pages/setup.js @@ -636,7 +636,9 @@ $(document).ready(() => { }) .then((res) => { if (res.status === 200) { - window.location.href = redirect; + setTimeout(() => { + window.location.href = redirect; + }, 1000); } }) .catch((err) => { diff --git a/src/ui/main.py b/src/ui/main.py index 7b7e9b866..00d78aaee 100644 --- a/src/ui/main.py +++ b/src/ui/main.py @@ -141,7 +141,7 @@ with app.app_context(): @app.context_processor def inject_variables(): current_endpoint = request.path.split("/")[-1] - if request.path.startswith(("/check_reloading", "/setup", "/loading", "/login", "/totp")): + if request.path.startswith(("/check", "/setup", "/loading", "/login", "/totp")): return dict(current_endpoint=current_endpoint, script_nonce=app.config["SCRIPT_NONCE"]) DATA.load_from_file() @@ -329,7 +329,7 @@ def before_request(): DB.readonly = DATA.get("READONLY_MODE", False) - if not request.path.startswith(("/check_reloading", "/loading", "/login", "/totp")) and DB.readonly: + if not request.path.startswith(("/check", "/loading", "/login", "/totp")) and DB.readonly: flask_flash("Database connection is in read-only mode : no modifications possible.", "error") if current_user.is_authenticated: @@ -410,7 +410,7 @@ def set_security_headers(response): ### * MISC ROUTES * ### -@app.route("/", strict_slashes=False) +@app.route("/", strict_slashes=False, methods=["GET"]) def index(): if DB.get_ui_user(): if current_user.is_authenticated: # type: ignore @@ -419,12 +419,16 @@ def index(): return redirect(url_for("setup.setup_page"), 301) -@app.route("/loading") +@app.route("/loading", methods=["GET"]) @login_required def loading(): - return render_template( - "loading.html", message=request.values.get("message", "Loading..."), next=request.values.get("next", None) or url_for("home.home_page") - ) + home_url = url_for("home.home_page") + next_url = request.values.get("next", None) or home_url + + if not next_url.startswith(home_url.replace("/home", "/", 1).replace("//", "/")): + return Response(status=400) + + return render_template("loading.html", message=request.values.get("message", "Loading..."), next=next_url) @app.route("/check", methods=["GET"]) @@ -433,7 +437,7 @@ def check(): return Response(status=200, headers={"Access-Control-Allow-Origin": "*"}, response=dumps({"message": "ok"}), content_type="application/json") -@app.route("/check_reloading") +@app.route("/check_reloading", methods=["GET"]) @login_required def check_reloading(): DATA.load_from_file()