mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 13:36:58 +02:00
Compare commits
1 commit
main
...
claude/deb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c635a1d99 |
15 changed files with 238 additions and 32 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
from datasette.permissions import Permission # noqa
|
from datasette.permissions import Permission # noqa
|
||||||
|
from datasette.permissions import DebugItem # noqa
|
||||||
from datasette.version import __version_info__, __version__ # noqa
|
from datasette.version import __version_info__, __version__ # noqa
|
||||||
from datasette.events import Event # noqa
|
from datasette.events import Event # noqa
|
||||||
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ from .views.index import IndexView
|
||||||
from .views.special import (
|
from .views.special import (
|
||||||
JsonDataView,
|
JsonDataView,
|
||||||
PatternPortfolioView,
|
PatternPortfolioView,
|
||||||
|
DebugMenuView,
|
||||||
AuthTokenView,
|
AuthTokenView,
|
||||||
ApiExplorerView,
|
ApiExplorerView,
|
||||||
CreateTokenView,
|
CreateTokenView,
|
||||||
|
|
@ -1996,6 +1997,10 @@ class Datasette:
|
||||||
AllowDebugView.as_view(self),
|
AllowDebugView.as_view(self),
|
||||||
r"/-/allow-debug$",
|
r"/-/allow-debug$",
|
||||||
)
|
)
|
||||||
|
add_route(
|
||||||
|
wrap_view(DebugMenuView, self),
|
||||||
|
r"/-/debug$",
|
||||||
|
)
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(PatternPortfolioView, self),
|
wrap_view(PatternPortfolioView, self),
|
||||||
r"/-/patterns$",
|
r"/-/patterns$",
|
||||||
|
|
|
||||||
95
datasette/default_debug_menu.py
Normal file
95
datasette/default_debug_menu.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -8,34 +8,7 @@ def menu_links(datasette, actor):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{"href": datasette.urls.path("/-/databases"), "label": "Databases"},
|
{"href": datasette.urls.path("/-/debug"), "label": "Debug"},
|
||||||
{
|
|
||||||
"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"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,11 @@ def forbidden(datasette, request, message):
|
||||||
"""Custom response for a 403 forbidden error"""
|
"""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
|
@hookspec
|
||||||
def menu_links(datasette, actor, request):
|
def menu_links(datasette, actor, request):
|
||||||
"""Links for the navigation menu"""
|
"""Links for the navigation menu"""
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,13 @@ class AllowedResource(NamedTuple):
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class DebugItem:
|
||||||
|
title: str
|
||||||
|
path: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class Action:
|
class Action:
|
||||||
name: str
|
name: str
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ DEFAULT_PLUGINS = (
|
||||||
"datasette.default_magic_parameters",
|
"datasette.default_magic_parameters",
|
||||||
"datasette.blob_renderer",
|
"datasette.blob_renderer",
|
||||||
"datasette.default_menu_links",
|
"datasette.default_menu_links",
|
||||||
|
"datasette.default_debug_menu",
|
||||||
"datasette.handle_exception",
|
"datasette.handle_exception",
|
||||||
"datasette.forbidden",
|
"datasette.forbidden",
|
||||||
"datasette.events",
|
"datasette.events",
|
||||||
|
|
|
||||||
23
datasette/templates/debug.html
Normal file
23
datasette/templates/debug.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Debug{% endblock %}
|
||||||
|
|
||||||
|
{% block body_class %}debug{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Debug</h1>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<ul class="debug-items">
|
||||||
|
{% for item in items %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ urls.path(item.path) }}">{{ item.title }}</a>{% if item.description %}
|
||||||
|
<p>{{ item.description }}</p>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No debug tools available.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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):
|
class AuthTokenView(BaseView):
|
||||||
name = "auth_token"
|
name = "auth_token"
|
||||||
has_json_alternate = False
|
has_json_alternate = False
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 <authentication_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:
|
.. _plugin_hook_menu_links:
|
||||||
|
|
||||||
menu_links(datasette, actor, request)
|
menu_links(datasette, actor, request)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ EXPECTED_PLUGINS = [
|
||||||
"asgi_wrapper",
|
"asgi_wrapper",
|
||||||
"canned_queries",
|
"canned_queries",
|
||||||
"database_actions",
|
"database_actions",
|
||||||
|
"debug_menu",
|
||||||
"extra_body_script",
|
"extra_body_script",
|
||||||
"extra_css_urls",
|
"extra_css_urls",
|
||||||
"extra_js_urls",
|
"extra_js_urls",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import asyncio
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.facets import Facet
|
from datasette.facets import Facet
|
||||||
from datasette import tracer
|
from datasette import tracer
|
||||||
from datasette.permissions import Action
|
from datasette.permissions import Action, DebugItem
|
||||||
from datasette.resources import DatabaseResource
|
from datasette.resources import DatabaseResource
|
||||||
from datasette.utils import path_with_added_args
|
from datasette.utils import path_with_added_args
|
||||||
from datasette.utils.asgi import asgi_send_json, Response
|
from datasette.utils.asgi import asgi_send_json, Response
|
||||||
|
|
@ -354,6 +354,18 @@ def forbidden(datasette, request, message):
|
||||||
return Response.redirect("/login?message=" + 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
|
@hookimpl
|
||||||
def menu_links(datasette, actor, request):
|
def menu_links(datasette, actor, request):
|
||||||
if actor:
|
if actor:
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ def documented_views():
|
||||||
if first_word.endswith("View"):
|
if first_word.endswith("View"):
|
||||||
view_labels.add(first_word)
|
view_labels.add(first_word)
|
||||||
# We deliberately don't document these:
|
# We deliberately don't document these:
|
||||||
view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView"))
|
view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView", "DebugMenuView"))
|
||||||
return view_labels
|
return view_labels
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"actor_id,should_have_links,should_not_have_links",
|
||||||
[
|
[
|
||||||
(None, None, None),
|
(None, None, None),
|
||||||
("test", None, ["/-/permissions"]),
|
("test", None, ["/-/debug"]),
|
||||||
("root", ["/-/permissions", "/-/allow-debug"], None),
|
("root", ["/-/debug"], None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_navigation_menu_links(
|
async def test_navigation_menu_links(
|
||||||
|
|
|
||||||
|
|
@ -927,6 +927,18 @@ async def test_hook_handle_exception_custom_response(ds_client, param):
|
||||||
assert response.text == 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
|
@pytest.mark.asyncio
|
||||||
async def test_hook_menu_links(ds_client):
|
async def test_hook_menu_links(ds_client):
|
||||||
def get_menu_links(html):
|
def get_menu_links(html):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue