from __future__ import annotations import asyncio import contextvars from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from datasette.permissions import Resource from datasette.tokens import TokenRestrictions import collections import dataclasses import datetime import functools import glob import importlib.metadata import inspect import json import logging import os import re import secrets import sys import threading import time import types import urllib.parse from concurrent import futures from pathlib import Path import httpx2 from itsdangerous import BadSignature, URLSafeSerializer from jinja2 import ( ChoiceLoader, Environment, FileSystemLoader, PrefixLoader, pass_context, ) from jinja2.environment import Template from jinja2.exceptions import TemplateNotFound from markupsafe import Markup, escape from . import stored_queries, write_sql from .background_tasks import BackgroundTask, BackgroundTaskSupervisor from .column_types import SQLiteType from .csrf import CrossOriginProtectionMiddleware from .database import Database, QueryInterrupted from .events import Event from .plugins import DEFAULT_PLUGINS, get_plugins, pm from .renderer import json_renderer from .resources import DatabaseResource, TableResource from .tokens import TokenInvalid from .tracer import AsgiTracer from .url_builder import Urls from .utils import ( SPATIALITE_FUNCTIONS, PaginatedResources, PrefixedUrlString, StartupError, add_cors_headers, async_call_with_supported_arguments, await_me_maybe, baseconv, call_with_supported_arguments, detect_json1, display_actor, escape_css_string, escape_sqlite, find_spatialite, format_bytes, module_from_path, move_plugins_and_allow, move_table_config, parse_metadata, redact_keys, resolve_env_secrets, resolve_routes, row_sql_params_pks, sha256_file, tilde_decode, tilde_encode, to_css_class, urlsafe_components, ) from .utils.asgi import ( AsgiLifespan, AsgiRunOnFirstRequest, BadRequest, DatabaseNotFound, Forbidden, NotFound, Request, Response, RowNotFound, TableNotFound, asgi_send, asgi_send_file, asgi_send_redirect, asgi_static, ) from .utils.internal_db import init_internal_db, populate_schema_tables from .utils.sqlite import ( sqlite3, using_pysqlite3, ) from .version import __version__ from .views import Context from .views.database import ( DatabaseView, QueryView, database_download, ) from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView from .views.index import IndexView from .views.row import RowDeleteView, RowUpdateView, RowView from .views.special import ( AllowDebugView, AllowedResourcesView, ApiExplorerView, AuthTokenView, AutocompleteDebugView, CreateTokenView, DatabaseSchemaView, InstanceSchemaView, JsonDataView, JumpView, LogoutView, MessagesDebugView, PatternPortfolioView, PermissionCheckView, PermissionRulesView, PermissionsDebugView, TableSchemaView, ) from .views.stored_queries import ( GlobalQueryListView, QueryCreateAnalyzeView, QueryDefinitionView, QueryDeleteView, QueryEditView, QueryListView, QueryParametersView, QueryStoreView, QueryUpdateView, ) from .views.table import ( TableAutocompleteView, TableCountView, TableDropView, TableFragmentView, TableInsertView, TableSetColumnTypeView, TableUpsertView, table_view, ) from .views.table_create_alter import ( DatabaseForeignKeyTargetsView, TableAlterView, TableCreateView, TableForeignKeySuggestionsView, ) app_root = Path(__file__).parent.parent logger = logging.getLogger(__name__) # Context variable to track when code is executing within a datasette.client request _in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False) class _DatasetteClientContext: """Context manager to mark code as executing within a datasette.client request.""" def __enter__(self): self.token = _in_datasette_client.set(True) return self def __exit__(self, exc_type, exc_val, exc_tb): _in_datasette_client.reset(self.token) return False @dataclasses.dataclass class PermissionCheck: """Represents a logged permission check for debugging purposes.""" when: str actor: dict[str, Any] | None action: str parent: str | None child: str | None result: bool # https://github.com/simonw/datasette/issues/283#issuecomment-781591015 SQLITE_LIMIT_ATTACHED = 10 INTERNAL_DB_NAME = "__INTERNAL__" Setting = collections.namedtuple("Setting", ("name", "default", "help")) SETTINGS = ( Setting("default_page_size", 100, "Default page size for the table view"), Setting( "max_returned_rows", 1000, "Maximum rows that can be returned from a table or custom query", ), Setting( "max_insert_rows", 100, "Maximum rows that can be inserted at a time using the bulk insert API", ), Setting( "max_post_body_bytes", 2 * 1024 * 1024, "Maximum size in bytes for a POST body read into memory, e.g. JSON API requests - set 0 to disable this limit", ), Setting( "num_sql_threads", 3, "Number of threads in the thread pool for executing SQLite queries", ), Setting("sql_time_limit_ms", 1000, "Time limit for a SQL query in milliseconds"), Setting( "default_facet_size", 30, "Number of values to return for requested facets" ), Setting("facet_time_limit_ms", 200, "Time limit for calculating a requested facet"), Setting( "facet_suggest_time_limit_ms", 50, "Time limit for calculating a suggested facet", ), Setting( "allow_facet", True, "Allow users to specify columns to facet using ?_facet= parameter", ), Setting( "allow_download", True, "Allow users to download the original SQLite database files", ), Setting( "allow_signed_tokens", True, "Allow users to create and use signed API tokens", ), Setting( "default_allow_sql", True, "Allow anyone to run arbitrary SQL queries", ), Setting( "max_signed_tokens_ttl", 0, "Maximum allowed expiry time for signed API tokens", ), Setting("suggest_facets", True, "Calculate and display suggested facets"), Setting( "default_cache_ttl", 5, "Default HTTP cache TTL (used in Cache-Control: max-age= header)", ), Setting("cache_size_kb", 0, "SQLite cache size in KB (0 == use SQLite default)"), Setting( "allow_csv_stream", True, "Allow .csv?_stream=1 to download all rows (ignoring max_returned_rows)", ), Setting( "max_csv_mb", 100, "Maximum size allowed for CSV export in MB - set 0 to disable this limit", ), Setting( "truncate_cells_html", 2048, "Truncate cells longer than this in HTML table view - set 0 to disable", ), Setting( "force_https_urls", False, "Force URLs in API output to always use https:// protocol", ), Setting( "template_debug", False, "Allow display of template debug information with ?_context=1", ), Setting( "trace_debug", False, "Allow display of SQL trace debug information with ?_trace=1", ), Setting("base_url", "/", "Datasette URLs should use this base path"), ) _HASH_URLS_REMOVED = "The hash_urls setting has been removed, try the datasette-hashed-urls plugin instead" OBSOLETE_SETTINGS = { "hash_urls": _HASH_URLS_REMOVED, "default_cache_ttl_hashed": _HASH_URLS_REMOVED, } DEFAULT_SETTINGS = {option.name: option.default for option in SETTINGS} FAVICON_PATH = app_root / "datasette" / "static" / "favicon.png" DEFAULT_NOT_SET = object() ResourcesSQL = collections.namedtuple("ResourcesSQL", ("sql", "params")) def _permission_cache_key(actor, action, parent, child): # Key on the full serialized actor so actors differing in any field # (e.g. token restrictions) never share cache entries actor_key = ( json.dumps(actor, sort_keys=True, default=repr) if actor is not None else None ) return (actor_key, action.name, parent, action.normalize_child(child)) async def favicon(request, send): await asgi_send_file( send, str(FAVICON_PATH), content_type="image/png", headers={"Cache-Control": "max-age=3600, public"}, ) ResolvedTable = collections.namedtuple("ResolvedTable", ("db", "table", "is_view")) ResolvedRow = collections.namedtuple( "ResolvedRow", ("db", "table", "sql", "params", "pks", "pk_values", "row") ) def _to_string(value): if isinstance(value, str): return value else: return json.dumps(value, default=str) def _template_context_json_default(value): if dataclasses.is_dataclass(value) and not isinstance(value, type): return { field.name: getattr(value, field.name) for field in dataclasses.fields(value) } return repr(value) @pass_context def _legacy_template_csrftoken(context): request = context.get("request") if request and "csrftoken" in request.scope: return request.scope["csrftoken"]() return "" def _resolve_static_asset_path(root_path, path): root = Path(root_path).resolve() full_path = (root / path).resolve() try: full_path.relative_to(root) except ValueError: raise ValueError("Static asset path cannot escape static root") from None return full_path # Documentation for the variables Datasette.render_template() adds to the # context for every page. This is part of the documented template contract: # keys added in render_template() must be documented here - the contract # tests in tests/test_template_context.py enforce this, and the docs in # docs/template_context.rst are generated from it. TEMPLATE_BASE_CONTEXT = { "request": "The current :ref:`Request object `, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.", "crumb_items": 'Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.', "urls": "Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.", "actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.", "menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.", "display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.", "show_logout": "True if the logout link should be shown in the navigation menu", "zip": "Python's ``zip()`` builtin, made available to template logic", "body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``