diff --git a/datasette/app.py b/datasette/app.py index 6c7026a8..2658d848 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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(\.(?Pjson))?$", + ) add_route( wrap_view(PatternPortfolioView, self), r"/-/patterns$", diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js new file mode 100644 index 00000000..202839d5 --- /dev/null +++ b/datasette/static/navigation-search.js @@ -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 = ` + + + +
+
+ +
+
+
+ Navigate + Enter Select + Esc Close +
+
+
+ `; + } + + 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 = `
${message}
`; + return; + } + + container.innerHTML = this.matches.map((match, index) => ` +
+
+
${this.escapeHtml(match.name)}
+
${this.escapeHtml(match.url)}
+
+
+ `).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); \ No newline at end of file diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 0b2def5a..0d89e11c 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -72,5 +72,7 @@ {% endfor %} {% if select_templates %}{% endif %} + + diff --git a/datasette/views/special.py b/datasette/views/special.py index 7e5ce517..bba44a45 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -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 diff --git a/docs/introspection.rst b/docs/introspection.rst index ff78ec78..0328120a 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -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.