diff --git a/datasette/cli.py b/datasette/cli.py index 93aa22ef21..a6bd10a0fd 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -154,7 +154,7 @@ async def inspect_(files, sqlite_extensions): app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions) data = {} for name, database in app.databases.items(): - counts = await database.table_counts(limit=3600 * 1000) + counts = await database.table_counts(limit=3600 * 1000, exact=True) data[name] = { "hash": database.hash, "size": database.size, diff --git a/datasette/database.py b/datasette/database.py index 66d50ffa50..6c0175e2e1 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -561,25 +561,26 @@ def size(self): 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, exact=False): if not self.is_mutable and self.cached_table_counts is not None: return self.cached_table_counts # Try to get counts for each table, $limit timeout for each count counts = {} for table in await self.table_names(): try: + if exact: + sql = f"select count(*) from [{table}]" + else: + sql = f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})" table_count = ( - await self.execute( - f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})", - custom_time_limit=limit, - ) + await self.execute(sql, custom_time_limit=limit) ).rows[0][0] counts[table] = table_count # In some cases I saw "SQL Logic Error" here in addition to # QueryInterrupted - so we catch that too: except (QueryInterrupted, sqlite3.OperationalError, sqlite3.DatabaseError): counts[table] = None - if not self.is_mutable: + if not self.is_mutable and not exact: self._cached_table_counts = counts return counts diff --git a/datasette/utils/actions_sql.py b/datasette/utils/actions_sql.py index e679ae76da..4ce1f9645c 100644 --- a/datasette/utils/actions_sql.py +++ b/datasette/utils/actions_sql.py @@ -241,6 +241,20 @@ async def _build_single_action_sql( "),", ] ) + else: + # The anon_*_lvl CTEs below LEFT JOIN anon_rules unconditionally, + # so when no anonymous rules are produced (e.g. --default-deny + # without a config file) we still need an empty CTE with the + # expected columns. Without it the whole permissions query is + # invalid SQL and the index pages 500. + query_parts.extend( + [ + "anon_rules AS (", + " SELECT NULL AS parent, NULL AS child, NULL AS allow,", + " NULL AS reason, NULL AS source_plugin WHERE 0", + "),", + ] + ) # Continue with the cascading logic query_parts.extend( diff --git a/tests/test_default_deny.py b/tests/test_default_deny.py index 81e95b845b..9f797fe3e2 100644 --- a/tests/test_default_deny.py +++ b/tests/test_default_deny.py @@ -127,3 +127,25 @@ async def test_default_deny_basic_permissions(): # Authenticated user without explicit permission should also be denied assert await ds.allowed(action="view-instance", actor={"id": "user"}) is False + + +@pytest.mark.asyncio +async def test_default_deny_no_config_index_pages_dont_500(): + """Regression for #2644: root visiting the index pages on a --default-deny + instance without any config file used to return 500 because the anon_rules + CTE was conditionally emitted but unconditionally referenced.""" + ds = Datasette(default_deny=True) + ds.root_enabled = True + await ds.invoke_startup() + + db = ds.add_memory_database("test_db_no_config") + await db.execute_write("create table test_table (id integer primary key)") + await ds._refresh_schemas() + + cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})} + + response = await ds.client.get("/", cookies=cookies) + assert response.status_code == 200 + + response = await ds.client.get("/test_db_no_config", cookies=cookies) + assert response.status_code == 200