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,
|
||||
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$",
|
||||
|
|
|
|||
|
|
@ -374,12 +374,25 @@ class Database:
|
|||
self.cached_size = Path(self.path).stat().st_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:
|
||||
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
|
||||
counts = {}
|
||||
for table in await self.table_names():
|
||||
for table in tables_to_count:
|
||||
try:
|
||||
table_count = (
|
||||
await self.execute(
|
||||
|
|
@ -392,8 +405,11 @@ class Database:
|
|||
# QueryInterrupted - so we catch that too:
|
||||
except (QueryInterrupted, sqlite3.OperationalError, sqlite3.DatabaseError):
|
||||
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
|
||||
|
||||
return counts
|
||||
|
||||
@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():
|
||||
# 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)
|
||||
add_row(
|
||||
db_name,
|
||||
|
|
@ -325,7 +328,6 @@ async def _config_permission_rules(datasette, actor, action) -> list[PluginSQL]:
|
|||
params[f"{key}_reason"] = reason
|
||||
|
||||
sql = "\nUNION ALL\n".join(parts)
|
||||
print(sql, 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 %}
|
||||
|
||||
{% 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>
|
||||
|
|
|
|||
|
|
@ -273,34 +273,157 @@ class QueryContext:
|
|||
async def get_tables(datasette, request, db):
|
||||
tables = []
|
||||
database = db.name
|
||||
table_counts = await db.table_counts(100)
|
||||
hidden_table_names = set(await db.hidden_table_names())
|
||||
all_foreign_keys = await db.get_all_foreign_keys()
|
||||
|
||||
for table in table_counts:
|
||||
table_visible, table_private = await datasette.check_visibility(
|
||||
request.actor,
|
||||
permissions=[
|
||||
("view-table", (database, table)),
|
||||
("view-database", database),
|
||||
"view-instance",
|
||||
],
|
||||
# Use the new SQL-based permission system to check all tables at once
|
||||
from datasette.utils.permissions import resolve_permissions_from_catalog, PluginSQL
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import await_me_maybe
|
||||
|
||||
# Get all table names from catalog (cheap operation, no scanning)
|
||||
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)
|
||||
tables.append(
|
||||
{
|
||||
"name": table,
|
||||
"columns": table_columns,
|
||||
"primary_keys": await db.primary_keys(table),
|
||||
"count": table_counts[table],
|
||||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"foreign_keys": all_foreign_keys[table],
|
||||
"private": table_private,
|
||||
}
|
||||
)
|
||||
table_dict = {
|
||||
"name": table,
|
||||
"columns": table_columns,
|
||||
"primary_keys": await db.primary_keys(table),
|
||||
"count": table_counts.get(table),
|
||||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"foreign_keys": all_foreign_keys.get(table, {}),
|
||||
"private": table_private,
|
||||
}
|
||||
tables.append(table_dict)
|
||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
||||
return tables
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue