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 %} +
{{ item.description }}
{% endif %} +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