mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Compare commits
2 commits
main
...
integrate-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb6ffca471 | ||
|
|
75298db4ae |
8 changed files with 811 additions and 30 deletions
170
datasette/app.py
170
datasette/app.py
|
|
@ -52,6 +52,7 @@ from .views.special import (
|
||||||
AllowedResourcesView,
|
AllowedResourcesView,
|
||||||
PermissionRulesView,
|
PermissionRulesView,
|
||||||
PermissionCheckView,
|
PermissionCheckView,
|
||||||
|
TablesSearchView,
|
||||||
)
|
)
|
||||||
from .views.table import (
|
from .views.table import (
|
||||||
TableInsertView,
|
TableInsertView,
|
||||||
|
|
@ -1069,8 +1070,161 @@ class Datasette:
|
||||||
)
|
)
|
||||||
return sql, params
|
return sql, params
|
||||||
|
|
||||||
async def permission_allowed_2(
|
async def get_allowed_tables(
|
||||||
self, actor, action, resource=None, *, default=DEFAULT_NOT_SET
|
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."""
|
"""Permission check backed by permission_resources_sql rules."""
|
||||||
|
|
||||||
|
|
@ -1178,6 +1332,14 @@ class Datasette:
|
||||||
|
|
||||||
return result
|
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(
|
async def ensure_permissions(
|
||||||
self,
|
self,
|
||||||
actor: dict,
|
actor: dict,
|
||||||
|
|
@ -1754,6 +1916,10 @@ class Datasette:
|
||||||
AllowDebugView.as_view(self),
|
AllowDebugView.as_view(self),
|
||||||
r"/-/allow-debug$",
|
r"/-/allow-debug$",
|
||||||
)
|
)
|
||||||
|
add_route(
|
||||||
|
TablesSearchView.as_view(self),
|
||||||
|
r"/-/tables(\.(?P<format>json))?$",
|
||||||
|
)
|
||||||
add_route(
|
add_route(
|
||||||
wrap_view(PatternPortfolioView, self),
|
wrap_view(PatternPortfolioView, self),
|
||||||
r"/-/patterns$",
|
r"/-/patterns$",
|
||||||
|
|
|
||||||
|
|
@ -374,12 +374,25 @@ class Database:
|
||||||
self.cached_size = Path(self.path).stat().st_size
|
self.cached_size = Path(self.path).stat().st_size
|
||||||
return self.cached_size
|
return self.cached_size
|
||||||
|
|
||||||
async def table_counts(self, limit=10):
|
async def table_counts(self, limit=10, tables=None):
|
||||||
|
# Determine which tables we need counts for
|
||||||
|
if tables is None:
|
||||||
|
tables_to_count = await self.table_names()
|
||||||
|
else:
|
||||||
|
tables_to_count = tables
|
||||||
|
|
||||||
|
# If we have cached counts for immutable database, use them
|
||||||
if not self.is_mutable and self.cached_table_counts is not None:
|
if not self.is_mutable and self.cached_table_counts is not None:
|
||||||
return self.cached_table_counts
|
# Return only the requested tables from cache
|
||||||
|
return {
|
||||||
|
table: self.cached_table_counts.get(table)
|
||||||
|
for table in tables_to_count
|
||||||
|
if table in self.cached_table_counts
|
||||||
|
}
|
||||||
|
|
||||||
# Try to get counts for each table, $limit timeout for each count
|
# Try to get counts for each table, $limit timeout for each count
|
||||||
counts = {}
|
counts = {}
|
||||||
for table in await self.table_names():
|
for table in tables_to_count:
|
||||||
try:
|
try:
|
||||||
table_count = (
|
table_count = (
|
||||||
await self.execute(
|
await self.execute(
|
||||||
|
|
@ -392,8 +405,11 @@ class Database:
|
||||||
# QueryInterrupted - so we catch that too:
|
# QueryInterrupted - so we catch that too:
|
||||||
except (QueryInterrupted, sqlite3.OperationalError, sqlite3.DatabaseError):
|
except (QueryInterrupted, sqlite3.OperationalError, sqlite3.DatabaseError):
|
||||||
counts[table] = None
|
counts[table] = None
|
||||||
if not self.is_mutable:
|
|
||||||
|
# Only cache if we counted all tables
|
||||||
|
if tables is None and not self.is_mutable:
|
||||||
self._cached_table_counts = counts
|
self._cached_table_counts = counts
|
||||||
|
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,9 @@ async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
||||||
)
|
)
|
||||||
|
|
||||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||||
|
# query_config can be a string (just SQL) or a dict
|
||||||
|
if isinstance(query_config, str):
|
||||||
|
continue
|
||||||
query_perm = (query_config.get("permissions") or {}).get(action)
|
query_perm = (query_config.get("permissions") or {}).get(action)
|
||||||
add_row(
|
add_row(
|
||||||
db_name,
|
db_name,
|
||||||
|
|
@ -325,7 +328,6 @@ async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
||||||
params[f"{key}_reason"] = reason
|
params[f"{key}_reason"] = reason
|
||||||
|
|
||||||
sql = "\nUNION ALL\n".join(parts)
|
sql = "\nUNION ALL\n".join(parts)
|
||||||
print(sql, params)
|
|
||||||
return [PluginSQL(source="config_permissions", sql=sql, params=params)]
|
return [PluginSQL(source="config_permissions", sql=sql, params=params)]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
401
datasette/static/navigation-search.js
Normal file
401
datasette/static/navigation-search.js
Normal 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);
|
||||||
|
|
@ -72,5 +72,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
{% 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -273,34 +273,157 @@ class QueryContext:
|
||||||
async def get_tables(datasette, request, db):
|
async def get_tables(datasette, request, db):
|
||||||
tables = []
|
tables = []
|
||||||
database = db.name
|
database = db.name
|
||||||
table_counts = await db.table_counts(100)
|
|
||||||
hidden_table_names = set(await db.hidden_table_names())
|
hidden_table_names = set(await db.hidden_table_names())
|
||||||
all_foreign_keys = await db.get_all_foreign_keys()
|
all_foreign_keys = await db.get_all_foreign_keys()
|
||||||
|
|
||||||
for table in table_counts:
|
# Use the new SQL-based permission system to check all tables at once
|
||||||
table_visible, table_private = await datasette.check_visibility(
|
from datasette.utils.permissions import resolve_permissions_from_catalog, PluginSQL
|
||||||
request.actor,
|
from datasette.plugins import pm
|
||||||
permissions=[
|
from datasette.utils import await_me_maybe
|
||||||
("view-table", (database, table)),
|
|
||||||
("view-database", database),
|
# Get all table names from catalog (cheap operation, no scanning)
|
||||||
"view-instance",
|
internal_db = datasette.get_internal_database()
|
||||||
],
|
table_names_result = await internal_db.execute(
|
||||||
|
"SELECT table_name FROM catalog_tables WHERE database_name = ?", [database]
|
||||||
|
)
|
||||||
|
table_names = [row["table_name"] for row in table_names_result.rows]
|
||||||
|
|
||||||
|
if table_names:
|
||||||
|
# Use catalog_tables for candidate SQL to query all tables in this database at once
|
||||||
|
candidate_sql = "SELECT :database AS parent, table_name AS child FROM catalog_tables WHERE database_name = :database"
|
||||||
|
candidate_params = {"database": database}
|
||||||
|
|
||||||
|
# Get plugin SQL blocks for view-table permission
|
||||||
|
plugins = []
|
||||||
|
for block in pm.hook.permission_resources_sql(
|
||||||
|
datasette=datasette,
|
||||||
|
actor=request.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
|
||||||
|
plugins.append(candidate)
|
||||||
|
|
||||||
|
# Resolve permissions for all tables at once
|
||||||
|
if isinstance(request.actor, dict):
|
||||||
|
actor_id = request.actor.get("id")
|
||||||
|
elif request.actor:
|
||||||
|
actor_id = request.actor
|
||||||
|
else:
|
||||||
|
actor_id = None
|
||||||
|
internal_db = datasette.get_internal_database()
|
||||||
|
permission_results = await resolve_permissions_from_catalog(
|
||||||
|
internal_db,
|
||||||
|
actor=str(actor_id) if actor_id is not None else "",
|
||||||
|
plugins=plugins,
|
||||||
|
action="view-table",
|
||||||
|
candidate_sql=candidate_sql,
|
||||||
|
candidate_params=candidate_params,
|
||||||
|
implicit_deny=True,
|
||||||
)
|
)
|
||||||
if not table_visible:
|
|
||||||
continue
|
# Create a lookup dict for allowed tables and their privacy status
|
||||||
|
allowed_tables = {}
|
||||||
|
for result in permission_results:
|
||||||
|
table_name = result["child"]
|
||||||
|
if result["allow"] == 1:
|
||||||
|
allowed_tables[table_name] = result
|
||||||
|
|
||||||
|
# Check which tables are visible to anonymous users (for determining "private" status)
|
||||||
|
# A table is visible to anonymous users if BOTH view-database AND view-table pass
|
||||||
|
anon_allowed_tables = set()
|
||||||
|
if request.actor:
|
||||||
|
# Check if anonymous users can view the database
|
||||||
|
anon_can_view_database = await datasette.permission_allowed(
|
||||||
|
None, "view-database", database
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate new plugin SQL blocks for anonymous user to check table permissions
|
||||||
|
anon_plugins = []
|
||||||
|
for block in pm.hook.permission_resources_sql(
|
||||||
|
datasette=datasette,
|
||||||
|
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
|
||||||
|
anon_plugins.append(candidate)
|
||||||
|
|
||||||
|
anon_permission_results = await resolve_permissions_from_catalog(
|
||||||
|
internal_db,
|
||||||
|
actor="",
|
||||||
|
plugins=anon_plugins,
|
||||||
|
action="view-table",
|
||||||
|
candidate_sql=candidate_sql,
|
||||||
|
candidate_params=candidate_params,
|
||||||
|
implicit_deny=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A table is not private if anonymous users can view it
|
||||||
|
# This requires passing BOTH view-table AND view-database checks
|
||||||
|
# UNLESS the table has an explicit allow block that overrides database restrictions
|
||||||
|
# We approximate this by checking if the permission result has a specific "config allow" reason
|
||||||
|
# which indicates an explicit table-level allow block
|
||||||
|
for result in anon_permission_results:
|
||||||
|
if result["allow"] == 1:
|
||||||
|
# Check if this is from an explicit table-level allow block
|
||||||
|
# or if the anonymous user can also view the database
|
||||||
|
reason = result.get("reason", "")
|
||||||
|
has_explicit_table_allow = (
|
||||||
|
"config allow allow for view-table" in reason
|
||||||
|
)
|
||||||
|
if has_explicit_table_allow or anon_can_view_database:
|
||||||
|
anon_allowed_tables.add(result["child"])
|
||||||
|
else:
|
||||||
|
allowed_tables = {}
|
||||||
|
anon_allowed_tables = set()
|
||||||
|
|
||||||
|
# Build the tables list for allowed tables only
|
||||||
|
# Only get table counts for the tables we're actually going to display
|
||||||
|
allowed_table_names = list(allowed_tables.keys())
|
||||||
|
|
||||||
|
# Get counts only for allowed tables (uses caching mechanism)
|
||||||
|
if allowed_table_names:
|
||||||
|
table_counts = await db.table_counts(limit=10, tables=allowed_table_names)
|
||||||
|
else:
|
||||||
|
table_counts = {}
|
||||||
|
|
||||||
|
for table in allowed_table_names:
|
||||||
|
# Determine if table is private (not visible to anonymous users)
|
||||||
|
table_private = bool(request.actor and table not in anon_allowed_tables)
|
||||||
|
|
||||||
table_columns = await db.table_columns(table)
|
table_columns = await db.table_columns(table)
|
||||||
tables.append(
|
table_dict = {
|
||||||
{
|
"name": table,
|
||||||
"name": table,
|
"columns": table_columns,
|
||||||
"columns": table_columns,
|
"primary_keys": await db.primary_keys(table),
|
||||||
"primary_keys": await db.primary_keys(table),
|
"count": table_counts.get(table),
|
||||||
"count": table_counts[table],
|
"hidden": table in hidden_table_names,
|
||||||
"hidden": table in hidden_table_names,
|
"fts_table": await db.fts_table(table),
|
||||||
"fts_table": await db.fts_table(table),
|
"foreign_keys": all_foreign_keys.get(table, {}),
|
||||||
"foreign_keys": all_foreign_keys[table],
|
"private": table_private,
|
||||||
"private": table_private,
|
}
|
||||||
}
|
tables.append(table_dict)
|
||||||
)
|
|
||||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
||||||
return tables
|
return tables
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -776,6 +776,43 @@ class CreateTokenView(BaseView):
|
||||||
return await self.render(["create_token.html"], request, context)
|
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):
|
class ApiExplorerView(BaseView):
|
||||||
name = "api_explorer"
|
name = "api_explorer"
|
||||||
has_json_alternate = False
|
has_json_alternate = False
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue