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/requirements/build.txt b/requirements/build.txt index 0416fc7..4ce6108 100644 --- a/requirements/build.txt +++ b/requirements/build.txt @@ -14,5 +14,5 @@ pyproject-hooks==1.1.0 # via build tomli==2.0.1 # via build -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata diff --git a/requirements/dev.txt b/requirements/dev.txt index 907a5e2..f9db780 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ blinker==1.8.1 # flask cachetools==5.3.3 # via tox -certifi==2024.2.2 +certifi==2024.7.4 # via # -r docs.txt # requests @@ -108,7 +108,7 @@ markupsafe==2.1.5 # -r typing.txt # jinja2 # werkzeug -mypy==1.10.0 +mypy==1.10.1 # via -r typing.txt mypy-extensions==1.0.0 # via @@ -150,9 +150,9 @@ pygments==2.18.0 # sphinx pyproject-api==1.6.1 # via tox -pyright==1.1.365 +pyright==1.1.370 # via -r typing.txt -pytest==8.2.1 +pytest==8.2.2 # via # -r tests.txt # -r typing.txt @@ -214,7 +214,7 @@ tomli==2.0.1 # pyproject-api # pytest # tox -tox==4.15.0 +tox==4.15.1 # via -r dev.in types-docutils==0.21.0.20240423 # via @@ -232,7 +232,7 @@ typing-extensions==4.11.0 # -r typing.txt # mypy # sqlalchemy -urllib3==2.2.1 +urllib3==2.2.2 # via # -r docs.txt # requests @@ -245,7 +245,7 @@ werkzeug==3.0.3 # -r tests.txt # -r typing.txt # flask -zipp==3.18.1 +zipp==3.19.1 # via # -r docs.txt # -r tests.txt diff --git a/requirements/docs.txt b/requirements/docs.txt index 624e1f4..be9c28a 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -8,7 +8,7 @@ alabaster==0.7.13 # via sphinx babel==2.14.0 # via sphinx -certifi==2024.2.2 +certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests @@ -57,7 +57,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -urllib3==2.2.1 +urllib3==2.2.2 # via requests -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata diff --git a/requirements/tests.txt b/requirements/tests.txt index 5bc0675..f1c9bb5 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -34,7 +34,7 @@ pluggy==1.5.0 # via pytest pygments==2.18.0 # via -r tests.in -pytest==8.2.1 +pytest==8.2.2 # via -r tests.in sqlalchemy==2.0.29 # via flask-sqlalchemy @@ -44,5 +44,5 @@ typing-extensions==4.11.0 # via sqlalchemy werkzeug==3.0.3 # via flask -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata diff --git a/requirements/typing.txt b/requirements/typing.txt index a43f839..e7a19d5 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -28,7 +28,7 @@ markupsafe==2.1.5 # via # jinja2 # werkzeug -mypy==1.10.0 +mypy==1.10.1 # via -r typing.in mypy-extensions==1.0.0 # via mypy @@ -38,9 +38,9 @@ packaging==24.0 # via pytest pluggy==1.5.0 # via pytest -pyright==1.1.365 +pyright==1.1.370 # via -r typing.in -pytest==8.2.1 +pytest==8.2.2 # via -r typing.in sqlalchemy==2.0.29 # via flask-sqlalchemy @@ -60,7 +60,7 @@ typing-extensions==4.11.0 # sqlalchemy werkzeug==3.0.3 # via flask -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: 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 @@ class DebugToolbarExtension: "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 @@ class DebugToolbarExtension: "/_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 @@ class DebugToolbarExtension: 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 @@ class DebugToolbarExtension: "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