diff --git a/datasette/__init__.py b/datasette/__init__.py index 47d2b4f6..93afd8e1 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,4 +1,5 @@ from datasette.permissions import Permission # noqa +from datasette.permissions import DebugItem # noqa from datasette.version import __version_info__, __version__ # noqa from datasette.events import Event # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa diff --git a/datasette/app.py b/datasette/app.py index 75f6071e..ad99796a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -48,6 +48,7 @@ from .views.index import IndexView from .views.special import ( JsonDataView, PatternPortfolioView, + DebugMenuView, AuthTokenView, ApiExplorerView, CreateTokenView, @@ -1996,6 +1997,10 @@ class Datasette: AllowDebugView.as_view(self), r"/-/allow-debug$", ) + add_route( + wrap_view(DebugMenuView, self), + r"/-/debug$", + ) add_route( wrap_view(PatternPortfolioView, self), r"/-/patterns$", diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py new file mode 100644 index 00000000..98bbf3e5 --- /dev/null +++ b/datasette/default_debug_menu.py @@ -0,0 +1,95 @@ +from datasette import hookimpl +from datasette.permissions import DebugItem + + +@hookimpl +def debug_menu(datasette, actor): + async def inner(): + items = [] + + # Items visible to anyone + items.append( + DebugItem( + title="Actor", + description="Current authenticated actor", + path="/-/actor", + ) + ) + + # Items requiring view-instance + if await datasette.allowed(action="view-instance", actor=actor): + items.extend( + [ + DebugItem( + title="Databases", + description="Connected databases", + path="/-/databases", + ), + DebugItem( + title="Installed plugins", + description="Plugins currently installed", + path="/-/plugins", + ), + DebugItem( + title="Version info", + description="Python, Datasette and SQLite versions", + path="/-/versions", + ), + DebugItem( + title="Settings", + description="Datasette configuration settings", + path="/-/settings", + ), + DebugItem( + title="Config", + description="Full configuration output", + path="/-/config", + ), + DebugItem( + title="Threads", + description="Active threads", + path="/-/threads", + ), + DebugItem( + title="Messages", + description="Debug the flash messaging system", + path="/-/messages", + ), + DebugItem( + title="Pattern portfolio", + description="Showcase of UI patterns and components", + path="/-/patterns", + ), + ] + ) + + # Items requiring permissions-debug + if await datasette.allowed(action="permissions-debug", actor=actor): + items.extend( + [ + DebugItem( + title="Permissions", + description="Debug and test permission checks", + path="/-/permissions", + ), + DebugItem( + title="Allow rules", + description="Debug actor_matches_allow logic", + path="/-/allow-debug", + ), + DebugItem( + title="Actions", + description="Available permission actions", + path="/-/actions", + ), + DebugItem( + title="Permission rules", + description="Permission rules from all sources", + path="/-/rules", + ), + ] + ) + + return items + + return inner diff --git a/datasette/default_menu_links.py b/datasette/default_menu_links.py index 85032387..4401889b 100644 --- a/datasette/default_menu_links.py +++ b/datasette/default_menu_links.py @@ -8,34 +8,7 @@ def menu_links(datasette, actor): return [] return [ - {"href": datasette.urls.path("/-/databases"), "label": "Databases"}, - { - "href": datasette.urls.path("/-/plugins"), - "label": "Installed plugins", - }, - { - "href": datasette.urls.path("/-/versions"), - "label": "Version info", - }, - { - "href": datasette.urls.path("/-/settings"), - "label": "Settings", - }, - { - "href": datasette.urls.path("/-/permissions"), - "label": "Debug permissions", - }, - { - "href": datasette.urls.path("/-/messages"), - "label": "Debug messages", - }, - { - "href": datasette.urls.path("/-/allow-debug"), - "label": "Debug allow rules", - }, - {"href": datasette.urls.path("/-/threads"), "label": "Debug threads"}, - {"href": datasette.urls.path("/-/actor"), "label": "Debug actor"}, - {"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"}, + {"href": datasette.urls.path("/-/debug"), "label": "Debug"}, ] return inner diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index b993fb61..851b3c39 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -137,6 +137,11 @@ def forbidden(datasette, request, message): """Custom response for a 403 forbidden error""" +@hookspec +def debug_menu(datasette, actor, request): + """Return a list of DebugItem objects for the /-/debug page""" + + @hookspec def menu_links(datasette, actor, request): """Links for the navigation menu""" diff --git a/datasette/permissions.py b/datasette/permissions.py index c48293ac..6473b516 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -122,6 +122,13 @@ class AllowedResource(NamedTuple): reason: str +@dataclass(frozen=True, kw_only=True) +class DebugItem: + title: str + path: str + description: str | None = None + + @dataclass(frozen=True, kw_only=True) class Action: name: str diff --git a/datasette/plugins.py b/datasette/plugins.py index e9818885..55d8a96e 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -27,6 +27,7 @@ DEFAULT_PLUGINS = ( "datasette.default_magic_parameters", "datasette.blob_renderer", "datasette.default_menu_links", + "datasette.default_debug_menu", "datasette.handle_exception", "datasette.forbidden", "datasette.events", diff --git a/datasette/templates/debug.html b/datasette/templates/debug.html new file mode 100644 index 00000000..98660645 --- /dev/null +++ b/datasette/templates/debug.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Debug{% endblock %} + +{% block body_class %}debug{% endblock %} + +{% block content %} +

Debug

+ +{% if items %} + +{% else %} +

No debug tools available.

+{% endif %} + +{% endblock %} diff --git a/datasette/views/special.py b/datasette/views/special.py index 57a3024d..1b5cde0e 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -89,6 +89,30 @@ class PatternPortfolioView(View): ) +class DebugMenuView(View): + async def get(self, request, datasette): + from datasette.plugins import pm + from datasette.utils import await_me_maybe + + items = [] + for hook in pm.hook.debug_menu( + datasette=datasette, + actor=request.actor, + request=request, + ): + extra_items = await await_me_maybe(hook) + if extra_items: + items.extend(extra_items) + return Response.html( + await datasette.render_template( + "debug.html", + request=request, + view_name="debug", + context={"items": items}, + ) + ) + + class AuthTokenView(BaseView): name = "auth_token" has_json_alternate = False diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 468b0ade..1b72503f 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1758,6 +1758,53 @@ This example will disable CSRF protection for that specific URL path: If any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request. +.. _plugin_hook_debug_menu: + +debug_menu(datasette, actor, request) +------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. + +``request`` - :ref:`internals_request` or None + The current HTTP request. This can be ``None`` if the request object is not available. + +This hook allows plugins to add items to the ``/-/debug`` page, which serves as a hub for all debug and diagnostic tools. + +The hook should return a list of ``DebugItem`` objects (importable from ``datasette.permissions``). Each item has a ``title``, an optional ``description``, and a ``path`` to link to. + +It can alternatively return an ``async def`` awaitable function which returns a list of ``DebugItem`` objects. + +Hook implementations are responsible for checking permissions before returning items - only return items that the current actor should be able to see. + +This example adds a debug item only if the actor has the ``view-instance`` permission: + +.. code-block:: python + + from datasette import hookimpl + from datasette.permissions import DebugItem + + + @hookimpl + def debug_menu(datasette, actor): + async def inner(): + if not await datasette.allowed( + action="view-instance", actor=actor + ): + return [] + return [ + DebugItem( + title="My debug tool", + description="Custom diagnostic page", + path="/-/my-debug-tool", + ) + ] + + return inner + .. _plugin_hook_menu_links: menu_links(datasette, actor, request) diff --git a/tests/fixtures.py b/tests/fixtures.py index 01c501f2..916a16bb 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -37,6 +37,7 @@ EXPECTED_PLUGINS = [ "asgi_wrapper", "canned_queries", "database_actions", + "debug_menu", "extra_body_script", "extra_css_urls", "extra_js_urls", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 96a8b4d7..b2ff0b4e 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -2,7 +2,7 @@ import asyncio from datasette import hookimpl from datasette.facets import Facet from datasette import tracer -from datasette.permissions import Action +from datasette.permissions import Action, DebugItem from datasette.resources import DatabaseResource from datasette.utils import path_with_added_args from datasette.utils.asgi import asgi_send_json, Response @@ -354,6 +354,18 @@ def forbidden(datasette, request, message): return Response.redirect("/login?message=" + message) +@hookimpl +def debug_menu(datasette, actor, request): + if actor: + return [ + DebugItem( + title="Test debug item", + description="From test plugin", + path="/-/plugins", + ) + ] + + @hookimpl def menu_links(datasette, actor, request): if actor: diff --git a/tests/test_docs.py b/tests/test_docs.py index b94a6f23..4cfb0bef 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -65,7 +65,7 @@ def documented_views(): if first_word.endswith("View"): view_labels.add(first_word) # We deliberately don't document these: - view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView")) + view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView", "DebugMenuView")) return view_labels diff --git a/tests/test_html.py b/tests/test_html.py index 8fad5764..dd68be57 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -955,8 +955,8 @@ def test_edit_sql_link_not_shown_if_user_lacks_permission(has_permission): "actor_id,should_have_links,should_not_have_links", [ (None, None, None), - ("test", None, ["/-/permissions"]), - ("root", ["/-/permissions", "/-/allow-debug"], None), + ("test", None, ["/-/debug"]), + ("root", ["/-/debug"], None), ], ) async def test_navigation_menu_links( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 7c2180e8..c91b932d 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -927,6 +927,18 @@ async def test_hook_handle_exception_custom_response(ds_client, param): assert response.text == param +@pytest.mark.asyncio +async def test_hook_debug_menu(ds_client): + # Without authentication, no plugin debug items + response = await ds_client.get("/-/debug") + assert "Test debug item" not in response.text + + # With authentication, plugin debug items appear + response_2 = await ds_client.get("/-/debug?_bot=1") + assert "Test debug item" in response_2.text + assert "From test plugin" in response_2.text + + @pytest.mark.asyncio async def test_hook_menu_links(ds_client): def get_menu_links(html):