diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..d75dec6cb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,196 @@ +name: Test + +on: [push, pull_request] + +jobs: + mysql: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8'] + + services: + mariadb: + image: mariadb:10.3 + env: + MYSQL_ROOT_PASSWORD: debug_toolbar + options: >- + --health-cmd "mysqladmin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + - name: Test with tox + run: tox + env: + DB_BACKEND: mysql + DB_USER: root + DB_PASSWORD: debug_toolbar + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} + + postgres: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8'] + + services: + postgres: + image: 'postgres:9.5' + env: + POSTGRES_DB: debug_toolbar + POSTGRES_USER: debug_toolbar + POSTGRES_PASSWORD: debug_toolbar + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + - name: Test with tox + run: tox + env: + DB_BACKEND: postgresql + DB_HOST: localhost + DB_PORT: 5432 + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} + + sqlite: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8'] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + - name: Test with tox + run: tox + env: + DB_BACKEND: sqlite3 + DB_NAME: ":memory:" + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} + + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: 3.7 + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox + - name: Test with tox + run: tox -e style,readme \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 438460742..ba33fff97 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,52 +1,73 @@ -dist: xenial language: python cache: pip matrix: fast_finish: true include: - env: TOXENV=flake8 - - python: 3.7 - env: TOXENV=style - - python: 3.7 - env: TOXENV=readme - - python: 3.5 - env: TOXENV=py35-dj111 - - python: 3.6 - env: TOXENV=py36-dj111 - - python: 3.7 - env: TOXENV=py37-dj111 - - python: 3.5 - env: TOXENV=py35-dj20 - - python: 3.6 - env: TOXENV=py36-dj20 - - python: 3.7 - env: TOXENV=py37-dj20 + - env: TOXENV=style + - env: TOXENV=readme - python: 3.5 - env: TOXENV=py35-dj21 + env: TOXENV=py35-dj111-sqlite - python: 3.6 - env: TOXENV=py36-dj21 + env: TOXENV=py36-dj111-sqlite - python: 3.7 - env: TOXENV=py37-dj21 + env: TOXENV=py37-dj111-sqlite - python: 3.5 - env: TOXENV=py35-dj22 + env: TOXENV=py35-dj22-sqlite - python: 3.6 - env: TOXENV=py36-dj22 + env: TOXENV=py36-dj22-sqlite - python: 3.7 - env: TOXENV=py37-dj22 + env: TOXENV=py37-dj22-sqlite + - python: 3.8 + env: TOXENV=py38-dj22-sqlite - python: 3.6 - env: TOXENV=py36-dj30 + env: TOXENV=py36-dj30-sqlite - python: 3.7 - env: TOXENV=py37-dj30 + env: TOXENV=py37-dj30-sqlite + - python: 3.8 + env: TOXENV=py38-dj30-sqlite - python: 3.6 - env: TOXENV=py36-djmaster + env: TOXENV=py36-djmaster-sqlite - python: 3.7 - env: TOXENV=py37-djmaster + env: TOXENV=py37-djmaster-sqlite + - python: 3.8 + env: TOXENV=py38-djmaster-sqlite - python: 3.7 - env: TOXENV=postgresql + env: TOXENV=py37-dj111-postgresql + addons: + postgresql: "9.5" + - python: 3.8 + env: TOXENV=py38-dj22-postgresql + addons: + postgresql: "9.5" + - python: 3.8 + env: TOXENV=py38-dj30-postgresql addons: postgresql: "9.5" - python: 3.7 - env: TOXENV=mariadb + env: TOXENV=py37-dj111-mariadb + addons: + mariadb: "10.3" + script: + # working around https://travis-ci.community/t/mariadb-build-error-with-xenial/3160 + - mysql -u root -e "DROP USER IF EXISTS 'travis'@'%';" + - mysql -u root -e "CREATE USER 'travis'@'%';" + - mysql -u root -e "CREATE DATABASE debug_toolbar;" + - mysql -u root -e "GRANT ALL PRIVILEGES ON *.* TO 'travis'@'%';"; + - tox -v + - python: 3.8 + env: TOXENV=py38-dj22-mariadb + addons: + mariadb: "10.3" + script: + # working around https://travis-ci.community/t/mariadb-build-error-with-xenial/3160 + - mysql -u root -e "DROP USER IF EXISTS 'travis'@'%';" + - mysql -u root -e "CREATE USER 'travis'@'%';" + - mysql -u root -e "CREATE DATABASE debug_toolbar;" + - mysql -u root -e "GRANT ALL PRIVILEGES ON *.* TO 'travis'@'%';"; + - tox -v + - python: 3.8 + env: TOXENV=py38-dj30-mariadb addons: mariadb: "10.3" script: @@ -57,8 +78,11 @@ matrix: - mysql -u root -e "GRANT ALL PRIVILEGES ON *.* TO 'travis'@'%';"; - tox -v allow_failures: - - env: TOXENV=py36-djmaster - - env: TOXENV=py37-djmaster + - env: TOXENV=py36-djmaster-sqlite + - env: TOXENV=py37-djmaster-sqlite + - env: TOXENV=py38-djmaster-sqlite + - env: TOXENV=py38-djmaster-postgresql + - env: TOXENV=py38-djmaster-mariadb install: - pip install tox codecov diff --git a/README.rst b/README.rst index d4ff773d2..b1bf5b557 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Here's a screenshot of the toolbar in action: In addition to the built-in panels, a number of third-party panels are contributed by the community. -The current stable version of the Debug Toolbar is 2.1. It works on +The current stable version of the Debug Toolbar is 2.2.1. It works on Django ≥ 1.11. Documentation, including installation and configuration instructions, is diff --git a/debug_toolbar/apps.py b/debug_toolbar/apps.py index f6eeff849..5b69df7de 100644 --- a/debug_toolbar/apps.py +++ b/debug_toolbar/apps.py @@ -21,6 +21,17 @@ def check_middleware(app_configs, **kwargs): gzip_index = None debug_toolbar_indexes = [] + # If old style MIDDLEWARE_CLASSES is being used, report an error. + if settings.is_overridden("MIDDLEWARE_CLASSES"): + errors.append( + Warning( + "debug_toolbar is incompatible with MIDDLEWARE_CLASSES setting.", + hint="Use MIDDLEWARE instead of MIDDLEWARE_CLASSES", + id="debug_toolbar.W004", + ) + ) + return errors + # Determine the indexes which gzip and/or the toolbar are installed at for i, middleware in enumerate(settings.MIDDLEWARE): if is_middleware_class(GZipMiddleware, middleware): diff --git a/debug_toolbar/decorators.py b/debug_toolbar/decorators.py index 8114b05d7..2abfb22f9 100644 --- a/debug_toolbar/decorators.py +++ b/debug_toolbar/decorators.py @@ -1,6 +1,6 @@ import functools -from django.http import Http404 +from django.http import Http404, HttpResponseBadRequest def require_show_toolbar(view): @@ -15,3 +15,21 @@ def inner(request, *args, **kwargs): return view(request, *args, **kwargs) return inner + + +def signed_data_view(view): + """Decorator that handles unpacking a signed data form""" + + @functools.wraps(view) + def inner(request, *args, **kwargs): + from debug_toolbar.forms import SignedDataForm + + data = request.GET if request.method == "GET" else request.POST + signed_form = SignedDataForm(data) + if signed_form.is_valid(): + return view( + request, *args, verified_data=signed_form.verified_data(), **kwargs + ) + return HttpResponseBadRequest("Invalid signature") + + return inner diff --git a/debug_toolbar/forms.py b/debug_toolbar/forms.py new file mode 100644 index 000000000..06d25dfaa --- /dev/null +++ b/debug_toolbar/forms.py @@ -0,0 +1,54 @@ +import json +from collections import OrderedDict + +from django import forms +from django.core import signing +from django.core.exceptions import ValidationError +from django.utils.encoding import force_str + + +class SignedDataForm(forms.Form): + """Helper form that wraps a form to validate its contents on post. + + class PanelForm(forms.Form): + # fields + + On render: + form = SignedDataForm(initial=PanelForm(initial=data).initial) + + On POST: + signed_form = SignedDataForm(request.POST) + if signed_form.is_valid(): + panel_form = PanelForm(signed_form.verified_data) + if panel_form.is_valid(): + # Success + Or wrap the FBV with ``debug_toolbar.decorators.signed_data_view`` + """ + + salt = "django_debug_toolbar" + signed = forms.CharField(required=True, widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + initial = kwargs.pop("initial", None) + if initial: + initial = {"signed": self.sign(initial)} + super().__init__(*args, initial=initial, **kwargs) + + def clean_signed(self): + try: + verified = json.loads( + signing.Signer(salt=self.salt).unsign(self.cleaned_data["signed"]) + ) + return verified + except signing.BadSignature: + raise ValidationError("Bad signature") + + def verified_data(self): + return self.is_valid() and self.cleaned_data["signed"] + + @classmethod + def sign(cls, data): + items = sorted(data.items(), key=lambda item: item[0]) + return signing.Signer(salt=cls.salt).sign( + json.dumps(OrderedDict((key, force_str(value)) for key, value in items)) + ) diff --git a/debug_toolbar/management/commands/debugsqlshell.py b/debug_toolbar/management/commands/debugsqlshell.py index ea39f3e1c..78e09e27d 100644 --- a/debug_toolbar/management/commands/debugsqlshell.py +++ b/debug_toolbar/management/commands/debugsqlshell.py @@ -1,13 +1,19 @@ from time import time +import django import sqlparse from django.core.management.commands.shell import Command # noqa -from django.db.backends import utils as db_backends_utils +from django.db import connection + +if connection.vendor == "postgresql" and django.VERSION >= (3, 0, 0): + from django.db.backends.postgresql import base as base_module +else: + from django.db.backends import utils as base_module # 'debugsqlshell' is the same as the 'shell'. -class PrintQueryWrapper(db_backends_utils.CursorDebugWrapper): +class PrintQueryWrapper(base_module.CursorDebugWrapper): def execute(self, sql, params=()): start_time = time() try: @@ -20,4 +26,4 @@ def execute(self, sql, params=()): print("{} [{:.2f}ms]".format(formatted_sql, duration)) -db_backends_utils.CursorDebugWrapper = PrintQueryWrapper +base_module.CursorDebugWrapper = PrintQueryWrapper diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index a53bda8d4..a42bcd912 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -112,9 +112,9 @@ def generate_server_timing_header(response, panels): continue for key, record in stats.items(): - # example: `SQLPanel_sql_time=0; "SQL 0 queries"` + # example: `SQLPanel_sql_time;dur=0;desc="SQL 0 queries"` data.append( - '{}_{}={}; "{}"'.format( + '{}_{};dur={};desc="{}"'.format( panel.panel_id, key, record.get("value"), record.get("title") ) ) diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 91f51f6fb..97d03e2c8 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -216,20 +216,26 @@ def _store_call_info( @property def nav_subtitle(self): cache_calls = len(self.calls) - return __( - "%(cache_calls)d call in %(time).2fms", - "%(cache_calls)d calls in %(time).2fms", - cache_calls, - ) % {"cache_calls": cache_calls, "time": self.total_time} + return ( + __( + "%(cache_calls)d call in %(time).2fms", + "%(cache_calls)d calls in %(time).2fms", + cache_calls, + ) + % {"cache_calls": cache_calls, "time": self.total_time} + ) @property def title(self): count = len(getattr(settings, "CACHES", ["default"])) - return __( - "Cache calls from %(count)d backend", - "Cache calls from %(count)d backends", - count, - ) % {"count": count} + return ( + __( + "Cache calls from %(count)d backend", + "Cache calls from %(count)d backends", + count, + ) + % {"count": count} + ) def enable_instrumentation(self): if isinstance(middleware_cache.caches, CacheHandlerPatch): diff --git a/debug_toolbar/panels/history/forms.py b/debug_toolbar/panels/history/forms.py new file mode 100644 index 000000000..9280c3cc9 --- /dev/null +++ b/debug_toolbar/panels/history/forms.py @@ -0,0 +1,11 @@ +from django import forms + + +class HistoryStoreForm(forms.Form): + """ + Validate params + + store_id: The key for the store instance to be fetched. + """ + + store_id = forms.CharField(widget=forms.HiddenInput()) diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py new file mode 100644 index 000000000..4494bbfcd --- /dev/null +++ b/debug_toolbar/panels/history/panel.py @@ -0,0 +1,102 @@ +import json +from collections import OrderedDict + +from django.http.request import RawPostDataException +from django.template.loader import render_to_string +from django.templatetags.static import static +from django.urls import path +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from debug_toolbar.forms import SignedDataForm +from debug_toolbar.panels import Panel +from debug_toolbar.panels.history import views +from debug_toolbar.panels.history.forms import HistoryStoreForm + + +class HistoryPanel(Panel): + """ A panel to display History """ + + title = _("History") + nav_title = _("History") + template = "debug_toolbar/panels/history.html" + + @property + def is_historical(self): + """The HistoryPanel should not be included in the historical panels.""" + return False + + @classmethod + def get_urls(cls): + return [ + path("history_sidebar/", views.history_sidebar, name="history_sidebar"), + path("history_refresh/", views.history_refresh, name="history_refresh"), + ] + + @property + def nav_subtitle(self): + return self.get_stats().get("request_url", "") + + def generate_stats(self, request, response): + try: + if request.method == "GET": + data = request.GET.copy() + else: + data = request.POST.copy() + # GraphQL tends to not be populated in POST. If the request seems + # empty, check if it's a JSON request. + if ( + not data + and request.body + and request.META.get("CONTENT_TYPE") == "application/json" + ): + try: + data = json.loads(request.body) + except ValueError: + pass + except RawPostDataException: + # It is not guaranteed that we may read the request data (again). + data = None + + self.record_stats( + { + "request_url": request.get_full_path(), + "request_method": request.method, + "data": data, + "time": timezone.now(), + } + ) + + @property + def content(self): + """Content of the panel when it's displayed in full screen. + + Fetch every store for the toolbar and include it in the template. + """ + stores = OrderedDict() + for id, toolbar in reversed(self.toolbar._store.items()): + stores[id] = { + "toolbar": toolbar, + "form": SignedDataForm( + initial=HistoryStoreForm(initial={"store_id": id}).initial + ), + } + + return render_to_string( + self.template, + { + "current_store_id": self.toolbar.store_id, + "stores": stores, + "refresh_form": SignedDataForm( + initial=HistoryStoreForm( + initial={"store_id": self.toolbar.store_id} + ).initial + ), + }, + ) + + @property + def scripts(self): + scripts = super().scripts + scripts.append(static("debug_toolbar/js/history.js")) + return scripts diff --git a/debug_toolbar/panels/history/views.py b/debug_toolbar/panels/history/views.py new file mode 100644 index 000000000..b4cf8c835 --- /dev/null +++ b/debug_toolbar/panels/history/views.py @@ -0,0 +1,61 @@ +from django.http import HttpResponseBadRequest, JsonResponse +from django.template.loader import render_to_string + +from debug_toolbar.decorators import require_show_toolbar, signed_data_view +from debug_toolbar.panels.history.forms import HistoryStoreForm +from debug_toolbar.toolbar import DebugToolbar + + +@require_show_toolbar +@signed_data_view +def history_sidebar(request, verified_data): + """Returns the selected debug toolbar history snapshot.""" + form = HistoryStoreForm(verified_data) + + if form.is_valid(): + store_id = form.cleaned_data["store_id"] + toolbar = DebugToolbar.fetch(store_id) + context = {} + for panel in toolbar.panels: + if not panel.is_historical: + continue + panel_context = {"panel": panel} + context[panel.panel_id] = { + "button": render_to_string( + "debug_toolbar/includes/panel_button.html", panel_context + ), + "content": render_to_string( + "debug_toolbar/includes/panel_content.html", panel_context + ), + } + return JsonResponse(context) + return HttpResponseBadRequest("Form errors") + + +@require_show_toolbar +@signed_data_view +def history_refresh(request, verified_data): + """Returns the refreshed list of table rows for the History Panel.""" + form = HistoryStoreForm(verified_data) + + if form.is_valid(): + requests = [] + for id, toolbar in reversed(DebugToolbar._store.items()): + requests.append( + { + "id": id, + "content": render_to_string( + "debug_toolbar/panels/history_tr.html", + { + "id": id, + "store_context": { + "toolbar": toolbar, + "form": HistoryStoreForm(initial={"store_id": id}), + }, + }, + ), + } + ) + + return JsonResponse({"requests": requests}) + return HttpResponseBadRequest("Form errors") diff --git a/debug_toolbar/panels/redirects.py b/debug_toolbar/panels/redirects.py index 53f5a301c..8f26f6fc3 100644 --- a/debug_toolbar/panels/redirects.py +++ b/debug_toolbar/panels/redirects.py @@ -15,7 +15,7 @@ class RedirectsPanel(Panel): def process_request(self, request): response = super().process_request(request) - if 300 <= int(response.status_code) < 400: + if 300 <= response.status_code < 400: redirect_to = response.get("Location", None) if redirect_to: status_line = "{} {}".format( diff --git a/debug_toolbar/panels/settings.py b/debug_toolbar/panels/settings.py index 0d35f80e2..549aad353 100644 --- a/debug_toolbar/panels/settings.py +++ b/debug_toolbar/panels/settings.py @@ -1,11 +1,18 @@ from collections import OrderedDict +import django from django.conf import settings from django.utils.translation import gettext_lazy as _ -from django.views.debug import get_safe_settings from debug_toolbar.panels import Panel +if django.VERSION >= (3, 1): + from django.views.debug import get_default_exception_reporter_filter + + get_safe_settings = get_default_exception_reporter_filter().get_safe_settings +else: + from django.views.debug import get_safe_settings + class SettingsPanel(Panel): """ diff --git a/debug_toolbar/panels/signals.py b/debug_toolbar/panels/signals.py index b707a2bd1..5f932a0d4 100644 --- a/debug_toolbar/panels/signals.py +++ b/debug_toolbar/panels/signals.py @@ -43,16 +43,22 @@ def nav_subtitle(self): # here we have to handle a double count translation, hence the # hard coding of one signal if num_signals == 1: - return __( - "%(num_receivers)d receiver of 1 signal", - "%(num_receivers)d receivers of 1 signal", + return ( + __( + "%(num_receivers)d receiver of 1 signal", + "%(num_receivers)d receivers of 1 signal", + num_receivers, + ) + % {"num_receivers": num_receivers} + ) + return ( + __( + "%(num_receivers)d receiver of %(num_signals)d signals", + "%(num_receivers)d receivers of %(num_signals)d signals", num_receivers, - ) % {"num_receivers": num_receivers} - return __( - "%(num_receivers)d receiver of %(num_signals)d signals", - "%(num_receivers)d receivers of %(num_signals)d signals", - num_receivers, - ) % {"num_receivers": num_receivers, "num_signals": num_signals} + ) + % {"num_receivers": num_receivers, "num_signals": num_signals} + ) title = _("Signals") diff --git a/debug_toolbar/panels/sql/forms.py b/debug_toolbar/panels/sql/forms.py index 4131cb775..a69b47519 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -1,13 +1,8 @@ -import hashlib -import hmac import json from django import forms -from django.conf import settings from django.core.exceptions import ValidationError from django.db import connections -from django.utils.crypto import constant_time_compare -from django.utils.encoding import force_bytes from django.utils.functional import cached_property from debug_toolbar.panels.sql.utils import reformat_sql @@ -21,7 +16,6 @@ class SQLSelectForm(forms.Form): raw_sql: The sql statement with placeholders params: JSON encoded parameter values duration: time for SQL to execute passed in from toolbar just for redisplay - hash: the hash of (secret + sql + params) for tamper checking """ sql = forms.CharField() @@ -29,14 +23,8 @@ class SQLSelectForm(forms.Form): params = forms.CharField() alias = forms.CharField(required=False, initial="default") duration = forms.FloatField() - hash = forms.CharField() def __init__(self, *args, **kwargs): - initial = kwargs.get("initial", None) - - if initial is not None: - initial["hash"] = self.make_hash(initial) - super().__init__(*args, **kwargs) for name in self.fields: @@ -66,23 +54,9 @@ def clean_alias(self): return value - def clean_hash(self): - hash = self.cleaned_data["hash"] - - if not constant_time_compare(hash, self.make_hash(self.data)): - raise ValidationError("Tamper alert") - - return hash - def reformat_sql(self): return reformat_sql(self.cleaned_data["sql"], with_toggle=False) - def make_hash(self, data): - m = hmac.new(key=force_bytes(settings.SECRET_KEY), digestmod=hashlib.sha1) - for item in [data["sql"], data["params"]]: - m.update(force_bytes(item)) - return m.hexdigest() - @property def connection(self): return connections[self.cleaned_data["alias"]] diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index 0280a06e6..5f717e803 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -7,6 +7,7 @@ from django.db import connections from django.utils.translation import gettext_lazy as _, ngettext_lazy as __ +from debug_toolbar.forms import SignedDataForm from debug_toolbar.panels import Panel from debug_toolbar.panels.sql import views from debug_toolbar.panels.sql.forms import SQLSelectForm @@ -117,11 +118,14 @@ def nav_subtitle(self): @property def title(self): count = len(self._databases) - return __( - "SQL queries from %(count)d connection", - "SQL queries from %(count)d connections", - count, - ) % {"count": count} + return ( + __( + "SQL queries from %(count)d connection", + "SQL queries from %(count)d connections", + count, + ) + % {"count": count} + ) template = "debug_toolbar/panels/sql.html" @@ -208,7 +212,9 @@ def duplicate_key(query): query["vendor"], query["trans_status"] ) - query["form"] = SQLSelectForm(auto_id=None, initial=copy(query)) + query["form"] = SignedDataForm( + auto_id=None, initial=SQLSelectForm(initial=copy(query)).initial + ) if query["sql"]: query["sql"] = reformat_sql(query["sql"], with_toggle=True) diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index c16f2319f..75366802c 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -8,6 +8,11 @@ from debug_toolbar import settings as dt_settings from debug_toolbar.utils import get_stack, get_template_info, tidy_stacktrace +try: + from psycopg2._json import Json as PostgresJson +except ImportError: + PostgresJson = None + class SQLQueryTriggered(Exception): """Thrown when template panel triggers a query""" @@ -105,6 +110,8 @@ def _quote_params(self, params): return [self._quote_expr(p) for p in params] def _decode(self, param): + if PostgresJson and isinstance(param, PostgresJson): + return param.dumps(param.adapted) # If a sequence type, decode each element separately if isinstance(param, (tuple, list)): return [self._decode(element) for element in param] @@ -136,7 +143,6 @@ def _record(self, method, sql, params): _params = json.dumps(self._decode(params)) except TypeError: pass # object not JSON serializable - template_info = get_template_info() alias = getattr(self.db, "alias", "default") diff --git a/debug_toolbar/panels/sql/views.py b/debug_toolbar/panels/sql/views.py index de525a87c..db2f0aae8 100644 --- a/debug_toolbar/panels/sql/views.py +++ b/debug_toolbar/panels/sql/views.py @@ -2,15 +2,16 @@ from django.template.response import SimpleTemplateResponse from django.views.decorators.csrf import csrf_exempt -from debug_toolbar.decorators import require_show_toolbar +from debug_toolbar.decorators import require_show_toolbar, signed_data_view from debug_toolbar.panels.sql.forms import SQLSelectForm @csrf_exempt @require_show_toolbar -def sql_select(request): +@signed_data_view +def sql_select(request, verified_data): """Returns the output of the SQL SELECT statement""" - form = SQLSelectForm(request.POST or None) + form = SQLSelectForm(verified_data) if form.is_valid(): sql = form.cleaned_data["raw_sql"] @@ -34,9 +35,10 @@ def sql_select(request): @csrf_exempt @require_show_toolbar -def sql_explain(request): +@signed_data_view +def sql_explain(request, verified_data): """Returns the output of the SQL EXPLAIN on the given query""" - form = SQLSelectForm(request.POST or None) + form = SQLSelectForm(verified_data) if form.is_valid(): sql = form.cleaned_data["raw_sql"] @@ -71,9 +73,10 @@ def sql_explain(request): @csrf_exempt @require_show_toolbar -def sql_profile(request): +@signed_data_view +def sql_profile(request, verified_data): """Returns the output of running the SQL and getting the profiling statistics""" - form = SQLSelectForm(request.POST or None) + form = SQLSelectForm(verified_data) if form.is_valid(): sql = form.cleaned_data["raw_sql"] diff --git a/debug_toolbar/panels/templates/views.py b/debug_toolbar/panels/templates/views.py index 2b3089798..38a31766d 100644 --- a/debug_toolbar/panels/templates/views.py +++ b/debug_toolbar/panels/templates/views.py @@ -48,8 +48,8 @@ def template_source(request): try: from pygments import highlight - from pygments.lexers import HtmlDjangoLexer from pygments.formatters import HtmlFormatter + from pygments.lexers import HtmlDjangoLexer source = highlight(source, HtmlDjangoLexer(), HtmlFormatter()) source = mark_safe(source) diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index e4fa7261e..74a74d178 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -24,6 +24,7 @@ # Panel options "EXTRA_SIGNALS": [], "ENABLE_STACKTRACES": True, + "ENABLE_STACKTRACES_LOCALS": False, "HIDE_IN_STACKTRACES": ( "socketserver", "threading", diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index b5aed6259..eaaacf6c3 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -619,13 +619,18 @@ color: #000; font-weight: bold; } -#djDebug .djdt-stack span.djdt-path { +#djDebug .djdt-stack span.djdt-path, +#djDebug .djdt-stack pre.djdt-locals, +#djDebug .djdt-stack pre.djdt-locals span { color: #777; font-weight: normal; } #djDebug .djdt-stack span.djdt-code { font-weight: normal; } +#djDebug .djdt-stack pre.djdt-locals { + margin: 0 27px 27px 27px; +} #djDebug .djdt-width-20 { width: 20%; diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index fff7a2f41..76839f263 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -78,9 +78,9 @@ this.parentElement.classList.add('djdt-active'); var inner = current.querySelector('.djDebugPanelContent .djdt-scroll'), - store_id = djDebug.getAttribute('data-store-id'); + store_id = djDebug.dataset.storeId; if (store_id && inner.children.length === 0) { - var url = djDebug.getAttribute('data-render-panel-url'); + var url = djDebug.dataset.renderPanelUrl; var url_params = new URLSearchParams(); url_params.append('store_id', store_id); url_params.append('panel_id', this.className); @@ -98,7 +98,7 @@ djdt.hide_one_level(); }); $$.on(djDebug, 'click', '.djDebugPanelButton input[type=checkbox]', function() { - djdt.cookie.set(this.getAttribute('data-cookie'), this.checked ? 'on' : 'off', { + djdt.cookie.set(this.dataset.cookie, this.checked ? 'on' : 'off', { path: '/', expires: 10 }); @@ -137,12 +137,12 @@ $$.on(djDebug, 'click', 'a.djToggleSwitch', function(event) { event.preventDefault(); var self = this; - var id = this.getAttribute('data-toggle-id'); - var open_me = this.textContent == this.getAttribute('data-toggle-open'); + var id = this.dataset.toggleId; + var open_me = this.textContent == this.dataset.toggleOpen; if (id === '' || !id) { return; } - var name = this.getAttribute('data-toggle-name'); + var name = this.dataset.toggleName; var container = this.closest('.djDebugPanelContent').querySelector('#' + name + '_' + id); container.querySelectorAll('.djDebugCollapsed').forEach(function(e) { $$.toggle(e, open_me); @@ -154,11 +154,11 @@ if (open_me) { e.classList.add('djSelected'); e.classList.remove('djUnselected'); - self.textContent = self.getAttribute('data-toggle-close'); + self.textContent = self.dataset.toggleClose; } else { e.classList.remove('djSelected'); e.classList.add('djUnselected'); - self.textContent = self.getAttribute('data-toggle-open'); + self.textContent = self.dataset.toggleOpen; } var switch_ = e.querySelector('.djToggleSwitch') if (switch_) switch_.textContent = self.textContent; @@ -312,12 +312,6 @@ return value; } }, - applyStyle: function(name) { - var selector = '#djDebug [data-' + name + ']'; - document.querySelectorAll(selector).forEach(function(element) { - element.style[name] = element.getAttribute('data-' + name); - }); - } }; window.djdt = { show_toolbar: djdt.show_toolbar, @@ -325,7 +319,6 @@ init: djdt.init, close: djdt.hide_one_level, cookie: djdt.cookie, - applyStyle: djdt.applyStyle }; document.addEventListener('DOMContentLoaded', djdt.init); })(); diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.profiling.js b/debug_toolbar/static/debug_toolbar/js/toolbar.profiling.js deleted file mode 100644 index 5823cfbcd..000000000 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.profiling.js +++ /dev/null @@ -1,3 +0,0 @@ -(function () { - djdt.applyStyle('padding-left'); -})(); diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.sql.js b/debug_toolbar/static/debug_toolbar/js/toolbar.sql.js deleted file mode 100644 index 65093c8ee..000000000 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.sql.js +++ /dev/null @@ -1,5 +0,0 @@ -(function () { - djdt.applyStyle('background-color'); - djdt.applyStyle('left'); - djdt.applyStyle('width'); -})(); diff --git a/debug_toolbar/templates/debug_toolbar/panels/profiling.html b/debug_toolbar/templates/debug_toolbar/panels/profiling.html index 953f8477f..45199c695 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/profiling.html +++ b/debug_toolbar/templates/debug_toolbar/panels/profiling.html @@ -1,5 +1,5 @@ {% load i18n %}{% load static %} - +
@@ -12,9 +12,9 @@ {% for call in func_list %} - +
{% trans "Call" %}
-
+
{% if call.has_subfuncs %} - {% else %} @@ -32,5 +32,3 @@ {% endfor %}
- - diff --git a/debug_toolbar/templates/debug_toolbar/panels/sql.html b/debug_toolbar/templates/debug_toolbar/panels/sql.html index da7e161a2..9dbb6827e 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/sql.html +++ b/debug_toolbar/templates/debug_toolbar/panels/sql.html @@ -3,7 +3,7 @@