Skip to content

Commit

Permalink
Add support for Flask.host_matching (#274)
Browse files Browse the repository at this point in the history
  • Loading branch information
macnewbold committed Jul 15, 2024
2 parents 449405e + 44ee4b5 commit 877e69d
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 0 deletions.
7 changes: 7 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/flask_debugtoolbar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -110,6 +114,7 @@ def init_app(self, app: Flask) -> None:
"/_debug_toolbar/static/<path:filename>",
"_debug_toolbar.static",
self.send_static_file,
host=self.toolbar_routes_host,
)

app.register_blueprint(module, url_prefix="/_debug_toolbar/views")
Expand All @@ -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",
Expand All @@ -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 = "<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`.
Expand Down
150 changes: 150 additions & 0 deletions tests/test_toolbar.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,3 +23,145 @@ def test_basic_app() -> None:
index = app.get("/")
assert index.status_code == 200
assert b'<div id="flDebug"' in index.data


def app_with_config(
app_config: dict[str, t.Any], toolbar_config: dict[str, t.Any]
) -> 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="<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 "<html><head></head><body>OK</body></html>"

with app.test_client() as client:
with app.app_context():
response = client.get("/", headers={"Host": "myapp.com"})
assert '<div id="flDebug" ' in response.text


@pytest.mark.parametrize(
"tb_routes_host, request_host, expected_static_path",
(
("myapp.com", "myapp.com", "/_debug_toolbar/static/"),
("toolbar.com", "myapp.com", "http://toolbar.com/_debug_toolbar/static/"),
("*", "myapp.com", "/_debug_toolbar/static/"),
),
)
def test_toolbar_injects_expected_static_path_for_host(
tb_routes_host: str, request_host: str, expected_static_path: str
) -> 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 "<html><head></head><body>OK</body></html>"

with app.test_client() as client:
with app.app_context():
response = client.get("/", headers={"Host": request_host})

assert (
"""<script type="text/javascript">"""
f"""var DEBUG_TOOLBAR_STATIC_PATH = '{expected_static_path}'"""
"""</script>"""
) 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

0 comments on commit 877e69d

Please sign in to comment.