diff --git a/docs/index.rst b/docs/index.rst index cc3a40c..71b5f01 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -55,6 +55,13 @@ Name Description De ==================================== ===================================== ========================== ``DEBUG_TB_ENABLED`` Enable the toolbar? ``app.debug`` ``DEBUG_TB_HOSTS`` Whitelist of hosts to display toolbar any host +``DEBUG_TB_ROUTES_HOST`` The host to associate with toolbar ``None`` + routes (where its assets are served + from), or the sentinel value `*` to + serve from the same host as the + current request (ie any host). This + is only required if Flask is + configured to use `host_matching`. ``DEBUG_TB_INTERCEPT_REDIRECTS`` Should intercept redirects? ``True`` ``DEBUG_TB_PANELS`` List of module/class names of panels enable all built-in panels ``DEBUG_TB_PROFILER_ENABLED`` Enable the profiler on all requests ``False``, user-enabled diff --git a/src/flask_debugtoolbar/__init__.py b/src/flask_debugtoolbar/__init__.py index 6a0f3b4..f223f3d 100644 --- a/src/flask_debugtoolbar/__init__.py +++ b/src/flask_debugtoolbar/__init__.py @@ -59,6 +59,8 @@ class DebugToolbarExtension: def __init__(self, app: Flask | None = None) -> None: self.app = app + self.toolbar_routes_host: str | None = None + # Support threads running `flask.copy_current_request_context` without # poping toolbar during `teardown_request` self.debug_toolbars_var: ContextVar[dict[Request, DebugToolbar]] = ContextVar( @@ -97,6 +99,8 @@ def init_app(self, app: Flask) -> None: "var to be set" ) + self._validate_and_configure_toolbar_routes_host(app) + DebugToolbar.load_panels(app) app.before_request(self.process_request) @@ -110,6 +114,7 @@ def init_app(self, app: Flask) -> None: "/_debug_toolbar/static/", "_debug_toolbar.static", self.send_static_file, + host=self.toolbar_routes_host, ) app.register_blueprint(module, url_prefix="/_debug_toolbar/views") @@ -118,6 +123,7 @@ def _default_config(self, app: Flask) -> dict[str, t.Any]: return { "DEBUG_TB_ENABLED": app.debug, "DEBUG_TB_HOSTS": (), + "DEBUG_TB_ROUTES_HOST": None, "DEBUG_TB_INTERCEPT_REDIRECTS": True, "DEBUG_TB_PANELS": ( "flask_debugtoolbar.panels.versions.VersionDebugPanel", @@ -135,6 +141,61 @@ def _default_config(self, app: Flask) -> dict[str, t.Any]: "SQLALCHEMY_RECORD_QUERIES": app.debug, } + def _validate_and_configure_toolbar_routes_host(self, app: Flask) -> None: + toolbar_routes_host = app.config["DEBUG_TB_ROUTES_HOST"] + if app.url_map.host_matching and not toolbar_routes_host: + import warnings + + warnings.warn( + "Flask-DebugToolbar requires DEBUG_TB_ROUTES_HOST to be set if Flask " + "is running in `host_matching` mode. Static assets for the toolbar " + "will not be served correctly unless this is set.", + stacklevel=1, + ) + + if toolbar_routes_host: + if not app.url_map.host_matching: + raise ValueError( + "`DEBUG_TB_ROUTES_HOST` should only be set if your Flask app is " + "using `host_matching`." + ) + + if toolbar_routes_host.strip() == "*": + toolbar_routes_host = "" + elif "<" in toolbar_routes_host and ">" in toolbar_routes_host: + raise ValueError( + "`DEBUG_TB_ROUTES_HOST` must either be a host name with no " + "variables, to serve all Flask-DebugToolbar assets from a single " + "host, or `*` to match the current request's host." + ) + + # Automatically inject `toolbar_routes_host` into `url_for` calls for + # the toolbar's `send_static_file` method. + @app.url_defaults + def inject_toolbar_routes_host_if_required( + endpoint: str, values: dict[str, t.Any] + ) -> None: + if app.url_map.is_endpoint_expecting(endpoint, "toolbar_routes_host"): + values.setdefault("toolbar_routes_host", request.host) + + # Automatically strip `toolbar_routes_host` from the endpoint values so + # that the `send_static_host` method doesn't receive that parameter, + # as it's not actually required internally. + @app.url_value_preprocessor + def strip_toolbar_routes_host_from_static_endpoint( + endpoint: str | None, values: dict[str, t.Any] | None + ) -> None: + if ( + endpoint + and values + and app.url_map.is_endpoint_expecting( + endpoint, "toolbar_routes_host" + ) + ): + values.pop("toolbar_routes_host", None) + + self.toolbar_routes_host = toolbar_routes_host + def dispatch_request(self) -> t.Any: """Modified version of ``Flask.dispatch_request`` to call :meth:`process_view`. diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py index f392def..7e974fb 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -1,8 +1,16 @@ from __future__ import annotations +import typing as t +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest from flask import Flask +from flask import Response from flask.testing import FlaskClient +from flask_debugtoolbar import DebugToolbarExtension + def load_app(name: str) -> FlaskClient: app: Flask = __import__(name).app @@ -15,3 +23,145 @@ def test_basic_app() -> None: index = app.get("/") assert index.status_code == 200 assert b'
Flask: + app = Flask(__name__, **app_config) + app.config["DEBUG"] = True + app.config["SECRET_KEY"] = "abc123" + + for key, value in toolbar_config.items(): + app.config[key] = value + + DebugToolbarExtension(app) + + return app + + +def test_toolbar_is_host_matching_but_flask_is_not() -> None: + with pytest.raises(ValueError) as e: + app_with_config( + app_config=dict(host_matching=False), + toolbar_config=dict( + DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST="myapp.com" + ), + ) + + assert str(e.value) == ( + "`DEBUG_TB_ROUTES_HOST` should only be set if your Flask app is " + "using `host_matching`." + ) + + +def test_flask_is_host_matching_but_toolbar_is_not() -> None: + with pytest.warns(UserWarning) as record: + app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True), + ) + + assert isinstance(record[0].message, UserWarning) + assert record[0].message.args[0] == ( + "Flask-DebugToolbar requires DEBUG_TB_ROUTES_HOST to be set if Flask " + "is running in `host_matching` mode. Static assets for the toolbar " + "will not be served correctly unless this is set." + ) + + +def test_toolbar_host_variables_rejected() -> None: + with pytest.raises(ValueError) as e: + app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict( + DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST=".com" + ), + ) + + assert str(e.value) == ( + "`DEBUG_TB_ROUTES_HOST` must either be a host name with no " + "variables, to serve all Flask-DebugToolbar assets from a single " + "host, or `*` to match the current request's host." + ) + + +def test_toolbar_in_host_mode_injects_toolbar_html() -> None: + app = app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST="myapp.com"), + ) + + @app.route("/", host="myapp.com") + def index() -> str: + return "OK" + + with app.test_client() as client: + with app.app_context(): + response = client.get("/", headers={"Host": "myapp.com"}) + assert '
None: + app = app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST=tb_routes_host), + ) + + @app.route("/", host=request_host) + def index() -> str: + return "OK" + + with app.test_client() as client: + with app.app_context(): + response = client.get("/", headers={"Host": request_host}) + + assert ( + """""" + ) in response.text + + +@patch( + "flask.helpers.werkzeug.utils.send_from_directory", + return_value=Response(b"some-file", mimetype="text/css", status=200), +) +@pytest.mark.parametrize( + "tb_routes_host, request_host, expected_status_code", + ( + ("toolbar.com", "toolbar.com", 200), + ("toolbar.com", "myapp.com", 404), + ("toolbar.com", "static.com", 404), + ("*", "toolbar.com", 200), + ("*", "myapp.com", 200), + ("*", "static.com", 200), + ), +) +def test_toolbar_serves_assets_based_on_host_configuration( + mock_send_from_directory: MagicMock, + tb_routes_host: str, + request_host: str, + expected_status_code: int, +) -> None: + app = app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST=tb_routes_host), + ) + + with app.test_client() as client: + with app.app_context(): + response = client.get( + "/_debug_toolbar/static/js/toolbar.js", headers={"Host": request_host} + ) + assert response.status_code == expected_status_code