New navigation-search feature, new /-/tables.json endpoint

Closes #2523

Also added allow() method with keyword-only arguments, closes #2519
This commit is contained in:
Simon Willison 2025-10-23 08:39:53 -07:00
commit cb6ffca471
5 changed files with 642 additions and 2 deletions

View file

@ -52,6 +52,7 @@ from .views.special import (
AllowedResourcesView,
PermissionRulesView,
PermissionCheckView,
TablesSearchView,
)
from .views.table import (
TableInsertView,
@ -1069,8 +1070,161 @@ class Datasette:
)
return sql, params
async def permission_allowed_2(
self, actor, action, resource=None, *, default=DEFAULT_NOT_SET
async def get_allowed_tables(
self,
actor,
database: Optional[str] = None,
extra_sql: str = "",
extra_params: Optional[dict] = None,
):
"""
Get list of tables the actor is allowed to view.
Args:
actor: The actor dict (or None for anonymous)
database: Optional database name to filter by
extra_sql: Optional extra SQL to add to the WHERE clause
extra_params: Optional parameters for the extra SQL
Returns:
List of dicts with keys: database, table, resource
"""
from datasette.utils.permissions import resolve_permissions_from_catalog
await self.refresh_schemas()
internal_db = self.get_internal_database()
# Build the candidate SQL query
where_clauses = []
params = extra_params.copy() if extra_params else {}
if database:
where_clauses.append("database_name = :database")
params["database"] = database
if extra_sql:
where_clauses.append(f"({extra_sql})")
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
candidate_sql = f"""
SELECT database_name AS parent, table_name AS child
FROM catalog_tables
WHERE {where_sql}
"""
# Collect plugin SQL blocks for view-table permission
table_plugins = []
for block in pm.hook.permission_resources_sql(
datasette=self,
actor=actor,
action="view-table",
):
block = await await_me_maybe(block)
if block is None:
continue
if isinstance(block, (list, tuple)):
candidates = block
else:
candidates = [block]
for candidate in candidates:
if candidate is None:
continue
if not isinstance(candidate, PluginSQL):
continue
table_plugins.append(candidate)
# Collect plugin SQL blocks for view-database permission
db_plugins = []
for block in pm.hook.permission_resources_sql(
datasette=self,
actor=actor,
action="view-database",
):
block = await await_me_maybe(block)
if block is None:
continue
if isinstance(block, (list, tuple)):
candidates = block
else:
candidates = [block]
for candidate in candidates:
if candidate is None:
continue
if not isinstance(candidate, PluginSQL):
continue
db_plugins.append(candidate)
# Get actor_id for resolve_permissions_from_catalog
if isinstance(actor, dict):
actor_id = actor.get("id")
elif actor:
actor_id = actor
else:
actor_id = None
actor_str = str(actor_id) if actor_id is not None else ""
# Resolve permissions for all matching tables
table_permission_results = await resolve_permissions_from_catalog(
internal_db,
actor=actor_str,
plugins=table_plugins,
action="view-table",
candidate_sql=candidate_sql,
candidate_params=params,
implicit_deny=True,
)
# Get unique database names from table results
database_names = list(
set(r["parent"] for r in table_permission_results if r["allow"] == 1)
)
# Check view-database permissions for those databases
if database_names:
# Build placeholders and params dict for database check
placeholders = ",".join(f":db{i}" for i in range(len(database_names)))
db_params = {f"db{i}": db_name for i, db_name in enumerate(database_names)}
db_candidate_sql = f"""
SELECT database_name AS parent, NULL AS child
FROM catalog_databases
WHERE database_name IN ({placeholders})
"""
db_permission_results = await resolve_permissions_from_catalog(
internal_db,
actor=actor_str,
plugins=db_plugins,
action="view-database",
candidate_sql=db_candidate_sql,
candidate_params=db_params,
implicit_deny=True,
)
# Create set of allowed databases
allowed_databases = {
r["parent"] for r in db_permission_results if r["allow"] == 1
}
else:
allowed_databases = set()
# Filter to only tables in allowed databases
allowed = []
for result in table_permission_results:
if result["allow"] == 1 and result["parent"] in allowed_databases:
allowed.append(
{
"database": result["parent"],
"table": result["child"],
"resource": result["resource"],
}
)
return allowed
async def allowed(
self, *, actor, action, resource=None, default=DEFAULT_NOT_SET
):
"""Permission check backed by permission_resources_sql rules."""
@ -1178,6 +1332,14 @@ class Datasette:
return result
async def permission_allowed_2(
self, actor, action, resource=None, *, default=DEFAULT_NOT_SET
):
"""Legacy method that delegates to allowed()."""
return await self.allowed(
actor=actor, action=action, resource=resource, default=default
)
async def ensure_permissions(
self,
actor: dict,
@ -1754,6 +1916,10 @@ class Datasette:
AllowDebugView.as_view(self),
r"/-/allow-debug$",
)
add_route(
TablesSearchView.as_view(self),
r"/-/tables(\.(?P<format>json))?$",
)
add_route(
wrap_view(PatternPortfolioView, self),
r"/-/patterns$",

View file

@ -0,0 +1,401 @@
class NavigationSearch extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.selectedIndex = -1;
this.matches = [];
this.debounceTimer = null;
this.render();
this.setupEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: contents;
}
dialog {
border: none;
border-radius: 0.75rem;
padding: 0;
max-width: 90vw;
width: 600px;
max-height: 80vh;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: slideIn 0.2s ease-out;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.search-container {
display: flex;
flex-direction: column;
height: 100%;
}
.search-input-wrapper {
padding: 1.25rem;
border-bottom: 1px solid #e5e7eb;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.search-input:focus {
border-color: #2563eb;
}
.results-container {
overflow-y: auto;
height: calc(80vh - 180px);
padding: 0.5rem;
}
.result-item {
padding: 0.875rem 1rem;
cursor: pointer;
border-radius: 0.5rem;
transition: background-color 0.15s;
display: flex;
align-items: center;
gap: 0.75rem;
}
.result-item:hover {
background-color: #f3f4f6;
}
.result-item.selected {
background-color: #dbeafe;
}
.result-name {
font-weight: 500;
color: #111827;
}
.result-url {
font-size: 0.875rem;
color: #6b7280;
}
.no-results {
padding: 2rem;
text-align: center;
color: #6b7280;
}
.hint-text {
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
color: #6b7280;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.hint-text kbd {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
border: 1px solid #d1d5db;
font-family: monospace;
}
/* Mobile optimizations */
@media (max-width: 640px) {
dialog {
width: 95vw;
max-height: 85vh;
border-radius: 0.5rem;
}
.search-input-wrapper {
padding: 1rem;
}
.search-input {
font-size: 16px; /* Prevents zoom on iOS */
}
.result-item {
padding: 1rem 0.75rem;
}
.hint-text {
font-size: 0.8rem;
padding: 0.5rem 1rem;
}
}
</style>
<dialog>
<div class="search-container">
<div class="search-input-wrapper">
<input
type="text"
class="search-input"
placeholder="Search..."
aria-label="Search navigation"
autocomplete="off"
spellcheck="false"
>
</div>
<div class="results-container" role="listbox"></div>
<div class="hint-text">
<span><kbd></kbd> <kbd></kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
</dialog>
`;
}
setupEventListeners() {
const dialog = this.shadowRoot.querySelector('dialog');
const input = this.shadowRoot.querySelector('.search-input');
const resultsContainer = this.shadowRoot.querySelector('.results-container');
// Global keyboard listener for "/"
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !this.isInputFocused() && !dialog.open) {
e.preventDefault();
this.openMenu();
}
});
// Input event
input.addEventListener('input', (e) => {
this.handleSearch(e.target.value);
});
// Keyboard navigation
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.moveSelection(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.moveSelection(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
this.selectCurrentItem();
} else if (e.key === 'Escape') {
this.closeMenu();
}
});
// Click on result item
resultsContainer.addEventListener('click', (e) => {
const item = e.target.closest('.result-item');
if (item) {
const index = parseInt(item.dataset.index);
this.selectItem(index);
}
});
// Close on backdrop click
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
this.closeMenu();
}
});
// Initial load
this.loadInitialData();
}
isInputFocused() {
const activeElement = document.activeElement;
return activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable
);
}
loadInitialData() {
const itemsAttr = this.getAttribute('items');
if (itemsAttr) {
try {
this.allItems = JSON.parse(itemsAttr);
this.matches = this.allItems;
} catch (e) {
console.error('Failed to parse items attribute:', e);
this.allItems = [];
this.matches = [];
}
}
}
handleSearch(query) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
const url = this.getAttribute('url');
if (url) {
// Fetch from API
this.fetchResults(url, query);
} else {
// Filter local items
this.filterLocalItems(query);
}
}, 200);
}
async fetchResults(url, query) {
try {
const searchUrl = `${url}?q=${encodeURIComponent(query)}`;
const response = await fetch(searchUrl);
const data = await response.json();
this.matches = data.matches || [];
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults();
} catch (e) {
console.error('Failed to fetch search results:', e);
this.matches = [];
this.renderResults();
}
}
filterLocalItems(query) {
if (!query.trim()) {
this.matches = [];
} else {
const lowerQuery = query.toLowerCase();
this.matches = (this.allItems || []).filter(item =>
item.name.toLowerCase().includes(lowerQuery) ||
item.url.toLowerCase().includes(lowerQuery)
);
}
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
this.renderResults();
}
renderResults() {
const container = this.shadowRoot.querySelector('.results-container');
const input = this.shadowRoot.querySelector('.search-input');
if (this.matches.length === 0) {
const message = input.value.trim() ? 'No results found' : 'Start typing to search...';
container.innerHTML = `<div class="no-results">${message}</div>`;
return;
}
container.innerHTML = this.matches.map((match, index) => `
<div
class="result-item ${index === this.selectedIndex ? 'selected' : ''}"
data-index="${index}"
role="option"
aria-selected="${index === this.selectedIndex}"
>
<div>
<div class="result-name">${this.escapeHtml(match.name)}</div>
<div class="result-url">${this.escapeHtml(match.url)}</div>
</div>
</div>
`).join('');
// Scroll selected item into view
if (this.selectedIndex >= 0) {
const selectedItem = container.children[this.selectedIndex];
if (selectedItem) {
selectedItem.scrollIntoView({ block: 'nearest' });
}
}
}
moveSelection(direction) {
const newIndex = this.selectedIndex + direction;
if (newIndex >= 0 && newIndex < this.matches.length) {
this.selectedIndex = newIndex;
this.renderResults();
}
}
selectCurrentItem() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
this.selectItem(this.selectedIndex);
}
}
selectItem(index) {
const match = this.matches[index];
if (match) {
// Dispatch custom event
this.dispatchEvent(new CustomEvent('select', {
detail: match,
bubbles: true,
composed: true
}));
// Navigate to URL
window.location.href = match.url;
this.closeMenu();
}
}
openMenu() {
const dialog = this.shadowRoot.querySelector('dialog');
const input = this.shadowRoot.querySelector('.search-input');
dialog.showModal();
input.value = '';
input.focus();
// Reset state - start with no items shown
this.matches = [];
this.selectedIndex = -1;
this.renderResults();
}
closeMenu() {
const dialog = this.shadowRoot.querySelector('dialog');
dialog.close();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Register the custom element
customElements.define('navigation-search', NavigationSearch);

View file

@ -72,5 +72,7 @@
{% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="/-/tables"></navigation-search>
</body>
</html>

View file

@ -776,6 +776,43 @@ class CreateTokenView(BaseView):
return await self.render(["create_token.html"], request, context)
class TablesSearchView(BaseView):
name = "tables_search"
has_json_alternate = False
async def get(self, request):
# Get the search query parameter
query = request.args.get("q", "").strip()
if not query:
return Response.json({"matches": []})
# Use the new get_allowed_tables() method with search
extra_sql = "table_name LIKE :search"
extra_params = {"search": f"%{query}%"}
allowed_tables = await self.ds.get_allowed_tables(
actor=request.actor, extra_sql=extra_sql, extra_params=extra_params
)
# Format the response
matches = []
for item in allowed_tables:
database = item["database"]
table = item["table"]
matches.append(
{
"url": self.ds.urls.table(database, table),
"name": f"{database}: {table}",
}
)
response = Response.json({"matches": matches})
if self.ds.cors:
add_cors_headers(response.headers)
return response
class ApiExplorerView(BaseView):
name = "api_explorer"
has_json_alternate = False

View file

@ -198,3 +198,37 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti
-----------
The debug tool at ``/-/messages`` can be used to set flash messages to try out that feature. See :ref:`datasette_add_message` for details of this feature.
.. _TablesSearchView:
/-/tables.json
--------------
The ``/-/tables.json`` endpoint provides a JSON API for searching tables that the current user has permission to access.
Pass a ``?q=`` query parameter with your search term to find matching tables. The search matches against table names using a case-insensitive substring match.
This endpoint returns JSON only and respects the current user's permissions - only tables they are allowed to view will be included in the results.
Example request:
``/-/tables.json?q=users``
Example response:
.. code-block:: json
{
"matches": [
{
"url": "/mydb/users",
"name": "mydb: users"
},
{
"url": "/otherdb/users_archive",
"name": "otherdb: users_archive"
}
]
}
If no search query is provided, the endpoint returns an empty matches array.