Compare commits

..

No commits in common. "main" and "1.0a34" have entirely different histories.

200 changed files with 4444 additions and 21789 deletions

View file

@ -1,39 +0,0 @@
name: "Setup SQLite version"
description: "Build and activate a specific SQLite version from its amalgamation archive"
inputs:
version:
description: "The SQLite version to install"
required: true
cflags:
description: "CFLAGS to use when compiling SQLite"
required: false
default: ""
skip-activate:
description: "Set to true to skip modifying the library path"
required: false
default: "false"
fallback-urls:
description: "Whitespace-separated fallback download URLs to try after sqlite.org"
required: false
default: ""
outputs:
sqlite-location:
description: "Directory containing the compiled SQLite library"
value: ${{ steps.build.outputs.sqlite-location }}
runs:
using: "composite"
steps:
- shell: bash
run: mkdir -p "$RUNNER_TEMP/sqlite-versions/downloads"
- uses: actions/cache@v6
with:
path: ${{ runner.temp }}/sqlite-versions/downloads
key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1
- id: build
shell: bash
run: bash "$GITHUB_ACTION_PATH/setup-sqlite-version.sh"
env:
SQLITE_VERSION: ${{ inputs.version }}
SQLITE_CFLAGS: ${{ inputs.cflags }}
SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}
SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}

View file

@ -1,144 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}"
cflags="${SQLITE_CFLAGS:-}"
skip_activate="${SQLITE_SKIP_ACTIVATE:-false}"
extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}"
case "$version_spec" in
3.46 | 3.46.0)
sqlite_version="3.46.0"
sqlite_year="2024"
amalgamation_id="3460000"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip"
;;
3.25 | 3.25.0)
sqlite_version="3.25.0"
sqlite_year="2018"
amalgamation_id="3250000"
builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3250000.zip?v=1"
;;
*)
echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh."
exit 1
;;
esac
case "$(uname -s)" in
Linux)
library_name="libsqlite3.so.0"
library_path_var="LD_LIBRARY_PATH"
;;
Darwin)
library_name="libsqlite3.dylib"
library_path_var="DYLD_LIBRARY_PATH"
;;
*)
echo "::error::Unsupported platform $(uname -s)"
exit 1
;;
esac
runner_temp="${RUNNER_TEMP:-}"
if [ -z "$runner_temp" ]; then
runner_temp="$(mktemp -d)"
fi
filename="sqlite-amalgamation-${amalgamation_id}"
official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip"
download_dir="${runner_temp}/sqlite-versions/downloads"
source_root="${runner_temp}/sqlite-versions/source"
source_dir="${source_root}/${filename}"
build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}"
archive_path="${download_dir}/${filename}.zip"
mkdir -p "$download_dir" "$source_root" "$build_dir"
download_archive() {
local url
local candidate_path="${archive_path}.tmp"
local urls=("$official_url")
for url in $builtin_fallback_urls $extra_fallback_urls; do
urls+=("$url")
done
rm -f "$candidate_path"
for url in "${urls[@]}"; do
echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}"
if curl \
--fail \
--location \
--show-error \
--retry 5 \
--retry-delay 2 \
--retry-max-time 180 \
--retry-all-errors \
--connect-timeout 20 \
--max-time 240 \
--output "$candidate_path" \
"$url"; then
mv "$candidate_path" "$archive_path"
return 0
fi
echo "::warning::Download failed from ${url}"
rm -f "$candidate_path"
done
echo "::error::Could not download SQLite ${sqlite_version} amalgamation"
return 1
}
if [ ! -f "${source_dir}/sqlite3.c" ]; then
if [ ! -f "$archive_path" ]; then
download_archive
fi
rm -rf "$source_dir"
unzip -q "$archive_path" -d "$source_root"
fi
if [ ! -f "${source_dir}/sqlite3.c" ]; then
echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}"
exit 1
fi
read -r -a cflag_args <<< "$cflags"
echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}"
gcc \
-fPIC \
-shared \
"${cflag_args[@]}" \
"${source_dir}/sqlite3.c" \
"-I${source_dir}" \
-o "${build_dir}/${library_name}"
if [ "$library_name" = "libsqlite3.so.0" ]; then
ln -sf "$library_name" "${build_dir}/libsqlite3.so"
fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT"
else
echo "sqlite-location=${build_dir}"
fi
case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in
true | 1 | yes)
echo "Skipping ${library_path_var} activation"
;;
*)
existing_value="${!library_path_var:-}"
if [ -n "${GITHUB_ENV:-}" ]; then
if [ -n "$existing_value" ]; then
echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV"
else
echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV"
fi
fi
echo "Added ${build_dir} to ${library_path_var}"
;;
esac

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out datasette - name: Check out datasette
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:

View file

@ -0,0 +1,16 @@
name: Read the Docs Pull Request Preview
on:
pull_request:
types:
- opened
permissions:
pull-requests: write
jobs:
documentation-links:
runs-on: ubuntu-latest
steps:
- uses: readthedocs/actions/preview@v1
with:
project-slug: "datasette"

View file

@ -16,7 +16,7 @@ jobs:
matrix: matrix:
browser: [chromium, firefox, webkit] browser: [chromium, firefox, webkit]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python 3.14 - name: Set up Python 3.14
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -25,14 +25,14 @@ jobs:
cache: pip cache: pip
cache-dependency-path: pyproject.toml cache-dependency-path: pyproject.toml
- name: Cache uv - name: Cache uv
uses: actions/cache@v6 uses: actions/cache@v5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }} key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
restore-keys: | restore-keys: |
${{ runner.os }}-py3.14-uv- ${{ runner.os }}-py3.14-uv-
- name: Cache Playwright browsers - name: Cache Playwright browsers
uses: actions/cache@v6 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright/ path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }} key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}

View file

@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@v7 uses: actions/checkout@v6
- uses: actions/cache@v6 - uses: actions/cache@v5
name: Configure npm caching name: Configure npm caching
with: with:
path: ~/.npm path: ~/.npm

View file

@ -14,7 +14,7 @@ jobs:
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -35,7 +35,7 @@ jobs:
permissions: permissions:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -56,7 +56,7 @@ jobs:
needs: [deploy] needs: [deploy]
if: "!github.event.release.prerelease" if: "!github.event.release.prerelease"
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -92,7 +92,7 @@ jobs:
needs: [deploy] needs: [deploy]
if: "!github.event.release.prerelease" if: "!github.event.release.prerelease"
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Build and push to Docker Hub - name: Build and push to Docker Hub
env: env:
DOCKER_USER: ${{ secrets.DOCKER_USER }} DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -13,7 +13,7 @@ jobs:
deploy_docker: deploy_docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Build and push to Docker Hub - name: Build and push to Docker Hub
env: env:
DOCKER_USER: ${{ secrets.DOCKER_USER }} DOCKER_USER: ${{ secrets.DOCKER_USER }}

View file

@ -9,7 +9,7 @@ jobs:
spellcheck: spellcheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # We need all commits to find docs/ changes fetch-depth: 0 # We need all commits to find docs/ changes
- name: Set up Git user - name: Set up Git user

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out datasette - name: Check out datasette
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:

View file

@ -12,7 +12,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -20,7 +20,7 @@ jobs:
cache: 'pip' cache: 'pip'
cache-dependency-path: '**/pyproject.toml' cache-dependency-path: '**/pyproject.toml'
- name: Cache Playwright browsers - name: Cache Playwright browsers
uses: actions/cache@v6 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright/ path: ~/.cache/ms-playwright/
key: ${{ runner.os }}-browsers key: ${{ runner.os }}-browsers

View file

@ -25,7 +25,7 @@ jobs:
#"3.23.1" # 2018-04-10, before UPSERT #"3.23.1" # 2018-04-10, before UPSERT
] ]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@ -34,7 +34,7 @@ jobs:
cache: pip cache: pip
cache-dependency-path: pyproject.toml cache-dependency-path: pyproject.toml
- name: Set up SQLite ${{ matrix.sqlite-version }} - name: Set up SQLite ${{ matrix.sqlite-version }}
uses: ./.github/actions/setup-sqlite-version uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
with: with:
version: ${{ matrix.sqlite-version }} version: ${{ matrix.sqlite-version }}
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1" cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"

View file

@ -9,11 +9,10 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:

View file

@ -10,6 +10,6 @@ jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Setup tmate session - name: Setup tmate session
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3

View file

@ -11,7 +11,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Setup tmate session - name: Setup tmate session
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3
env: env:

2
.gitignore vendored
View file

@ -5,8 +5,6 @@ datasets.json
scratchpad scratchpad
ignored/
.vscode .vscode
uv.lock uv.lock

View file

@ -33,11 +33,10 @@ export DATASETTE_SECRET := "not_a_secret"
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
# Run linters: black, ruff, prettier, cog # Run linters: black, ruff, cog
@lint: codespell @lint: codespell
uv run black datasette tests --check uv run black datasette tests --check
uv run ruff check datasette tests uv run ruff check datasette tests
npm run prettier -- --check
uv run cog --check README.md docs/*.rst uv run cog --check README.md docs/*.rst
# Apply ruff fixes # Apply ruff fixes

View file

@ -1,14 +1,8 @@
from datasette.permissions import Permission # noqa from datasette.permissions import Permission # noqa
from datasette.version import __version_info__, __version__ # noqa from datasette.version import __version_info__, __version__ # noqa
from datasette.events import Event # noqa from datasette.events import Event # noqa
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa from datasette.tokens import TokenHandler, TokenRestrictions # noqa
from datasette.utils.asgi import ( # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
Forbidden,
NotFound,
PayloadTooLarge,
Request,
Response,
)
from datasette.utils import actor_matches_allow # noqa from datasette.utils import actor_matches_allow # noqa
from datasette.views import Context # noqa from datasette.views import Context # noqa
from .hookspecs import hookimpl # noqa from .hookspecs import hookimpl # noqa

View file

@ -89,8 +89,7 @@ def pytest_runtest_protocol(item, nextitem):
continue continue
try: try:
ds.close() ds.close()
except Exception as e: # noqa: BLE001 except Exception as e:
# Surfaced as a pytest warning; teardown must not fail the run
item.warn( item.warn(
pytest.PytestUnraisableExceptionWarning( pytest.PytestUnraisableExceptionWarning(
f"Error closing Datasette instance: {e!r}" f"Error closing Datasette instance: {e!r}"

View file

@ -1,9 +1,7 @@
import time
from itsdangerous import BadSignature
from datasette import hookimpl from datasette import hookimpl
from itsdangerous import BadSignature
from datasette.utils import baseconv from datasette.utils import baseconv
import time
@hookimpl @hookimpl

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
import hashlib
from datasette import hookimpl from datasette import hookimpl
from datasette.utils.asgi import Response, BadRequest
from datasette.utils import to_css_class from datasette.utils import to_css_class
from datasette.utils.asgi import BadRequest, Response import hashlib
_BLOB_COLUMN = "_blob_column" _BLOB_COLUMN = "_blob_column"
_BLOB_HASH = "_blob_hash" _BLOB_HASH = "_blob_hash"

View file

@ -1,45 +1,43 @@
import asyncio import asyncio
import uvicorn
import click
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
import functools import functools
import json import json
import os import os
import pathlib import pathlib
from runpy import run_module
import shutil import shutil
from subprocess import call
import sys import sys
import textwrap import textwrap
import webbrowser import webbrowser
from runpy import run_module
from subprocess import call
import click
import uvicorn
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
from .app import ( from .app import (
Datasette,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
SETTINGS, SETTINGS,
SQLITE_LIMIT_ATTACHED, SQLITE_LIMIT_ATTACHED,
Datasette,
pm, pm,
) )
from .inspect import inspect_tables from .inspect import inspect_tables
from .utils import ( from .utils import (
ConnectionProblem,
LoadExtension, LoadExtension,
SpatialiteConnectionProblem,
SpatialiteNotFound,
StartupError, StartupError,
StaticMount,
ValueAsBooleanError,
check_connection, check_connection,
deep_dict_update, deep_dict_update,
find_spatialite, find_spatialite,
parse_metadata,
ConnectionProblem,
SpatialiteConnectionProblem,
initial_path_for_datasette, initial_path_for_datasette,
pairs_to_nested_config, pairs_to_nested_config,
parse_metadata,
temporary_docker_directory, temporary_docker_directory,
value_as_boolean, value_as_boolean,
SpatialiteNotFound,
StaticMount,
ValueAsBooleanError,
) )
from .utils.sqlite import sqlite3 from .utils.sqlite import sqlite3
from .utils.testing import TestClient from .utils.testing import TestClient
@ -77,7 +75,7 @@ class Setting(CompositeParamType):
# Datasette 1.0, we turn bare setting names into setting.name # Datasette 1.0, we turn bare setting names into setting.name
# Type checking for those older settings # Type checking for those older settings
default = DEFAULT_SETTINGS[name] default = DEFAULT_SETTINGS[name]
name = f"settings.{name}" name = "settings.{}".format(name)
if isinstance(default, bool): if isinstance(default, bool):
try: try:
return name, "true" if value_as_boolean(value) else "false" return name, "true" if value_as_boolean(value) else "false"
@ -173,6 +171,7 @@ async def inspect_(files, sqlite_extensions):
@cli.group() @cli.group()
def publish(): def publish():
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API""" """Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
pass
# Register publish plugins # Register publish plugins
@ -579,27 +578,27 @@ def serve(
# https://github.com/simonw/datasette/issues/2389 # https://github.com/simonw/datasette/issues/2389
deep_dict_update(config_data, settings_updates) deep_dict_update(config_data, settings_updates)
kwargs = { kwargs = dict(
"immutables": immutable, immutables=immutable,
"cache_headers": not reload, cache_headers=not reload,
"cors": cors, cors=cors,
"inspect_data": inspect_data, inspect_data=inspect_data,
"config": config_data, config=config_data,
"metadata": metadata_data, metadata=metadata_data,
"sqlite_extensions": sqlite_extensions, sqlite_extensions=sqlite_extensions,
"template_dir": template_dir, template_dir=template_dir,
"plugins_dir": plugins_dir, plugins_dir=plugins_dir,
"static_mounts": static, static_mounts=static,
"settings": None, # These are passed in config= now settings=None, # These are passed in config= now
"memory": memory, memory=memory,
"secret": secret, secret=secret,
"version_note": version_note, version_note=version_note,
"pdb": pdb, pdb=pdb,
"crossdb": crossdb, crossdb=crossdb,
"nolock": nolock, nolock=nolock,
"internal": internal, internal=internal,
"default_deny": default_deny, default_deny=default_deny,
} )
# Separate directories from files # Separate directories from files
directories = [f for f in files if os.path.isdir(f)] directories = [f for f in files if os.path.isdir(f)]
@ -622,7 +621,9 @@ def serve(
conn.close() conn.close()
else: else:
raise click.ClickException( raise click.ClickException(
f"Invalid value for '[FILES]...': Path '{file}' does not exist." "Invalid value for '[FILES]...': Path '{}' does not exist.".format(
file
)
) )
# Check for duplicate files by resolving all paths to their absolute forms # Check for duplicate files by resolving all paths to their absolute forms
@ -683,7 +684,7 @@ def serve(
client = TestClient(ds) client = TestClient(ds)
request_headers = {} request_headers = {}
if token: if token:
request_headers["Authorization"] = f"Bearer {token}" request_headers["Authorization"] = "Bearer {}".format(token)
cookies = {} cookies = {}
if actor: if actor:
cookies["ds_actor"] = client.actor_cookie(json.loads(actor)) cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
@ -718,13 +719,9 @@ def serve(
path = run_sync(lambda: initial_path_for_datasette(ds)) path = run_sync(lambda: initial_path_for_datasette(ds))
url = f"http://{host}:{port}{path}" url = f"http://{host}:{port}{path}"
webbrowser.open(url) webbrowser.open(url)
uvicorn_kwargs = { uvicorn_kwargs = dict(
"host": host, host=host, port=port, log_level="info", lifespan="on", workers=1
"port": port, )
"log_level": "info",
"lifespan": "on",
"workers": 1,
}
if uds: if uds:
uvicorn_kwargs["uds"] = uds uvicorn_kwargs["uds"] = uds
if ssl_keyfile: if ssl_keyfile:
@ -888,7 +885,7 @@ async def check_databases(ds):
) )
except ConnectionProblem as e: except ConnectionProblem as e:
raise click.UsageError( raise click.UsageError(
f"Connection to {database.path} failed check: {e.args[0]!s}" f"Connection to {database.path} failed check: {str(e.args[0])}"
) )
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning # If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
if ( if (
@ -896,5 +893,9 @@ async def check_databases(ds):
and len([db for db in ds.databases.values() if not db.is_memory]) and len([db for db in ds.databases.values() if not db.is_memory])
> SQLITE_LIMIT_ATTACHED > SQLITE_LIMIT_ATTACHED
): ):
msg = f"Warning: --crossdb only works with the first {SQLITE_LIMIT_ATTACHED} attached databases" msg = (
"Warning: --crossdb only works with the first {} attached databases".format(
SQLITE_LIMIT_ATTACHED
)
)
click.echo(click.style(msg, bold=True, fg="yellow"), err=True) click.echo(click.style(msg, bold=True, fg="yellow"), err=True)

View file

@ -64,14 +64,14 @@ class ColumnType:
Return an HTML string to render this cell value, or None to Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain. fall through to the default render_cell plugin hook chain.
""" """
return return None
async def validate(self, value, datasette): async def validate(self, value, datasette):
""" """
Validate a value before it is written. Return None if valid, Validate a value before it is written. Return None if valid,
or a string error message if invalid. or a string error message if invalid.
""" """
return return None
async def transform_value(self, value, datasette): async def transform_value(self, value, datasette):
""" """

View file

@ -40,12 +40,12 @@ def _origin_tuple(value):
scheme = (parsed.scheme or "").lower() scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower() host = (parsed.hostname or "").lower()
if not scheme or not host: if not scheme or not host:
raise ValueError(f"missing scheme or host in {value!r}") raise ValueError("missing scheme or host in {!r}".format(value))
port = parsed.port # may raise ValueError on bad ports port = parsed.port # may raise ValueError on bad ports
if port is None: if port is None:
port = DEFAULT_PORTS.get(scheme) port = DEFAULT_PORTS.get(scheme)
if port is None: if port is None:
raise ValueError(f"unknown default port for scheme {scheme!r}") raise ValueError("unknown default port for scheme {!r}".format(scheme))
return scheme, host, port return scheme, host, port
@ -125,7 +125,9 @@ class CrossOriginProtectionMiddleware:
return return
await self._forbid( await self._forbid(
send, send,
f"Sec-Fetch-Site was {sec_fetch_site!r}, expected 'same-origin' or 'none'", "Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
sec_fetch_site
),
) )
return return
@ -139,11 +141,11 @@ class CrossOriginProtectionMiddleware:
request_scheme = self._request_scheme(scope) request_scheme = self._request_scheme(scope)
try: try:
origin_tuple = _origin_tuple(origin) origin_tuple = _origin_tuple(origin)
expected_tuple = _origin_tuple(f"{request_scheme}://{host}") expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
except ValueError: except ValueError:
await self._forbid( await self._forbid(
send, send,
f"Malformed Origin {origin!r} or Host {host!r}", "Malformed Origin {!r} or Host {!r}".format(origin, host),
) )
return return
@ -153,7 +155,7 @@ class CrossOriginProtectionMiddleware:
await self._forbid( await self._forbid(
send, send,
f"Origin {origin!r} does not match Host {host!r}", "Origin {!r} does not match Host {!r}".format(origin, host),
) )
def _request_scheme(self, scope): def _request_scheme(self, scope):
@ -161,8 +163,7 @@ class CrossOriginProtectionMiddleware:
try: try:
if self.datasette.setting("force_https_urls"): if self.datasette.setting("force_https_urls"):
return "https" return "https"
except Exception: # noqa: BLE001, S110 except Exception:
# Settings may not be readable this early; fall back to the ASGI scheme
pass pass
return scope.get("scheme") or "http" return scope.get("scheme") or "http"

View file

@ -1,35 +1,33 @@
import asyncio import asyncio
import atexit import atexit
from collections import namedtuple
import inspect import inspect
import os import os
from pathlib import Path
import queue import queue
import sqlite_utils
import sys import sys
import tempfile import tempfile
import threading import threading
import uuid import uuid
from collections import namedtuple
from pathlib import Path
import sqlite_utils
from .inspect import inspect_hash
from .tracer import trace from .tracer import trace
from .utils import ( from .utils import (
call_with_supported_arguments, call_with_supported_arguments,
detect_fts, detect_fts,
detect_primary_keys, detect_primary_keys,
detect_spatialite, detect_spatialite,
escape_sqlite,
get_all_foreign_keys, get_all_foreign_keys,
get_outbound_foreign_keys, get_outbound_foreign_keys,
md5_not_usedforsecurity, md5_not_usedforsecurity,
sqlite3,
sqlite_timelimit, sqlite_timelimit,
table_column_details, sqlite3,
table_columns, table_columns,
table_column_details,
) )
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
from .utils.sqlite import sqlite_hidden_table_names from .utils.sqlite import sqlite_hidden_table_names
from .inspect import inspect_hash
connections = threading.local() connections = threading.local()
@ -100,7 +98,9 @@ class Database:
def _check_not_closed(self): def _check_not_closed(self):
if self._closed: if self._closed:
raise DatasetteClosedError(f"Database {self.name!r} has been closed") raise DatasetteClosedError(
"Database {!r} has been closed".format(self.name)
)
def _remove_pending_execute_future(self, future): def _remove_pending_execute_future(self, future):
with self._pending_execute_futures_lock: with self._pending_execute_futures_lock:
@ -139,7 +139,7 @@ class Database:
if write: if write:
extra_kwargs["isolation_level"] = "IMMEDIATE" extra_kwargs["isolation_level"] = "IMMEDIATE"
if self.memory_name: if self.memory_name:
uri = f"file:{self.memory_name}?mode=memory&cache=shared" uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
conn = sqlite3.connect( conn = sqlite3.connect(
uri, uri=True, check_same_thread=False, **extra_kwargs uri, uri=True, check_same_thread=False, **extra_kwargs
) )
@ -192,20 +192,21 @@ class Database:
write_thread.join(timeout=10) write_thread.join(timeout=10)
if write_thread.is_alive(): if write_thread.is_alive():
sys.stderr.write( sys.stderr.write(
f"Datasette: write thread for {self.name!r} did not exit within 10s\n" "Datasette: write thread for {!r} did not exit within 10s\n".format(
self.name
)
) )
sys.stderr.flush() sys.stderr.flush()
for future in pending_execute_futures: for future in pending_execute_futures:
try: try:
future.result() future.result()
except Exception: # noqa: BLE001, S110 except Exception:
# Shutdown teardown - a failed pending write must not block close()
pass pass
# Close anything still tracked in _all_file_connections # Close anything still tracked in _all_file_connections
for connection in self._all_file_connections: for connection in self._all_file_connections:
try: try:
connection.close() connection.close()
except Exception: # noqa: BLE001, S110 except Exception:
pass pass
self._all_file_connections = [] self._all_file_connections = []
# Drop per-thread cached read connections we can reach # Drop per-thread cached read connections we can reach
@ -217,13 +218,13 @@ class Database:
if self._read_connection is not None: if self._read_connection is not None:
try: try:
self._read_connection.close() self._read_connection.close()
except Exception: # noqa: BLE001, S110 except Exception:
pass pass
self._read_connection = None self._read_connection = None
if self._write_connection is not None: if self._write_connection is not None:
try: try:
self._write_connection.close() self._write_connection.close()
except Exception: # noqa: BLE001, S110 except Exception:
pass pass
self._write_connection = None self._write_connection = None
if self.is_temp_disk: if self.is_temp_disk:
@ -245,7 +246,6 @@ class Database:
request=None, request=None,
return_all=False, return_all=False,
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
transaction=True,
): ):
self._check_not_closed() self._check_not_closed()
if returning_limit < 0: if returning_limit < 0:
@ -258,9 +258,7 @@ class Database:
) )
with trace("sql", database=self.name, sql=sql.strip(), params=params): with trace("sql", database=self.name, sql=sql.strip(), params=params):
results = await self.execute_write_fn( results = await self.execute_write_fn(_inner, block=block, request=request)
_inner, block=block, request=request, transaction=transaction
)
return results return results
async def execute_write_script(self, sql, block=True, request=None): async def execute_write_script(self, sql, block=True, request=None):
@ -350,7 +348,6 @@ class Database:
self.ds._prepare_connection(self._write_connection, self.name) self.ds._prepare_connection(self._write_connection, self.name)
if transaction: if transaction:
with self._write_connection: with self._write_connection:
self._write_connection.execute("BEGIN IMMEDIATE")
result = fn(self._write_connection) result = fn(self._write_connection)
else: else:
result = fn(self._write_connection) result = fn(self._write_connection)
@ -369,8 +366,7 @@ class Database:
async def _dispatch_events_after_write(): async def _dispatch_events_after_write():
try: try:
await reply_future await reply_future
except Exception: # noqa: BLE001 except Exception:
# The write failed; skip success events regardless of why
# if the write failed, don't emit success events # if the write failed, don't emit success events
return return
for event in pending_events: for event in pending_events:
@ -423,7 +419,9 @@ class Database:
self._write_thread = threading.Thread( self._write_thread = threading.Thread(
target=self._execute_writes, daemon=True target=self._execute_writes, daemon=True
) )
self._write_thread.name = f"_execute_writes for database {self.name}" self._write_thread.name = "_execute_writes for database {}".format(
self.name
)
self._write_thread.start() self._write_thread.start()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -444,8 +442,7 @@ class Database:
try: try:
conn = self.connect(write=True) conn = self.connect(write=True)
self.ds._prepare_connection(conn, self.name) self.ds._prepare_connection(conn, self.name)
except Exception as e: # noqa: BLE001 except Exception as e:
# Stored and re-raised to whoever queues the next write
conn_exception = e conn_exception = e
while True: while True:
task = self._write_queue.get() task = self._write_queue.get()
@ -453,8 +450,7 @@ class Database:
if conn is not None: if conn is not None:
try: try:
conn.close() conn.close()
except Exception: # noqa: BLE001, S110 except Exception:
# Best-effort close as the write thread exits
pass pass
return return
exception = None exception = None
@ -473,21 +469,19 @@ class Database:
except ValueError: except ValueError:
# Was probably a memory connection # Was probably a memory connection
pass pass
except Exception as e: # noqa: BLE001 except Exception as e:
# Write thread must survive any task failure or the database wedges sys.stderr.write("{}\n".format(e))
sys.stderr.write(f"{e}\n")
sys.stderr.flush() sys.stderr.flush()
exception = e exception = e
else: else:
try: try:
if task.transaction: if task.transaction:
with conn: with conn:
conn.execute("BEGIN IMMEDIATE")
result = task.fn(conn) result = task.fn(conn)
else: else:
result = task.fn(conn) result = task.fn(conn)
except Exception as e: # noqa: BLE001 except Exception as e:
sys.stderr.write(f"{e}\n") sys.stderr.write("{}\n".format(e))
sys.stderr.flush() sys.stderr.flush()
exception = e exception = e
_deliver_write_result(task, result, exception) _deliver_write_result(task, result, exception)
@ -554,7 +548,9 @@ class Database:
raise QueryInterrupted(e, sql, params) raise QueryInterrupted(e, sql, params)
if log_sql_errors: if log_sql_errors:
sys.stderr.write( sys.stderr.write(
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n" "ERROR: conn={}, sql = {}, params = {}: {}\n".format(
conn, repr(sql), params, e
)
) )
sys.stderr.flush() sys.stderr.flush()
raise raise
@ -607,7 +603,7 @@ class Database:
try: try:
table_count = ( table_count = (
await self.execute( await self.execute(
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})", f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
custom_time_limit=limit, custom_time_limit=limit,
) )
).rows[0][0] ).rows[0][0]
@ -711,9 +707,9 @@ class Database:
column_names column_names
and len(column_names) == 2 and len(column_names) == 2
and ("id" in column_names or "pk" in column_names) and ("id" in column_names or "pk" in column_names)
and set(column_names) != {"id", "pk"} and not set(column_names) == {"id", "pk"}
): ):
return next(c for c in column_names if c not in ("id", "pk")) return [c for c in column_names if c not in ("id", "pk")][0]
# Couldn't find a label: # Couldn't find a label:
return None return None
@ -855,10 +851,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
class WriteTask: class WriteTask:
__slots__ = ( __slots__ = (
"fn", "fn",
"isolated_connection", "task_id",
"loop", "loop",
"reply_future", "reply_future",
"task_id", "isolated_connection",
"transaction", "transaction",
) )
@ -899,7 +895,7 @@ class QueryInterrupted(Exception):
self.params = params self.params = params
def __str__(self): def __str__(self):
return f"QueryInterrupted: {self.e}" return "QueryInterrupted: {}".format(self.e)
class MultipleValues(Exception): class MultipleValues(Exception):

View file

@ -2,8 +2,8 @@ from datasette import hookimpl
from datasette.permissions import Action from datasette.permissions import Action
from datasette.resources import ( from datasette.resources import (
DatabaseResource, DatabaseResource,
QueryResource,
TableResource, TableResource,
QueryResource,
) )
@ -61,12 +61,6 @@ def register_actions():
description="Create tables", description="Create tables",
resource_class=DatabaseResource, resource_class=DatabaseResource,
), ),
Action(
name="create-view",
abbr="cv",
description="Create views",
resource_class=DatabaseResource,
),
Action( Action(
name="store-query", name="store-query",
abbr="sq", abbr="sq",
@ -117,12 +111,6 @@ def register_actions():
description="Drop tables", description="Drop tables",
resource_class=TableResource, resource_class=TableResource,
), ),
Action(
name="drop-view",
abbr="dv",
description="Drop views",
resource_class=TableResource,
),
# Query-level actions (child-level) # Query-level actions (child-level)
Action( Action(
name="view-query", name="view-query",

View file

@ -1,9 +1,8 @@
from datasette import hookimpl
import datetime import datetime
import os import os
import time import time
from datasette import hookimpl
def header(key, request): def header(key, request):
key = key.replace("_", "-").encode("utf-8") key = key.replace("_", "-").encode("utf-8")

View file

@ -17,29 +17,18 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations from __future__ import annotations
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
from .defaults import (
default_action_permissions_sql as default_action_permissions_sql,
)
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
)
from .defaults import (
default_query_permissions_sql as default_query_permissions_sql,
)
from .restrictions import (
ActorRestrictions as ActorRestrictions,
)
# Re-export all hooks and public utilities # Re-export all hooks and public utilities
from .restrictions import ( from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql, actor_restrictions_sql as actor_restrictions_sql,
)
from .restrictions import (
restrictions_allow_action as restrictions_allow_action, restrictions_allow_action as restrictions_allow_action,
ActorRestrictions as ActorRestrictions,
) )
from .root import root_user_permissions_sql as root_user_permissions_sql from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql,
default_query_permissions_sql as default_query_permissions_sql,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)

View file

@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
def __init__( def __init__(
self, self,
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
): ):
self.datasette = datasette self.datasette = datasette
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
self.restrictions = actor.get("_r", {}) if actor else {} self.restrictions = actor.get("_r", {}) if actor else {}
# Pre-compute restriction info for efficiency # Pre-compute restriction info for efficiency
self.restricted_databases: set[str] = set() self.restricted_databases: Set[str] = set()
self.restricted_tables: set[tuple[str, str]] = set() self.restricted_tables: Set[Tuple[str, str]] = set()
if self.has_restrictions: if self.has_restrictions:
self.restricted_databases = { self.restricted_databases = {
@ -92,20 +92,16 @@ class ConfigPermissionProcessor:
# Tables implicitly reference their parent databases # Tables implicitly reference their parent databases
self.restricted_databases.update(db for db, _ in self.restricted_tables) self.restricted_databases.update(db for db, _ in self.restricted_tables)
def evaluate_allow_block(self, allow_block: Any) -> bool | None: def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
"""Evaluate an allow block against the current actor.""" """Evaluate an allow block against the current actor."""
if allow_block is None: if allow_block is None:
return None return None
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
# integers, but should retain the CLI's boolean 1/0 behavior.
if isinstance(allow_block, int) and allow_block in (0, 1):
return bool(allow_block)
return actor_matches_allow(self.actor, allow_block) return actor_matches_allow(self.actor, allow_block)
def is_in_restriction_allowlist( def is_in_restriction_allowlist(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
) -> bool: ) -> bool:
"""Check if resource is allowed by actor restrictions.""" """Check if resource is allowed by actor restrictions."""
if not self.has_restrictions: if not self.has_restrictions:
@ -147,9 +143,9 @@ class ConfigPermissionProcessor:
def add_permissions_rule( def add_permissions_rule(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
permissions_block: dict | None, permissions_block: Optional[dict],
scope_desc: str, scope_desc: str,
) -> None: ) -> None:
"""Add a rule from a permissions:{action} block.""" """Add a rule from a permissions:{action} block."""
@ -169,8 +165,8 @@ class ConfigPermissionProcessor:
def add_allow_block_rule( def add_allow_block_rule(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
allow_block: Any, allow_block: Any,
scope_desc: str, scope_desc: str,
) -> None: ) -> None:
@ -202,8 +198,8 @@ class ConfigPermissionProcessor:
def _add_restriction_gate_denies( def _add_restriction_gate_denies(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
is_allowed: bool, is_allowed: bool,
scope_desc: str, scope_desc: str,
) -> None: ) -> None:
@ -235,7 +231,7 @@ class ConfigPermissionProcessor:
if db_name == parent: if db_name == parent:
self.collector.add(db_name, table_name, False, reason) self.collector.add(db_name, table_name, False, reason)
def process(self) -> PermissionSQL | None: def process(self) -> Optional[PermissionSQL]:
"""Process all config rules and return combined PermissionSQL.""" """Process all config rules and return combined PermissionSQL."""
self._process_root_permissions() self._process_root_permissions()
self._process_databases() self._process_databases()
@ -425,10 +421,10 @@ class ConfigPermissionProcessor:
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def config_permissions_sql( async def config_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> list[PermissionSQL] | None: ) -> Optional[List[PermissionSQL]]:
""" """
Apply permission rules from datasette.yaml configuration. Apply permission rules from datasette.yaml configuration.

View file

@ -6,7 +6,7 @@ Provides default allow rules for standard view/execute actions.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -29,28 +29,29 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check( async def default_allow_sql_check(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
""" """
Enforce the default_allow_sql setting. Enforce the default_allow_sql setting.
When default_allow_sql is false (the default), execute-sql is denied When default_allow_sql is false (the default), execute-sql is denied
unless explicitly allowed by config or other rules. unless explicitly allowed by config or other rules.
""" """
if action == "execute-sql" and not datasette.setting("default_allow_sql"): if action == "execute-sql":
return PermissionSQL.deny(reason="default_allow_sql is false") if not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None return None
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql( async def default_action_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
""" """
Provide default allow rules for standard view/execute actions. Provide default allow rules for standard view/execute actions.
@ -70,10 +71,10 @@ async def default_action_permissions_sql(
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def default_query_permissions_sql( async def default_query_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
actor_id = actor.get("id") if isinstance(actor, dict) else None actor_id = actor.get("id") if isinstance(actor, dict) else None
if action not in {"view-query", "update-query", "delete-query"}: if action not in {"view-query", "update-query", "delete-query"}:

View file

@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List, Optional, Set
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -13,7 +13,7 @@ if TYPE_CHECKING:
from datasette.permissions import PermissionSQL from datasette.permissions import PermissionSQL
def get_action_name_variants(datasette: Datasette, action: str) -> set[str]: def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
""" """
Get all name variants for an action (full name and abbreviation). Get all name variants for an action (full name and abbreviation).
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
return variants return variants
def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool: def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
"""Check if an action (or its abbreviation) is in a list.""" """Check if an action (or its abbreviation) is in a list."""
return bool(get_action_name_variants(datasette, action).intersection(action_list)) return bool(get_action_name_variants(datasette, action).intersection(action_list))
@ -36,8 +36,8 @@ def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool
class PermissionRow: class PermissionRow:
"""A single permission rule row.""" """A single permission rule row."""
parent: str | None parent: Optional[str]
child: str | None child: Optional[str]
allow: bool allow: bool
reason: str reason: str
@ -46,14 +46,14 @@ class PermissionRowCollector:
"""Collects permission rows and converts them to PermissionSQL.""" """Collects permission rows and converts them to PermissionSQL."""
def __init__(self, prefix: str = "row"): def __init__(self, prefix: str = "row"):
self.rows: list[PermissionRow] = [] self.rows: List[PermissionRow] = []
self.prefix = prefix self.prefix = prefix
def add( def add(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
allow: bool | None, allow: Optional[bool],
reason: str, reason: str,
if_not_none: bool = False, if_not_none: bool = False,
) -> None: ) -> None:
@ -62,7 +62,7 @@ class PermissionRowCollector:
return return
self.rows.append(PermissionRow(parent, child, allow, reason)) self.rows.append(PermissionRow(parent, child, allow, reason))
def to_permission_sql(self) -> PermissionSQL | None: def to_permission_sql(self) -> Optional[PermissionSQL]:
"""Convert collected rows to a PermissionSQL object.""" """Convert collected rows to a PermissionSQL object."""
if not self.rows: if not self.rows:
return None return None

View file

@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List, Optional, Set, Tuple
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
class ActorRestrictions: class ActorRestrictions:
"""Parsed actor restrictions from the _r key.""" """Parsed actor restrictions from the _r key."""
global_actions: list[str] # _r.a - globally allowed actions global_actions: List[str] # _r.a - globally allowed actions
database_actions: dict # _r.d - {db_name: [actions]} database_actions: dict # _r.d - {db_name: [actions]}
table_actions: dict # _r.r - {db_name: {table: [actions]}} table_actions: dict # _r.r - {db_name: {table: [actions]}}
@classmethod @classmethod
def from_actor(cls, actor: dict | None) -> ActorRestrictions | None: def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
"""Parse restrictions from actor dict. Returns None if no restrictions.""" """Parse restrictions from actor dict. Returns None if no restrictions."""
if not actor: if not actor:
return None return None
@ -44,11 +44,11 @@ class ActorRestrictions:
table_actions=restrictions.get("r", {}), table_actions=restrictions.get("r", {}),
) )
def is_action_globally_allowed(self, datasette: Datasette, action: str) -> bool: def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
"""Check if action is in the global allowlist.""" """Check if action is in the global allowlist."""
return action_in_list(datasette, action, self.global_actions) return action_in_list(datasette, action, self.global_actions)
def get_allowed_databases(self, datasette: Datasette, action: str) -> set[str]: def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
"""Get database names where this action is allowed.""" """Get database names where this action is allowed."""
allowed = set() allowed = set()
for db_name, db_actions in self.database_actions.items(): for db_name, db_actions in self.database_actions.items():
@ -57,8 +57,8 @@ class ActorRestrictions:
return allowed return allowed
def get_allowed_tables( def get_allowed_tables(
self, datasette: Datasette, action: str self, datasette: "Datasette", action: str
) -> set[tuple[str, str]]: ) -> Set[Tuple[str, str]]:
"""Get (database, table) pairs where this action is allowed.""" """Get (database, table) pairs where this action is allowed."""
allowed = set() allowed = set()
for db_name, tables in self.table_actions.items(): for db_name, tables in self.table_actions.items():
@ -70,10 +70,10 @@ class ActorRestrictions:
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql( async def actor_restrictions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> list[PermissionSQL] | None: ) -> Optional[List[PermissionSQL]]:
""" """
Handle actor restriction-based permission rules. Handle actor restriction-based permission rules.
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
def restrictions_allow_action( def restrictions_allow_action(
datasette: Datasette, datasette: "Datasette",
restrictions: dict, restrictions: dict,
action: str, action: str,
resource: str | tuple[str, str] | None, resource: Optional[str | Tuple[str, str]],
) -> bool: ) -> bool:
""" """
Check if restrictions allow the requested action on the requested resource. Check if restrictions allow the requested action on the requested resource.

View file

@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql( async def root_user_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
""" """
Grant root user full permissions when --root flag is used. Grant root user full permissions when --root flag is used.
""" """

View file

@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -17,13 +17,15 @@ from datasette.tokens import SignedTokenHandler
@hookimpl @hookimpl
def register_token_handler(datasette: Datasette): def register_token_handler(datasette: "Datasette"):
"""Register the default signed token handler.""" """Register the default signed token handler."""
return SignedTokenHandler() return SignedTokenHandler()
@hookimpl(specname="actor_from_request") @hookimpl(specname="actor_from_request")
async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None: async def actor_from_signed_api_token(
datasette: "Datasette", request
) -> Optional[dict]:
""" """
Authenticate requests using API tokens by delegating to all registered Authenticate requests using API tokens by delegating to all registered
token handlers via datasette.verify_token(). token handlers via datasette.verify_token().

View file

@ -1,29 +0,0 @@
from datasette import hookimpl
from datasette.resources import TableResource
@hookimpl
def table_actions(datasette, actor, database, table, request):
async def inner():
db = datasette.get_database(database)
if not db.is_mutable:
return []
if not await datasette.allowed(
action="alter-table",
resource=TableResource(database=database, table=table),
actor=actor,
):
return []
return [
{
"type": "button",
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": f"Alter table {table}",
"data-table-action": "alter-table",
},
}
]
return inner

View file

@ -1,8 +1,7 @@
from abc import ABC, abstractproperty from abc import ABC, abstractproperty
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from datasette.hookspecs import hookimpl from datasette.hookspecs import hookimpl
from datetime import datetime, timezone
@dataclass @dataclass

View file

@ -5,8 +5,6 @@ from typing import ClassVar
from asyncinject import Registry from asyncinject import Registry
from datasette.utils.asgi import BadRequest
def extra_names_from_request(request): def extra_names_from_request(request):
extra_bits = request.args.getlist("_extra") extra_bits = request.args.getlist("_extra")
@ -83,16 +81,6 @@ class ExtraRegistry:
def public_classes_for_scope(self, scope): def public_classes_for_scope(self, scope):
return self.classes_for_scope(scope, include_internal=False) return self.classes_for_scope(scope, include_internal=False)
def internal_classes_for_scope(self, scope):
# Extras that are available to HTML templates but excluded from
# JSON responses - plain Providers are dependency plumbing and
# never surface as keys, so they are not included
return [
cls
for cls in self.classes_for_scope(scope)
if issubclass(cls, Extra) and not cls.public
]
def _registry_for_scope(self, scope): def _registry_for_scope(self, scope):
registry = self._scope_registries.get(scope) registry = self._scope_registries.get(scope)
if registry is None: if registry is None:
@ -115,17 +103,6 @@ class ExtraRegistry:
self._allowed_names[key] = names self._allowed_names[key] = names
return names return names
def validate_requested(self, requested, scope):
"""
Raise BadRequest if any requested extra name is not a public extra
for this scope. Used by data formats such as .json - HTML pages
silently ignore unknown names instead.
"""
allowed = self._allowed_names_for_scope(scope, include_internal=False)
unknown = sorted(name for name in requested if name not in allowed)
if unknown:
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
async def resolve(self, requested, context, scope, include_internal=False): async def resolve(self, requested, context, scope, include_internal=False):
allowed_names = self._allowed_names_for_scope(scope, include_internal) allowed_names = self._allowed_names_for_scope(scope, include_internal)
requested_names = [name for name in requested if name in allowed_names] requested_names = [name for name in requested if name in allowed_names]

View file

@ -1,13 +1,12 @@
import json import json
import urllib import urllib
from datasette import hookimpl from datasette import hookimpl
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.utils import ( from datasette.utils import (
detect_json1,
escape_sqlite, escape_sqlite,
path_with_added_args, path_with_added_args,
path_with_removed_args, path_with_removed_args,
detect_json1,
sqlite3, sqlite3,
) )
@ -31,7 +30,7 @@ def load_facet_configs(request, table_config):
assert ( assert (
len(facet_config.values()) == 1 len(facet_config.values()) == 1
), "Metadata config dicts should be {type: config}" ), "Metadata config dicts should be {type: config}"
type, facet_config = next(iter(facet_config.items())) type, facet_config = list(facet_config.items())[0]
if isinstance(facet_config, str): if isinstance(facet_config, str):
facet_config = {"simple": facet_config} facet_config = {"simple": facet_config}
facet_configs.setdefault(type, []).append( facet_configs.setdefault(type, []).append(
@ -86,7 +85,7 @@ class Facet:
self.database = database self.database = database
# For foreign key expansion. Can be None for e.g. stored SQL queries: # For foreign key expansion. Can be None for e.g. stored SQL queries:
self.table = table self.table = table
self.sql = sql or f"select * from {escape_sqlite(table)}" self.sql = sql or f"select * from [{table}]"
self.params = params or [] self.params = params or []
self.table_config = table_config self.table_config = table_config
# row_count can be None, in which case we calculate it ourselves: # row_count can be None, in which case we calculate it ourselves:
@ -161,13 +160,18 @@ class ColumnFacet(Facet):
for column in columns: for column in columns:
if column in already_enabled: if column in already_enabled:
continue continue
suggested_facet_sql = f""" suggested_facet_sql = """
with limited as (select * from ({self.sql}) limit {self.suggest_consider}) with limited as (select * from ({sql}) limit {suggest_consider})
select {escape_sqlite(column)} as value, count(*) as n from limited select {column} as value, count(*) as n from limited
where value is not null where value is not null
group by value group by value
limit {facet_size + 1} limit {limit}
""" """.format(
column=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
suggest_consider=self.suggest_consider,
)
distinct_values = None distinct_values = None
try: try:
distinct_values = await self.ds.execute( distinct_values = await self.ds.execute(
@ -263,7 +267,7 @@ class ColumnFacet(Facet):
for row in facet_rows: for row in facet_rows:
column_qs = column column_qs = column
if column.startswith("_"): if column.startswith("_"):
column_qs = f"{column}__exact" column_qs = "{}__exact".format(column)
selected = (column_qs, str(row["value"])) in qs_pairs selected = (column_qs, str(row["value"])) in qs_pairs
if selected: if selected:
toggle_path = path_with_removed_args( toggle_path = path_with_removed_args(
@ -338,12 +342,12 @@ class ArrayFacet(Facet):
for v in await self.ds.execute( for v in await self.ds.execute(
self.database, self.database,
( (
f"select {escape_sqlite(column)} from ({self.sql}) " "select {column} from ({sql}) "
f"where {escape_sqlite(column)} is not null " "where {column} is not null "
f"and {escape_sqlite(column)} != '' " "and {column} != '' "
f"and json_array_length({escape_sqlite(column)}) > 0 " "and json_array_length({column}) > 0 "
"limit 100" "limit 100"
), ).format(column=escape_sqlite(column), sql=self.sql),
self.params, self.params,
truncate=False, truncate=False,
custom_time_limit=self.ds.setting( custom_time_limit=self.ds.setting(
@ -384,14 +388,14 @@ class ArrayFacet(Facet):
source = source_and_config["source"] source = source_and_config["source"]
column = config.get("column") or config["simple"] column = config.get("column") or config["simple"]
# https://github.com/simonw/datasette/issues/448 # https://github.com/simonw/datasette/issues/448
facet_sql = f""" facet_sql = """
with inner as ({self.sql}), with inner as ({sql}),
deduped_array_items as ( deduped_array_items as (
select select
distinct j.value, distinct j.value,
inner.* inner.*
from from
json_each([inner].{escape_sqlite(column)}) j json_each([inner].{col}) j
join inner join inner
) )
select select
@ -402,8 +406,12 @@ class ArrayFacet(Facet):
group by group by
value value
order by order by
count(*) desc, value limit {facet_size + 1} count(*) desc, value limit {limit}
""" """.format(
col=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
)
try: try:
facet_rows_results = await self.ds.execute( facet_rows_results = await self.ds.execute(
self.database, self.database,

View file

@ -1,11 +1,8 @@
import json
from typing import ClassVar
from datasette import hookimpl from datasette import hookimpl
from datasette.resources import DatabaseResource from datasette.resources import DatabaseResource
from datasette.utils.asgi import BadRequest
from datasette.views.base import DatasetteError from datasette.views.base import DatasetteError
from datasette.utils.asgi import BadRequest
import json
from .utils import detect_json1, escape_sqlite, path_with_removed_args from .utils import detect_json1, escape_sqlite, path_with_removed_args
@ -102,9 +99,9 @@ def search_filters(request, database, table, datasette):
fts_table=escape_sqlite(fts_table), fts_table=escape_sqlite(fts_table),
search_col=escape_sqlite(search_col), search_col=escape_sqlite(search_col),
match_clause=( match_clause=(
f":search_{i}" ":search_{}".format(i)
if search_mode_raw if search_mode_raw
else f"escape_fts(:search_{i})" else "escape_fts(:search_{})".format(i)
), ),
) )
) )
@ -137,11 +134,11 @@ def through_filters(request, database, table, datasette):
value = through_data["value"] value = through_data["value"]
db = datasette.get_database(database) db = datasette.get_database(database)
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table) outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
fk_to_us = next( try:
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table), fk_to_us = [
None, fk for fk in outgoing_foreign_keys if fk["other_table"] == table
) ][0]
if fk_to_us is None: except IndexError:
raise DatasetteError( raise DatasetteError(
"Invalid _through - could not find corresponding foreign key" "Invalid _through - could not find corresponding foreign key"
) )
@ -368,7 +365,7 @@ class Filters:
), ),
] ]
) )
_filters_by_key: ClassVar[dict[str, Filter]] = {f.key: f for f in _filters} _filters_by_key = {f.key: f for f in _filters}
def __init__(self, pairs): def __init__(self, pairs):
self.pairs = pairs self.pairs = pairs

View file

@ -1,10 +1,9 @@
from datasette.utils.sqlite import sqlite3
from datasette.utils import documented
import itertools import itertools
import random import random
import string import string
from datasette.utils import documented
from datasette.utils.sqlite import sqlite3
__all__ = [ __all__ = [
"EXTRA_DATABASE_SQL", "EXTRA_DATABASE_SQL",
"TABLES", "TABLES",
@ -347,7 +346,9 @@ CREATE VIEW searchable_view_configured_by_metadata AS
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n' + '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
+ "\n".join( + "\n".join(
[ [
f'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");' 'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
a=a, b=b, c=c, content=content
)
for a, b, c, content in generate_compound_rows(1001) for a, b, c, content in generate_compound_rows(1001)
] ]
) )

View file

@ -1,20 +1,9 @@
from datasette import Response, hookimpl from datasette import hookimpl, Response
from .utils import add_cors_headers
@hookimpl(trylast=True) @hookimpl(trylast=True)
def forbidden(datasette, request, message): def forbidden(datasette, request, message):
async def inner(): async def inner():
if (
request.path.split("?")[0].endswith(".json")
or "application/json" in (request.headers.get("accept") or "")
or request.headers.get("content-type") == "application/json"
):
headers = {}
if datasette.cors:
add_cors_headers(headers)
return Response.error(message, 403, headers=headers)
return Response.html( return Response.html(
await datasette.render_template( await datasette.render_template(
"error.html", "error.html",

View file

@ -1,21 +1,16 @@
import traceback from datasette import hookimpl, Response
from .utils import add_cors_headers
from markupsafe import Markup
from datasette import Response, hookimpl
from .utils import add_cors_headers, error_body
from .utils.asgi import ( from .utils.asgi import (
Base400, Base400,
) )
from .views.base import DatasetteError from .views.base import DatasetteError
from markupsafe import Markup
import traceback
# Debugger imports are deliberate - they back the "pdb" setting, which drops
# into a debugger on unhandled exceptions
try: try:
import ipdb as pdb # noqa: T100 import ipdb as pdb
except ImportError: except ImportError:
import pdb # noqa: T100 import pdb
try: try:
import rich import rich
@ -33,7 +28,6 @@ def handle_exception(datasette, request, exception):
rich.get_console().print_exception(show_locals=True) rich.get_console().print_exception(show_locals=True)
title = None title = None
plain_message = None
if isinstance(exception, Base400): if isinstance(exception, Base400):
status = exception.status status = exception.status
info = {} info = {}
@ -42,7 +36,6 @@ def handle_exception(datasette, request, exception):
status = exception.status status = exception.status
info = exception.error_dict info = exception.error_dict
message = exception.message message = exception.message
plain_message = exception.plain_message
if exception.message_is_html: if exception.message_is_html:
message = Markup(message) message = Markup(message)
title = exception.title title = exception.title
@ -52,13 +45,6 @@ def handle_exception(datasette, request, exception):
message = str(exception) message = str(exception)
traceback.print_exc() traceback.print_exc()
templates = [f"{status}.html", "error.html"] templates = [f"{status}.html", "error.html"]
headers = {}
if datasette.cors:
add_cors_headers(headers)
if request.path.split("?")[0].endswith(".json"):
body = dict(info)
body.update(error_body(plain_message or message, status))
return Response.json(body, status=status, headers=headers)
info.update( info.update(
{ {
"ok": False, "ok": False,
@ -67,18 +53,25 @@ def handle_exception(datasette, request, exception):
"title": title, "title": title,
} }
) )
environment = datasette.get_jinja_environment(request) headers = {}
template = environment.select_template(templates) if datasette.cors:
return Response.html( add_cors_headers(headers)
await template.render_async( if request.path.split("?")[0].endswith(".json"):
dict( return Response.json(info, status=status, headers=headers)
info, else:
urls=datasette.urls, environment = datasette.get_jinja_environment(request)
menu_links=list, template = environment.select_template(templates)
) return Response.html(
), await template.render_async(
status=status, dict(
headers=headers, info,
) urls=datasette.urls,
app_css_hash=datasette.app_css_hash(),
menu_links=lambda: [],
)
),
status=status,
headers=headers,
)
return inner return inner

View file

@ -1,4 +1,5 @@
from pluggy import HookimplMarker, HookspecMarker from pluggy import HookimplMarker
from pluggy import HookspecMarker
hookspec = HookspecMarker("datasette") hookspec = HookspecMarker("datasette")
hookimpl = HookimplMarker("datasette") hookimpl = HookimplMarker("datasette")

View file

@ -1,13 +1,13 @@
import hashlib import hashlib
from .utils import ( from .utils import (
detect_spatialite,
detect_fts, detect_fts,
detect_primary_keys, detect_primary_keys,
detect_spatialite,
escape_sqlite, escape_sqlite,
get_all_foreign_keys, get_all_foreign_keys,
sqlite3,
table_columns, table_columns,
sqlite3,
) )
HASH_BLOCK_SIZE = 1024 * 1024 HASH_BLOCK_SIZE = 1024 * 1024
@ -95,10 +95,10 @@ def inspect_tables(conn, database_metadata):
""") """)
] ]
for t, table_info in tables.items(): for t in tables.keys():
for hidden_table in hidden_tables: for hidden_table in hidden_tables:
if t == hidden_table or t.startswith(hidden_table): if t == hidden_table or t.startswith(hidden_table):
table_info["hidden"] = True tables[t]["hidden"] = True
continue continue
return tables return tables

View file

@ -21,7 +21,7 @@ class JumpSQL:
search_text: str | None = None, search_text: str | None = None,
display_name: str | None = None, display_name: str | None = None,
item_type: str = "menu", item_type: str = "menu",
) -> JumpSQL: ) -> "JumpSQL":
if search_text is None: if search_text is None:
search_text = " ".join( search_text = " ".join(
text for text in (label, display_name, description) if text is not None text for text in (label, display_name, description) if text is not None

View file

@ -1,7 +1,7 @@
import contextvars
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, NamedTuple from typing import Any, NamedTuple
import contextvars
# Context variable to track when permission checks should be skipped # Context variable to track when permission checks should be skipped
_skip_permission_checks = contextvars.ContextVar( _skip_permission_checks = contextvars.ContextVar(
@ -72,8 +72,8 @@ class Resource(ABC):
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return "{}(parent={!r}, child={!r})".format(
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})" self.__class__.__name__, self.parent, self.child
) )
@property @property
@ -129,6 +129,7 @@ class Resource(ABC):
Must return two columns: parent, child Must return two columns: parent, child
""" """
pass
class AllowedResource(NamedTuple): class AllowedResource(NamedTuple):

View file

@ -1,14 +1,20 @@
import importlib import importlib
import importlib.metadata as importlib_metadata
import importlib.resources as importlib_resources
import os import os
import sys
from pprint import pprint
import pluggy import pluggy
from pprint import pprint
import sys
from . import hookspecs from . import hookspecs
if sys.version_info >= (3, 9):
import importlib.resources as importlib_resources
else:
import importlib_resources
if sys.version_info >= (3, 10):
import importlib.metadata as importlib_metadata
else:
import importlib_metadata
DEFAULT_PLUGINS = ( DEFAULT_PLUGINS = (
"datasette.publish.heroku", "datasette.publish.heroku",
"datasette.publish.cloudrun", "datasette.publish.cloudrun",
@ -25,7 +31,6 @@ DEFAULT_PLUGINS = (
"datasette.default_debug_menu", "datasette.default_debug_menu",
"datasette.default_jump_items", "datasette.default_jump_items",
"datasette.default_database_actions", "datasette.default_database_actions",
"datasette.default_table_actions",
"datasette.default_query_actions", "datasette.default_query_actions",
"datasette.handle_exception", "datasette.handle_exception",
"datasette.forbidden", "datasette.forbidden",
@ -79,7 +84,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
# Ensure name can be found in plugin_to_distinfo later: # Ensure name can be found in plugin_to_distinfo later:
pm._plugin_distinfo.append((mod, distribution)) pm._plugin_distinfo.append((mod, distribution))
except importlib_metadata.PackageNotFoundError: except importlib_metadata.PackageNotFoundError:
sys.stderr.write(f"Plugin {package_name} could not be found\n") sys.stderr.write("Plugin {} could not be found\n".format(package_name))
# Load default plugins # Load default plugins

View file

@ -1,17 +1,15 @@
from datasette import hookimpl
import click
import json import json
import os import os
import re import re
from subprocess import CalledProcessError, check_call, check_output from subprocess import CalledProcessError, check_call, check_output
import click
from datasette import hookimpl
from ..utils import temporary_docker_directory
from .common import ( from .common import (
add_common_publish_arguments_and_options, add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed, fail_if_publish_binary_not_installed,
) )
from ..utils import temporary_docker_directory
@hookimpl @hookimpl
@ -221,7 +219,7 @@ def publish_subcommand(publish):
check_call( check_call(
"gcloud builds submit --tag {}{}".format( "gcloud builds submit --tag {}{}".format(
image_id, f" --timeout {timeout}" if timeout else "" image_id, " --timeout {}".format(timeout) if timeout else ""
), ),
shell=True, shell=True,
) )
@ -233,7 +231,7 @@ def publish_subcommand(publish):
("--min-instances", min_instances), ("--min-instances", min_instances),
): ):
if value is not None: if value is not None:
extra_deploy_options.append(f"{option} {value}") extra_deploy_options.append("{} {}".format(option, value))
check_call( check_call(
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format( "gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
image_id, image_id,
@ -260,16 +258,24 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
) from exc ) from exc
describe_cmd = ( describe_cmd = (
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} " "gcloud artifacts repositories describe {repo} --project {project} "
f"--location {artifact_region} --quiet" "--location {location} --quiet"
).format(
repo=artifact_repository,
project=artifact_project,
location=artifact_region,
) )
try: try:
check_call(describe_cmd, shell=True) check_call(describe_cmd, shell=True)
return return
except CalledProcessError: except CalledProcessError:
create_cmd = ( create_cmd = (
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker " "gcloud artifacts repositories create {repo} --repository-format=docker "
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet' '--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
).format(
repo=artifact_repository,
location=artifact_region,
project=artifact_project,
) )
try: try:
check_call(create_cmd, shell=True) check_call(create_cmd, shell=True)

View file

@ -1,11 +1,9 @@
from ..utils import StaticMount
import click
import os import os
import shutil import shutil
import sys import sys
import click
from ..utils import StaticMount
def add_common_publish_arguments_and_options(subcommand): def add_common_publish_arguments_and_options(subcommand):
for decorator in reversed( for decorator in reversed(
@ -78,7 +76,9 @@ def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
"""Exit (with error message) if ``binary` isn't installed""" """Exit (with error message) if ``binary` isn't installed"""
if not shutil.which(binary): if not shutil.which(binary):
click.secho( click.secho(
f"Publishing to {publish_target} requires {binary} to be installed and configured", "Publishing to {publish_target} requires {binary} to be installed and configured".format(
publish_target=publish_target, binary=binary
),
bg="red", bg="red",
fg="white", fg="white",
bold=True, bold=True,

View file

@ -1,21 +1,19 @@
from contextlib import contextmanager
from datasette import hookimpl
import click
import json import json
import os import os
import pathlib import pathlib
import shlex import shlex
import shutil import shutil
import tempfile
from contextlib import contextmanager
from subprocess import call, check_output from subprocess import call, check_output
import tempfile
import click
from datasette import hookimpl
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
from .common import ( from .common import (
add_common_publish_arguments_and_options, add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed, fail_if_publish_binary_not_installed,
) )
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
@hookimpl @hookimpl
@ -236,7 +234,7 @@ def temporary_heroku_directory(
extras.extend(["--static", f"{mount_point}:{mount_point}"]) extras.extend(["--static", f"{mount_point}:{mount_point}"])
quoted_files = " ".join( quoted_files = " ".join(
[f"-i {shlex.quote(file_name)}" for file_name in file_names] ["-i {}".format(shlex.quote(file_name)) for file_name in file_names]
) )
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format( procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
quoted_files=quoted_files, extras=" ".join(extras) quoted_files=quoted_files, extras=" ".join(extras)

View file

@ -1,13 +1,11 @@
import json import json
from datasette.extras import extra_names_from_request from datasette.extras import extra_names_from_request
from datasette.utils import ( from datasette.utils import (
CustomJSONEncoder,
error_body,
path_from_row_pks,
remove_infinites,
sqlite3,
value_as_boolean, value_as_boolean,
remove_infinites,
CustomJSONEncoder,
path_from_row_pks,
sqlite3,
) )
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
@ -54,7 +52,8 @@ def json_renderer(request, args, data, error, truncated=None):
if error: if error:
shape = "objects" shape = "objects"
status_code = 400 status_code = 400
data.update(error_body(error, status_code)) data["error"] = error
data["ok"] = False
if truncated is not None: if truncated is not None:
data["truncated"] = truncated data["truncated"] = truncated
@ -88,8 +87,7 @@ def json_renderer(request, args, data, error, truncated=None):
object_rows[pk_string] = row object_rows[pk_string] = row
data = object_rows data = object_rows
if shape_error: if shape_error:
status_code = 400 data = {"ok": False, "error": shape_error}
data = error_body(shape_error, status_code)
elif shape == "array": elif shape == "array":
data = data["rows"] data = data["rows"]
@ -102,7 +100,12 @@ def json_renderer(request, args, data, error, truncated=None):
data["rows"] = [list(row.values()) for row in data["rows"]] data["rows"] = [list(row.values()) for row in data["rows"]]
else: else:
status_code = 400 status_code = 400
data = error_body(f"Invalid _shape: {shape}", status_code) data = {
"ok": False,
"error": f"Invalid _shape: {shape}",
"status": 400,
"title": None,
}
# Don't include "columns" in output # Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136 # https://github.com/simonw/datasette/issues/2136

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -633,151 +633,32 @@ const initDatasetteTable = function (manager) {
}); });
}; };
function filterRowSelector(manager) { /* Add x buttons to the filter rows */
return manager.selectors.filterRows || manager.selectors.filterRow;
}
function filterRowsWithControls(manager) {
return Array.from(
document.querySelectorAll(filterRowSelector(manager)),
).filter((el) => el.querySelector(".filter-op"));
}
function filterRowNumberFromName(name) {
var match = name && name.match(/^_filter_column_(\d+)$/);
return match ? parseInt(match[1], 10) : 0;
}
function nextFilterRowNumber(manager) {
return filterRowsWithControls(manager).reduce((max, row) => {
var column = row.querySelector("select");
return Math.max(max, filterRowNumberFromName(column && column.name));
}, 0) + 1;
}
function setFilterRowNumber(row, number) {
row.querySelector("select").name = `_filter_column_${number}`;
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
}
function resetFilterRow(row) {
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
}
function updateFilterRowButtons(manager) {
var rows = filterRowsWithControls(manager);
rows.forEach((row, index) => {
var removeButton = row.querySelector(".filter-row-remove");
var addButton = row.querySelector(".filter-row-add");
var column = row.querySelector("select");
if (removeButton) {
removeButton.hidden = index === 0;
}
if (addButton) {
addButton.hidden = index !== rows.length - 1 || !column.value;
}
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
return button && !button.hidden;
}).length;
row.classList.toggle(
"filter-controls-row-has-buttons",
visibleButtonCount > 0,
);
row.classList.toggle(
"filter-controls-row-one-button",
visibleButtonCount === 1,
);
row.classList.toggle(
"filter-controls-row-two-buttons",
visibleButtonCount === 2,
);
});
}
function cloneFilterRow(row) {
var clone = row.cloneNode(true);
clone.querySelector("select").name = "_filter_column";
clone.querySelector(".filter-op select").name = "_filter_op";
clone.querySelector("input.filter-value").name = "_filter_value";
resetFilterRow(clone);
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
return clone;
}
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>`;
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>`;
function addFilterRowButtons(row, manager) {
var removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "filter-row-icon filter-row-remove";
removeButton.setAttribute("aria-label", "Remove this filter");
removeButton.title = "Remove this filter";
removeButton.tabIndex = 0;
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
removeButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
var rows = filterRowsWithControls(manager);
var rowIndex = rows.indexOf(row);
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
row.remove();
updateFilterRowButtons(manager);
if (focusRow) {
var focusTarget =
focusRow.querySelector(".filter-row-add:not([hidden])") ||
focusRow.querySelector("select");
if (focusTarget) {
focusTarget.focus();
}
}
});
row.appendChild(removeButton);
var addButton = document.createElement("button");
addButton.type = "button";
addButton.className = "filter-row-icon filter-row-add";
addButton.setAttribute("aria-label", "Add another filter");
addButton.title = "Add another filter";
addButton.tabIndex = 0;
addButton.innerHTML = FILTER_ADD_ICON_SVG;
addButton.addEventListener("click", (ev) => {
var row = ev.currentTarget.closest(filterRowSelector(manager));
if (row.querySelector("select").name === "_filter_column") {
setFilterRowNumber(row, nextFilterRowNumber(manager));
}
var clone = cloneFilterRow(row);
addFilterRowButtons(clone, manager);
row.parentNode.insertBefore(clone, row.nextSibling);
updateFilterRowButtons(manager);
clone.querySelector("select").focus();
});
row.appendChild(addButton);
row.querySelector("select").addEventListener("change", () => {
updateFilterRowButtons(manager);
});
}
/* Add buttons to the filter rows */
function addButtonsToFilterRows(manager) { function addButtonsToFilterRows(manager) {
var rows = filterRowsWithControls(manager); var x = "✖";
var rows = Array.from(
document.querySelectorAll(manager.selectors.filterRow),
).filter((el) => el.querySelector(".filter-op"));
rows.forEach((row) => { rows.forEach((row) => {
addFilterRowButtons(row, manager); var a = document.createElement("a");
a.setAttribute("href", "#");
a.setAttribute("aria-label", "Remove this filter");
a.style.textDecoration = "none";
a.innerText = x;
a.addEventListener("click", (ev) => {
ev.preventDefault();
let row = ev.target.closest("div");
row.querySelector("select").value = "";
row.querySelector(".filter-op select").value = "exact";
row.querySelector("input.filter-value").value = "";
ev.target.closest("a").style.display = "none";
});
row.appendChild(a);
var column = row.querySelector("select");
if (!column.value) {
a.style.display = "none";
}
}); });
updateFilterRowButtons(manager);
} }
/* Set up datalist autocomplete for filter values */ /* Set up datalist autocomplete for filter values */
@ -806,11 +687,11 @@ function initAutocompleteForFilterValues(manager) {
}); });
} }
createDataLists(); createDataLists();
// When any filter column select changes, update the datalist // When any select with name=_filter_column changes, update the datalist
document.body.addEventListener("change", function (event) { document.body.addEventListener("change", function (event) {
if (event.target.name && event.target.name.startsWith("_filter_column")) { if (event.target.name === "_filter_column") {
event.target event.target
.closest(filterRowSelector(manager)) .closest(manager.selectors.filterRow)
.querySelector(".filter-value") .querySelector(".filter-value")
.setAttribute("list", "datalist-" + event.target.value); .setAttribute("list", "datalist-" + event.target.value);
} }

View file

@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any import json
from typing import Any, Iterable
from .utils import tilde_encode, urlsafe_components from .utils import tilde_encode, urlsafe_components
@ -63,6 +62,7 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
"description_html": query.description_html, "description_html": query.description_html,
"hide_sql": query.hide_sql, "hide_sql": query.hide_sql,
"fragment": query.fragment, "fragment": query.fragment,
"params": list(query.parameters),
"parameters": list(query.parameters), "parameters": list(query.parameters),
"is_write": query.is_write, "is_write": query.is_write,
"is_private": query.is_private, "is_private": query.is_private,
@ -84,6 +84,7 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
return { return {
"queries": [stored_query_to_dict(query) for query in page.queries], "queries": [stored_query_to_dict(query) for query in page.queries],
"next": page.next, "next": page.next,
"has_more": page.has_more,
"limit": page.limit, "limit": page.limit,
} }
@ -387,7 +388,7 @@ async def count_queries(
OR q.sql LIKE :query_search OR q.sql LIKE :query_search
) )
""") """)
params["query_search"] = f"%{q}%" params["query_search"] = "%{}%".format(q)
if is_write is not None: if is_write is not None:
where_clauses.append("q.is_write = :query_is_write") where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write)) params["query_is_write"] = int(bool(is_write))
@ -463,7 +464,7 @@ async def list_queries(
except ValueError: except ValueError:
components = [] components = []
if database is None and len(components) == 3: if database is None and len(components) == 3:
where_clauses.append(f""" where_clauses.append("""
( (
q.database_name > :cursor_database q.database_name > :cursor_database
OR ( OR (
@ -477,12 +478,12 @@ async def list_queries(
) )
) )
) )
""") """.format(sort_key_sql=sort_key_sql))
params["cursor_database"] = components[0] params["cursor_database"] = components[0]
params["cursor_sort_key"] = components[1] params["cursor_sort_key"] = components[1]
params["cursor_name"] = components[2] params["cursor_name"] = components[2]
elif database is not None and len(components) == 2: elif database is not None and len(components) == 2:
where_clauses.append(f""" where_clauses.append("""
( (
{sort_key_sql} > :cursor_sort_key {sort_key_sql} > :cursor_sort_key
OR ( OR (
@ -490,7 +491,7 @@ async def list_queries(
AND q.name > :cursor_name AND q.name > :cursor_name
) )
) )
""") """.format(sort_key_sql=sort_key_sql))
params["cursor_sort_key"] = components[0] params["cursor_sort_key"] = components[0]
params["cursor_name"] = components[1] params["cursor_name"] = components[1]
@ -503,7 +504,7 @@ async def list_queries(
OR q.sql LIKE :query_search OR q.sql LIKE :query_search
) )
""") """)
params["query_search"] = f"%{q}%" params["query_search"] = "%{}%".format(q)
if is_write is not None: if is_write is not None:
where_clauses.append("q.is_write = :query_is_write") where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write)) params["query_is_write"] = int(bool(is_write))

View file

@ -1,40 +0,0 @@
"""
Index of the documented template contexts for Datasette's core HTML pages.
This module deliberately contains no documentation strings of its own -
the documentation lives next to the code it describes:
- Every page renders a Context dataclass defined in its view module
(DatabaseContext, QueryContext in views/database.py, TableContext in
views/table.py, RowContext in views/row.py). Fields added by view code
carry ``help`` metadata; fields declared with from_extra() take their
documentation from the description on the matching Extra class in
views/table_extras.py.
- The keys render_template() adds to every page are documented in
TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds
them.
The contract tests in tests/test_template_context.py assert that the real
rendered context for each page exactly matches what is documented, and
docs/template_context_doc.py generates docs/template_context.rst from the
same classes.
"""
from datasette.app import TEMPLATE_BASE_CONTEXT
from datasette.views.database import DatabaseContext, QueryContext
from datasette.views.row import RowContext
from datasette.views.table import TableContext
PAGES = {
"database": DatabaseContext,
"query": QueryContext,
"table": TableContext,
"row": RowContext,
}
def documented_context_keys(page_name):
"Set of every documented key for the named page, including base context keys"
return set(TEMPLATE_BASE_CONTEXT) | {
f.name for f in PAGES[page_name].documented_fields()
}

View file

@ -1,5 +1,5 @@
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script> <script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script> <script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
<style> <style>
.cm-editor { .cm-editor {
resize: both; resize: both;

View file

@ -6,20 +6,8 @@
padding: 1.5em; padding: 1.5em;
margin-bottom: 2em; margin-bottom: 2em;
} }
.permission-form form {
max-width: 60rem;
}
.permission-form-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-form-result {
margin-top: 1rem;
max-width: 60rem;
}
.form-section { .form-section {
margin-bottom: 1.25em; margin-bottom: 1em;
} }
.form-section label { .form-section label {
display: block; display: block;
@ -27,51 +15,22 @@
font-weight: bold; font-weight: bold;
} }
.form-section input[type="text"], .form-section input[type="text"],
.form-section input[type="number"],
.form-section select,
.permission-textarea {
background-color: #fff;
border: 1px solid #aaa;
border-radius: 4px;
box-sizing: border-box;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
color: #222;
font-family: inherit;
font-size: 1rem;
line-height: 1.4;
max-width: none;
width: 100%;
}
.form-section input[type="text"] {
height: 3rem;
padding: 0.6rem 0.75rem;
}
.form-section input[type="number"] {
height: 3rem;
max-width: 7rem;
padding: 0.6rem 0.75rem;
}
.form-section select { .form-section select {
height: 3rem; width: 100%;
padding: 0.6rem 0.75rem; max-width: 500px;
} padding: 0.5em;
.permission-textarea { box-sizing: border-box;
font-family: monospace; border: 1px solid #ccc;
min-height: 12rem; border-radius: 3px;
padding: 0.75rem;
resize: vertical;
} }
.form-section input[type="text"]:focus, .form-section input[type="text"]:focus,
.form-section input[type="number"]:focus, .form-section select:focus {
.form-section select:focus, outline: 2px solid #0066cc;
.permission-textarea:focus {
border-color: #0066cc; border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
outline: none;
} }
.form-section small { .form-section small {
display: block; display: block;
margin-top: 0.45em; margin-top: 0.3em;
color: #666; color: #666;
} }
.form-actions { .form-actions {
@ -183,9 +142,4 @@
text-align: center; text-align: center;
color: #666; color: #666;
} }
@media only screen and (max-width: 576px) {
.permission-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style> </style>

View file

@ -44,10 +44,10 @@
</style> </style>
<nav class="permissions-debug-tabs"> <nav class="permissions-debug-tabs">
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Explain</a> <a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a> <a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a> <a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a> <a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a> <a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a> <a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
</nav> </nav>

View file

@ -3,11 +3,29 @@
{% block title %}Debug allow rules{% endblock %} {% block title %}Debug allow rules{% endblock %}
{% block extra_head %} {% block extra_head %}
{% include "_permission_ui_styles.html" %}
<style> <style>
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
p.message-warning { p.message-warning {
white-space: pre-wrap; white-space: pre-wrap;
} }
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
@ -20,28 +38,24 @@ p.message-warning {
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p> <p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
<div class="permission-form"> <form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get"> <div class="two-col">
<div class="permission-form-grid"> <p><label>Allow block</label></p>
<div class="form-section"> <textarea name="allow">{{ allow_input }}</textarea>
<label for="allow-block">Allow block</label> </div>
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea> <div class="two-col">
</div> <p><label>Actor</label></p>
<div class="form-section"> <textarea name="actor">{{ actor_input }}</textarea>
<label for="allow-actor">Actor</label> </div>
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea> <div style="margin-top: 1em;">
</div> <input type="submit" value="Apply allow block to actor">
</div> </div>
<div class="form-actions"> </form>
<button type="submit" class="submit-btn">Apply allow block to actor</button>
</div>
</form>
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %} {% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %} {% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %} {% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
</div>
{% endblock %} {% endblock %}

View file

@ -3,7 +3,7 @@
{% block title %}API Explorer{% endblock %} {% block title %}API Explorer{% endblock %}
{% block extra_head %} {% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script> <script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -2,13 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ static('app.css') }}"> <link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% for url in extra_css_urls %} {% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}> <link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %} {% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script> <script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ static('datasette-manager.js') }}" defer></script> <script src="{{ urls.static('datasette-manager.js') }}" defer></script>
{% for url in extra_js_urls %} {% for url in extra_js_urls %}
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script> <script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
{% endfor %} {% endfor %}
@ -70,7 +70,7 @@
{% endfor %} {% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %} {% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ static('navigation-search.js') }}" defer></script> <script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search> <navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
</body> </body>
</html> </html>

View file

@ -6,10 +6,6 @@
{{- super() -}} {{- super() -}}
{% include "_codemirror.html" %} {% include "_codemirror.html" %}
{% include "_sql_parameter_styles.html" %} {% include "_sql_parameter_styles.html" %}
{% if database_page_data.createTable %}
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
<script src="{{ static('edit-tools.js') }}" defer></script>
{% endif %}
{% endblock %} {% endblock %}
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
@ -76,7 +72,7 @@
<div class="db-table"> <div class="db-table">
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3> <h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p> <p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}&gt;{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p> <p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -9,7 +9,7 @@
{% include "_permissions_debug_tabs.html" %} {% include "_permissions_debug_tabs.html" %}
<p style="margin-bottom: 2em;"> <p style="margin-bottom: 2em;">
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}. This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
Actions are used by the permission system to control access to different features. Actions are used by the permission system to control access to different features.
</p> </p>
@ -26,7 +26,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for action in data.actions %} {% for action in data %}
<tr> <tr>
<td><strong>{{ action.name }}</strong></td> <td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td> <td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>

View file

@ -3,7 +3,7 @@
{% block title %}Allowed Resources{% endblock %} {% block title %}Allowed Resources{% endblock %}
{% block extra_head %} {% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script> <script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %} {% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %} {% include "_debug_common_functions.html" %}
{% endblock %} {% endblock %}
@ -49,7 +49,7 @@
<div class="form-section"> <div class="form-section">
<label for="page_size">Page size:</label> <label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200"> <input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small> <small>Number of results per page (max 200)</small>
</div> </div>
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
(function() { (function() {
const params = populateFormFromURL(); const params = populateFormFromURL();
const action = params.get('action'); const action = params.get('action');
const page = params.get('_page'); const page = params.get('page');
if (action) { if (action) {
fetchResults(page ? parseInt(page) : 1); fetchResults(page ? parseInt(page) : 1);
} }
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value && key !== '_size' && key !== '_page') { if (value && key !== 'page_size') {
params.append(key, value); params.append(key, value);
} }
} }
const pageSize = document.getElementById('page_size').value || '50'; const pageSize = document.getElementById('page_size').value || '50';
params.append('_page', page.toString()); params.append('page', page.toString());
params.append('_size', pageSize); params.append('page_size', pageSize);
try { try {
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), { const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {

View file

@ -4,7 +4,7 @@
{% block extra_head %} {% block extra_head %}
{{ super() }} {{ super() }}
<script src="{{ static('autocomplete.js') }}" defer></script> <script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Explain a permission decision{% endblock %} {% block title %}Permission Check{% endblock %}
{% block extra_head %} {% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script> <script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %} {% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %} {% include "_debug_common_functions.html" %}
<style> <style>
@ -13,35 +13,29 @@
border-radius: 5px; border-radius: 5px;
} }
#output.allowed { #output.allowed {
background-color: #f3fbf4; background-color: #e8f5e9;
border: 2px solid #4caf50; border: 2px solid #4caf50;
} }
#output.denied { #output.denied {
background-color: #fff7f7; background-color: #ffebee;
border: 2px solid #f44336; border: 2px solid #f44336;
} }
#output h2 { #output h2 {
margin-top: 0; margin-top: 0;
} }
#output h3 { #output .result-badge {
margin-bottom: 0.5em;
}
#output .result-badge,
.effect-badge,
.rule-status {
display: inline-block; display: inline-block;
padding: 0.2em 0.5em; padding: 0.3em 0.8em;
border-radius: 3px; border-radius: 3px;
font-weight: bold; font-weight: bold;
font-size: 1.1em;
} }
#output .allowed-badge, #output .allowed-badge {
.effect-allow { background-color: #4caf50;
background-color: #2e7d32;
color: white; color: white;
} }
#output .denied-badge, #output .denied-badge {
.effect-deny { background-color: #f44336;
background-color: #c62828;
color: white; color: white;
} }
.details-section { .details-section {
@ -54,130 +48,70 @@
.details-section dd { .details-section dd {
margin-left: 1em; margin-left: 1em;
} }
.explanation-section {
background: rgba(255, 255, 255, 0.75);
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 1em;
padding: 0 1em 1em;
}
.rules-table {
border-collapse: collapse;
width: 100%;
}
.rules-table th,
.rules-table td {
border-bottom: 1px solid #ddd;
padding: 0.5em;
text-align: left;
vertical-align: top;
}
.rule-status {
background: #e8f5e9;
color: #1b5e20;
}
.rule-ignored {
background: #eee;
color: #555;
font-weight: normal;
}
.requirement-allowed {
color: #1b5e20;
}
.requirement-denied {
color: #b71c1c;
}
@media only screen and (max-width: 576px) {
.rules-table,
.rules-table tbody,
.rules-table tr,
.rules-table td {
display: block;
}
.rules-table thead {
display: none;
}
.rules-table td::before {
content: attr(data-label) ": ";
font-weight: bold;
}
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>Explain a permission decision</h1> <h1>Permission check</h1>
{% set current_tab = "check" %} {% set current_tab = "check" %}
{% include "_permissions_debug_tabs.html" %} {% include "_permissions_debug_tabs.html" %}
<p>Test an actor, action and resource. The result explains which rules matched, which specificity level won, and whether actor restrictions or required actions changed the verdict.</p> <p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
{% if request.actor %}
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
{% else %}
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
{% endif %}
<div class="permission-form"> <div class="permission-form">
<form id="check-form" method="get" action="{{ urls.path('-/check') }}"> <form id="check-form" method="get" action="{{ urls.path("-/check") }}">
<div class="form-section"> <div class="form-section">
<label for="actor">Actor JSON:</label> <label for="action">Action (permission name):</label>
<textarea class="permission-textarea" id="actor" name="actor">{{ actor_json }}</textarea>
<small>Use <code>null</code> for an anonymous actor. This actor is simulated; it does not change who you are signed in as.</small>
</div>
<div class="form-section">
<label for="action">Action:</label>
<select id="action" name="action" required> <select id="action" name="action" required>
<option value="">Select an action...</option> <option value="">Select an action...</option>
{% for action in actions %} {% for action_name in sorted_actions %}
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option> <option value="{{ action_name }}">{{ action_name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<small id="action-help">The operation to evaluate</small> <small>The permission action to check</small>
</div> </div>
<div class="form-section" id="parent-section"> <div class="form-section">
<label for="parent">Parent resource:</label> <label for="parent">Parent resource (optional):</label>
<input type="text" id="parent" name="parent" placeholder="e.g., database name"> <input type="text" id="parent" name="parent" placeholder="e.g., database name">
<small>The database or other parent resource</small> <small>For database-level permissions, specify the database name</small>
</div> </div>
<div class="form-section" id="child-section"> <div class="form-section">
<label for="child">Child resource:</label> <label for="child">Child resource (optional):</label>
<input type="text" id="child" name="child" placeholder="e.g., table or query name"> <input type="text" id="child" name="child" placeholder="e.g., table name">
<small>The table, query or other child resource</small> <small>For table-level permissions, specify the table name (requires parent)</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button> <button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
</div> </div>
</form> </form>
</div> </div>
<div id="output" style="display: none;"> <div id="output" style="display: none;">
<h2>Result: <span class="result-badge" id="result-badge"></span></h2> <h2>Result: <span class="result-badge" id="result-badge"></span></h2>
<p id="result-summary"></p>
<dl class="details-section"> <dl class="details-section">
<dt>Actor:</dt>
<dd><code id="result-actor"></code></dd>
<dt>Action:</dt> <dt>Action:</dt>
<dd><code id="result-action"></code></dd> <dd id="result-action"></dd>
<dt>Resource:</dt>
<dd><code id="result-resource"></code></dd> <dt>Resource Path:</dt>
<dd id="result-resource"></dd>
<dt>Actor ID:</dt>
<dd id="result-actor"></dd>
<div id="additional-details"></div>
</dl> </dl>
<section class="explanation-section">
<h3>Matching rules</h3>
<div id="matching-rules"></div>
</section>
<section class="explanation-section" id="restrictions-section">
<h3>Actor restrictions</h3>
<div id="restriction-results"></div>
</section>
<section class="explanation-section" id="requirements-section">
<h3>Required actions</h3>
<div id="requirement-results"></div>
</section>
<details style="margin-top: 1em;"> <details style="margin-top: 1em;">
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary> <summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre> <pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
@ -185,134 +119,152 @@
</div> </div>
<script> <script>
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
const form = document.getElementById('check-form'); const form = document.getElementById('check-form');
const output = document.getElementById('output'); const output = document.getElementById('output');
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
const actionSelect = document.getElementById('action');
function updateResourceFields() {
const action = actions[actionSelect.value];
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
let help = action && action.description ? action.description : 'The operation to evaluate';
if (action && action.also_requires) {
help += `; also requires ${action.also_requires}`;
}
document.getElementById('action-help').textContent = help;
}
async function performCheck() { async function performCheck() {
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Explaining...'; submitBtn.textContent = 'Checking...';
const params = new URLSearchParams(new FormData(form));
const formData = new FormData(form);
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value) {
params.append(key, value);
}
}
try { try {
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), { const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
headers: {'Accept': 'application/json'} method: 'GET',
headers: {
'Accept': 'application/json',
}
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
displayResult(data); displayResult(data);
} else { } else {
displayError(data); displayError(data);
} }
} catch (error) { } catch (error) {
displayError({error: error.message}); alert('Error: ' + error.message);
} finally { } finally {
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = 'Explain decision'; submitBtn.textContent = 'Check Permission';
} }
} }
// Populate form on initial load
(function() {
const params = populateFormFromURL();
const action = params.get('action');
if (action) {
performCheck();
}
})();
function displayResult(data) { function displayResult(data) {
output.style.display = 'block'; output.style.display = 'block';
// Set badge and styling
const resultBadge = document.getElementById('result-badge'); const resultBadge = document.getElementById('result-badge');
output.className = data.allowed ? 'allowed' : 'denied'; if (data.allowed) {
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`; output.className = 'allowed';
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗'; resultBadge.className = 'result-badge allowed-badge';
document.getElementById('result-summary').textContent = data.explanation.summary; resultBadge.textContent = 'ALLOWED ✓';
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor); } else {
document.getElementById('result-action').textContent = data.action; output.className = 'denied';
document.getElementById('result-resource').textContent = data.resource.path; resultBadge.className = 'result-badge denied-badge';
displayRules(data.explanation); resultBadge.textContent = 'DENIED ✗';
displayRestrictions(data.explanation.restrictions); }
displayRequirements(data.explanation.required_actions);
// Basic details
document.getElementById('result-action').textContent = data.action || 'N/A';
document.getElementById('result-resource').textContent = data.resource?.path || '/';
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
// Additional details
const additionalDetails = document.getElementById('additional-details');
additionalDetails.innerHTML = '';
if (data.reason !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Reason:';
const dd = document.createElement('dd');
dd.textContent = data.reason || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.source_plugin !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Source Plugin:';
const dd = document.createElement('dd');
dd.textContent = data.source_plugin || 'N/A';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.used_default !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Used Default:';
const dd = document.createElement('dd');
dd.textContent = data.used_default ? 'Yes' : 'No';
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
if (data.depth !== undefined) {
const dt = document.createElement('dt');
dt.textContent = 'Depth:';
const dd = document.createElement('dd');
dd.textContent = data.depth;
additionalDetails.appendChild(dt);
additionalDetails.appendChild(dd);
}
// Raw JSON
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data); document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
}
function displayRules(explanation) { // Scroll to output
const container = document.getElementById('matching-rules'); output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (!explanation.matched_rules.length) {
container.innerHTML = '<p>No rules matched. Datasette denies access when there is no matching rule.</p>';
return;
}
let html = '<table class="rules-table"><thead><tr><th>Effect</th><th>Scope</th><th>Source</th><th>Reason</th><th>Role in decision</th></tr></thead><tbody>';
for (const rule of explanation.matched_rules) {
const status = rule.decisive
? '<span class="rule-status">Decisive</span>'
: `<span class="rule-status rule-ignored">${escapeHtml(rule.ignored_because)}</span>`;
html += '<tr>';
html += `<td data-label="Effect"><span class="effect-badge effect-${rule.effect}">${rule.effect.toUpperCase()}</span></td>`;
html += `<td data-label="Scope">${escapeHtml(rule.scope)}</td>`;
html += `<td data-label="Source"><code>${escapeHtml(rule.source || 'unknown')}</code></td>`;
html += `<td data-label="Reason">${escapeHtml(rule.reason || 'No reason supplied')}</td>`;
html += `<td data-label="Role in decision">${status}</td>`;
html += '</tr>';
}
container.innerHTML = html + '</tbody></table>';
}
function displayRestrictions(restrictions) {
const section = document.getElementById('restrictions-section');
const container = document.getElementById('restriction-results');
section.style.display = restrictions.length ? 'block' : 'none';
container.innerHTML = restrictions.map(restriction => {
const className = restriction.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = restriction.allowed ? 'INCLUDED ✓' : 'EXCLUDED ✗';
return `<p class="${className}"><strong>${verdict}</strong> by <code>${escapeHtml(restriction.source || 'unknown')}</code>: ${escapeHtml(restriction.reason)}</p>`;
}).join('');
}
function displayRequirements(requirements) {
const section = document.getElementById('requirements-section');
const container = document.getElementById('requirement-results');
section.style.display = requirements.length ? 'block' : 'none';
container.innerHTML = requirements.map(requirement => {
const className = requirement.allowed ? 'requirement-allowed' : 'requirement-denied';
const verdict = requirement.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
return `<p class="${className}"><strong>${escapeHtml(requirement.action)}: ${verdict}</strong> — ${escapeHtml(requirement.summary)}</p>`;
}).join('');
} }
function displayError(data) { function displayError(data) {
output.style.display = 'block'; output.style.display = 'block';
output.className = 'denied'; output.className = 'denied';
const resultBadge = document.getElementById('result-badge'); const resultBadge = document.getElementById('result-badge');
resultBadge.className = 'result-badge denied-badge'; resultBadge.className = 'result-badge denied-badge';
resultBadge.textContent = 'ERROR'; resultBadge.textContent = 'ERROR';
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
document.getElementById('result-actor').textContent = '—'; document.getElementById('result-action').textContent = 'N/A';
document.getElementById('result-action').textContent = '—'; document.getElementById('result-resource').textContent = 'N/A';
document.getElementById('result-resource').textContent = '—'; document.getElementById('result-actor').textContent = 'N/A';
document.getElementById('matching-rules').innerHTML = '';
document.getElementById('restrictions-section').style.display = 'none'; const additionalDetails = document.getElementById('additional-details');
document.getElementById('requirements-section').style.display = 'none'; additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data); document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
form.addEventListener('submit', event => { // Disable child input if parent is empty
event.preventDefault(); const parentInput = document.getElementById('parent');
performCheck(); const childInput = document.getElementById('child');
});
actionSelect.addEventListener('change', updateResourceFields);
(function initializeFromUrl() { childInput.addEventListener('focus', () => {
const params = populateFormFromURL(); if (!parentInput.value) {
updateResourceFields(); alert('Please specify a parent resource first before adding a child resource.');
if (params.get('action')) { parentInput.focus();
performCheck();
} }
})(); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Permission activity{% endblock %} {% block title %}Debug permissions{% endblock %}
{% block extra_head %} {% block extra_head %}
{% include "_permission_ui_styles.html" %} {% include "_permission_ui_styles.html" %}
@ -20,45 +20,60 @@
.check-action, .check-when, .check-result { .check-action, .check-when, .check-result {
font-size: 1.3em; font-size: 1.3em;
} }
textarea {
height: 10em;
width: 95%;
box-sizing: border-box;
padding: 0.5em;
border: 2px dotted black;
}
.two-col {
display: inline-block;
width: 48%;
}
.two-col label {
width: 48%;
}
@media only screen and (max-width: 576px) {
.two-col {
width: 100%;
}
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>Permission activity</h1> <h1>Permission playground</h1>
{% set current_tab = "permissions" %} {% set current_tab = "permissions" %}
{% include "_permissions_debug_tabs.html" %} {% include "_permissions_debug_tabs.html" %}
<h2>Raw simulator</h2> <p>This tool lets you simulate an actor and a permission check for that actor.</p>
<p>This form runs a hypothetical permission check and returns its raw explanation JSON. Use the <a href="{{ urls.path('-/check') }}">Explain tool</a> for a visual explanation of the same decision.</p>
<div class="permission-form"> <div class="permission-form">
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post"> <form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
<div class="permission-form-grid"> <div class="two-col">
<div> <div class="form-section">
<div class="form-section"> <label>Actor</label>
<label for="activity-actor">Actor</label> <textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
</div>
</div> </div>
<div> </div>
<div class="form-section"> <div class="two-col" style="vertical-align: top">
<label for="permission">Action</label> <div class="form-section">
<select name="permission" id="permission"> <label for="permission">Action</label>
{% for permission in permissions %} <select name="permission" id="permission">
<option value="{{ permission.name }}">{{ permission.name }}</option> {% for permission in permissions %}
{% endfor %} <option value="{{ permission.name }}">{{ permission.name }}</option>
</select> {% endfor %}
</div> </select>
<div class="form-section"> </div>
<label for="resource_1">Parent</label> <div class="form-section">
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name"> <label for="resource_1">Parent</label>
</div> <input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
<div class="form-section"> </div>
<label for="resource_2">Child</label> <div class="form-section">
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name"> <label for="resource_2">Child</label>
</div> <input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) {
}); });
</script> </script>
<h2>Recent permission checks</h2> <h1>Recent permissions checks</h1>
<p> <p>
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %}, {% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},

View file

@ -3,7 +3,7 @@
{% block title %}Permission Rules{% endblock %} {% block title %}Permission Rules{% endblock %}
{% block extra_head %} {% block extra_head %}
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script> <script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
{% include "_permission_ui_styles.html" %} {% include "_permission_ui_styles.html" %}
{% include "_debug_common_functions.html" %} {% include "_debug_common_functions.html" %}
{% endblock %} {% endblock %}
@ -37,7 +37,7 @@
<div class="form-section"> <div class="form-section">
<label for="page_size">Page size:</label> <label for="page_size">Page size:</label>
<input type="number" id="page_size" name="_size" value="50" min="1" max="200"> <input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small> <small>Number of results per page (max 200)</small>
</div> </div>
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
(function() { (function() {
const params = populateFormFromURL(); const params = populateFormFromURL();
const action = params.get('action'); const action = params.get('action');
const page = params.get('_page'); const page = params.get('page');
if (action) { if (action) {
fetchResults(page ? parseInt(page) : 1); fetchResults(page ? parseInt(page) : 1);
} }
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value && key !== '_size' && key !== '_page') { if (value && key !== 'page_size') {
params.append(key, value); params.append(key, value);
} }
} }
const pageSize = document.getElementById('page_size').value || '50'; const pageSize = document.getElementById('page_size').value || '50';
params.append('_page', page.toString()); params.append('page', page.toString());
params.append('_size', pageSize); params.append('page_size', pageSize);
try { try {
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), { const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {

View file

@ -56,11 +56,6 @@ form.sql.core input[data-execute-write-submit]:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 1; opacity: 1;
} }
.execute-write form.sql .sql-editor-min-lines .cm-content,
.execute-write form.sql .sql-editor-min-lines .cm-gutter {
/* Four visible editor lines without adding blank lines to the SQL value. */
min-height: calc(5.6em + 8px);
}
.execute-write-disabled-reason { .execute-write-disabled-reason {
color: #4f5b6d; color: #4f5b6d;
font-size: 0.85rem; font-size: 0.85rem;
@ -98,25 +93,20 @@ form.sql.core input[data-execute-write-submit]:disabled {
{% endif %} {% endif %}
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze"> <form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
{% if write_create_table_template_sql or write_template_tables %} {% if write_template_tables %}
<div class="execute-write-template-menu"> <div class="execute-write-template-menu">
<details> <details>
<summary>Start with a template</summary> <summary>Start with a template</summary>
<p class="execute-write-template-controls"> <p class="execute-write-template-controls">
{% if write_create_table_template_sql %} <label for="execute-write-template-table">Table</label>
<button type="button" data-sql-template="create" data-template-sql="{{ write_create_table_template_sql }}">Create table</button> <select id="execute-write-template-table">
{% endif %} {% for table_name, table in write_template_tables|dictsort %}
{% if write_template_tables %} <option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
<label for="execute-write-template-table">{% if write_create_table_template_sql %}or table:{% else %}Table{% endif %}</label>
<select id="execute-write-template-table">
{% for table_name, table in write_template_tables|dictsort %}
<option value="{{ table_name }}"{% for operation, template_sql in table.templates|dictsort %} data-template-{{ operation }}-sql="{{ template_sql }}"{% endfor %}>{{ table_name }}</option>
{% endfor %}
</select>
{% for operation in write_template_operations %}
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
{% endfor %} {% endfor %}
{% endif %} </select>
{% for operation in write_template_operations %}
<button type="button" data-sql-template="{{ operation.name }}">{{ operation.label }}</button>
{% endfor %}
</p> </p>
</details> </details>
</div> </div>
@ -124,7 +114,7 @@ form.sql.core input[data-execute-write-submit]:disabled {
<p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p> <p class="message-warning execute-write-template-unavailable">There are no tables that you can currently edit.</p>
{% endif %} {% endif %}
<p class="sql-editor{% if not sql %} sql-editor-min-lines{% endif %}"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p> <p class="sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
{% set sql_parameters_section_id = "execute-write-parameters-section" %} {% set sql_parameters_section_id = "execute-write-parameters-section" %}
{% set sql_parameters_allow_expand = true %} {% set sql_parameters_allow_expand = true %}
@ -169,13 +159,19 @@ form.sql.core input[data-execute-write-submit]:disabled {
</p> </p>
</form> </form>
<script>
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
if (executeWriteSqlInput && !executeWriteSqlInput.value) {
executeWriteSqlInput.value = "\n\n\n";
}
</script>
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}
{% include "_sql_parameter_scripts.html" %} {% include "_sql_parameter_scripts.html" %}
{% include "_execute_write_analysis_scripts.html" %} {% include "_execute_write_analysis_scripts.html" %}
<script> <script>
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
const form = document.querySelector("form.sql.core"); const form = document.querySelector("form.sql.core");
const analysisSection = document.querySelector("#execute-write-analysis-section"); const analysisSection = document.querySelector("#execute-write-analysis-section");
const submitButton = form const submitButton = form
@ -256,12 +252,11 @@ window.addEventListener("DOMContentLoaded", () => {
}); });
</script> </script>
{% if write_create_table_template_sql or write_template_tables %} {% if write_template_tables %}
<script> <script>
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
const tableSelect = document.querySelector("#execute-write-template-table"); const tableSelect = document.querySelector("#execute-write-template-table");
const templateButtons = document.querySelectorAll("[data-sql-template]"); const templateButtons = document.querySelectorAll("[data-sql-template]");
const sqlInput = document.querySelector("textarea#sql-editor");
function dataKey(operation) { function dataKey(operation) {
return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`; return `template${operation.charAt(0).toUpperCase()}${operation.slice(1)}Sql`;
@ -271,59 +266,26 @@ window.addEventListener("DOMContentLoaded", () => {
return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null; return tableSelect ? tableSelect.options[tableSelect.selectedIndex] : null;
} }
function templateSql(button) { function templateSql(operation) {
if (button.dataset.templateSql) {
return button.dataset.templateSql;
}
const operation = button.dataset.sqlTemplate;
const option = selectedOption(); const option = selectedOption();
return option ? option.dataset[dataKey(operation)] || "" : ""; return option ? option.dataset[dataKey(operation)] || "" : "";
} }
function updateTemplateButtons() { function updateTemplateButtons() {
templateButtons.forEach((button) => { templateButtons.forEach((button) => {
button.hidden = !templateSql(button); button.hidden = !templateSql(button.dataset.sqlTemplate);
}); });
} }
function updateSqlUrl(sql) {
if (!window.history || !window.history.replaceState) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.history.replaceState(null, "", url.toString());
}
function setEditorSql(sql) {
if (window.editor) {
window.editor.dispatch({
changes: {
from: 0,
to: window.editor.state.doc.length,
insert: sql,
},
selection: { anchor: sql.length },
});
window.editor.focus();
if (sqlInput) {
sqlInput.value = sql;
}
} else if (sqlInput) {
sqlInput.value = sql;
sqlInput.dispatchEvent(new Event("input", { bubbles: true }));
sqlInput.focus();
}
updateSqlUrl(sql);
}
templateButtons.forEach((button) => { templateButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const sql = templateSql(button); const sql = templateSql(button.dataset.sqlTemplate);
if (!sql) { if (!sql) {
return; return;
} }
setEditorSql(sql); const url = new URL(window.location.href);
url.searchParams.set("sql", sql);
window.location.href = url.toString();
}); });
}); });
if (tableSelect) { if (tableSelect) {

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Datasette: Pattern Portfolio</title> <title>Datasette: Pattern Portfolio</title>
<link rel="stylesheet" href="{{ static('app.css') }}"> <link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
<style></style> <style></style>
@ -202,9 +202,9 @@
<h3>3 rows <h3>3 rows
where characteristic_id = 2 where characteristic_id = 2
</h3> </h3>
<form class="core filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get"> <form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div> <div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
<div class="filter-row filter-controls-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
<select name="_filter_column_1"> <select name="_filter_column_1">
<option value="">- remove filter -</option> <option value="">- remove filter -</option>
@ -238,7 +238,7 @@
</select> </select>
</div><input type="text" name="_filter_value_1" class="filter-value" value="2"> </div><input type="text" name="_filter_value_1" class="filter-value" value="2">
</div> </div>
<div class="filter-row filter-controls-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
<select name="_filter_column"> <select name="_filter_column">
<option value="">- column -</option> <option value="">- column -</option>
@ -272,8 +272,8 @@
</select> </select>
</div><input type="text" name="_filter_value" class="filter-value"> </div><input type="text" name="_filter_value" class="filter-value">
</div> </div>
<div class="filter-row filter-actions-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper small-screen-only">
<select name="_sort" id="sort_by"> <select name="_sort" id="sort_by">
<option value="">Sort...</option> <option value="">Sort...</option>
<option value="rowid" selected>Sort by rowid</option> <option value="rowid" selected>Sort by rowid</option>
@ -281,8 +281,8 @@
<option value="characteristic_id">Sort by characteristic_id</option> <option value="characteristic_id">Sort by characteristic_id</option>
</select> </select>
</div> </div>
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label> <label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
<input type="submit" value="Apply filters"> <input type="submit" value="Apply">
</div> </div>
</form> </form>

View file

@ -7,9 +7,9 @@
{% if row_mutation_ui %} {% if row_mutation_ui %}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script> <script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
{% if table_page_data.foreignKeys %} {% if table_page_data.foreignKeys %}
<script src="{{ static('autocomplete.js') }}" defer></script> <script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %} {% endif %}
<script src="{{ static('edit-tools.js') }}" defer></script> <script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
{% endif %} {% endif %}
<style> <style>
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {

View file

@ -1,17 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}&gt;{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %} {% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
{% block extra_head %} {% block extra_head %}
{{- super() -}} {{- super() -}}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script> <script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
<script src="{{ static('column-chooser.js') }}" defer></script> <script src="{{ urls.static('column-chooser.js') }}" defer></script>
{% if table_page_data.foreignKeys %} {% if table_page_data.foreignKeys %}
<script src="{{ static('autocomplete.js') }}" defer></script> <script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %} {% endif %}
<script src="{{ static('edit-tools.js') }}" defer></script> <script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ static('table.js') }}" defer></script> <script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
<script src="{{ static('mobile-column-actions.js') }}" defer></script> <script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script> <script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style> <style>
@media only screen and (max-width: 576px) { @media only screen and (max-width: 576px) {
@ -48,19 +48,19 @@
{% if count or human_description_en %} {% if count or human_description_en %}
<h3> <h3>
{% if count_truncated %}&gt;{{ "{:,}".format(count - 1) }} rows {% if count == count_limit + 1 %}&gt;{{ "{:,}".format(count_limit) }} rows
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %} {% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %} {% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
{% if human_description_en %}{{ human_description_en }}{% endif %} {% if human_description_en %}{{ human_description_en }}{% endif %}
</h3> </h3>
{% endif %} {% endif %}
<form class="core filters" action="{{ urls.table(database, table) }}" method="get"> <form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
{% if supports_search %} {% if supports_search %}
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div> <div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %} {% endif %}
{% for column, lookup, value in filters.selections() %} {% for column, lookup, value in filters.selections() %}
<div class="filter-row filter-controls-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
<select name="_filter_column_{{ loop.index }}"> <select name="_filter_column_{{ loop.index }}">
<option value="">- remove filter -</option> <option value="">- remove filter -</option>
@ -77,7 +77,7 @@
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}"> </div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
</div> </div>
{% endfor %} {% endfor %}
<div class="filter-row filter-controls-row"> <div class="filter-row">
<div class="select-wrapper"> <div class="select-wrapper">
<select name="_filter_column"> <select name="_filter_column">
<option value="">- column -</option> <option value="">- column -</option>
@ -93,9 +93,9 @@
</select> </select>
</div><input type="text" name="_filter_value" class="filter-value"> </div><input type="text" name="_filter_value" class="filter-value">
</div> </div>
<div class="filter-row filter-actions-row"> <div class="filter-row">
{% if is_sortable %} {% if is_sortable %}
<div class="select-wrapper"> <div class="select-wrapper small-screen-only">
<select name="_sort" id="sort_by"> <select name="_sort" id="sort_by">
<option value="">Sort...</option> <option value="">Sort...</option>
{% for column in display_columns %} {% for column in display_columns %}
@ -105,12 +105,12 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label> <label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
{% endif %} {% endif %}
{% for key, value in form_hidden_args %} {% for key, value in form_hidden_args %}
<input type="hidden" name="{{ key }}" value="{{ value }}"> <input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %} {% endfor %}
<input type="submit" value="Apply filters" tabindex="0"> <input type="submit" value="Apply">
</div> </div>
</form> </form>

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
import itsdangerous import itsdangerous
@ -18,21 +18,6 @@ if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
class TokenInvalid(Exception):
"""
Raised by a TokenHandler when a token it recognizes is invalid -
for example a bad signature, malformed payload or expired token.
Datasette responds to this with an HTTP 401 error. Handlers should
return None instead for tokens they do not recognize at all, so that
other registered handlers get a chance to verify them.
"""
def __init__(self, message="Invalid token"):
self.message = message
super().__init__(message)
@dataclasses.dataclass @dataclasses.dataclass
class TokenRestrictions: class TokenRestrictions:
""" """
@ -50,24 +35,24 @@ class TokenRestrictions:
database: dict[str, list[str]] = dataclasses.field(default_factory=dict) database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict) resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
def allow_all(self, action: str) -> TokenRestrictions: def allow_all(self, action: str) -> "TokenRestrictions":
"""Allow an action across all databases and resources.""" """Allow an action across all databases and resources."""
self.all.append(action) self.all.append(action)
return self return self
def allow_database(self, database: str, action: str) -> TokenRestrictions: def allow_database(self, database: str, action: str) -> "TokenRestrictions":
"""Allow an action on a specific database.""" """Allow an action on a specific database."""
self.database.setdefault(database, []).append(action) self.database.setdefault(database, []).append(action)
return self return self
def allow_resource( def allow_resource(
self, database: str, resource: str, action: str self, database: str, resource: str, action: str
) -> TokenRestrictions: ) -> "TokenRestrictions":
"""Allow an action on a specific resource within a database.""" """Allow an action on a specific resource within a database."""
self.resource.setdefault(database, {}).setdefault(resource, []).append(action) self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
return self return self
def abbreviated(self, datasette: Datasette) -> dict | None: def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
""" """
Return the abbreviated ``_r`` dictionary shape for this set of Return the abbreviated ``_r`` dictionary shape for this set of
restrictions, using action abbreviations registered with ``datasette``. restrictions, using action abbreviations registered with ``datasette``.
@ -112,23 +97,19 @@ class TokenHandler:
async def create_token( async def create_token(
self, self,
datasette: Datasette, datasette: "Datasette",
actor_id: str, actor_id: str,
*, *,
expires_after: int | None = None, expires_after: Optional[int] = None,
restrictions: TokenRestrictions | None = None, restrictions: Optional[TokenRestrictions] = None,
) -> str: ) -> str:
"""Create and return a token string for the given actor.""" """Create and return a token string for the given actor."""
raise NotImplementedError raise NotImplementedError
async def verify_token(self, datasette: Datasette, token: str) -> dict | None: async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
""" """
Verify a token and return an actor dict. Verify a token and return an actor dict, or None if this handler
does not recognize the token.
Return None if this handler does not recognize the token at all,
so other handlers can try it. Raise TokenInvalid if the token is
recognized but invalid (bad signature, malformed, expired) - the
request will fail with a 401 error.
""" """
raise NotImplementedError raise NotImplementedError
@ -142,11 +123,11 @@ class SignedTokenHandler(TokenHandler):
async def create_token( async def create_token(
self, self,
datasette: Datasette, datasette: "Datasette",
actor_id: str, actor_id: str,
*, *,
expires_after: int | None = None, expires_after: Optional[int] = None,
restrictions: TokenRestrictions | None = None, restrictions: Optional[TokenRestrictions] = None,
) -> str: ) -> str:
if not datasette.setting("allow_signed_tokens"): if not datasette.setting("allow_signed_tokens"):
raise ValueError( raise ValueError(
@ -163,35 +144,32 @@ class SignedTokenHandler(TokenHandler):
token["_r"] = abbreviated token["_r"] = abbreviated
return "dstok_{}".format(datasette.sign(token, namespace="token")) return "dstok_{}".format(datasette.sign(token, namespace="token"))
async def verify_token(self, datasette: Datasette, token: str) -> dict | None: async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
prefix = "dstok_" prefix = "dstok_"
if not token.startswith(prefix): if not datasette.setting("allow_signed_tokens"):
# Not one of our tokens - leave it for other handlers
return None return None
if not datasette.setting("allow_signed_tokens"):
raise TokenInvalid(
"Signed tokens are not enabled for this Datasette instance"
)
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl") max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
if not token.startswith(prefix):
return None
raw = token[len(prefix) :] raw = token[len(prefix) :]
try: try:
decoded = datasette.unsign(raw, namespace="token") decoded = datasette.unsign(raw, namespace="token")
except itsdangerous.BadSignature: except itsdangerous.BadSignature:
raise TokenInvalid("Invalid token signature") return None
if "t" not in decoded: if "t" not in decoded:
raise TokenInvalid("Invalid token: no timestamp") return None
created = decoded["t"] created = decoded["t"]
if not isinstance(created, int): if not isinstance(created, int):
raise TokenInvalid("Invalid token: invalid timestamp") return None
duration = decoded.get("d") duration = decoded.get("d")
if duration is not None and not isinstance(duration, int): if duration is not None and not isinstance(duration, int):
raise TokenInvalid("Invalid token: invalid duration") return None
if (duration is None and max_signed_tokens_ttl) or ( if (duration is None and max_signed_tokens_ttl) or (
duration is not None duration is not None
@ -200,8 +178,9 @@ class SignedTokenHandler(TokenHandler):
): ):
duration = max_signed_tokens_ttl duration = max_signed_tokens_ttl
if duration and time.time() - created > duration: if duration:
raise TokenInvalid("Token has expired") if time.time() - created > duration:
return None
actor = {"id": decoded["a"], "token": "dstok"} actor = {"id": decoded["a"], "token": "dstok"}

View file

@ -1,11 +1,10 @@
import asyncio import asyncio
import json
import time
import traceback
from contextlib import contextmanager from contextlib import contextmanager
from contextvars import ContextVar from contextvars import ContextVar
from markupsafe import escape from markupsafe import escape
import time
import json
import traceback
tracers = {} tracers = {}
@ -133,17 +132,17 @@ class AsgiTracer:
"num_traces": len(traces), "num_traces": len(traces),
"traces": traces, "traces": traces,
} }
content_type = next( try:
( content_type = [
v.decode("utf8") v.decode("utf8")
for k, v in response_headers for k, v in response_headers
if k.lower() == b"content-type" if k.lower() == b"content-type"
), ][0]
"", except IndexError:
) content_type = ""
if "text/html" in content_type and b"</body>" in accumulated_body: if "text/html" in content_type and b"</body>" in accumulated_body:
extra = escape(json.dumps(trace_info, indent=2)) extra = escape(json.dumps(trace_info, indent=2))
extra_html = f"<pre>{extra}</pre></body>".encode() extra_html = f"<pre>{extra}</pre></body>".encode("utf8")
accumulated_body = accumulated_body.replace(b"</body>", extra_html) accumulated_body = accumulated_body.replace(b"</body>", extra_html)
elif "json" in content_type and accumulated_body.startswith(b"{"): elif "json" in content_type and accumulated_body.startswith(b"{"):
data = json.loads(accumulated_body.decode("utf8")) data = json.loads(accumulated_body.decode("utf8"))

View file

@ -1,7 +1,6 @@
from .utils import tilde_encode, path_with_format, PrefixedUrlString
import urllib import urllib
from .utils import PrefixedUrlString, path_with_format, tilde_encode
class Urls: class Urls:
def __init__(self, ds): def __init__(self, ds):
@ -9,7 +8,8 @@ class Urls:
def path(self, path, format=None): def path(self, path, format=None):
if not isinstance(path, PrefixedUrlString): if not isinstance(path, PrefixedUrlString):
path = path.removeprefix("/") if path.startswith("/"):
path = path[1:]
path = self.ds.setting("base_url") + path path = self.ds.setting("base_url") + path
if format is not None: if format is not None:
path = path_with_format(path=path, format=format) path = path_with_format(path=path, format=format)
@ -56,7 +56,6 @@ class Urls:
return PrefixedUrlString(path) return PrefixedUrlString(path)
def row_blob(self, database, table, row_path, column): def row_blob(self, database, table, row_path, column):
return ( return self.table(database, table) + "/{}.blob?_blob_column={}".format(
self.table(database, table) row_path, urllib.parse.quote_plus(column)
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
) )

View file

@ -1,31 +1,28 @@
import asyncio import asyncio
import base64 from contextlib import contextmanager
import binascii import aiofiles
import click
from collections import OrderedDict, namedtuple, Counter
import copy import copy
import dataclasses import dataclasses
import base64
import hashlib import hashlib
import inspect import inspect
import json import json
import os
import re
import secrets
import shlex
import shutil
import tempfile
import time
import types
import typing
import urllib
from collections import Counter, OrderedDict, namedtuple
from collections.abc import Iterable
from contextlib import contextmanager
import aiofiles
import click
import markupsafe import markupsafe
import mergedeep import mergedeep
import os
import re
import shlex
import tempfile
import typing
import time
import types
import secrets
import shutil
from typing import Iterable, List, Tuple
import urllib
import yaml import yaml
from .shutil_backport import copytree from .shutil_backport import copytree
from .sqlite import sqlite3, supports_table_xinfo from .sqlite import sqlite3, supports_table_xinfo
@ -38,7 +35,7 @@ if typing.TYPE_CHECKING:
class PaginatedResources: class PaginatedResources:
"""Paginated results from allowed_resources query.""" """Paginated results from allowed_resources query."""
resources: list["Resource"] resources: List["Resource"]
next: str | None # Keyset token for next page (None if no more results) next: str | None # Keyset token for next page (None if no more results)
_datasette: typing.Any = dataclasses.field(default=None, repr=False) _datasette: typing.Any = dataclasses.field(default=None, repr=False)
_action: str = dataclasses.field(default=None, repr=False) _action: str = dataclasses.field(default=None, repr=False)
@ -85,132 +82,22 @@ class PaginatedResources:
# From https://www.sqlite.org/lang_keywords.html # From https://www.sqlite.org/lang_keywords.html
reserved_words = { reserved_words = set(
"abort", (
"action", "abort action add after all alter analyze and as asc attach autoincrement "
"add", "before begin between by cascade case cast check collate column commit "
"after", "conflict constraint create cross current_date current_time "
"all", "current_timestamp database default deferrable deferred delete desc detach "
"alter", "distinct drop each else end escape except exclusive exists explain fail "
"analyze", "for foreign from full glob group having if ignore immediate in index "
"and", "indexed initially inner insert instead intersect into is isnull join key "
"as", "left like limit match natural no not notnull null of offset on or order "
"asc", "outer plan pragma primary query raise recursive references regexp reindex "
"attach", "release rename replace restrict right rollback row savepoint select set "
"autoincrement", "table temp temporary then to transaction trigger union unique update using "
"before", "vacuum values view virtual when where with without"
"begin", ).split()
"between", )
"by",
"cascade",
"case",
"cast",
"check",
"collate",
"column",
"commit",
"conflict",
"constraint",
"create",
"cross",
"current_date",
"current_time",
"current_timestamp",
"database",
"default",
"deferrable",
"deferred",
"delete",
"desc",
"detach",
"distinct",
"drop",
"each",
"else",
"end",
"escape",
"except",
"exclusive",
"exists",
"explain",
"fail",
"for",
"foreign",
"from",
"full",
"glob",
"group",
"having",
"if",
"ignore",
"immediate",
"in",
"index",
"indexed",
"initially",
"inner",
"insert",
"instead",
"intersect",
"into",
"is",
"isnull",
"join",
"key",
"left",
"like",
"limit",
"match",
"natural",
"no",
"not",
"notnull",
"null",
"of",
"offset",
"on",
"or",
"order",
"outer",
"plan",
"pragma",
"primary",
"query",
"raise",
"recursive",
"references",
"regexp",
"reindex",
"release",
"rename",
"replace",
"restrict",
"right",
"rollback",
"row",
"savepoint",
"select",
"set",
"table",
"temp",
"temporary",
"then",
"to",
"transaction",
"trigger",
"union",
"unique",
"update",
"using",
"vacuum",
"values",
"view",
"virtual",
"when",
"where",
"with",
"without",
}
APT_GET_DOCKERFILE_EXTRAS = r""" APT_GET_DOCKERFILE_EXTRAS = r"""
RUN apt-get update && \ RUN apt-get update && \
@ -270,7 +157,7 @@ functions_marked_as_documented = []
def documented(fn=None, *, label=None): def documented(fn=None, *, label=None):
def decorate(fn): def decorate(fn):
fn._datasette_docs_label = label or f"internals_utils_{fn.__name__}" fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
functions_marked_as_documented.append(fn) functions_marked_as_documented.append(fn)
return fn return fn
@ -337,71 +224,24 @@ def compound_keys_after_sql(pks, start_index=0):
return "({})".format("\n or\n".join(or_clauses)) return "({})".format("\n or\n".join(or_clauses))
@documented
class CustomJSONEncoder(json.JSONEncoder): class CustomJSONEncoder(json.JSONEncoder):
"""
The CustomJSONEncoder class handles serialization for objects commonly used by Datasette,
including SQLite cursors and binary blobs. Datasette uses it internally to serve .json endpoints,
and plugins that return JSON can use it to match Datasette's own handling.
Built-in types (text, numbers, lists, etc) are encoded the same as Python's built-in ``json`` module.
- ``sqlite3.Row`` becomes a tuple
- ``sqlite3.Cursor`` becomes a list
Binary blobs are encoded as an object, with the actual data base64-encoded,
like so: ::
{
"$base64": True,
"encoded": ...,
}
Example: https://latest.datasette.io/fixtures/binary_data.json
"""
def default(self, obj): def default(self, obj):
if isinstance(obj, sqlite3.Row): if isinstance(obj, sqlite3.Row):
return tuple(obj) return tuple(obj)
if isinstance(obj, sqlite3.Cursor): if isinstance(obj, sqlite3.Cursor):
return list(obj) return list(obj)
if isinstance(obj, bytes): if isinstance(obj, bytes):
return { # Does it encode to utf8?
"$base64": True, try:
"encoded": base64.b64encode(obj).decode("latin1"), return obj.decode("utf8")
} except UnicodeDecodeError:
return {
"$base64": True,
"encoded": base64.b64encode(obj).decode("latin1"),
}
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
class WriteJsonValueError(ValueError):
pass
def decode_write_json_cell(value):
if not isinstance(value, dict):
return value
keys = set(value.keys())
if keys == {"$raw"}:
return value["$raw"]
if keys == {"$base64", "encoded"} and value.get("$base64") is True:
encoded = value["encoded"]
if not isinstance(encoded, str):
raise WriteJsonValueError("$base64 encoded value must be a string")
try:
return base64.b64decode(encoded, validate=True)
except binascii.Error as ex:
raise WriteJsonValueError("Invalid $base64 encoded value") from ex
return value
def decode_write_json_row(row):
return {key: decode_write_json_cell(value) for key, value in row.items()}
def decode_write_json_rows(rows):
return [decode_write_json_row(row) for row in rows]
@contextmanager @contextmanager
def sqlite_timelimit(conn, ms): def sqlite_timelimit(conn, ms):
deadline = time.perf_counter() + (ms / 1000) deadline = time.perf_counter() + (ms / 1000)
@ -472,7 +312,7 @@ disallawed_sql_res = [
( (
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"), re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format( "Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas) ", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
), ),
) )
] ]
@ -570,7 +410,12 @@ def escape_css_string(s):
def escape_sqlite(s): def escape_sqlite(s):
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words): if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
return s return s
return '"{}"'.format(s.replace('"', '""')) elif "]" in s:
# SQLite does not support escaping ] inside [bracket] quoting, so fall
# back to double-quote quoting (doubling any embedded ") - #2677
return '"{}"'.format(s.replace('"', '""'))
else:
return f"[{s}]"
def make_dockerfile( def make_dockerfile(
@ -646,7 +491,10 @@ CMD {cmd}""".format(
else "" else ""
), ),
environment_variables="\n".join( environment_variables="\n".join(
[f"ENV {key} '{value}'" for key, value in environment_variables.items()] [
"ENV {} '{}'".format(key, value)
for key, value in environment_variables.items()
]
), ),
install_from=" ".join(install), install_from=" ".join(install),
files=" ".join(files), files=" ".join(files),
@ -745,11 +593,11 @@ def detect_primary_keys(conn, table):
def get_outbound_foreign_keys(conn, table): def get_outbound_foreign_keys(conn, table):
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall() infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
fks = [] fks = []
for info in infos: for info in infos:
if info is not None: if info is not None:
id, seq, table_name, from_, to_, _on_update, _on_delete, _match = info id, seq, table_name, from_, to_, on_update, on_delete, match = info
fks.append( fks.append(
{ {
"column": from_, "column": from_,
@ -850,7 +698,7 @@ def detect_json1(conn=None):
try: try:
conn.execute("SELECT json('{}')") conn.execute("SELECT json('{}')")
return True return True
except sqlite3.Error: except Exception:
return False return False
finally: finally:
if close_conn: if close_conn:
@ -930,7 +778,9 @@ def is_url(value):
if not value.startswith("http://") and not value.startswith("https://"): if not value.startswith("http://") and not value.startswith("https://"):
return False return False
# Any whitespace at all is invalid # Any whitespace at all is invalid
return not whitespace_re.search(value) if whitespace_re.search(value):
return False
return True
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$") css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
@ -983,9 +833,7 @@ def module_from_path(path, name):
mod.__file__ = path mod.__file__ = path
with open(path, "r") as file: with open(path, "r") as file:
code = compile(file.read(), path, "exec", dont_inherit=True) code = compile(file.read(), path, "exec", dont_inherit=True)
# Executing the file is the whole point - this is how --plugins-dir loads exec(code, mod.__dict__)
# plugins and how metadata/config .py files are evaluated
exec(code, mod.__dict__) # noqa: S102
return mod return mod
@ -1142,7 +990,9 @@ def escape_fts(query):
query += '"' query += '"'
bits = _escape_fts_re.split(query) bits = _escape_fts_re.split(query)
bits = [b for b in bits if b and b != '""'] bits = [b for b in bits if b and b != '""']
return " ".join(f'"{bit}"' if not bit.startswith('"') else bit for bit in bits) return " ".join(
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
)
class MultiParams: class MultiParams:
@ -1154,7 +1004,7 @@ class MultiParams:
data[key], (list, tuple) data[key], (list, tuple)
), "dictionary data should be a dictionary of key => [list]" ), "dictionary data should be a dictionary of key => [list]"
self._data = data self._data = data
elif isinstance(data, (list, tuple)): elif isinstance(data, list) or isinstance(data, tuple):
new_data = {} new_data = {}
for item in data: for item in data:
assert ( assert (
@ -1244,7 +1094,9 @@ def _gather_arguments(fn, kwargs):
for parameter in parameters: for parameter in parameters:
if parameter not in kwargs: if parameter not in kwargs:
raise TypeError( raise TypeError(
f"{fn} requires parameters {tuple(parameters)}, missing: {set(parameters) - set(kwargs.keys())}" "{} requires parameters {}, missing: {}".format(
fn, tuple(parameters), set(parameters) - set(kwargs.keys())
)
) )
call_with.append(kwargs[parameter]) call_with.append(kwargs[parameter])
return call_with return call_with
@ -1313,9 +1165,9 @@ def resolve_env_secrets(config, environ):
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ""" """Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
if isinstance(config, dict): if isinstance(config, dict):
if list(config.keys()) == ["$env"]: if list(config.keys()) == ["$env"]:
return environ.get(next(iter(config.values()))) return environ.get(list(config.values())[0])
elif list(config.keys()) == ["$file"]: elif list(config.keys()) == ["$file"]:
with open(next(iter(config.values()))) as fp: with open(list(config.values())[0]) as fp:
return fp.read() return fp.read()
else: else:
return { return {
@ -1393,38 +1245,29 @@ class StartupError(Exception):
pass pass
# Comments and string literals, matched in a single pass so that whichever _single_line_comment_re = re.compile(r"--.*")
# construct starts first "wins" - this ensures a comment marker inside a string _multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
# literal (or a quote inside a comment) does not confuse the parameter scan. _single_quote_re = re.compile(r"'(?:''|[^'])*'")
_comments_and_strings_re = re.compile( _double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
r"""
--[^\n]* # single line comment
| /\*.*?(?:\*/|\Z) # multi line comment, possibly to end-of-input
| '(?:''|[^'])*' # single quoted string ('' escapes a quote)
| "(?:""|[^"])*" # double quoted identifier ("" escapes a quote)
| \[(?:[^\]])*\] # square-bracket quoted identifier
| `(?:``|[^`])*` # backtick quoted identifier
""",
re.DOTALL | re.VERBOSE,
)
_named_param_re = re.compile(r":(\w+)") _named_param_re = re.compile(r":(\w+)")
@documented @documented
def named_parameters(sql: str) -> list[str]: def named_parameters(sql: str) -> List[str]:
""" """
Given a SQL statement, return a list of named parameters that are used in the statement Given a SQL statement, return a list of named parameters that are used in the statement
e.g. for ``select * from foo where id=:id`` this would return ``["id"]`` e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
""" """
# Strip comments and string literals first so that any ":name" sequences sql = _single_line_comment_re.sub("", sql)
# inside them are not mistaken for named parameters sql = _multi_line_comment_re.sub("", sql)
sql = _comments_and_strings_re.sub("", sql) sql = _single_quote_re.sub("", sql)
sql = _double_quote_re.sub("", sql)
# Extract parameters from what is left # Extract parameters from what is left
return _named_param_re.findall(sql) return _named_param_re.findall(sql)
async def derive_named_parameters(db: "Database", sql: str) -> list[str]: async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
""" """
This undocumented but stable method exists for backwards compatibility This undocumented but stable method exists for backwards compatibility
with plugins that were using it before it switched to named_parameters() with plugins that were using it before it switched to named_parameters()
@ -1432,54 +1275,6 @@ async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
return named_parameters(sql) return named_parameters(sql)
def parse_size_limit(value, default, maximum, name="_size"):
"""
Parse a page-size parameter using the same semantics as the table
view's ?_size=: blank means default, "max" means maximum, integers
must be 0 or greater and no larger than maximum. Raises ValueError
with a message suitable for a 400 response.
"""
if value in (None, ""):
return default
if value == "max":
return maximum
try:
size = int(value)
if size < 0:
raise ValueError
except ValueError:
raise ValueError(f"{name} must be a positive integer")
if size > maximum:
raise ValueError(f"{name} must be <= {maximum}")
return size
UNSTABLE_API_MESSAGE = (
"This API is not part of Datasette's stable interface and may change at any time"
)
def error_body(messages, status):
"""
The canonical JSON error body used by every Datasette JSON error response:
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
"error" is all of the messages joined with "; ", "errors" is the full
list, "status" matches the HTTP status code. Callers may add extra
context keys to the returned dictionary but must not remove these four.
"""
if isinstance(messages, str):
messages = [messages]
messages = [str(message) for message in messages]
return {
"ok": False,
"error": "; ".join(messages),
"errors": messages,
"status": status,
}
def add_cors_headers(headers): def add_cors_headers(headers):
headers["Access-Control-Allow-Origin"] = "*" headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type" headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
@ -1508,7 +1303,7 @@ class TildeEncoder(dict):
elif b == _space: elif b == _space:
res = "+" res = "+"
else: else:
res = f"~{b:02X}" res = "~{:02X}".format(b)
self[b] = res self[b] = res
return res return res
@ -1603,7 +1398,7 @@ def _combine(base: dict, update: dict) -> dict:
return base return base
def pairs_to_nested_config(pairs: list[tuple[str, typing.Any]]) -> dict: def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
""" """
Parse a list of key-value pairs into a nested dictionary. Parse a list of key-value pairs into a nested dictionary.
""" """
@ -1618,7 +1413,7 @@ def make_slot_function(name, datasette, request, **kwargs):
from datasette.plugins import pm from datasette.plugins import pm
method = getattr(pm.hook, name, None) method = getattr(pm.hook, name, None)
assert method is not None, f"No hook found for {name}" assert method is not None, "No hook found for {}".format(name)
async def inner(): async def inner():
html_bits = [] html_bits = []
@ -1642,7 +1437,7 @@ def prune_empty_dicts(d: dict):
d.pop(key, None) d.pop(key, None)
def move_plugins_and_allow(source: dict, destination: dict) -> tuple[dict, dict]: def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
""" """
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
hierarchy in destination if needed. After moving, recursively remove any keys hierarchy in destination if needed. After moving, recursively remove any keys
@ -1753,17 +1548,6 @@ def md5_not_usedforsecurity(s):
_etag_cache = {} _etag_cache = {}
def sha256_file(filepath, chunk_size=4096):
hasher = hashlib.sha256()
with open(filepath, "rb") as fp:
while True:
chunk = fp.read(chunk_size)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
async def calculate_etag(filepath, chunk_size=4096): async def calculate_etag(filepath, chunk_size=4096):
if filepath in _etag_cache: if filepath in _etag_cache:
return _etag_cache[filepath] return _etag_cache[filepath]

View file

@ -252,62 +252,88 @@ async def _build_single_action_sql(
] ]
) )
# Continue with the cascading logic. # Continue with the cascading logic
# Aggregate the RULES by cascade level (small), rather than grouping
# base x rules (which scales with the number of resources).
def _agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
f" FROM all_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend( query_parts.extend(
["child_agg AS ("] [
+ _agg( "child_lvl AS (",
"parent, child,", " SELECT b.parent, b.child,",
"parent IS NOT NULL AND child IS NOT NULL", " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
"parent, child", " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
) " json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
+ ["),", "parent_agg AS ("] " json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent") " FROM base b",
+ ["),", "global_agg AS ("] " LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
+ _agg("", "parent IS NULL AND child IS NULL", None) " GROUP BY b.parent, b.child",
+ ["),"] "),",
"parent_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
" FROM base b",
" LEFT JOIN all_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
]
) )
# Add anonymous decision logic if needed # Add anonymous decision logic if needed
if include_is_private: if include_is_private:
def _anon_agg(select_key, where, group_by):
parts = [
f" SELECT {select_key}",
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
f" FROM anon_rules WHERE {where}",
]
if group_by:
parts.append(f" GROUP BY {group_by}")
return parts
query_parts.extend( query_parts.extend(
["anon_child_agg AS ("] [
+ _anon_agg( "anon_child_lvl AS (",
"parent, child,", " SELECT b.parent, b.child,",
"parent IS NOT NULL AND child IS NOT NULL", " MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
"parent, child", " MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
) " FROM base b",
+ ["),", "anon_parent_agg AS ("] " LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child = b.child",
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent") " GROUP BY b.parent, b.child",
+ ["),", "anon_global_agg AS ("] "),",
+ _anon_agg("", "parent IS NULL AND child IS NULL", None) "anon_parent_lvl AS (",
+ ["),"] " SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent = b.parent AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_global_lvl AS (",
" SELECT b.parent, b.child,",
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
" FROM base b",
" LEFT JOIN anon_rules ar ON ar.parent IS NULL AND ar.child IS NULL",
" GROUP BY b.parent, b.child",
"),",
"anon_decisions AS (",
" SELECT",
" b.parent, b.child,",
" CASE",
" WHEN acl.any_deny = 1 THEN 0",
" WHEN acl.any_allow = 1 THEN 1",
" WHEN apl.any_deny = 1 THEN 0",
" WHEN apl.any_allow = 1 THEN 1",
" WHEN agl.any_deny = 1 THEN 0",
" WHEN agl.any_allow = 1 THEN 1",
" ELSE 0",
" END AS anon_is_allowed",
" FROM base b",
" JOIN anon_child_lvl acl ON b.parent = acl.parent AND (b.child = acl.child OR (b.child IS NULL AND acl.child IS NULL))",
" JOIN anon_parent_lvl apl ON b.parent = apl.parent AND (b.child = apl.child OR (b.child IS NULL AND apl.child IS NULL))",
" JOIN anon_global_lvl agl ON b.parent = agl.parent AND (b.child = agl.child OR (b.child IS NULL AND agl.child IS NULL))",
"),",
]
) )
# Final decisions # Final decisions
@ -316,28 +342,31 @@ async def _build_single_action_sql(
"decisions AS (", "decisions AS (",
" SELECT", " SELECT",
" b.parent, b.child,", " b.parent, b.child,",
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level", " -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
" -- Priority order:", " -- Priority order:",
" -- 1. Child-level deny 2. Child-level allow", " -- 1. Child-level deny (most specific, blocks access)",
" -- 3. Parent-level deny 4. Parent-level allow", " -- 2. Child-level allow (most specific, grants access)",
" -- 5. Global-level deny 6. Global-level allow", " -- 3. Parent-level deny (intermediate, blocks access)",
" -- 4. Parent-level allow (intermediate, grants access)",
" -- 5. Global-level deny (least specific, blocks access)",
" -- 6. Global-level allow (least specific, grants access)",
" -- 7. Default deny (no rules match)", " -- 7. Default deny (no rules match)",
" CASE", " CASE",
" WHEN ca.any_deny = 1 THEN 0", " WHEN cl.any_deny = 1 THEN 0",
" WHEN ca.any_allow = 1 THEN 1", " WHEN cl.any_allow = 1 THEN 1",
" WHEN pa.any_deny = 1 THEN 0", " WHEN pl.any_deny = 1 THEN 0",
" WHEN pa.any_allow = 1 THEN 1", " WHEN pl.any_allow = 1 THEN 1",
" WHEN ga.any_deny = 1 THEN 0", " WHEN gl.any_deny = 1 THEN 0",
" WHEN ga.any_allow = 1 THEN 1", " WHEN gl.any_allow = 1 THEN 1",
" ELSE 0", " ELSE 0",
" END AS is_allowed,", " END AS is_allowed,",
" CASE", " CASE",
" WHEN ca.any_deny = 1 THEN ca.deny_reasons", " WHEN cl.any_deny = 1 THEN cl.deny_reasons",
" WHEN ca.any_allow = 1 THEN ca.allow_reasons", " WHEN cl.any_allow = 1 THEN cl.allow_reasons",
" WHEN pa.any_deny = 1 THEN pa.deny_reasons", " WHEN pl.any_deny = 1 THEN pl.deny_reasons",
" WHEN pa.any_allow = 1 THEN pa.allow_reasons", " WHEN pl.any_allow = 1 THEN pl.allow_reasons",
" WHEN ga.any_deny = 1 THEN ga.deny_reasons", " WHEN gl.any_deny = 1 THEN gl.deny_reasons",
" WHEN ga.any_allow = 1 THEN ga.allow_reasons", " WHEN gl.any_allow = 1 THEN gl.allow_reasons",
" ELSE '[]'", " ELSE '[]'",
" END AS reason", " END AS reason",
] ]
@ -345,34 +374,21 @@ async def _build_single_action_sql(
if include_is_private: if include_is_private:
query_parts.append( query_parts.append(
" , CASE WHEN (" " , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
"CASE"
" WHEN aca.any_deny = 1 THEN 0"
" WHEN aca.any_allow = 1 THEN 1"
" WHEN apa.any_deny = 1 THEN 0"
" WHEN apa.any_allow = 1 THEN 1"
" WHEN aga.any_deny = 1 THEN 0"
" WHEN aga.any_allow = 1 THEN 1"
" ELSE 0 END"
") = 0 THEN 1 ELSE 0 END AS is_private"
) )
query_parts.extend( query_parts.extend(
[ [
" FROM base b", " FROM base b",
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child", " JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
" LEFT JOIN parent_agg pa ON pa.parent = b.parent", " JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
" CROSS JOIN global_agg ga", " JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
] ]
) )
if include_is_private: if include_is_private:
query_parts.extend( query_parts.append(
[ " JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
" CROSS JOIN anon_global_agg aga",
]
) )
query_parts.append(")") query_parts.append(")")
@ -384,28 +400,8 @@ async def _build_single_action_sql(
restriction_intersect = "\nINTERSECT\n".join( restriction_intersect = "\nINTERSECT\n".join(
f"SELECT * FROM ({sql})" for sql in restriction_sqls f"SELECT * FROM ({sql})" for sql in restriction_sqls
) )
# Decompose by NULL-pattern so the final filter can use pure-equality
# EXISTS lookups (satisfiable via automatic indexes) instead of a
# correlated OR-scan over the whole list.
query_parts.extend( query_parts.extend(
[ [",", "restriction_list AS (", f" {restriction_intersect}", ")"]
",",
"restriction_list AS (",
f" {restriction_intersect}",
"),",
"restriction_exact AS (",
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
"),",
"restriction_parent_any AS (",
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
"),",
"restriction_child_any AS (",
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
"),",
"restriction_all AS (",
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
")",
]
) )
# Final SELECT # Final SELECT
@ -420,11 +416,10 @@ async def _build_single_action_sql(
# Add restriction filter if there are restrictions # Add restriction filter if there are restrictions
if restriction_sqls: if restriction_sqls:
query_parts.append(""" query_parts.append("""
AND ( AND EXISTS (
EXISTS (SELECT 1 FROM restriction_all) SELECT 1 FROM restriction_list r
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent) WHERE (r.parent = decisions.parent OR r.parent IS NULL)
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child) AND (r.child = decisions.child OR r.child IS NULL)
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
)""") )""")
# Add parent filter if specified # Add parent filter if specified
@ -678,239 +673,3 @@ async def check_permission_for_resource(
child=child, child=child,
) )
return results[action] return results[action]
async def explain_permission_for_resource(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Explain a permission decision for one action and resource.
This is intended for Datasette's permission debugging tools. It uses the
same ``permission_resources_sql`` hook results and the same resolution
rules as :func:`check_permissions_for_actions`, but also returns the
matching rules, actor restriction results and ``also_requires`` chain.
The returned dictionary is part of Datasette's unstable debugging API.
"""
action_obj = datasette.actions.get(action)
if action_obj is None:
raise ValueError(f"Unknown action: {action}")
explanation = await _explain_single_action(
datasette=datasette,
actor=actor,
action=action,
parent=parent,
child=child,
)
required_actions = []
if action_obj.also_requires:
required = await explain_permission_for_resource(
datasette=datasette,
actor=actor,
action=action_obj.also_requires,
parent=parent,
child=child,
)
required_actions.append(required)
explanation["required_actions"] = required_actions
explanation["allowed"] = bool(
explanation["rule_allowed"]
and explanation["restriction_allowed"]
and all(required["allowed"] for required in required_actions)
)
explanation["summary"] = _permission_explanation_summary(explanation)
return explanation
async def _explain_single_action(
*,
datasette: "Datasette",
actor: dict | None,
action: str,
parent: str | None,
child: str | None,
) -> dict:
"""Return matching rules and restrictions for a single action."""
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
permission_sqls = await gather_permission_sql_from_hooks(
datasette=datasette,
actor=actor,
action=action,
)
if permission_sqls is SKIP_PERMISSION_CHECKS:
return {
"action": action,
"rule_allowed": True,
"restriction_allowed": True,
"winning_scope": "global",
"matched_rules": [
{
"scope": "global",
"effect": "allow",
"source": "skip_permission_checks",
"reason": "Permission checks were explicitly skipped",
"decisive": True,
"ignored_because": None,
}
],
"restrictions": [],
}
db = datasette.get_internal_database()
matched_rules = []
restrictions = []
for permission_sql in permission_sqls:
params = dict(permission_sql.params or {})
parent_param = _unused_parameter_name(params, "_explain_parent")
params[parent_param] = parent
child_param = _unused_parameter_name(params, "_explain_child")
params[child_param] = child
if permission_sql.sql:
rows = await db.execute(
f"""
SELECT parent, child, allow, reason
FROM ({permission_sql.sql}) AS permission_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child = :{child_param})
""",
params,
)
for row in rows:
specificity = (
2
if row["child"] is not None
else 1 if row["parent"] is not None else 0
)
matched_rules.append(
{
"scope": ("resource", "parent", "global")[2 - specificity],
"effect": "allow" if row["allow"] else "deny",
"source": permission_sql.source,
"reason": row["reason"],
"_specificity": specificity,
}
)
if permission_sql.restriction_sql:
restriction_row = (
await db.execute(
f"""
SELECT EXISTS(
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
WHERE (parent IS NULL OR parent = :{parent_param})
AND (child IS NULL OR child = :{child_param})
) AS resource_is_in_allowlist
""",
params,
)
).first()
restriction_allowed = bool(restriction_row[0])
restrictions.append(
{
"source": permission_sql.source,
"allowed": restriction_allowed,
"reason": params.get("deny")
or (
"Resource is included in this restriction allowlist"
if restriction_allowed
else "Resource is not included in this restriction allowlist"
),
}
)
matched_rules.sort(
key=lambda rule: (
-rule["_specificity"],
0 if rule["effect"] == "deny" else 1,
rule["source"] or "",
rule["reason"] or "",
)
)
if matched_rules:
winning_specificity = matched_rules[0]["_specificity"]
winning_rules = [
rule
for rule in matched_rules
if rule["_specificity"] == winning_specificity
]
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
winning_scope = winning_rules[0]["scope"]
else:
winning_specificity = None
rule_allowed = False
winning_scope = None
for rule in matched_rules:
specificity = rule.pop("_specificity")
if specificity != winning_specificity:
rule["decisive"] = False
rule["ignored_because"] = "A more specific rule matched"
elif not rule_allowed and rule["effect"] == "allow":
rule["decisive"] = False
rule["ignored_because"] = "A deny rule matched at the same scope"
else:
rule["decisive"] = True
rule["ignored_because"] = None
return {
"action": action,
"rule_allowed": rule_allowed,
"restriction_allowed": all(
restriction["allowed"] for restriction in restrictions
),
"winning_scope": winning_scope,
"matched_rules": matched_rules,
"restrictions": restrictions,
}
def _unused_parameter_name(params: dict, preferred: str) -> str:
"""Return a SQL parameter name that is not already in ``params``."""
candidate = preferred
suffix = 2
while candidate in params:
candidate = f"{preferred}_{suffix}"
suffix += 1
return candidate
def _permission_explanation_summary(explanation: dict) -> str:
denied_requirement = next(
(
required
for required in explanation["required_actions"]
if not required["allowed"]
),
None,
)
if denied_requirement:
return (
f"Denied because {explanation['action']} also requires "
f"{denied_requirement['action']}, which was denied."
)
if not explanation["matched_rules"]:
return "Denied because no permission rule matched this actor and resource."
if not explanation["rule_allowed"]:
return (
f"Denied by a {explanation['winning_scope']}-level rule. "
"Deny rules take precedence over allow rules at the same scope."
)
if not explanation["restriction_allowed"]:
return (
"Denied because the resource is not included in the actor's restrictions."
)
return f"Allowed by the matching {explanation['winning_scope']}-level rule."

View file

@ -1,29 +1,28 @@
import json import json
import re from typing import Optional
from http.cookies import Morsel, SimpleCookie from datasette.utils import MultiParams, calculate_etag
from mimetypes import guess_type
from pathlib import Path
from urllib.parse import parse_qs, parse_qsl, urlunparse
import aiofiles
import aiofiles.os
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
from datasette.utils.multipart import ( from datasette.utils.multipart import (
DEFAULT_MAX_FIELD_SIZE, parse_form_data,
DEFAULT_MAX_FIELDS, MultipartParseError,
FormData,
DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MAX_FIELDS,
DEFAULT_MAX_FILES, DEFAULT_MAX_FILES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_FIELD_SIZE,
DEFAULT_MAX_MEMORY_FILE_SIZE, DEFAULT_MAX_MEMORY_FILE_SIZE,
DEFAULT_MAX_PART_HEADER_BYTES, DEFAULT_MAX_PART_HEADER_BYTES,
DEFAULT_MAX_PART_HEADER_LINES, DEFAULT_MAX_PART_HEADER_LINES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MIN_FREE_DISK_BYTES, DEFAULT_MIN_FREE_DISK_BYTES,
FormData,
MultipartParseError,
parse_form_data,
) )
from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path
from http.cookies import SimpleCookie, Morsel
import aiofiles
import aiofiles.os
import re
# Workaround for adding samesite support to pre 3.8 python # Workaround for adding samesite support to pre 3.8 python
Morsel._reserved["samesite"] = "SameSite" Morsel._reserved["samesite"] = "SameSite"
@ -68,28 +67,16 @@ class BadRequest(Base400):
status = 400 status = 400
class PayloadTooLarge(Base400):
status = 413
SAMESITE_VALUES = ("strict", "lax", "none") SAMESITE_VALUES = ("strict", "lax", "none")
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
# while these bodies are held in RAM and json.loads() can multiply their
# footprint several times over.
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
class Request: class Request:
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES): def __init__(self, scope, receive):
self.scope = scope self.scope = scope
self.receive = receive self.receive = receive
self.max_post_body_bytes = max_post_body_bytes
def __repr__(self): def __repr__(self):
return f'<asgi.Request method="{self.method}" url="{self.url}">' return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
@property @property
def method(self): def method(self):
@ -154,43 +141,15 @@ class Request:
def actor(self): def actor(self):
return self.scope.get("actor", None) return self.scope.get("actor", None)
async def post_body(self, max_bytes=None): async def post_body(self):
""" body = b""
Read the request body fully into memory.
The body is capped at max_bytes - or self.max_post_body_bytes
(default 2MB, set from the max_post_body_bytes setting for requests
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
oversized bodies are rejected as soon as the limit is passed, without
buffering the rest.
"""
if max_bytes is None:
max_bytes = self.max_post_body_bytes
too_large = PayloadTooLarge(
f"Request body exceeded maximum size of {max_bytes} bytes"
)
if max_bytes:
# Reject early if the client declares an oversized body
try:
if int(self.headers.get("content-length", "")) > max_bytes:
raise too_large
except ValueError:
# Missing or malformed - the streaming check below still applies
pass
chunks = []
received = 0
more_body = True more_body = True
while more_body: while more_body:
message = await self.receive() message = await self.receive()
assert message["type"] == "http.request", message assert message["type"] == "http.request", message
chunk = message.get("body", b"") body += message.get("body", b"")
received += len(chunk)
if max_bytes and received > max_bytes:
raise too_large
chunks.append(chunk)
more_body = message.get("more_body", False) more_body = message.get("more_body", False)
return b"".join(chunks) return body
async def post_vars(self): async def post_vars(self):
body = await self.post_body() body = await self.post_body()
@ -207,7 +166,7 @@ class Request:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS, max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES, max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS, max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE, max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -438,9 +397,6 @@ async def asgi_send_file(
) )
HASHED_STATIC_CACHE_CONTROL = "max-age=31536000, immutable, public"
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None): def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
root_path = Path(root_path) root_path = Path(root_path)
static_headers = {} static_headers = {}
@ -467,17 +423,11 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
return return
try: try:
# Calculate ETag for filepath # Calculate ETag for filepath
hash_value = request.args.get("_hash")
if (
hash_value
and hash_value == sha256_file(full_path, chunk_size=chunk_size)[:12]
):
headers["Cache-Control"] = HASHED_STATIC_CACHE_CONTROL
etag = await calculate_etag(full_path, chunk_size=chunk_size) etag = await calculate_etag(full_path, chunk_size=chunk_size)
headers["ETag"] = etag headers["ETag"] = etag
if_none_match = request.headers.get("if-none-match") if_none_match = request.headers.get("if-none-match")
if if_none_match and if_none_match == etag: if if_none_match and if_none_match == etag:
return await asgi_send(send, "", 304, headers=headers) return await asgi_send(send, "", 304)
await asgi_send_file( await asgi_send_file(
send, full_path, chunk_size=chunk_size, headers=headers send, full_path, chunk_size=chunk_size, headers=headers
) )
@ -530,9 +480,9 @@ class Response:
httponly=False, httponly=False,
samesite="lax", samesite="lax",
): ):
assert ( assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
samesite in SAMESITE_VALUES SAMESITE_VALUES
), f"samesite should be one of {SAMESITE_VALUES}" )
cookie = SimpleCookie() cookie = SimpleCookie()
cookie[key] = value cookie[key] = value
for prop_name, prop_value in ( for prop_name, prop_value in (
@ -576,18 +526,6 @@ class Response:
content_type="application/json; charset=utf-8", content_type="application/json; charset=utf-8",
) )
@classmethod
def error(cls, messages, status=400, headers=None):
"""
A JSON error response using Datasette's standard error format.
messages can be a single string or a list of strings. For errors
that should content-negotiate between JSON and HTML, raise
Forbidden, NotFound, BadRequest or DatasetteError instead and let
Datasette's error handling hooks build the response.
"""
return cls.json(error_body(messages, status), status=status, headers=headers)
@classmethod @classmethod
def redirect(cls, path, status=302, headers=None): def redirect(cls, path, status=302, headers=None):
headers = headers or {} headers = headers or {}

View file

@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
""" """
class BaseConverter: class BaseConverter(object):
decimal_digits = "0123456789" decimal_digits = "0123456789"
def __init__(self, digits): def __init__(self, digits):

View file

@ -1,6 +1,6 @@
import inspect import inspect
import types import types
from typing import Any, NamedTuple from typing import NamedTuple, Any
class CallableStatus(NamedTuple): class CallableStatus(NamedTuple):
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
if isinstance(obj, types.FunctionType): if isinstance(obj, types.FunctionType):
return CallableStatus(True, inspect.iscoroutinefunction(obj)) return CallableStatus(True, inspect.iscoroutinefunction(obj))
if callable(obj): if hasattr(obj, "__call__"):
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__)) return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
assert False, f"obj {obj!r} is somehow callable with no __call__ method" assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))

View file

@ -1,30 +1,9 @@
import textwrap import textwrap
from datasette.utils import table_column_details
from sqlite_utils import Database as SQLiteUtilsDatabase
from sqlite_utils import Migrations
from datasette.utils import escape_sqlite, table_column_details async def init_internal_db(db):
create_tables_sql = textwrap.dedent("""
INTERNAL_DB_SCHEMA_TABLES = {
"catalog_databases",
"catalog_tables",
"catalog_views",
"catalog_columns",
"catalog_indexes",
"catalog_foreign_keys",
"metadata_instance",
"metadata_databases",
"metadata_resources",
"metadata_columns",
"column_types",
"queries",
}
INTERNAL_DB_SCHEMA_INDEXES = {
"queries_owner_idx",
}
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
CREATE TABLE IF NOT EXISTS catalog_databases ( CREATE TABLE IF NOT EXISTS catalog_databases (
database_name TEXT PRIMARY KEY, database_name TEXT PRIMARY KEY,
path TEXT, path TEXT,
@ -88,101 +67,99 @@ INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name), FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name) FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
); );
CREATE TABLE IF NOT EXISTS metadata_instance (
key text,
value text,
unique(key)
);
CREATE TABLE IF NOT EXISTS metadata_databases (
database_name text,
key text,
value text,
unique(database_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_resources (
database_name text,
resource_name text,
key text,
value text,
unique(database_name, resource_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_columns (
database_name text,
resource_name text,
column_name text,
key text,
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
""").strip() """).strip()
await db.execute_write_script(create_tables_sql)
await initialize_metadata_tables(db)
internal_migrations = Migrations("datasette_internal") async def initialize_metadata_tables(db):
await db.execute_write_script(textwrap.dedent("""
CREATE TABLE IF NOT EXISTS metadata_instance (
key text,
value text,
unique(key)
);
CREATE TABLE IF NOT EXISTS metadata_databases (
database_name text,
key text,
value text,
unique(database_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_resources (
database_name text,
resource_name text,
key text,
value text,
unique(database_name, resource_name, key)
);
CREATE TABLE IF NOT EXISTS metadata_columns (
database_name text,
resource_name text,
column_name text,
key text,
value text,
unique(database_name, resource_name, column_name, key)
);
CREATE TABLE IF NOT EXISTS column_types (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
column_name TEXT NOT NULL,
column_type TEXT NOT NULL,
config TEXT,
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,
sql TEXT NOT NULL,
title TEXT,
description TEXT,
description_html TEXT,
options TEXT NOT NULL DEFAULT '{}',
parameters TEXT NOT NULL DEFAULT '[]',
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
source TEXT NOT NULL DEFAULT 'user',
owner_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (database_name, name)
);
CREATE INDEX IF NOT EXISTS queries_owner_idx
ON queries(owner_id);
"""))
def _internal_schema_exists(db): async def populate_schema_tables(internal_db, db):
table_names = set(db.table_names())
if not INTERNAL_DB_SCHEMA_TABLES.issubset(table_names):
return False
index_names = {
row[0]
for row in db.execute("select name from sqlite_master where type = 'index'")
}
return INTERNAL_DB_SCHEMA_INDEXES.issubset(index_names)
@internal_migrations(name="0001_initial")
def initial_internal_schema(db):
if _internal_schema_exists(db):
return
db.executescript(INTERNAL_DB_SCHEMA_SQL)
async def init_internal_db(db):
def apply_migrations(conn):
internal_migrations.apply(SQLiteUtilsDatabase(conn, execute_plugins=False))
await db.execute_write_fn(apply_migrations, transaction=False)
async def populate_schema_tables(internal_db, db, schema_version):
database_name = db.name database_name = db.name
def delete_everything(conn):
conn.execute(
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
)
conn.execute(
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
[database_name],
)
conn.execute(
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
)
await internal_db.execute_write_fn(delete_everything)
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
@ -207,30 +184,25 @@ async def populate_schema_tables(internal_db, db, schema_version):
columns = table_column_details(conn, table_name) columns = table_column_details(conn, table_name)
columns_to_insert.extend( columns_to_insert.extend(
{ {
"database_name": database_name, **{"database_name": database_name, "table_name": table_name},
"table_name": table_name,
**column._asdict(), **column._asdict(),
} }
for column in columns for column in columns
) )
foreign_keys = conn.execute( foreign_keys = conn.execute(
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})" f"PRAGMA foreign_key_list([{table_name}])"
).fetchall() ).fetchall()
foreign_keys_to_insert.extend( foreign_keys_to_insert.extend(
{ {
"database_name": database_name, **{"database_name": database_name, "table_name": table_name},
"table_name": table_name,
**dict(foreign_key), **dict(foreign_key),
} }
for foreign_key in foreign_keys for foreign_key in foreign_keys
) )
indexes = conn.execute( indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
f"PRAGMA index_list({escape_sqlite(table_name)})"
).fetchall()
indexes_to_insert.extend( indexes_to_insert.extend(
{ {
"database_name": database_name, **{"database_name": database_name, "table_name": table_name},
"table_name": table_name,
**dict(index), **dict(index),
} }
for index in indexes for index in indexes
@ -251,76 +223,47 @@ async def populate_schema_tables(internal_db, db, schema_version):
indexes_to_insert, indexes_to_insert,
) = await db.execute_fn(collect_info) ) = await db.execute_fn(collect_info)
def replace_catalog(conn): await internal_db.execute_write_many(
# Delete child rows before their catalog_tables parents so this also """
# works if a prepare_connection plugin enables foreign key enforcement. INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
for table in ( values (?, ?, ?, ?)
"catalog_columns", """,
"catalog_foreign_keys", tables_to_insert,
"catalog_indexes", )
"catalog_views", await internal_db.execute_write_many(
"catalog_tables", """
): INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
conn.execute( values (?, ?, ?, ?)
f"DELETE FROM {table} WHERE database_name = ?", """,
[database_name], views_to_insert,
) )
conn.execute( await internal_db.execute_write_many(
""" """
INSERT OR REPLACE INTO catalog_databases ( INSERT INTO catalog_columns (
database_name, path, is_memory, schema_version database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (?, ?, ?, ?) ) VALUES (
""", :database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
[
database_name,
str(db.path) if db.path is not None else None,
db.is_memory,
schema_version,
],
) )
conn.executemany( """,
""" columns_to_insert,
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql) )
values (?, ?, ?, ?) await internal_db.execute_write_many(
""", """
tables_to_insert, INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
) )
conn.executemany( """,
""" foreign_keys_to_insert,
INSERT INTO catalog_views (database_name, view_name, rootpage, sql) )
values (?, ?, ?, ?) await internal_db.execute_write_many(
""", """
views_to_insert, INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
) )
conn.executemany( """,
""" indexes_to_insert,
INSERT INTO catalog_columns ( )
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
) VALUES (
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
)
""",
columns_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_foreign_keys (
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
) VALUES (
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
)
""",
foreign_keys_to_insert,
)
conn.executemany(
"""
INSERT INTO catalog_indexes (
database_name, table_name, seq, name, "unique", origin, partial
) VALUES (
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
)
""",
indexes_to_insert,
)
await internal_db.execute_write_fn(replace_catalog)

View file

@ -11,10 +11,15 @@ Supports:
import asyncio import asyncio
import shutil import shutil
import tempfile import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import ( from typing import (
Any, Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
) )
from urllib.parse import parse_qsl from urllib.parse import parse_qsl
@ -24,7 +29,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
DEFAULT_MAX_FIELDS = 1000 DEFAULT_MAX_FIELDS = 1000
DEFAULT_MAX_FILES = 100 DEFAULT_MAX_FILES = 100
# If max_parts is not specified, it defaults to max_fields + max_files # If max_parts is not specified, it defaults to max_fields + max_files
DEFAULT_MAX_PARTS: int | None = None DEFAULT_MAX_PARTS: Optional[int] = None
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
@ -35,6 +40,8 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
class MultipartParseError(Exception): class MultipartParseError(Exception):
"""Raised when multipart parsing fails.""" """Raised when multipart parsing fails."""
pass
@dataclass @dataclass
class UploadedFile: class UploadedFile:
@ -50,7 +57,7 @@ class UploadedFile:
name: str name: str
filename: str filename: str
content_type: str | None content_type: Optional[str]
size: int size: int
_file: tempfile.SpooledTemporaryFile = field(repr=False) _file: tempfile.SpooledTemporaryFile = field(repr=False)
@ -79,8 +86,7 @@ class UploadedFile:
def __del__(self): def __del__(self):
try: try:
self._file.close() self._file.close()
except Exception: # noqa: BLE001, S110 except Exception:
# __del__ must never raise
pass pass
@ -92,27 +98,27 @@ class FormData:
""" """
def __init__(self): def __init__(self):
self._data: list[tuple[str, str | UploadedFile]] = [] self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
def append(self, key: str, value: str | UploadedFile) -> None: def append(self, key: str, value: Union[str, UploadedFile]) -> None:
"""Add a key-value pair.""" """Add a key-value pair."""
self._data.append((key, value)) self._data.append((key, value))
def __getitem__(self, key: str) -> str | UploadedFile: def __getitem__(self, key: str) -> Union[str, UploadedFile]:
"""Get the first value for a key.""" """Get the first value for a key."""
for k, v in self._data: for k, v in self._data:
if k == key: if k == key:
return v return v
raise KeyError(key) raise KeyError(key)
def get(self, key: str, default: Any = None) -> str | UploadedFile | None: def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
"""Get the first value for a key, or default if not found.""" """Get the first value for a key, or default if not found."""
try: try:
return self[key] return self[key]
except KeyError: except KeyError:
return default return default
def getlist(self, key: str) -> list[str | UploadedFile]: def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
"""Get all values for a key.""" """Get all values for a key."""
return [v for k, v in self._data if k == key] return [v for k, v in self._data if k == key]
@ -136,15 +142,15 @@ class FormData:
"""Return unique keys.""" """Return unique keys."""
return list(self) return list(self)
def items(self) -> list[tuple[str, str | UploadedFile]]: def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
"""Return all key-value pairs.""" """Return all key-value pairs."""
return list(self._data) return list(self._data)
def values(self) -> list[str | UploadedFile]: def values(self) -> List[Union[str, UploadedFile]]:
"""Return all values.""" """Return all values."""
return [v for _, v in self._data] return [v for _, v in self._data]
def _uploaded_files(self) -> list[UploadedFile]: def _uploaded_files(self) -> List[UploadedFile]:
"""Return UploadedFile instances contained in this form.""" """Return UploadedFile instances contained in this form."""
return [v for _, v in self._data if isinstance(v, UploadedFile)] return [v for _, v in self._data if isinstance(v, UploadedFile)]
@ -157,7 +163,7 @@ class FormData:
for uploaded in self._uploaded_files(): for uploaded in self._uploaded_files():
try: try:
uploaded.close_sync() uploaded.close_sync()
except Exception: # noqa: BLE001, S110 except Exception:
# Best-effort cleanup; ignore close errors # Best-effort cleanup; ignore close errors
pass pass
@ -166,7 +172,7 @@ class FormData:
for uploaded in self._uploaded_files(): for uploaded in self._uploaded_files():
try: try:
await uploaded.close() await uploaded.close()
except Exception: # noqa: BLE001, S110 except Exception:
# Best-effort cleanup; ignore close errors # Best-effort cleanup; ignore close errors
pass pass
@ -183,13 +189,13 @@ class FormData:
await self.aclose() await self.aclose()
def parse_content_disposition(header: str) -> dict[str, str | None]: def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
""" """
Parse Content-Disposition header value. Parse Content-Disposition header value.
Returns dict with 'name', 'filename' keys (filename may be None). Returns dict with 'name', 'filename' keys (filename may be None).
""" """
result: dict[str, str | None] = {"name": None, "filename": None} result: Dict[str, Optional[str]] = {"name": None, "filename": None}
# Split on semicolons, handling quoted strings # Split on semicolons, handling quoted strings
parts = [] parts = []
@ -232,8 +238,7 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
from urllib.parse import unquote from urllib.parse import unquote
result["filename"] = unquote(encoded, encoding="utf-8") result["filename"] = unquote(encoded, encoding="utf-8")
except Exception: # noqa: BLE001, S110 except Exception:
# Malformed RFC 5987 filename* - fall back to the plain filename
pass pass
continue continue
@ -245,19 +250,20 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
if key == "name": if key == "name":
result["name"] = value result["name"] = value
# Only set filename if filename* hasn't already set it elif key == "filename":
elif key == "filename" and result["filename"] is None: # Only set if filename* hasn't already set it
# Strip path components (security) if result["filename"] is None:
# Handle both Unix and Windows paths # Strip path components (security)
value = value.replace("\\", "/") # Handle both Unix and Windows paths
if "/" in value: value = value.replace("\\", "/")
value = value.rsplit("/", 1)[-1] if "/" in value:
result["filename"] = value value = value.rsplit("/", 1)[-1]
result["filename"] = value
return result return result
def parse_content_type(header: str) -> tuple[str, dict[str, str]]: def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
""" """
Parse Content-Type header value. Parse Content-Type header value.
@ -301,7 +307,7 @@ class MultipartParser:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS, max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES, max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS, max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE, max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -342,12 +348,12 @@ class MultipartParser:
self._tempdir = tempfile.gettempdir() self._tempdir = tempfile.gettempdir()
# Current part state # Current part state
self.current_headers: dict[str, str] = {} self.current_headers: Dict[str, str] = {}
self.current_file: tempfile.SpooledTemporaryFile | None = None self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
self.current_body = bytearray() self.current_body = bytearray()
self.current_name: str | None = None self.current_name: Optional[str] = None
self.current_filename: str | None = None self.current_filename: Optional[str] = None
self.current_content_type: str | None = None self.current_content_type: Optional[str] = None
def feed(self, chunk: bytes) -> None: def feed(self, chunk: bytes) -> None:
"""Feed a chunk of data to the parser.""" """Feed a chunk of data to the parser."""
@ -448,7 +454,7 @@ class MultipartParser:
# Parse header # Parse header
try: try:
line_str = line.decode("utf-8", errors="replace") line_str = line.decode("utf-8", errors="replace")
except UnicodeDecodeError: except Exception:
line_str = line.decode("latin-1") line_str = line.decode("latin-1")
if ":" in line_str: if ":" in line_str:
@ -475,9 +481,7 @@ class MultipartParser:
if self.file_count > self.max_files: if self.file_count > self.max_files:
raise MultipartParseError("Too many files") raise MultipartParseError("Too many files")
if self.handle_files: if self.handle_files:
# Outlives this method - it is filled in across parser callbacks self.current_file = tempfile.SpooledTemporaryFile(
# and then handed to the UploadedFile the caller consumes
self.current_file = tempfile.SpooledTemporaryFile( # noqa: SIM115
max_size=self.max_memory_file_size max_size=self.max_memory_file_size
) )
else: else:
@ -640,7 +644,7 @@ async def parse_form_data(
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS, max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES, max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS, max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE, max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,

View file

@ -2,9 +2,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Dict, Iterable, List, Sequence, Tuple
import sqlite3 import sqlite3
from collections.abc import Iterable, Sequence
from typing import Any
from datasette.permissions import PermissionSQL from datasette.permissions import PermissionSQL
from datasette.plugins import pm from datasette.plugins import pm
@ -16,7 +15,7 @@ SKIP_PERMISSION_CHECKS = object()
async def gather_permission_sql_from_hooks( async def gather_permission_sql_from_hooks(
*, datasette, actor: dict | None, action: str *, datasette, actor: dict | None, action: str
) -> list[PermissionSQL] | object: ) -> List[PermissionSQL] | object:
"""Collect PermissionSQL objects from the permission_resources_sql hook. """Collect PermissionSQL objects from the permission_resources_sql hook.
Ensures that each returned PermissionSQL has a populated ``source``. Ensures that each returned PermissionSQL has a populated ``source``.
@ -35,7 +34,7 @@ async def gather_permission_sql_from_hooks(
hookimpls = hook_caller.get_hookimpls() hookimpls = hook_caller.get_hookimpls()
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action)) hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
collected: list[PermissionSQL] = [] collected: List[PermissionSQL] = []
actor_json = json.dumps(actor) if actor is not None else None actor_json = json.dumps(actor) if actor is not None else None
actor_id = actor.get("id") if isinstance(actor, dict) else None actor_id = actor.get("id") if isinstance(actor, dict) else None
@ -72,7 +71,7 @@ def _iter_permission_sql_from_result(
if isinstance(result, PermissionSQL): if isinstance(result, PermissionSQL):
return [result] return [result]
if isinstance(result, (list, tuple)): if isinstance(result, (list, tuple)):
collected: list[PermissionSQL] = [] collected: List[PermissionSQL] = []
for item in result: for item in result:
collected.extend(_iter_permission_sql_from_result(item, action=action)) collected.extend(_iter_permission_sql_from_result(item, action=action))
return collected return collected
@ -91,7 +90,7 @@ def _iter_permission_sql_from_result(
def build_rules_union( def build_rules_union(
actor: dict | None, plugins: Sequence[PermissionSQL] actor: dict | None, plugins: Sequence[PermissionSQL]
) -> tuple[str, dict[str, Any]]: ) -> Tuple[str, Dict[str, Any]]:
""" """
Compose plugin SQL into a UNION ALL. Compose plugin SQL into a UNION ALL.
@ -103,10 +102,10 @@ def build_rules_union(
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
Plugin parameters should be prefixed with a unique identifier (e.g., source name). Plugin parameters should be prefixed with a unique identifier (e.g., source name).
""" """
parts: list[str] = [] parts: List[str] = []
actor_json = json.dumps(actor) if actor else None actor_json = json.dumps(actor) if actor else None
actor_id = actor.get("id") if actor else None actor_id = actor.get("id") if actor else None
params: dict[str, Any] = {"actor": actor_json, "actor_id": actor_id} params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
for p in plugins: for p in plugins:
# No namespacing - just use plugin params as-is # No namespacing - just use plugin params as-is
@ -142,10 +141,10 @@ async def resolve_permissions_from_catalog(
plugins: Sequence[Any], plugins: Sequence[Any],
action: str, action: str,
candidate_sql: str, candidate_sql: str,
candidate_params: dict[str, Any] | None = None, candidate_params: Dict[str, Any] | None = None,
*, *,
implicit_deny: bool = True, implicit_deny: bool = True,
) -> list[dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Resolve permissions by embedding the provided *candidate_sql* in a CTE. Resolve permissions by embedding the provided *candidate_sql* in a CTE.
@ -169,8 +168,8 @@ async def resolve_permissions_from_catalog(
- parent, child, allow, reason, source_plugin, depth - parent, child, allow, reason, source_plugin, depth
- resource (rendered "/parent/child" or "/parent" or "/") - resource (rendered "/parent/child" or "/parent" or "/")
""" """
resolved_plugins: list[PermissionSQL] = [] resolved_plugins: List[PermissionSQL] = []
restriction_sqls: list[str] = [] restriction_sqls: List[str] = []
for plugin in plugins: for plugin in plugins:
if callable(plugin) and not isinstance(plugin, PermissionSQL): if callable(plugin) and not isinstance(plugin, PermissionSQL):
@ -399,11 +398,11 @@ async def resolve_permissions_with_candidates(
db, db,
actor: dict | None, actor: dict | None,
plugins: Sequence[Any], plugins: Sequence[Any],
candidates: list[tuple[str, str | None]], candidates: List[Tuple[str, str | None]],
action: str, action: str,
*, *,
implicit_deny: bool = True, implicit_deny: bool = True,
) -> list[dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Resolve permissions without any external candidate table by embedding Resolve permissions without any external candidate table by embedding
the candidates as a UNION of parameterized SELECTs in a CTE. the candidates as a UNION of parameterized SELECTs in a CTE.
@ -412,8 +411,8 @@ async def resolve_permissions_with_candidates(
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
""" """
# Build a small CTE for candidates. # Build a small CTE for candidates.
cand_rows_sql: list[str] = [] cand_rows_sql: List[str] = []
cand_params: dict[str, Any] = {} cand_params: Dict[str, Any] = {}
for i, (parent, child) in enumerate(candidates): for i, (parent, child) in enumerate(candidates):
pkey = f"cand_p_{i}" pkey = f"cand_p_{i}"
ckey = f"cand_c_{i}" ckey = f"cand_c_{i}"

View file

@ -6,7 +6,7 @@ https://github.com/python/cpython/blob/v3.8.3/LICENSE
""" """
import os import os
from shutil import Error, copy, copy2, copystat from shutil import copy, copy2, copystat, Error
def _copytree( def _copytree(

View file

@ -150,6 +150,7 @@ _SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
"sqlite_rename_test", "sqlite_rename_test",
"substr", "substr",
} }
_AUTHORIZER_ACTION_NAMES = { _AUTHORIZER_ACTION_NAMES = {
getattr(sqlite3, name): name getattr(sqlite3, name): name
for name in ( for name in (
@ -390,10 +391,6 @@ def analyze_sql_tables(
) )
return sqlite3.SQLITE_OK return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_RECURSIVE:
# Recursive CTE bookkeeping; table reads are reported separately.
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_FUNCTION and arg2 is not None: if action == sqlite3.SQLITE_FUNCTION and arg2 is not None:
record( record(
"function", "function",
@ -413,12 +410,12 @@ def analyze_sql_tables(
database=None, database=None,
table=None, table=None,
sqlite_schema=sqlite_schema, sqlite_schema=sqlite_schema,
target=f"{arg1} {arg2}" if arg2 is not None else arg1, target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
source=source, source=source,
) )
return sqlite3.SQLITE_OK return sqlite3.SQLITE_OK
action_name = _AUTHORIZER_ACTION_NAMES.get(action, f"SQLITE_{action}") action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
record( record(
"unknown", "unknown",
"unknown", "unknown",
@ -488,17 +485,17 @@ def analyze_sql_tables(
and key.operation in {"create", "alter", "drop"} and key.operation in {"create", "alter", "drop"}
for key in operations for key in operations
) )
dropped_tables_and_views = { dropped_tables = {
(key.database, key.table) (key.database, key.table)
for key in operations for key in operations
if key.operation == "drop" and key.target_type in {"table", "view"} if key.operation == "drop" and key.target_type == "table"
} }
def key_is_drop_table_delete(key: OperationKey) -> bool: def key_is_drop_table_delete(key: OperationKey) -> bool:
return ( return (
key.operation == "delete" key.operation == "delete"
and key.target_type == "table" and key.target_type == "table"
and (key.database, key.table) in dropped_tables_and_views and (key.database, key.table) in dropped_tables
) )
has_user_table_access_in_schema_operation = any( has_user_table_access_in_schema_operation = any(
@ -521,7 +518,9 @@ def analyze_sql_tables(
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
): ):
return True return True
return bool(key_is_drop_table_delete(key)) if key_is_drop_table_delete(key):
return True
return False
def table_kind_for(key: OperationKey) -> SQLiteTableType | None: def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
if ( if (

View file

@ -100,7 +100,7 @@ def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]
schema_table = _sqlite_schema_table(schema) schema_table = _sqlite_schema_table(schema)
try: try:
rows = conn.execute( rows = conn.execute(
f"select name, sql from {schema_table} where type = 'table'" "select name, sql from {} where type = 'table'".format(schema_table)
).fetchall() ).fetchall()
except sqlite3.DatabaseError: except sqlite3.DatabaseError:
return [] return []
@ -127,7 +127,7 @@ def _sqlite_table_type_from_schema(
schema_table = _sqlite_schema_table(schema) schema_table = _sqlite_schema_table(schema)
try: try:
row = conn.execute( row = conn.execute(
f"select type, sql from {schema_table} where name = ?", "select type, sql from {} where name = ?".format(schema_table),
(table,), (table,),
).fetchone() ).fetchone()
except sqlite3.DatabaseError: except sqlite3.DatabaseError:
@ -155,7 +155,7 @@ def _is_known_shadow_table(
schema_table = _sqlite_schema_table(schema) schema_table = _sqlite_schema_table(schema)
try: try:
rows = conn.execute( rows = conn.execute(
f"select name, sql from {schema_table} where type = 'table'" "select name, sql from {} where type = 'table'".format(schema_table)
).fetchall() ).fetchall()
except sqlite3.DatabaseError: except sqlite3.DatabaseError:
return False return False
@ -174,7 +174,7 @@ def _sqlite_schema_table(schema: str | None) -> str:
return "sqlite_master" return "sqlite_master"
if schema == "temp": if schema == "temp":
return "sqlite_temp_master" return "sqlite_temp_master"
return f"{_quote_identifier(schema)}.sqlite_master" return "{}.sqlite_master".format(_quote_identifier(schema))
def _quote_identifier(value: str) -> str: def _quote_identifier(value: str) -> str:

View file

@ -1,7 +1,6 @@
import json
from urllib.parse import urlencode
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from urllib.parse import urlencode
import json
# These wrapper classes pre-date the introduction of # These wrapper classes pre-date the introduction of
# datasette.client and httpx to Datasette. They could # datasette.client and httpx to Datasette. They could

View file

@ -1,2 +1,2 @@
__version__ = "1.0a37" __version__ = "1.0a34"
__version_info__ = tuple(__version__.split(".")) __version_info__ = tuple(__version__.split("."))

View file

@ -1,87 +1,2 @@
import dataclasses
import types
import typing
from dataclasses import dataclass
@dataclass(frozen=True)
class ContextField:
name: str
type_name: str
help: str
from_extra: bool = False
def _type_name(type_):
if type_ is type(None):
return "None"
origin = typing.get_origin(type_)
args = typing.get_args(type_)
if origin in (typing.Union, types.UnionType):
return " | ".join(_type_name(arg) for arg in args)
if origin is not None:
name = getattr(origin, "__name__", str(origin).removeprefix("typing."))
return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args))
return getattr(type_, "__name__", str(type_).removeprefix("typing."))
def from_extra():
"""
Declare a Context dataclass field whose value comes from a registered
Extra of the same name - its documentation is the Extra description,
so the doc string lives next to the resolve() code rather than being
duplicated on the dataclass.
"""
return dataclasses.field(metadata={"from_extra": True})
class Context: class Context:
"Base class for all documented contexts" "Base class for all documented contexts"
# Set on subclasses whose from_extra() fields should be resolved
# against the extras registry for this scope
extras_scope = None
@classmethod
def documented_fields(cls):
"List of ContextField describing the documented fields of this context"
documented = []
for f in dataclasses.fields(cls):
if f.name.startswith("_"):
continue
is_from_extra = bool(f.metadata.get("from_extra"))
if is_from_extra:
help_text = cls._extra_description(f.name)
else:
help_text = f.metadata.get("help", "")
documented.append(
ContextField(
name=f.name,
type_name=_type_name(f.type),
help=help_text,
from_extra=is_from_extra,
)
)
return documented
@classmethod
def _extra_description(cls, name):
# Imported lazily - table_extras is not needed just to define
# Context subclasses
from datasette.views.table_extras import table_extra_registry
try:
extra_class = table_extra_registry.classes_by_name[name]
except KeyError:
raise KeyError(
f"{cls.__name__}.{name} is declared with from_extra() but there is no "
"registered extra of that name"
)
if cls.extras_scope is not None and not extra_class.available_for(
cls.extras_scope
):
raise ValueError(
f"{cls.__name__}.{name} is declared with from_extra() but the {name} extra is "
f"not available for scope {cls.extras_scope}"
)
return extra_class.description or ""

View file

@ -1,21 +1,33 @@
import asyncio
import csv import csv
import hashlib import hashlib
import sys import sys
import textwrap
import time
import urllib
from markupsafe import escape
from datasette.database import QueryInterrupted
from datasette.utils.asgi import Request
from datasette.utils import ( from datasette.utils import (
add_cors_headers,
await_me_maybe,
EscapeHtmlWriter, EscapeHtmlWriter,
InvalidSql, InvalidSql,
LimitedWriter, LimitedWriter,
add_cors_headers, call_with_supported_arguments,
path_from_row_pks, path_from_row_pks,
path_with_added_args,
path_with_removed_args,
path_with_format, path_with_format,
sqlite3, sqlite3,
) )
from datasette.utils.asgi import ( from datasette.utils.asgi import (
AsgiStream, AsgiStream,
BadRequest, NotFound,
Request,
Response, Response,
BadRequest,
) )
@ -28,15 +40,12 @@ class DatasetteError(Exception):
status=500, status=500,
template=None, template=None,
message_is_html=False, message_is_html=False,
plain_message=None,
): ):
self.message = message self.message = message
self.title = title self.title = title
self.error_dict = error_dict or {} self.error_dict = error_dict or {}
self.status = status self.status = status
self.message_is_html = message_is_html self.message_is_html = message_is_html
# Plain text used for JSON error responses when message is HTML
self.plain_message = plain_message
class View: class View:
@ -52,7 +61,9 @@ class View:
request.path.endswith(".json") request.path.endswith(".json")
or request.headers.get("content-type") == "application/json" or request.headers.get("content-type") == "application/json"
): ):
response = Response.error("Method not allowed", 405) response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
else: else:
response = Response.text("Method not allowed", status=405) response = Response.text("Method not allowed", status=405)
return response return response
@ -91,7 +102,9 @@ class BaseView:
request.path.endswith(".json") request.path.endswith(".json")
or request.headers.get("content-type") == "application/json" or request.headers.get("content-type") == "application/json"
): ):
response = Response.error("Method not allowed", 405) response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
else: else:
response = Response.text("Method not allowed", status=405) response = Response.text("Method not allowed", status=405)
return response return response
@ -129,10 +142,12 @@ class BaseView:
template = environment.select_template(templates) template = environment.select_template(templates)
template_context = { template_context = {
**context, **context,
"select_templates": [ **{
f"{'*' if template_name == template.name else ''}{template_name}" "select_templates": [
for template_name in templates f"{'*' if template_name == template.name else ''}{template_name}"
], for template_name in templates
],
},
} }
headers = {} headers = {}
if self.has_json_alternate: if self.has_json_alternate:
@ -149,7 +164,9 @@ class BaseView:
template_context["alternate_url_json"] = alternate_url_json template_context["alternate_url_json"] = alternate_url_json
headers.update( headers.update(
{ {
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
} }
) )
return Response.html( return Response.html(
@ -175,12 +192,239 @@ class BaseView:
return view return view
class DataView(BaseView):
name = ""
def redirect(self, request, path, forward_querystring=True, remove_args=None):
if request.query_string and "?" not in path and forward_querystring:
path = f"{path}?{request.query_string}"
if remove_args:
path = path_with_removed_args(request, remove_args, path=path)
r = Response.redirect(path)
r.headers["Link"] = f"<{path}>; rel=preload"
if self.ds.cors:
add_cors_headers(r.headers)
return r
async def data(self, request):
raise NotImplementedError
async def as_csv(self, request, database):
return await stream_csv(self.ds, self.data, request, database)
async def get(self, request):
db = await self.ds.resolve_database(request)
database = db.name
database_route = db.route
_format = request.url_vars["format"]
data_kwargs = {}
if _format == "csv":
return await self.as_csv(request, database_route)
if _format is None:
# HTML views default to expanding all foreign key labels
data_kwargs["default_labels"] = True
extra_template_data = {}
start = time.perf_counter()
status_code = None
templates = []
try:
response_or_template_contexts = await self.data(request, **data_kwargs)
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
# If it has four items, it includes an HTTP status code
if len(response_or_template_contexts) == 4:
(
data,
extra_template_data,
templates,
status_code,
) = response_or_template_contexts
else:
data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""".format(escape(ex.sql))).strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError:
raise
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
# Special case for .jsono extension - redirect to _shape=objects
if _format == "jsono":
return self.redirect(
request,
path_with_added_args(
request,
{"_shape": "objects"},
path=request.path.rsplit(".jsono", 1)[0] + ".json",
),
forward_querystring=False,
)
if _format in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
self.ds.renderers[_format][0],
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
truncated=False, # TODO: support this
error=data.get("error"),
# These will be deprecated in Datasette 1.0:
args=request.args,
data=data,
)
if asyncio.iscoroutine(result):
result = await result
if result is None:
raise NotFound("No data")
if isinstance(result, dict):
r = Response(
body=result.get("body"),
status=result.get("status_code", status_code or 200),
content_type=result.get("content_type", "text/plain"),
headers=result.get("headers"),
)
elif isinstance(result, Response):
r = result
if status_code is not None:
# Over-ride the status code
r.status = status_code
else:
assert False, f"{result} should be dict or Response"
else:
extras = {}
if callable(extra_template_data):
extras = extra_template_data()
if asyncio.iscoroutine(extras):
extras = await extras
else:
extras = extra_template_data
url_labels_extra = {}
if data.get("expandable_columns"):
url_labels_extra = {"_labels": "on"}
renderers = {}
for key, (_, can_render) in self.ds.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
)
)
url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
)
url_csv_path = url_csv.split("?")[0]
context = {
**data,
**extras,
**{
"renderers": renderers,
"url_csv": url_csv,
"url_csv_path": url_csv_path,
"url_csv_hidden_args": [
(key, value)
for key, value in urllib.parse.parse_qsl(request.query_string)
if key not in ("_labels", "_facet", "_size")
]
+ [("_size", "max")],
"settings": self.ds.settings_dict(),
},
}
if "metadata" not in context:
context["metadata"] = await self.ds.get_instance_metadata()
r = await self.render(templates, request=request, context=context)
if status_code is not None:
r.status = status_code
ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(r, ttl)
def set_response_headers(self, response, ttl):
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
return response
def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)
async def stream_csv(datasette, fetch_data, request, database): async def stream_csv(datasette, fetch_data, request, database):
kwargs = {} kwargs = {}
stream = request.args.get("_stream") stream = request.args.get("_stream")
# Do not calculate facets or counts: # Do not calculate facets or counts:
extra_parameters = [ extra_parameters = [
f"{key}=1" for key in ("_nofacet", "_nocount") if not request.args.get(key) "{}=1".format(key)
for key in ("_nofacet", "_nocount")
if not request.args.get(key)
] ]
if extra_parameters: if extra_parameters:
# Replace request object with a new one with modified scope # Replace request object with a new one with modified scope
@ -210,6 +454,9 @@ async def stream_csv(datasette, fetch_data, request, database):
except (sqlite3.OperationalError, InvalidSql) as e: except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400) raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError: except DatasetteError:
raise raise
@ -316,9 +563,8 @@ async def stream_csv(datasette, fetch_data, request, database):
else: else:
new_row.append(cell) new_row.append(cell)
await writer.writerow(new_row) await writer.writerow(new_row)
except Exception as ex: # noqa: BLE001 except Exception as ex:
# Streaming CSV: report the error into the response body and stop sys.stderr.write("Caught this error: {}\n".format(ex))
sys.stderr.write(f"Caught this error: {ex}\n")
sys.stderr.flush() sys.stderr.flush()
await r.write(str(ex)) await r.write(str(ex))
return return

View file

@ -1,75 +1,50 @@
from dataclasses import dataclass, field
from urllib.parse import parse_qsl, urlencode
import asyncio import asyncio
import hashlib import hashlib
import itertools import itertools
import json import json
import os
import textwrap
from dataclasses import asdict, dataclass, field
from urllib.parse import parse_qsl, urlencode
import markupsafe import markupsafe
import os
import re
import sqlite_utils
import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.extras import extra_names_from_request
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.extras import ExtraScope, extra_names_from_request
from datasette.plugins import pm
from datasette.resources import DatabaseResource, QueryResource from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import StoredQuery, stored_query_to_dict from datasette.stored_queries import stored_query_to_dict
from datasette.write_sql import QueryWriteRejected
from datasette.utils import ( from datasette.utils import (
InvalidSql,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
call_with_supported_arguments, call_with_supported_arguments,
error_body, named_parameters as derive_named_parameters,
format_bytes, format_bytes,
is_url,
make_slot_function, make_slot_function,
tilde_decode,
to_css_class,
validate_sql_select,
is_url,
path_with_added_args, path_with_added_args,
path_with_format, path_with_format,
path_with_removed_args, path_with_removed_args,
sqlite3, sqlite3,
tilde_decode,
to_css_class,
truncate_url, truncate_url,
validate_sql_select, InvalidSql,
) )
from datasette.utils import ( from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
named_parameters as derive_named_parameters, from datasette.plugins import pm
)
from datasette.utils.asgi import AsgiFileDownload, Forbidden, NotFound, Response
from datasette.write_sql import QueryWriteRejected
from . import Context from .base import BaseView, DatasetteError, View, _error, stream_csv
from .base import DatasetteError, View, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_create_alter import _create_table_ui_context
from .table_extras import ( from .table_extras import (
QueryExtraContext, QueryExtraContext,
resolve_query_extras, resolve_query_extras,
table_extra_registry, table_extra_registry,
) )
from . import Context
@dataclass
class DatabaseTable:
"Summary of a table or view shown on database and query pages."
name: str
columns: list[str]
primary_keys: list[str]
count: int | None
count_truncated: bool
hidden: bool
fts_table: str | None
foreign_keys: dict[str, list[dict[str, str]]]
private: bool
@dataclass
class DatabaseViewInfo:
"Summary of a SQLite view shown on the database page."
name: str
private: bool
class DatabaseView(View): class DatabaseView(View):
@ -103,7 +78,7 @@ class DatabaseView(View):
return response return response
if format_ not in ("html", "json"): if format_ not in ("html", "json"):
raise NotFound(f"Invalid format: {format_}") raise NotFound("Invalid format: {}".format(format_))
metadata = await datasette.get_database_metadata(database) metadata = await datasette.get_database_metadata(database)
@ -121,7 +96,7 @@ class DatabaseView(View):
# Filter to just views # Filter to just views
view_names_set = set(await db.view_names()) view_names_set = set(await db.view_names())
sql_views = [ sql_views = [
DatabaseViewInfo(name=name, private=allowed_dict[name].private) {"name": name, "private": allowed_dict[name].private}
for name in allowed_dict for name in allowed_dict
if name in view_names_set if name in view_names_set
] ]
@ -142,36 +117,21 @@ class DatabaseView(View):
else len(stored_queries) else len(stored_queries)
) )
# Resolve the registered database-level actions for this database in
# one batched query, seeding the request permission cache so allowed()
# calls made inside plugin hooks below are served from the cache.
database_action_permissions = await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
create_table_ui = await _create_table_ui_context(
datasette, request, db, database, database_action_permissions
)
async def database_actions(): async def database_actions():
# Resolve the registered database-level actions for this
# database in one batched query, seeding the request permission
# cache so that allowed() calls made inside the plugin hooks
# below are served from the cache
await datasette.allowed_many(
actions=[
name
for name, action in datasette.actions.items()
if action.resource_class is DatabaseResource
],
resource=DatabaseResource(database),
actor=request.actor,
)
links = [] links = []
if create_table_ui:
links.append(
{
"type": "button",
"label": "Create table",
"description": "Create a new table in this database.",
"attrs": {
"aria-label": f"Create table in {database}",
"data-database-action": "create-table",
},
}
)
for hook in pm.hook.database_actions( for hook in pm.hook.database_actions(
datasette=datasette, datasette=datasette,
database=database, database=database,
@ -196,9 +156,9 @@ class DatabaseView(View):
"private": private, "private": private,
"path": datasette.urls.database(database), "path": datasette.urls.database(database),
"size": db.size, "size": db.size,
"tables": [asdict(table) for table in tables], "tables": tables,
"hidden_count": len([table for table in tables if table.hidden]), "hidden_count": len([t for t in tables if t["hidden"]]),
"views": [asdict(view) for view in sql_views], "views": sql_views,
"queries": [stored_query_to_dict(query) for query in stored_queries], "queries": [stored_query_to_dict(query) for query in stored_queries],
"queries_more": queries_more, "queries_more": queries_more,
"queries_count": queries_count, "queries_count": queries_count,
@ -238,7 +198,7 @@ class DatabaseView(View):
path=datasette.urls.database(database), path=datasette.urls.database(database),
size=db.size, size=db.size,
tables=tables, tables=tables,
hidden_count=len([table for table in tables if table.hidden]), hidden_count=len([t for t in tables if t["hidden"]]),
views=sql_views, views=sql_views,
queries=stored_queries, queries=stored_queries,
queries_more=queries_more, queries_more=queries_more,
@ -251,12 +211,10 @@ class DatabaseView(View):
), ),
metadata=metadata, metadata=metadata,
database_color=db.color, database_color=db.color,
database_page_data=(
{"createTable": create_table_ui} if create_table_ui else {}
),
database_actions=database_actions, database_actions=database_actions,
show_hidden=request.args.get("_show_hidden"), show_hidden=request.args.get("_show_hidden"),
editable=True, editable=True,
count_limit=db.count_limit,
allow_download=datasette.setting("allow_download") allow_download=datasette.setting("allow_download")
and not db.is_mutable and not db.is_mutable
and not db.is_memory, and not db.is_memory,
@ -274,39 +232,25 @@ class DatabaseView(View):
view_name="database", view_name="database",
), ),
headers={ headers={
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
}, },
) )
@dataclass @dataclass
class DatabaseContext(Context): class DatabaseContext(Context):
"The page listing the tables, views and queries in a database, e.g. /fixtures."
documented_template = "database.html"
database: str = field(metadata={"help": "The name of the database"}) database: str = field(metadata={"help": "The name of the database"})
private: bool = field( private: bool = field(
metadata={"help": "Boolean indicating if this is a private database"} metadata={"help": "Boolean indicating if this is a private database"}
) )
path: str = field(metadata={"help": "The URL path to this database"}) path: str = field(metadata={"help": "The URL path to this database"})
size: int = field(metadata={"help": "The size of the database in bytes"}) size: int = field(metadata={"help": "The size of the database in bytes"})
tables: list[DatabaseTable] = field( tables: list = field(metadata={"help": "List of table objects in the database"})
metadata={
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
}
)
hidden_count: int = field(metadata={"help": "Count of hidden tables"}) hidden_count: int = field(metadata={"help": "Count of hidden tables"})
views: list[DatabaseViewInfo] = field( views: list = field(metadata={"help": "List of view objects in the database"})
metadata={ queries: list = field(metadata={"help": "List of stored query objects"})
"help": "List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes."
}
)
queries: list[StoredQuery] = field(
metadata={
"help": "List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``."
}
)
queries_more: bool = field( queries_more: bool = field(
metadata={"help": "Boolean indicating if more stored queries are available"} metadata={"help": "Boolean indicating if more stored queries are available"}
) )
@ -315,65 +259,45 @@ class DatabaseContext(Context):
metadata={"help": "Boolean indicating if custom SQL can be executed"} metadata={"help": "Boolean indicating if custom SQL can be executed"}
) )
table_columns: dict = field( table_columns: dict = field(
metadata={ metadata={"help": "Dictionary mapping table names to their column lists"}
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
}
)
metadata: dict = field(
metadata={
"help": "Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata."
}
) )
metadata: dict = field(metadata={"help": "Metadata for the database"})
database_color: str = field(metadata={"help": "The color assigned to the database"}) database_color: str = field(metadata={"help": "The color assigned to the database"})
database_page_data: dict = field(
metadata={
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
}
)
database_actions: callable = field( database_actions: callable = field(
metadata={ metadata={
"help": 'Async callable returning action items for the database menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_database_actions`.' "help": "Callable returning list of action links for the database menu"
} }
) )
show_hidden: str = field(metadata={"help": "Value of _show_hidden query parameter"}) show_hidden: str = field(metadata={"help": "Value of _show_hidden query parameter"})
editable: bool = field( editable: bool = field(
metadata={"help": "Boolean indicating if the database is editable"} metadata={"help": "Boolean indicating if the database is editable"}
) )
count_limit: int = field(metadata={"help": "The maximum number of rows to count"})
allow_download: bool = field( allow_download: bool = field(
metadata={"help": "Boolean indicating if database download is allowed"} metadata={"help": "Boolean indicating if database download is allowed"}
) )
attached_databases: list = field( attached_databases: list = field(
metadata={ metadata={"help": "List of names of attached databases"}
"help": "List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`."
}
) )
alternate_url_json: str = field( alternate_url_json: str = field(
metadata={"help": "URL for the alternate JSON version of this page"} metadata={"help": "URL for the alternate JSON version of this page"}
) )
select_templates: list = field( select_templates: list = field(
metadata={ metadata={
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``." "help": "List of templates that were considered for rendering this page"
} }
) )
top_database: callable = field( top_database: callable = field(
metadata={ metadata={"help": "Callable to render the top_database slot"}
"help": "Async callable that renders the ``top_database`` plugin slot for this database and returns HTML."
}
) )
@dataclass @dataclass
class QueryContext(Context): class QueryContext(Context):
"The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name)."
documented_template = "query.html"
database: str = field(metadata={"help": "The name of the database being queried"}) database: str = field(metadata={"help": "The name of the database being queried"})
database_color: str = field(metadata={"help": "The color of the database"}) database_color: str = field(metadata={"help": "The color of the database"})
query: dict = field( query: dict = field(
metadata={ metadata={"help": "The SQL query object containing the `sql` string"}
"help": "Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys."
}
) )
stored_query: str = field( stored_query: str = field(
metadata={"help": "The name of the stored query if this is a stored query"} metadata={"help": "The name of the stored query if this is a stored query"}
@ -390,9 +314,7 @@ class QueryContext(Context):
} }
) )
metadata: dict = field( metadata: dict = field(
metadata={ metadata={"help": "Metadata about the database or the stored query"}
"help": "Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``."
}
) )
db_is_immutable: bool = field( db_is_immutable: bool = field(
metadata={"help": "Boolean indicating if this database is immutable"} metadata={"help": "Boolean indicating if this database is immutable"}
@ -416,44 +338,22 @@ class QueryContext(Context):
save_query_url: str = field( save_query_url: str = field(
metadata={"help": "URL to save the current arbitrary SQL as a query"} metadata={"help": "URL to save the current arbitrary SQL as a query"}
) )
tables: list[DatabaseTable] = field( tables: list = field(metadata={"help": "List of table objects in the database"})
metadata={
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
}
)
named_parameter_values: dict = field( named_parameter_values: dict = field(
metadata={ metadata={"help": "Dictionary of parameter names/values"}
"help": "Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``."
}
) )
edit_sql_url: str = field( edit_sql_url: str = field(
metadata={"help": "URL to edit the SQL for a stored query"} metadata={"help": "URL to edit the SQL for a stored query"}
) )
display_rows: list = field( display_rows: list = field(metadata={"help": "List of result rows to display"})
metadata={ columns: list = field(metadata={"help": "List of column names"})
"help": "List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``." renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"})
}
)
columns: list = field(
metadata={
"help": "List of result column names in the order they appear in ``display_rows`` and ``rows``."
}
)
renderers: dict = field(
metadata={
"help": "Dictionary mapping output format names such as ``json`` to URLs for this query in that format."
}
)
url_csv: str = field(metadata={"help": "URL for CSV export"}) url_csv: str = field(metadata={"help": "URL for CSV export"})
show_hide_hidden: str = field( show_hide_hidden: str = field(
metadata={ metadata={"help": "Hidden input field for the _show_sql parameter"}
"help": "Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state."
}
) )
table_columns: dict = field( table_columns: dict = field(
metadata={ metadata={"help": "Dictionary of table name to list of column names"}
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
}
) )
alternate_url_json: str = field( alternate_url_json: str = field(
metadata={"help": "URL for alternate JSON version of this page"} metadata={"help": "URL for alternate JSON version of this page"}
@ -461,27 +361,23 @@ class QueryContext(Context):
# TODO: refactor this to somewhere else, probably ds.render_template() # TODO: refactor this to somewhere else, probably ds.render_template()
select_templates: list = field( select_templates: list = field(
metadata={ metadata={
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``." "help": "List of templates that were considered for rendering this page"
} }
) )
top_query: callable = field( top_query: callable = field(
metadata={ metadata={"help": "Callable to render the top_query slot"}
"help": "Async callable that renders the ``top_query`` plugin slot for this query and returns HTML."
}
) )
top_stored_query: callable = field( top_stored_query: callable = field(
metadata={ metadata={"help": "Callable to render the top_stored_query slot"}
"help": "Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML."
}
) )
query_actions: callable = field( query_actions: callable = field(
metadata={ metadata={
"help": 'Async callable returning action items for the query menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_query_actions`.' "help": "Callable returning a list of links for the query action menu"
} }
) )
async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable]: async def get_tables(datasette, request, db, allowed_dict):
""" """
Get list of tables with metadata for the database view. Get list of tables with metadata for the database view.
@ -502,36 +398,21 @@ async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable
table_columns = await db.table_columns(table) table_columns = await db.table_columns(table)
tables.append( tables.append(
DatabaseTable( {
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[table], "count": table_counts[table],
count_truncated=_table_count_truncated( "hidden": table in hidden_table_names,
datasette, db, table, table_counts[table] "fts_table": await db.fts_table(table),
), "foreign_keys": all_foreign_keys[table],
hidden=table in hidden_table_names, "private": allowed_dict[table].private,
fts_table=await db.fts_table(table), }
foreign_keys=all_foreign_keys[table],
private=allowed_dict[table].private,
)
) )
tables.sort(key=lambda table: (table.hidden, table.name)) tables.sort(key=lambda t: (t["hidden"], t["name"]))
return tables return tables
def _table_count_truncated(datasette, db, table, count):
if count != db.count_limit + 1:
return False
if not db.is_mutable and datasette.inspect_data:
try:
datasette.inspect_data[db.name]["tables"][table]["count"]
return False
except KeyError:
pass
return True
async def database_download(request, datasette): async def database_download(request, datasette):
from datasette.resources import DatabaseResource from datasette.resources import DatabaseResource
@ -557,7 +438,7 @@ async def database_download(request, datasette):
if datasette.cors: if datasette.cors:
add_cors_headers(headers) add_cors_headers(headers)
if db.hash: if db.hash:
etag = f'"{db.hash}"' etag = '"{}"'.format(db.hash)
headers["Etag"] = etag headers["Etag"] = etag
# Has user seen this already? # Has user seen this already?
if_none_match = request.headers.get("if-none-match") if_none_match = request.headers.get("if-none-match")
@ -609,7 +490,11 @@ class QueryView(View):
"_json" "_json"
): ):
return Response.json( return Response.json(
dict(error_body([ex.message], 403), redirect=None), {
"ok": False,
"message": ex.message,
"redirect": None,
},
status=403, status=403,
) )
datasette.add_message(request, ex.message, datasette.ERROR) datasette.add_message(request, ex.message, datasette.ERROR)
@ -644,15 +529,8 @@ class QueryView(View):
ok = None ok = None
redirect_url = None redirect_url = None
try: try:
execute_write_kwargs = {"request": request}
if stored_query.is_trusted:
analysis = await db.analyze_sql(stored_query.sql, params_for_query)
if any(
operation.operation == "vacuum" for operation in analysis.operations
):
execute_write_kwargs["transaction"] = False
cursor = await db.execute_write( cursor = await db.execute_write(
stored_query.sql, params_for_query, **execute_write_kwargs stored_query.sql, params_for_query, request=request
) )
# success message can come from on_success_message or on_success_message_sql # success message can come from on_success_message or on_success_message_sql
message = None message = None
@ -665,9 +543,8 @@ class QueryView(View):
).first() ).first()
if message_result: if message_result:
message = message_result[0] message = message_result[0]
except Exception as ex: # noqa: BLE001 except Exception as ex:
# Stored-query on_success_message_sql is user-authored message = "Error running on_success_message_sql: {}".format(ex)
message = f"Error running on_success_message_sql: {ex}"
message_type = datasette.ERROR message_type = datasette.ERROR
if not message: if not message:
if stored_query.on_success_message: if stored_query.on_success_message:
@ -681,24 +558,18 @@ class QueryView(View):
redirect_url = stored_query.on_success_redirect redirect_url = stored_query.on_success_redirect
ok = True ok = True
except Exception as ex: # noqa: BLE001 except Exception as ex:
# Stored-query execution is user-authored SQL
message = stored_query.on_error_message or str(ex) message = stored_query.on_error_message or str(ex)
message_type = datasette.ERROR message_type = datasette.ERROR
redirect_url = stored_query.on_error_redirect redirect_url = stored_query.on_error_redirect
ok = False ok = False
if should_return_json: if should_return_json:
if ok:
return Response.json(
{
"ok": True,
"message": message,
"redirect": redirect_url,
}
)
return Response.json( return Response.json(
dict(error_body([message], 400), redirect=redirect_url), {
status=400, "ok": ok,
"message": message,
"redirect": redirect_url,
}
) )
else: else:
datasette.add_message(request, message, message_type) datasette.add_message(request, message, message_type)
@ -816,23 +687,19 @@ class QueryView(View):
rows = results.rows rows = results.rows
except QueryInterrupted as ex: except QueryInterrupted as ex:
raise DatasetteError( raise DatasetteError(
textwrap.dedent(f""" textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the <p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a> <a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p> configuration option.</p>
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea> <textarea style="width: 90%">{}</textarea>
<script> <script>
let ta = document.querySelector("textarea"); let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px"; ta.style.height = ta.scrollHeight + "px";
</script> </script>
""").strip(), """.format(markupsafe.escape(ex.sql))).strip(),
title="SQL Interrupted", title="SQL Interrupted",
status=400, status=400,
message_is_html=True, message_is_html=True,
plain_message=(
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
),
) )
except sqlite3.DatabaseError as ex: except sqlite3.DatabaseError as ex:
query_error = str(ex) query_error = str(ex)
@ -841,6 +708,8 @@ class QueryView(View):
columns = [] columns = []
except (sqlite3.OperationalError, InvalidSql) as ex: except (sqlite3.OperationalError, InvalidSql) as ex:
raise DatasetteError(str(ex), title="Invalid SQL", status=400) raise DatasetteError(str(ex), title="Invalid SQL", status=400)
except sqlite3.OperationalError as ex:
raise DatasetteError(str(ex))
except DatasetteError: except DatasetteError:
raise raise
@ -862,12 +731,9 @@ class QueryView(View):
return data, None, None return data, None, None
return await stream_csv(datasette, fetch_data_for_csv, request, db.name) return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
elif format_ in datasette.renderers: elif format_ in datasette.renderers.keys():
if not sql:
raise DatasetteError("?sql= is required", status=400)
data = {"ok": True, "rows": rows, "columns": columns} data = {"ok": True, "rows": rows, "columns": columns}
extras = extra_names_from_request(request) extras = extra_names_from_request(request)
table_extra_registry.validate_requested(extras, ExtraScope.QUERY)
if extras: if extras:
query_extra_context = QueryExtraContext( query_extra_context = QueryExtraContext(
datasette=datasette, datasette=datasette,
@ -954,7 +820,9 @@ class QueryView(View):
} }
headers.update( headers.update(
{ {
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
} }
) )
metadata = await query_metadata() metadata = await query_metadata()
@ -1035,7 +903,9 @@ class QueryView(View):
+ "?" + "?"
+ urlencode( + urlencode(
{ {
"sql": sql, **{
"sql": sql,
},
**named_parameter_values, **named_parameter_values,
} }
) )
@ -1137,7 +1007,7 @@ class QueryView(View):
headers=headers, headers=headers,
) )
else: else:
assert False, f"Invalid format: {format_}" assert False, "Invalid format: {}".format(format_)
if datasette.cors: if datasette.cors:
add_cors_headers(r.headers) add_cors_headers(r.headers)
return r return r
@ -1185,6 +1055,260 @@ class MagicParameters(dict):
return super().__getitem__(key) return super().__getitem__(key)
class TableCreateView(BaseView):
name = "table-create"
_valid_keys = {
"table",
"rows",
"row",
"columns",
"pk",
"pks",
"ignore",
"replace",
"alter",
}
_supported_column_types = {
"text",
"integer",
"float",
"blob",
}
# Any string that does not contain a newline or start with sqlite_
_table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$")
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
db = await self.ds.resolve_database(request)
database_name = db.name
# Must have create-table permission
if not await self.ds.allowed(
action="create-table",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
try:
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict):
return _error(["JSON must be an object"])
invalid_keys = set(data.keys()) - self._valid_keys
if invalid_keys:
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
# ignore and replace are mutually exclusive
if data.get("ignore") and data.get("replace"):
return _error(["ignore and replace are mutually exclusive"])
# ignore and replace only allowed with row or rows
if "ignore" in data or "replace" in data:
if not data.get("row") and not data.get("rows"):
return _error(["ignore and replace require row or rows"])
# ignore and replace require pk or pks
if "ignore" in data or "replace" in data:
if not data.get("pk") and not data.get("pks"):
return _error(["ignore and replace require pk or pks"])
ignore = data.get("ignore")
replace = data.get("replace")
if replace:
# Must have update-row permission
if not await self.ds.allowed(
action="update-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need update-row"], 403)
table_name = data.get("table")
if not table_name:
return _error(["Table is required"])
if not self._table_name_re.match(table_name):
return _error(["Invalid table name"])
table_exists = await db.table_exists(data["table"])
columns = data.get("columns")
rows = data.get("rows")
row = data.get("row")
if not columns and not rows and not row:
return _error(["columns, rows or row is required"])
if rows and row:
return _error(["Cannot specify both rows and row"])
if rows or row:
# Must have insert-row permission
if not await self.ds.allowed(
action="insert-row",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need insert-row"], 403)
alter = False
if rows or row:
if not table_exists:
# if table is being created for the first time, alter=True
alter = True
else:
# alter=True only if they request it AND they have permission
if data.get("alter"):
if not await self.ds.allowed(
action="alter-table",
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need alter-table"], 403)
alter = True
if columns:
if rows or row:
return _error(["Cannot specify columns with rows or row"])
if not isinstance(columns, list):
return _error(["columns must be a list"])
for column in columns:
if not isinstance(column, dict):
return _error(["columns must be a list of objects"])
if not column.get("name") or not isinstance(column.get("name"), str):
return _error(["Column name is required"])
if not column.get("type"):
column["type"] = "text"
if column["type"] not in self._supported_column_types:
return _error(
["Unsupported column type: {}".format(column["type"])]
)
# No duplicate column names
dupes = {c["name"] for c in columns if columns.count(c) > 1}
if dupes:
return _error(["Duplicate column name: {}".format(", ".join(dupes))])
if row:
rows = [row]
if rows:
if not isinstance(rows, list):
return _error(["rows must be a list"])
for row in rows:
if not isinstance(row, dict):
return _error(["rows must be a list of objects"])
pk = data.get("pk")
pks = data.get("pks")
if pk and pks:
return _error(["Cannot specify both pk and pks"])
if pk:
if not isinstance(pk, str):
return _error(["pk must be a string"])
if pks:
if not isinstance(pks, list):
return _error(["pks must be a list"])
for pk in pks:
if not isinstance(pk, str):
return _error(["pks must be a list of strings"])
# If table exists already, read pks from that instead
if table_exists:
actual_pks = await db.primary_keys(table_name)
# if pk passed and table already exists check it does not change
bad_pks = False
if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]:
bad_pks = True
elif (
len(actual_pks) > 1
and data.get("pks")
and set(data["pks"]) != set(actual_pks)
):
bad_pks = True
if bad_pks:
return _error(["pk cannot be changed for existing table"])
pks = actual_pks
initial_schema = None
if table_exists:
initial_schema = await db.execute_fn(
lambda conn: sqlite_utils.Database(conn)[table_name].schema
)
def create_table(conn):
table = sqlite_utils.Database(conn)[table_name]
if rows:
table.insert_all(
rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter
)
else:
table.create(
{c["name"]: c["type"] for c in columns},
pk=pks or pk,
)
return table.schema
try:
schema = await db.execute_write_fn(create_table, request=request)
except Exception as e:
return _error([str(e)])
if initial_schema is not None and initial_schema != schema:
await self.ds.track_event(
AlterTableEvent(
request.actor,
database=database_name,
table=table_name,
before_schema=initial_schema,
after_schema=schema,
)
)
table_url = self.ds.absolute_url(
request, self.ds.urls.table(db.name, table_name)
)
table_api_url = self.ds.absolute_url(
request, self.ds.urls.table(db.name, table_name, format="json")
)
details = {
"ok": True,
"database": db.name,
"table": table_name,
"table_url": table_url,
"table_api_url": table_api_url,
"schema": schema,
}
if rows:
details["row_count"] = len(rows)
if not table_exists:
# Only log creation if we created a table
await self.ds.track_event(
CreateTableEvent(
request.actor, database=db.name, table=table_name, schema=schema
)
)
if rows:
await self.ds.track_event(
InsertRowsEvent(
request.actor,
database=db.name,
table=table_name,
num_rows=len(rows),
ignore=ignore,
replace=replace,
)
)
return Response.json(details, status=201)
async def display_rows(datasette, database, request, rows, columns): async def display_rows(datasette, database, request, rows, columns):
display_rows = [] display_rows = []
truncate_cells = datasette.setting("truncate_cells_html") truncate_cells = datasette.setting("truncate_cells_html")
@ -1238,7 +1362,7 @@ async def display_rows(datasette, database, request, rows, columns):
'<a class="blob-download" href="{}"{}>&lt;Binary:&nbsp;{:,}&nbsp;byte{}&gt;</a>'.format( '<a class="blob-download" href="{}"{}>&lt;Binary:&nbsp;{:,}&nbsp;byte{}&gt;</a>'.format(
blob_url, blob_url,
( (
f' title="{formatted}"' ' title="{}"'.format(formatted)
if "bytes" not in formatted if "bytes" not in formatted
else "" else ""
), ),

View file

@ -2,14 +2,14 @@ import re
from urllib.parse import urlencode from urllib.parse import urlencode
from datasette.resources import DatabaseResource from datasette.resources import DatabaseResource
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3 from datasette.utils import sqlite3
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
from .base import BaseView from .base import BaseView, _error
from .database import display_rows as display_query_rows from .database import display_rows as display_query_rows
from .query_helpers import ( from .query_helpers import (
SQL_PARAMETER_FORM_PREFIX,
QueryValidationError, QueryValidationError,
SQL_PARAMETER_FORM_PREFIX,
_analysis_is_write, _analysis_is_write,
_analysis_rows, _analysis_rows,
_analysis_rows_with_permissions, _analysis_rows_with_permissions,
@ -31,7 +31,6 @@ WRITE_TEMPLATE_LABELS = {
"delete": "Delete rows", "delete": "Delete rows",
} }
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS) WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
CREATE_TABLE_TEMPLATE_SQL = "create table new_table (\n id integer primary key,\n name text\n -- created text default (datetime('now'))\n)"
def _parameter_names(columns): def _parameter_names(columns):
@ -41,11 +40,11 @@ def _parameter_names(columns):
base = re.sub(r"[^a-z0-9_]+", "_", column.lower()) base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
base = base.strip("_") or "value" base = base.strip("_") or "value"
if base[0].isdigit(): if base[0].isdigit():
base = f"p_{base}" base = "p_{}".format(base)
name = base name = base
index = 2 index = 2
while name in seen: while name in seen:
name = f"{base}_{index}" name = "{}_{}".format(base, index)
index += 1 index += 1
seen.add(name) seen.add(name)
names[column] = name names[column] = name
@ -57,7 +56,7 @@ def _quote_identifier(identifier):
def _preferred_where_column(table, columns): def _preferred_where_column(table, columns):
lower_table_id = f"{table.lower()}_id" lower_table_id = "{}_id".format(table.lower())
return ( return (
next((column for column in columns if column.lower() == "id"), None) next((column for column in columns if column.lower() == "id"), None)
or next( or next(
@ -82,15 +81,17 @@ def _insert_template_sql(table, columns):
auto_pk = _auto_incrementing_primary_key(columns) auto_pk = _auto_incrementing_primary_key(columns)
insert_columns = [column for column in column_names if column != auto_pk] insert_columns = [column for column in column_names if column != auto_pk]
if not insert_columns: if not insert_columns:
return f"insert into {_quote_identifier(table)}\ndefault values" return "insert into {}\ndefault values".format(_quote_identifier(table))
names = _parameter_names(insert_columns) names = _parameter_names(insert_columns)
return "\n".join( return "\n".join(
( (
f"insert into {_quote_identifier(table)} (", "insert into {} (".format(_quote_identifier(table)),
",\n".join(f" {_quote_identifier(column)}" for column in insert_columns), ",\n".join(
" {}".format(_quote_identifier(column)) for column in insert_columns
),
")", ")",
"values (", "values (",
",\n".join(f" :{names[column]}" for column in insert_columns), ",\n".join(" :{}".format(names[column]) for column in insert_columns),
")", ")",
) )
) )
@ -104,14 +105,18 @@ def _update_template_sql(table, columns):
if not set_columns: if not set_columns:
return "\n".join( return "\n".join(
( (
f"update {_quote_identifier(table)}", "update {}".format(_quote_identifier(table)),
f"set {_quote_identifier(where_column)} = :new_{names[where_column]}", "set {} = :new_{}".format(
f"where {_quote_identifier(where_column)} = :{names[where_column]}", _quote_identifier(where_column), names[where_column]
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
) )
) )
return "\n".join( return "\n".join(
( (
f"update {_quote_identifier(table)}", "update {}".format(_quote_identifier(table)),
"set " "set "
+ ",\n".join( + ",\n".join(
"{}{} = :{}".format( "{}{} = :{}".format(
@ -121,7 +126,9 @@ def _update_template_sql(table, columns):
) )
for index, column in enumerate(set_columns) for index, column in enumerate(set_columns)
), ),
f"where {_quote_identifier(where_column)} = :{names[where_column]}", "where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
) )
) )
@ -132,8 +139,10 @@ def _delete_template_sql(table, columns):
where_column = _preferred_where_column(table, column_names) where_column = _preferred_where_column(table, column_names)
return "\n".join( return "\n".join(
( (
f"delete from {_quote_identifier(table)}", "delete from {}".format(_quote_identifier(table)),
f"where {_quote_identifier(where_column)} = :{names[where_column]}", "where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
) )
) )
@ -198,23 +207,6 @@ def _write_template_operations(write_template_tables):
return operations return operations
async def _create_table_template_sql(datasette, db, actor):
if await datasette.allowed(
action="create-table",
resource=DatabaseResource(db.name),
actor=actor,
):
return CREATE_TABLE_TEMPLATE_SQL
return None
def _analysis_changes_schema(analysis):
return any(
operation.operation in {"create", "alter", "drop"}
for operation in analysis.operations
)
class ExecuteWriteView(BaseView): class ExecuteWriteView(BaseView):
name = "execute-write" name = "execute-write"
has_json_alternate = False has_json_alternate = False
@ -249,9 +241,6 @@ class ExecuteWriteView(BaseView):
self.ds, db, table_columns, hidden_table_names, request.actor self.ds, db, table_columns, hidden_table_names, request.actor
) )
write_template_operations = _write_template_operations(write_template_tables) write_template_operations = _write_template_operations(write_template_tables)
write_create_table_template_sql = await _create_table_template_sql(
self.ds, db, request.actor
)
if sql and analysis_error is None: if sql and analysis_error is None:
try: try:
parameter_names = _derived_query_parameters(sql) parameter_names = _derived_query_parameters(sql)
@ -313,7 +302,6 @@ class ExecuteWriteView(BaseView):
"table_columns": table_columns, "table_columns": table_columns,
"write_template_tables": write_template_tables, "write_template_tables": write_template_tables,
"write_template_operations": write_template_operations, "write_template_operations": write_template_operations,
"write_create_table_template_sql": write_create_table_template_sql,
"save_query_url": save_query_url, "save_query_url": save_query_url,
"save_query_base_url": save_query_base_url, "save_query_base_url": save_query_base_url,
}, },
@ -330,7 +318,7 @@ class ExecuteWriteView(BaseView):
) )
if not db.is_mutable: if not db.is_mutable:
return _block_framing( return _block_framing(
Response.error( _error(
["Cannot execute write SQL because this database is immutable."], ["Cannot execute write SQL because this database is immutable."],
403, 403,
) )
@ -349,10 +337,10 @@ class ExecuteWriteView(BaseView):
actor=request.actor, actor=request.actor,
): ):
return _block_framing( return _block_framing(
Response.error(["Permission denied: need execute-write-sql"], 403) _error(["Permission denied: need execute-write-sql"], 403)
) )
if not db.is_mutable: if not db.is_mutable:
return _block_framing(Response.error(["Database is immutable"], 403)) return _block_framing(_error(["Database is immutable"], 403))
data = {} data = {}
is_json = request.headers.get("content-type", "").startswith("application/json") is_json = request.headers.get("content-type", "").startswith("application/json")
@ -366,7 +354,7 @@ class ExecuteWriteView(BaseView):
) )
except QueryValidationError as ex: except QueryValidationError as ex:
if _wants_json(request, is_json, data): if _wants_json(request, is_json, data):
return _block_framing(Response.error([ex.message], ex.status)) return _block_framing(_error([ex.message], ex.status))
if ex.flash: if ex.flash:
self.ds.add_message(request, ex.message, self.ds.ERROR) self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form( return await self._render_form(
@ -387,7 +375,7 @@ class ExecuteWriteView(BaseView):
except sqlite3.DatabaseError as ex: except sqlite3.DatabaseError as ex:
message = str(ex) message = str(ex)
if wants_json: if wants_json:
return _block_framing(Response.error([message], 400)) return _block_framing(_error([message], 400))
return await self._render_form( return await self._render_form(
request, request,
db, db,
@ -399,9 +387,6 @@ class ExecuteWriteView(BaseView):
status=400, status=400,
) )
if _analysis_changes_schema(analysis):
await self.ds.refresh_schemas(force=True)
if cursor.rowcount == -1: if cursor.rowcount == -1:
message = "Query executed" message = "Query executed"
else: else:
@ -470,18 +455,20 @@ class ExecuteWriteAnalyzeView(BaseView):
actor=request.actor, actor=request.actor,
): ):
return _block_framing( return _block_framing(
Response.error(["Permission denied: need execute-write-sql"], 403) _error(["Permission denied: need execute-write-sql"], 403)
) )
invalid_keys = set(request.args) - {"sql"} invalid_keys = set(request.args) - {"sql"}
if invalid_keys: if invalid_keys:
return _block_framing( return _block_framing(
Response.error( _error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400, 400,
) )
) )
sql = request.args.get("sql") or "" sql = request.args.get("sql") or ""
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor) return _block_framing(
analysis["unstable"] = UNSTABLE_API_MESSAGE Response.json(
return _block_framing(Response.json(analysis)) await _execute_write_analysis_data(self.ds, db, sql, request.actor)
)
)

View file

@ -2,11 +2,10 @@ import json
from datasette.plugins import pm from datasette.plugins import pm
from datasette.utils import ( from datasette.utils import (
UNSTABLE_API_MESSAGE,
CustomJSONEncoder,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
make_slot_function, make_slot_function,
CustomJSONEncoder,
) )
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
from datasette.version import __version__ from datasette.version import __version__
@ -46,15 +45,15 @@ class IndexView(BaseView):
databases = [] databases = []
# Iterate over allowed databases instead of all databases # Iterate over allowed databases instead of all databases
for name, allowed_db in allowed_db_dict.items(): for name in allowed_db_dict.keys():
db = self.ds.databases[name] db = self.ds.databases[name]
database_private = allowed_db.private database_private = allowed_db_dict[name].private
# Get allowed tables/views for this database # Get allowed tables/views for this database
allowed_for_db = tables_by_db.get(name, {}) allowed_for_db = tables_by_db.get(name, {})
# Get table names from allowed set instead of db.table_names() # Get table names from allowed set instead of db.table_names()
table_names = [child_name for child_name in allowed_for_db] table_names = [child_name for child_name in allowed_for_db.keys()]
hidden_table_names = set(await db.hidden_table_names()) hidden_table_names = set(await db.hidden_table_names())
@ -99,7 +98,7 @@ class IndexView(BaseView):
# We will be sorting by number of relationships, so populate that field # We will be sorting by number of relationships, so populate that field
all_foreign_keys = await db.get_all_foreign_keys() all_foreign_keys = await db.get_all_foreign_keys()
for table, foreign_keys in all_foreign_keys.items(): for table, foreign_keys in all_foreign_keys.items():
if table in tables: if table in tables.keys():
count = len(foreign_keys["incoming"] + foreign_keys["outgoing"]) count = len(foreign_keys["incoming"] + foreign_keys["outgoing"])
tables[table]["num_relationships_for_sorting"] = count tables[table]["num_relationships_for_sorting"] = count
@ -121,7 +120,8 @@ class IndexView(BaseView):
# Only add views if this is less than TRUNCATE_AT # Only add views if this is less than TRUNCATE_AT
if len(tables_and_views_truncated) < TRUNCATE_AT: if len(tables_and_views_truncated) < TRUNCATE_AT:
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated) num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
tables_and_views_truncated.extend(views[:num_views_to_add]) for view in views[:num_views_to_add]:
tables_and_views_truncated.append(view)
databases.append( databases.append(
{ {
@ -151,9 +151,7 @@ class IndexView(BaseView):
return Response( return Response(
json.dumps( json.dumps(
{ {
"ok": True, "databases": {db["name"]: db for db in databases},
"unstable": UNSTABLE_API_MESSAGE,
"databases": databases,
"metadata": await self.ds.get_instance_metadata(), "metadata": await self.ds.get_instance_metadata(),
}, },
cls=CustomJSONEncoder, cls=CustomJSONEncoder,

View file

@ -5,19 +5,6 @@ from datasette.resources import DatabaseResource
from datasette.stored_queries import ( from datasette.stored_queries import (
StoredQuery, StoredQuery,
) )
from datasette.utils import (
InvalidSql,
escape_sqlite,
parse_size_limit,
path_from_row_pks,
sqlite3,
validate_sql_select,
)
from datasette.utils import (
named_parameters as derive_named_parameters,
)
from datasette.utils.asgi import Forbidden
from datasette.utils.sql_analysis import Operation, SQLAnalysis
from datasette.write_sql import ( from datasette.write_sql import (
IgnoreWriteSqlOperation, IgnoreWriteSqlOperation,
QueryWriteRejected, QueryWriteRejected,
@ -25,6 +12,16 @@ from datasette.write_sql import (
decision_for_write_sql_operation, decision_for_write_sql_operation,
operation_is_write, operation_is_write,
) )
from datasette.utils import (
named_parameters as derive_named_parameters,
escape_sqlite,
path_from_row_pks,
sqlite3,
validate_sql_select,
InvalidSql,
)
from datasette.utils.asgi import Forbidden
from datasette.utils.sql_analysis import Operation, SQLAnalysis
_query_name_re = re.compile(r"^[^/\.\n]+$") _query_name_re = re.compile(r"^[^/\.\n]+$")
@ -35,6 +32,7 @@ _query_fields = {
"hide_sql", "hide_sql",
"fragment", "fragment",
"parameters", "parameters",
"params",
"is_private", "is_private",
"on_success_message", "on_success_message",
"on_success_redirect", "on_success_redirect",
@ -93,14 +91,16 @@ def _as_optional_bool(value, name):
return True return True
if lowered in {"0", "false", "f", "no", "off"}: if lowered in {"0", "false", "f", "no", "off"}:
return False return False
raise QueryValidationError(f"{name} must be 0 or 1") raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default, maximum): def _query_list_limit(value, default=50):
if value in (None, ""):
return default
try: try:
return parse_size_limit(value, default, maximum) return min(max(1, int(value)), 1000)
except ValueError as ex: except ValueError as ex:
raise QueryValidationError(str(ex)) from ex raise QueryValidationError("_size must be an integer") from ex
def _derived_query_parameters(sql): def _derived_query_parameters(sql):
@ -173,7 +173,7 @@ async def _json_or_form_payload(request):
try: try:
return json.loads(body or b"{}"), True return json.loads(body or b"{}"), True
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise QueryValidationError(f"Invalid JSON: {e}") raise QueryValidationError("Invalid JSON: {}".format(e))
return await request.post_vars(), False return await request.post_vars(), False
@ -194,7 +194,7 @@ async def _analyze_user_query(datasette, db, sql, *, actor):
try: try:
analysis = await db.analyze_sql(sql, params) analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex: except sqlite3.DatabaseError as ex:
raise QueryValidationError(f"Could not analyze query: {ex}") from ex raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
is_write = _analysis_is_write(analysis) is_write = _analysis_is_write(analysis)
if is_write: if is_write:
@ -295,7 +295,8 @@ def _coerce_execute_write_payload(data, is_json):
for key, value in data.items(): for key, value in data.items():
if key in {"sql", "csrftoken", "_json"}: if key in {"sql", "csrftoken", "_json"}:
continue continue
key = key.removeprefix(SQL_PARAMETER_FORM_PREFIX) if key.startswith(SQL_PARAMETER_FORM_PREFIX):
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
params[key] = value params[key] = value
if not isinstance(params, dict): if not isinstance(params, dict):
raise QueryValidationError("params must be a dictionary") raise QueryValidationError("params must be a dictionary")
@ -315,7 +316,7 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
try: try:
analysis = await db.analyze_sql(sql, params) analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex: except sqlite3.DatabaseError as ex:
raise QueryValidationError(f"Could not analyze query: {ex}") from ex raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
if not _analysis_is_write(analysis): if not _analysis_is_write(analysis):
raise QueryValidationError( raise QueryValidationError(
"Use /-/query for read-only SQL; this endpoint only executes writes" "Use /-/query for read-only SQL; this endpoint only executes writes"
@ -497,7 +498,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor):
) )
try: try:
result = await db.execute( result = await db.execute(
f"select {select} from {escape_sqlite(table)} where rowid = ?", "select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
[lastrowid], [lastrowid],
) )
except sqlite3.DatabaseError: except sqlite3.DatabaseError:
@ -540,7 +541,7 @@ async def _prepare_query_create(datasette, request, db, data):
raise QueryValidationError("Writable query fields require writable SQL") raise QueryValidationError("Writable query fields require writable SQL")
parameters = _coerce_query_parameters( parameters = _coerce_query_parameters(
data.get("parameters"), data.get("parameters", data.get("params")),
derived, derived,
) )
return { return {
@ -585,9 +586,9 @@ async def _prepare_query_update(datasette, request, db, existing: StoredQuery, u
actor=request.actor, actor=request.actor,
) )
if "parameters" in update: if "parameters" in update or "params" in update:
parameters = _coerce_query_parameters( parameters = _coerce_query_parameters(
update.get("parameters"), update.get("parameters", update.get("params")),
derived, derived,
) )
elif "sql" in update: elif "sql" in update:

View file

@ -1,395 +1,32 @@
import asyncio from datasette.utils.asgi import NotFound, Forbidden, Response
import json
import textwrap
import time
import urllib.parse
from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.events import DeleteRowEvent, UpdateRowEvent from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.extras import ExtraScope, extra_names_from_request
from datasette.plugins import pm
from datasette.resources import TableResource from datasette.resources import TableResource
from .base import DataView, BaseView, _error
from datasette.utils import ( from datasette.utils import (
CustomJSONEncoder,
CustomRow,
InvalidSql,
WriteJsonValueError,
add_cors_headers,
await_me_maybe, await_me_maybe,
call_with_supported_arguments, CustomRow,
decode_write_json_row,
escape_sqlite,
make_slot_function, make_slot_function,
path_from_row_pks, path_from_row_pks,
path_with_format,
path_with_removed_args,
sqlite3,
to_css_class, to_css_class,
escape_sqlite,
) )
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response from datasette.plugins import pm
import json
from . import Context, from_extra import markupsafe
from .base import BaseView, DatasetteError, stream_csv import sqlite_utils
from datasette.extras import extra_names_from_request
from .table import ( from .table import (
_table_page_data,
display_columns_and_rows, display_columns_and_rows,
_table_page_data,
row_label_from_label_column, row_label_from_label_column,
) )
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
@dataclass class RowView(DataView):
class RowContext(Context):
"The page showing an individual row, e.g. /fixtures/facetable/1."
documented_template = "row.html"
extras_scope = ExtraScope.ROW
# Fields resolved by registered extras - their documentation comes
# from the description on each Extra class in table_extras.py
columns: list = from_extra()
database: str = from_extra()
database_color: str = from_extra()
foreign_key_tables: list = from_extra()
metadata: dict = from_extra()
primary_keys: list = from_extra()
private: bool = from_extra()
table: str = from_extra()
# Fields added by the view code
ok: bool = field(
metadata={"help": "True if the data for this page was retrieved without errors"}
)
rows: list = field(
metadata={
"help": "A single-item list containing this row as a dictionary mapping column name to raw value."
}
)
primary_key_values: list = field(
metadata={"help": "Values of the primary keys for this row, from the URL"}
)
query_ms: float = field(
metadata={
"help": "Time taken by the SQL queries for this page, in milliseconds"
}
)
display_columns: list = field(
metadata={
"help": "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
}
)
display_rows: list = field(
metadata={
"help": "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys."
}
)
custom_table_templates: list = field(
metadata={
"help": "Custom template names that were considered for displaying this row's table, in lookup order."
}
)
row_actions: list = field(
metadata={
"help": 'Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.'
}
)
row_mutation_ui: bool = field(
metadata={"help": "True if the row edit/delete JavaScript UI should be enabled"}
)
table_page_data: dict = field(
metadata={
"help": "JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs."
}
)
top_row: callable = field(
metadata={
"help": "Async callable that renders the ``top_row`` plugin slot for this row and returns HTML."
}
)
renderers: dict = field(
metadata={
"help": "Dictionary mapping output format names such as ``json`` to URLs for this row in that format."
}
)
url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
url_csv_hidden_args: list = field(
metadata={
"help": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``."
}
)
settings: dict = field(
metadata={
"help": "Dictionary of Datasette's current settings, keyed by setting name."
}
)
select_templates: list = field(
metadata={
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for the JSON version of this page"}
)
class RowView(BaseView):
name = "row" name = "row"
def redirect(self, request, path, forward_querystring=True, remove_args=None):
if request.query_string and "?" not in path and forward_querystring:
path = f"{path}?{request.query_string}"
if remove_args:
path = path_with_removed_args(request, remove_args, path=path)
response = Response.redirect(path)
response.headers["Link"] = f"<{path}>; rel=preload"
if self.ds.cors:
add_cors_headers(response.headers)
return response
async def as_csv(self, request, database):
return await stream_csv(self.ds, self.data, request, database)
async def get(self, request):
db = await self.ds.resolve_database(request)
database = db.name
database_route = db.route
format_ = request.url_vars.get("format") or "html"
data_kwargs = {}
if format_ == "csv":
return await self.as_csv(request, database_route)
if format_ == "html":
# HTML views default to expanding all foreign key labels
data_kwargs["default_labels"] = True
extra_template_data = {}
start = time.perf_counter()
status_code = None
templates = ()
try:
response_or_template_contexts = await self.data(request, **data_kwargs)
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
# If it has four items, it includes an HTTP status code
if len(response_or_template_contexts) == 4:
(
data,
extra_template_data,
templates,
status_code,
) = response_or_template_contexts
else:
data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex:
raise DatasetteError(
textwrap.dedent(f"""
<p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p>
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea>
<script>
let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px";
</script>
""").strip(),
title="SQL Interrupted",
status=400,
message_is_html=True,
plain_message=(
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
),
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
except DatasetteError:
raise
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
if format_ in self.ds.renderers:
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
result = call_with_supported_arguments(
self.ds.renderers[format_][0],
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=database,
table=data.get("table"),
request=request,
view_name=self.name,
truncated=False, # TODO: support this
error=data.get("error"),
# These will be deprecated in Datasette 1.0:
args=request.args,
data=data,
)
if asyncio.iscoroutine(result):
result = await result
if result is None:
raise NotFound("No data")
if isinstance(result, dict):
response = Response(
body=result.get("body"),
status=result.get("status_code", status_code or 200),
content_type=result.get("content_type", "text/plain"),
headers=result.get("headers"),
)
elif isinstance(result, Response):
response = result
if status_code is not None:
# Over-ride the status code
response.status = status_code
else:
assert False, f"{result} should be dict or Response"
elif format_ == "html":
response = await self.html(request, data, extra_template_data, templates)
if status_code is not None:
response.status = status_code
else:
raise NotFound(f"Invalid format: {format_}")
ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit():
ttl = self.ds.setting("default_cache_ttl")
return self.set_response_headers(response, ttl)
async def html(self, request, data, extra_template_data, templates):
extras = {}
if callable(extra_template_data):
extras = extra_template_data()
if asyncio.iscoroutine(extras):
extras = await extras
else:
extras = extra_template_data
url_labels_extra = {}
if data.get("expandable_columns"):
url_labels_extra = {"_labels": "on"}
renderers = {}
for key, (_, can_render) in self.ds.renderers.items():
it_can_render = call_with_supported_arguments(
can_render,
datasette=self.ds,
columns=data.get("columns") or [],
rows=data.get("rows") or [],
sql=data.get("query", {}).get("sql", None),
query_name=data.get("query_name"),
database=data.get("database"),
table=data.get("table"),
request=request,
view_name=self.name,
)
it_can_render = await await_me_maybe(it_can_render)
if it_can_render:
renderers[key] = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format=key,
extra_qs={**url_labels_extra},
)
)
url_csv_args = {"_size": "max", **url_labels_extra}
url_csv = self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="csv",
extra_qs=url_csv_args,
)
)
url_csv_path = url_csv.split("?")[0]
context = {**data, **extras}
if "metadata" not in context:
context["metadata"] = await self.ds.get_instance_metadata()
environment = self.ds.get_jinja_environment(request)
template = environment.select_template(templates)
alternate_url_json = self.ds.absolute_url(
request,
self.ds.urls.path(
path_with_format(
request=request,
path=request.scope.get("route_path"),
format="json",
)
),
)
return Response.html(
await self.ds.render_template(
template,
RowContext(
columns=context["columns"],
database=context["database"],
database_color=context["database_color"],
foreign_key_tables=context["foreign_key_tables"],
metadata=context["metadata"],
primary_keys=context["primary_keys"],
private=context["private"],
table=context["table"],
ok=context["ok"],
rows=context["rows"],
primary_key_values=context["primary_key_values"],
query_ms=context["query_ms"],
display_columns=context["display_columns"],
display_rows=context["display_rows"],
custom_table_templates=context["custom_table_templates"],
row_actions=context["row_actions"],
row_mutation_ui=context["row_mutation_ui"],
table_page_data=context["table_page_data"],
top_row=context["top_row"],
renderers=renderers,
url_csv=url_csv,
url_csv_path=url_csv_path,
url_csv_hidden_args=[
(key, value)
for key, value in urllib.parse.parse_qsl(request.query_string)
if key not in ("_labels", "_facet", "_size")
]
+ [("_size", "max")],
settings=self.ds.settings_dict(),
select_templates=[
f"{'*' if template_name == template.name else ''}{template_name}"
for template_name in templates
],
alternate_url_json=alternate_url_json,
),
request=request,
view_name=self.name,
),
headers={
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
},
)
def set_response_headers(self, response, ttl):
# Set far-future cache expiry
if self.ds.cache_headers and response.status == 200:
ttl = int(ttl)
if ttl == 0:
ttl_header = "no-cache"
else:
ttl_header = f"max-age={ttl}"
response.headers["Cache-Control"] = ttl_header
response.headers["Referrer-Policy"] = "no-referrer"
if self.ds.cors:
add_cors_headers(response.headers)
return response
async def data(self, request, default_labels=False): async def data(self, request, default_labels=False):
resolved = await self.ds.resolve_row(request) resolved = await self.ds.resolve_row(request)
db = resolved.db db = resolved.db
@ -497,7 +134,7 @@ class RowView(BaseView):
row_action_label = pk_path row_action_label = pk_path
if row_label and row_label != pk_path: if row_label and row_label != pk_path:
row_action_label = f"{pk_path} {row_label}" row_action_label = "{} {}".format(pk_path, row_label)
row_action_permissions = {} row_action_permissions = {}
if is_table and db.is_mutable: if is_table and db.is_mutable:
@ -510,7 +147,7 @@ class RowView(BaseView):
row_actions = [] row_actions = []
if row_action_permissions.get("update-row"): if row_action_permissions.get("update-row"):
attrs = { attrs = {
"aria-label": f"Edit row {row_action_label}", "aria-label": "Edit row {}".format(row_action_label),
"data-row": row_path, "data-row": row_path,
"data-row-action": "edit", "data-row-action": "edit",
} }
@ -526,7 +163,7 @@ class RowView(BaseView):
) )
if row_action_permissions.get("delete-row"): if row_action_permissions.get("delete-row"):
attrs = { attrs = {
"aria-label": f"Delete row {row_action_label}", "aria-label": "Delete row {}".format(row_action_label),
"data-row": row_path, "data-row": row_path,
"data-row-action": "delete", "data-row-action": "delete",
} }
@ -568,14 +205,13 @@ class RowView(BaseView):
], ],
"row_mutation_ui": any(row_action_permissions.values()), "row_mutation_ui": any(row_action_permissions.values()),
"table_page_data": await _table_page_data( "table_page_data": await _table_page_data(
datasette=self.ds, self.ds,
request=request, request,
db=db, db,
database_name=database, database,
table_name=table, table,
is_view=not is_table, not is_table,
table_insert_ui=None, None,
table_alter_ui=None,
), ),
"row_actions": row_actions, "row_actions": row_actions,
"top_row": make_slot_function( "top_row": make_slot_function(
@ -600,9 +236,6 @@ class RowView(BaseView):
} }
extras = extra_names_from_request(request) extras = extra_names_from_request(request)
if request.url_vars.get("format"):
# Data formats reject unknown extras; HTML ignores them
table_extra_registry.validate_requested(extras, ExtraScope.ROW)
# Process extras # Process extras
row_extra_context = RowExtraContext( row_extra_context = RowExtraContext(
@ -676,7 +309,7 @@ class RowView(BaseView):
key, key,
",".join(pk_values), ",".join(pk_values),
) )
foreign_key_tables.append({**fk, "count": count, "link": link}) foreign_key_tables.append({**fk, **{"count": count, "link": link}})
return foreign_key_tables return foreign_key_tables
@ -702,21 +335,21 @@ async def _row_flash_message(db, action, resolved, row=None):
if label: if label:
label = _truncated_row_flash_label(label) label = _truncated_row_flash_label(label)
if label and label != pk_label: if label and label != pk_label:
return f"{action} row {pk_label} ({label})" return "{} row {} ({})".format(action, pk_label, label)
return f"{action} row {pk_label}" return "{} row {}".format(action, pk_label)
async def _resolve_row_and_check_permission(datasette, request, permission): async def _resolve_row_and_check_permission(datasette, request, permission):
from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
try: try:
resolved = await datasette.resolve_row(request) resolved = await datasette.resolve_row(request)
except DatabaseNotFound as e: except DatabaseNotFound as e:
return False, Response.error([f"Database not found: {e.database_name}"], 404) return False, _error(["Database not found: {}".format(e.database_name)], 404)
except TableNotFound as e: except TableNotFound as e:
return False, Response.error([f"Table not found: {e.table}"], 404) return False, _error(["Table not found: {}".format(e.table)], 404)
except RowNotFound as e: except RowNotFound as e:
return False, Response.error([f"Record not found: {e.pk_values}"], 404) return False, _error(["Record not found: {}".format(e.pk_values)], 404)
# Ensure user has permission to delete this row # Ensure user has permission to delete this row
if not await datasette.allowed( if not await datasette.allowed(
@ -724,7 +357,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
resource=TableResource(database=resolved.db.name, table=resolved.table), resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor, actor=request.actor,
): ):
return False, Response.error(["Permission denied"], 403) return False, _error(["Permission denied"], 403)
return True, resolved return True, resolved
@ -748,9 +381,8 @@ class RowDeleteView(BaseView):
try: try:
await resolved.db.execute_write_fn(delete_row, request=request) await resolved.db.execute_write_fn(delete_row, request=request)
except Exception as e: # noqa: BLE001 except Exception as e:
# TODO: narrow to expected write errors so Datasette bugs surface as 500s return _error([str(e)], 500)
return Response.error([str(e)], 400)
await self.ds.track_event( await self.ds.track_event(
DeleteRowEvent( DeleteRowEvent(
@ -789,24 +421,18 @@ class RowUpdateView(BaseView):
try: try:
data = await request.json() data = await request.json()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"]) return _error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return Response.error([str(e)], 413)
if not isinstance(data, dict): if not isinstance(data, dict):
return Response.error(["JSON must be a dictionary"]) return _error(["JSON must be a dictionary"])
if "update" not in data or not isinstance(data["update"], dict): if "update" not in data or not isinstance(data["update"], dict):
return Response.error(["JSON must contain an update dictionary"]) return _error(["JSON must contain an update dictionary"])
invalid_keys = set(data.keys()) - {"update", "return", "alter"} invalid_keys = set(data.keys()) - {"update", "return", "alter"}
if invalid_keys: if invalid_keys:
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))]) return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
update = data["update"] update = data["update"]
try:
update = decode_write_json_row(update)
except WriteJsonValueError as e:
return Response.error([str(e)], 400)
# Validate column types # Validate column types
from datasette.views.table import _validate_column_types from datasette.views.table import _validate_column_types
@ -815,7 +441,7 @@ class RowUpdateView(BaseView):
self.ds, resolved.db.name, resolved.table, [update] self.ds, resolved.db.name, resolved.table, [update]
) )
if ct_errors: if ct_errors:
return Response.error(ct_errors, 400) return _error(ct_errors, 400)
alter = data.get("alter") alter = data.get("alter")
if alter and not await self.ds.allowed( if alter and not await self.ds.allowed(
@ -823,7 +449,7 @@ class RowUpdateView(BaseView):
resource=TableResource(database=resolved.db.name, table=resolved.table), resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied for alter-table"], 403) return _error(["Permission denied for alter-table"], 403)
def update_row(conn): def update_row(conn):
sqlite_utils.Database(conn)[resolved.table].update( sqlite_utils.Database(conn)[resolved.table].update(
@ -832,9 +458,8 @@ class RowUpdateView(BaseView):
try: try:
await resolved.db.execute_write_fn(update_row, request=request) await resolved.db.execute_write_fn(update_row, request=request)
except Exception as e: # noqa: BLE001 except Exception as e:
# TODO: narrow to expected write errors so Datasette bugs surface as 500s return _error([str(e)], 400)
return Response.error([str(e)], 400)
result = {"ok": True} result = {"ok": True}
returned_row = None returned_row = None
@ -843,7 +468,7 @@ class RowUpdateView(BaseView):
resolved.sql, resolved.params, truncate=True resolved.sql, resolved.params, truncate=True
) )
returned_row = results.dicts()[0] returned_row = results.dicts()[0]
result["rows"] = [returned_row] result["row"] = returned_row
await self.ds.track_event( await self.ds.track_event(
UpdateRowEvent( UpdateRowEvent(
@ -869,4 +494,4 @@ class RowUpdateView(BaseView):
self.ds.INFO, self.ds.INFO,
) )
return Response.json(result, status=200, default=CustomJSONEncoder().default) return Response.json(result, status=200)

View file

@ -1,25 +1,20 @@
import json import json
import logging import logging
import secrets
import urllib
from datasette.events import CreateTokenEvent, LoginEvent, LogoutEvent
from datasette.jump import JumpSQL, namespace_sql_params from datasette.jump import JumpSQL, namespace_sql_params
from datasette.plugins import pm from datasette.plugins import pm
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import ( from datasette.utils import (
UNSTABLE_API_MESSAGE,
actor_matches_allow, actor_matches_allow,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
error_body,
parse_size_limit,
tilde_decode,
tilde_encode, tilde_encode,
tilde_decode,
) )
from datasette.utils.asgi import Forbidden, Response
from .base import BaseView, View from .base import BaseView, View
import secrets
import urllib
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -57,9 +52,9 @@ class JsonDataView(BaseView):
if self.permission: if self.permission:
await self.ds.ensure_permission(action=self.permission, actor=request.actor) await self.ds.ensure_permission(action=self.permission, actor=request.actor)
if self.needs_request: if self.needs_request:
data = await await_me_maybe(self.data_callback(request)) data = self.data_callback(request)
else: else:
data = await await_me_maybe(self.data_callback()) data = self.data_callback()
# Return JSON or HTML depending on format parameter # Return JSON or HTML depending on format parameter
as_format = request.url_vars.get("format") as_format = request.url_vars.get("format")
@ -67,8 +62,6 @@ class JsonDataView(BaseView):
headers = {} headers = {}
if self.ds.cors: if self.ds.cors:
add_cors_headers(headers) add_cors_headers(headers)
if isinstance(data, dict):
data = {"ok": True, **data}
return Response.json(data, headers=headers) return Response.json(data, headers=headers)
else: else:
context = { context = {
@ -181,7 +174,9 @@ class AutocompleteDebugView(BaseView):
) )
context.update( context.update(
{ {
"autocomplete_url": f"{self.ds.urls.table(database_name, table_name)}/-/autocomplete", "autocomplete_url": "{}/-/autocomplete".format(
self.ds.urls.table(database_name, table_name)
),
"label_column": await db.label_column_for_table(table_name), "label_column": await db.label_column_for_table(table_name),
} }
) )
@ -297,12 +292,6 @@ class PermissionsDebugView(BaseView):
response, status = await _check_permission_for_actor( response, status = await _check_permission_for_actor(
self.ds, permission, parent, child, actor self.ds, permission, parent, child, actor
) )
if response.get("ok"):
response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
**response,
}
return Response.json(response, status=status) return Response.json(response, status=status)
@ -359,32 +348,29 @@ class AllowedResourcesView(BaseView):
async def _allowed_payload(self, request, has_debug_permission): async def _allowed_payload(self, request, has_debug_permission):
action = request.args.get("action") action = request.args.get("action")
if not action: if not action:
return error_body("action parameter is required", 400), 400 return {"error": "action parameter is required"}, 400
if action not in self.ds.actions: if action not in self.ds.actions:
return error_body(f"Unknown action: {action}", 404), 404 return {"error": f"Unknown action: {action}"}, 404
actor = request.actor if isinstance(request.actor, dict) else None actor = request.actor if isinstance(request.actor, dict) else None
actor_id = actor.get("id") if actor else None actor_id = actor.get("id") if actor else None
parent_filter = request.args.get("parent") parent_filter = request.args.get("parent")
child_filter = request.args.get("child") child_filter = request.args.get("child")
if child_filter and not parent_filter: if child_filter and not parent_filter:
return ( return {"error": "parent must be provided when child is specified"}, 400
error_body("parent must be provided when child is specified", 400),
400,
)
try: try:
page = int(request.args.get("_page", "1")) page = int(request.args.get("page", "1"))
if page < 1: page_size = int(request.args.get("page_size", "50"))
raise ValueError
except ValueError: except ValueError:
return error_body("_page must be a positive integer", 400), 400 return {"error": "page and page_size must be integers"}, 400
try: if page < 1:
page_size = parse_size_limit( return {"error": "page must be >= 1"}, 400
request.args.get("_size"), default=50, maximum=200 if page_size < 1:
) return {"error": "page_size must be >= 1"}, 400
except ValueError as ex: max_page_size = 200
return error_body(str(ex), 400), 400 if page_size > max_page_size:
page_size = max_page_size
offset = (page - 1) * page_size offset = (page - 1) * page_size
# Use the simplified allowed_resources method # Use the simplified allowed_resources method
@ -420,14 +406,10 @@ class AllowedResourcesView(BaseView):
row["reason"] = resource.reasons row["reason"] = resource.reasons
allowed_rows.append(row) allowed_rows.append(row)
except Exception: # noqa: BLE001 except Exception:
# Returns empty results if the catalog tables don't exist yet, but # If catalog tables don't exist yet, return empty results
# also swallows the AttributeError raised for instance-level actions
# such as view-instance, which have no resource_class.
# TODO: handle that case explicitly and narrow this to sqlite3.Error
return ( return (
{ {
"ok": True,
"action": action, "action": action,
"actor_id": actor_id, "actor_id": actor_id,
"page": page, "page": page,
@ -452,17 +434,16 @@ class AllowedResourcesView(BaseView):
def build_page_url(page_number): def build_page_url(page_number):
pairs = [] pairs = []
for key in request.args: for key in request.args:
if key in {"_page", "_size"}: if key in {"page", "page_size"}:
continue continue
for value in request.args.getlist(key): for value in request.args.getlist(key):
pairs.append((key, value)) pairs.append((key, value))
pairs.append(("_page", str(page_number))) pairs.append(("page", str(page_number)))
pairs.append(("_size", str(page_size))) pairs.append(("page_size", str(page_size)))
query = urllib.parse.urlencode(pairs) query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}" return f"{request.path}?{query}"
response = { response = {
"ok": True,
"action": action, "action": action,
"actor_id": actor_id, "actor_id": actor_id,
"page": page, "page": page,
@ -504,29 +485,31 @@ class PermissionRulesView(BaseView):
# JSON API - action parameter is required # JSON API - action parameter is required
action = request.args.get("action") action = request.args.get("action")
if not action: if not action:
return Response.error("action parameter is required", 400) return Response.json({"error": "action parameter is required"}, status=400)
if action not in self.ds.actions: if action not in self.ds.actions:
return Response.error(f"Unknown action: {action}", 404) return Response.json({"error": f"Unknown action: {action}"}, status=404)
actor = request.actor if isinstance(request.actor, dict) else None actor = request.actor if isinstance(request.actor, dict) else None
try: try:
page = int(request.args.get("_page", "1")) page = int(request.args.get("page", "1"))
if page < 1: page_size = int(request.args.get("page_size", "50"))
raise ValueError
except ValueError: except ValueError:
return Response.error("_page must be a positive integer", 400) return Response.json(
try: {"error": "page and page_size must be integers"}, status=400
page_size = parse_size_limit(
request.args.get("_size"), default=50, maximum=200
) )
except ValueError as ex: if page < 1:
return Response.error(str(ex), 400) return Response.json({"error": "page must be >= 1"}, status=400)
if page_size < 1:
return Response.json({"error": "page_size must be >= 1"}, status=400)
max_page_size = 200
if page_size > max_page_size:
page_size = max_page_size
offset = (page - 1) * page_size offset = (page - 1) * page_size
from datasette.utils.actions_sql import build_permission_rules_sql from datasette.utils.actions_sql import build_permission_rules_sql
union_sql, union_params, _restriction_sqls = await build_permission_rules_sql( union_sql, union_params, restriction_sqls = await build_permission_rules_sql(
self.ds, actor, action self.ds, actor, action
) )
await self.ds.refresh_schemas() await self.ds.refresh_schemas()
@ -572,17 +555,16 @@ class PermissionRulesView(BaseView):
def build_page_url(page_number): def build_page_url(page_number):
pairs = [] pairs = []
for key in request.args: for key in request.args:
if key in {"_page", "_size"}: if key in {"page", "page_size"}:
continue continue
for value in request.args.getlist(key): for value in request.args.getlist(key):
pairs.append((key, value)) pairs.append((key, value))
pairs.append(("_page", str(page_number))) pairs.append(("page", str(page_number)))
pairs.append(("_size", str(page_size))) pairs.append(("page_size", str(page_size)))
query = urllib.parse.urlencode(pairs) query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}" return f"{request.path}?{query}"
response = { response = {
"ok": True,
"action": action, "action": action,
"actor_id": (actor or {}).get("id") if actor else None, "actor_id": (actor or {}).get("id") if actor else None,
"page": page, "page": page,
@ -603,17 +585,17 @@ class PermissionRulesView(BaseView):
async def _check_permission_for_actor(ds, action, parent, child, actor): async def _check_permission_for_actor(ds, action, parent, child, actor):
"""Shared logic for checking and explaining a permission decision.""" """Shared logic for checking permissions. Returns a dict with check results."""
if action not in ds.actions: if action not in ds.actions:
return error_body(f"Unknown action: {action}", 404), 404 return {"error": f"Unknown action: {action}"}, 404
if child and not parent: if child and not parent:
return error_body("parent is required when child is provided", 400), 400 return {"error": "parent is required when child is provided"}, 400
# Use the action's properties to create the appropriate resource object # Use the action's properties to create the appropriate resource object
action_obj = ds.actions.get(action) action_obj = ds.actions.get(action)
if not action_obj: if not action_obj:
return error_body(f"Unknown action: {action}", 400), 400 return {"error": f"Unknown action: {action}"}, 400
# Global actions (no resource_class) don't have a resource # Global actions (no resource_class) don't have a resource
if action_obj.resource_class is None: if action_obj.resource_class is None:
@ -628,32 +610,18 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
resource_obj = action_obj.resource_class(parent) resource_obj = action_obj.resource_class(parent)
else: else:
# This shouldn't happen given validation in Action.__post_init__ # This shouldn't happen given validation in Action.__post_init__
return error_body(f"Invalid action configuration: {action}", 500), 500 return {"error": f"Invalid action configuration: {action}"}, 500
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor) allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
from datasette.utils.actions_sql import explain_permission_for_resource
explanation = await explain_permission_for_resource(
datasette=ds,
actor=actor,
action=action,
parent=parent,
child=child,
)
response = { response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"action": action, "action": action,
"allowed": bool(allowed), "allowed": bool(allowed),
"actor": actor,
"resource": { "resource": {
"parent": parent, "parent": parent,
"child": child, "child": child,
"path": _resource_path(parent, child), "path": _resource_path(parent, child),
}, },
"explanation": explanation,
} }
if actor and "id" in actor: if actor and "id" in actor:
@ -671,25 +639,11 @@ class PermissionCheckView(BaseView):
as_format = request.url_vars.get("format") as_format = request.url_vars.get("format")
if not as_format: if not as_format:
actions = [
{
"name": action.name,
"description": action.description,
"takes_parent": action.takes_parent,
"takes_child": action.takes_child,
"also_requires": action.also_requires,
}
for action in sorted(
self.ds.actions.values(), key=lambda action: action.name
)
]
return await self.render( return await self.render(
["debug_check.html"], ["debug_check.html"],
request, request,
{ {
"actions": actions, "sorted_actions": sorted(self.ds.actions.keys()),
"actor_json": request.args.get("actor")
or json.dumps(request.actor, indent=2),
"has_debug_permission": True, "has_debug_permission": True,
}, },
) )
@ -697,22 +651,13 @@ class PermissionCheckView(BaseView):
# JSON API - action parameter is required # JSON API - action parameter is required
action = request.args.get("action") action = request.args.get("action")
if not action: if not action:
return Response.error("action parameter is required", 400) return Response.json({"error": "action parameter is required"}, status=400)
parent = request.args.get("parent") parent = request.args.get("parent")
child = request.args.get("child") child = request.args.get("child")
actor = request.actor
actor_json = request.args.get("actor")
if actor_json is not None:
try:
actor = json.loads(actor_json)
except json.JSONDecodeError as ex:
return Response.error(f"Invalid actor JSON: {ex}", 400)
if actor is not None and not isinstance(actor, dict):
return Response.error("actor must be a JSON object or null", 400)
response, status = await _check_permission_for_actor( response, status = await _check_permission_for_actor(
self.ds, action, parent, child, actor self.ds, action, parent, child, request.actor
) )
return Response.json(response, status=status) return Response.json(response, status=status)
@ -939,7 +884,7 @@ class ApiExplorerView(BaseView):
tables.append({"name": table, "links": table_links}) tables.append({"name": table, "links": table_links})
table_links.append( table_links.append(
{ {
"label": f"Get rows for {table}", "label": "Get rows for {}".format(table),
"method": "GET", "method": "GET",
"path": self.ds.urls.table(name, table, format="json"), "path": self.ds.urls.table(name, table, format="json"),
} }
@ -959,7 +904,7 @@ class ApiExplorerView(BaseView):
{ {
"path": self.ds.urls.table(name, table) + "/-/insert", "path": self.ds.urls.table(name, table) + "/-/insert",
"method": "POST", "method": "POST",
"label": f"Insert rows into {table}", "label": "Insert rows into {}".format(table),
"json": { "json": {
"rows": [ "rows": [
{ {
@ -973,7 +918,7 @@ class ApiExplorerView(BaseView):
{ {
"path": self.ds.urls.table(name, table) + "/-/upsert", "path": self.ds.urls.table(name, table) + "/-/upsert",
"method": "POST", "method": "POST",
"label": f"Upsert rows into {table}", "label": "Upsert rows into {}".format(table),
"json": { "json": {
"rows": [ "rows": [
{ {
@ -1003,7 +948,7 @@ class ApiExplorerView(BaseView):
table_links.append( table_links.append(
{ {
"path": self.ds.urls.table(name, table) + "/-/drop", "path": self.ds.urls.table(name, table) + "/-/drop",
"label": f"Drop table {table}", "label": "Drop table {}".format(table),
"json": {"confirm": False}, "json": {"confirm": False},
"method": "POST", "method": "POST",
} }
@ -1020,7 +965,7 @@ class ApiExplorerView(BaseView):
database_links.append( database_links.append(
{ {
"path": self.ds.urls.database(name) + "/-/create", "path": self.ds.urls.database(name) + "/-/create",
"label": f"Create table in {name}", "label": "Create table in {}".format(name),
"json": { "json": {
"table": "new_table", "table": "new_table",
"columns": [ "columns": [
@ -1253,7 +1198,7 @@ class JumpView(BaseView):
match["display_name"] = row["display_name"] match["display_name"] = row["display_name"]
matches.append(match) matches.append(match)
return Response.json({"ok": True, "matches": matches, "truncated": truncated}) return Response.json({"matches": matches, "truncated": truncated})
class SchemaBaseView(BaseView): class SchemaBaseView(BaseView):
@ -1275,7 +1220,7 @@ class SchemaBaseView(BaseView):
headers = {} headers = {}
if self.ds.cors: if self.ds.cors:
add_cors_headers(headers) add_cors_headers(headers)
return Response.json({"ok": True, **data}, headers=headers) return Response.json(data, headers=headers)
def format_error_response(self, error_message, format_, status=404): def format_error_response(self, error_message, format_, status=404):
"""Format error response based on requested format.""" """Format error response based on requested format."""
@ -1284,7 +1229,7 @@ class SchemaBaseView(BaseView):
if self.ds.cors: if self.ds.cors:
add_cors_headers(headers) add_cors_headers(headers)
return Response.json( return Response.json(
error_body(error_message, status), status=status, headers=headers {"ok": False, "error": error_message}, status=status, headers=headers
) )
else: else:
return Response.text(error_message, status=status) return Response.text(error_message, status=status)
@ -1360,17 +1305,17 @@ class DatabaseSchemaView(SchemaBaseView):
database_name = request.url_vars["database"] database_name = request.url_vars["database"]
format_ = request.url_vars.get("format") or "html" format_ = request.url_vars.get("format") or "html"
# Permission check comes first, so actors without view-database # Check if database exists
# cannot distinguish existing databases from missing ones if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Check view-database permission
await self.ds.ensure_permission( await self.ds.ensure_permission(
action="view-database", action="view-database",
resource=DatabaseResource(database=database_name), resource=DatabaseResource(database=database_name),
actor=request.actor, actor=request.actor,
) )
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
schema = await self.get_database_schema(database_name) schema = await self.get_database_schema(database_name)
if format_ == "json": if format_ == "json":
@ -1404,9 +1349,6 @@ class TableSchemaView(SchemaBaseView):
actor=request.actor, actor=request.actor,
) )
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Get schema for the table # Get schema for the table
db = self.ds.databases[database_name] db = self.ds.databases[database_name]
result = await db.execute( result = await db.execute(

View file

@ -2,10 +2,10 @@ from urllib.parse import parse_qsl, urlencode
from datasette.resources import DatabaseResource, QueryResource from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict from datasette.stored_queries import stored_query_to_dict
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3, tilde_decode from datasette.utils import sqlite3, tilde_decode
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
from .base import BaseView from .base import BaseView, _error
from .query_helpers import ( from .query_helpers import (
QueryValidationError, QueryValidationError,
_as_bool, _as_bool,
@ -34,14 +34,12 @@ class QueryParametersView(BaseView):
resource=DatabaseResource(db.name), resource=DatabaseResource(db.name),
actor=request.actor, actor=request.actor,
): ):
return _block_framing( return _block_framing(_error(["Permission denied: need execute-sql"], 403))
Response.error(["Permission denied: need execute-sql"], 403)
)
invalid_keys = set(request.args) - {"sql"} invalid_keys = set(request.args) - {"sql"}
if invalid_keys: if invalid_keys:
return _block_framing( return _block_framing(
Response.error( _error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400, 400,
) )
@ -49,16 +47,8 @@ class QueryParametersView(BaseView):
try: try:
parameters = _derived_query_parameters(request.args.get("sql") or "") parameters = _derived_query_parameters(request.args.get("sql") or "")
except QueryValidationError as ex: except QueryValidationError as ex:
return _block_framing(Response.error([ex.message], ex.status)) return _block_framing(_error([ex.message], ex.status))
return _block_framing( return _block_framing(Response.json({"ok": True, "parameters": parameters}))
Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"parameters": parameters,
}
)
)
def _query_list_url(path, query_string, *, set_args=None, remove_args=None): def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
@ -92,12 +82,11 @@ class QueryListView(BaseView):
limit = _query_list_limit( limit = _query_list_limit(
request.args.get("_size"), request.args.get("_size"),
default=20 if format_ == "html" else 50, default=20 if format_ == "html" else 50,
maximum=self.ds.max_returned_rows,
) )
is_write = _as_optional_bool(request.args.get("is_write"), "is_write") is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
is_private = _as_optional_bool(request.args.get("is_private"), "is_private") is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
except QueryValidationError as ex: except QueryValidationError as ex:
return Response.error([ex.message], ex.status) return _error([ex.message], ex.status)
page = await self.ds.list_queries( page = await self.ds.list_queries(
database, database,
@ -122,9 +111,9 @@ class QueryListView(BaseView):
if key != "_next" if key != "_next"
] ]
pairs.append(("_next", page.next)) pairs.append(("_next", page.next))
next_url = self.ds.absolute_url( next_url = "{}?{}".format(
request, query_list_path,
f"{request.path}?{urlencode(pairs)}", urlencode(pairs),
) )
current_filters = { current_filters = {
@ -210,6 +199,7 @@ class QueryListView(BaseView):
"queries": page.queries, "queries": page.queries,
"next": page.next, "next": page.next,
"next_url": next_url, "next_url": next_url,
"has_more": page.has_more,
"limit": page.limit, "limit": page.limit,
"show_private_note": any(query.is_private for query in page.queries), "show_private_note": any(query.is_private for query in page.queries),
"show_trusted_note": any(query.is_trusted for query in page.queries), "show_trusted_note": any(query.is_trusted for query in page.queries),
@ -308,30 +298,28 @@ class QueryCreateAnalyzeView(BaseView):
resource=DatabaseResource(db.name), resource=DatabaseResource(db.name),
actor=request.actor, actor=request.actor,
): ):
return _block_framing( return _block_framing(_error(["Permission denied: need execute-sql"], 403))
Response.error(["Permission denied: need execute-sql"], 403)
)
if not await self.ds.allowed( if not await self.ds.allowed(
action="store-query", action="store-query",
resource=DatabaseResource(db.name), resource=DatabaseResource(db.name),
actor=request.actor, actor=request.actor,
): ):
return _block_framing( return _block_framing(_error(["Permission denied: need store-query"], 403))
Response.error(["Permission denied: need store-query"], 403)
)
invalid_keys = set(request.args) - {"sql"} invalid_keys = set(request.args) - {"sql"}
if invalid_keys: if invalid_keys:
return _block_framing( return _block_framing(
Response.error( _error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400, 400,
) )
) )
sql = request.args.get("sql") or "" sql = request.args.get("sql") or ""
analysis = await _query_create_analysis_data(self.ds, db, sql, request.actor) return _block_framing(
analysis["unstable"] = UNSTABLE_API_MESSAGE Response.json(
return _block_framing(Response.json(analysis)) await _query_create_analysis_data(self.ds, db, sql, request.actor)
)
)
class QueryStoreView(QueryCreateView): class QueryStoreView(QueryCreateView):
@ -358,13 +346,13 @@ class QueryStoreView(QueryCreateView):
resource=DatabaseResource(db.name), resource=DatabaseResource(db.name),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied: need execute-sql"], 403) return _error(["Permission denied: need execute-sql"], 403)
if not await self.ds.allowed( if not await self.ds.allowed(
action="store-query", action="store-query",
resource=DatabaseResource(db.name), resource=DatabaseResource(db.name),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied: need store-query"], 403) return _error(["Permission denied: need store-query"], 403)
is_json = False is_json = False
query_data = {} query_data = {}
@ -381,7 +369,7 @@ class QueryStoreView(QueryCreateView):
return await self._error_response( return await self._error_response(
request, db, query_data, ex.message, ex.status request, db, query_data, ex.message, ex.status
) )
return Response.error([ex.message], ex.status) return _error([ex.message], ex.status)
prepared.pop("analysis") prepared.pop("analysis")
name = prepared.pop("name") name = prepared.pop("name")
@ -390,18 +378,13 @@ class QueryStoreView(QueryCreateView):
except sqlite3.IntegrityError as ex: except sqlite3.IntegrityError as ex:
if not is_json and isinstance(query_data, dict): if not is_json and isinstance(query_data, dict):
return await self._error_response(request, db, query_data, str(ex), 400) return await self._error_response(request, db, query_data, str(ex), 400)
return Response.error([str(ex)], 400) return _error([str(ex)], 400)
query = await self.ds.get_query(db.name, name) query = await self.ds.get_query(db.name, name)
assert query is not None assert query is not None
if is_json: if is_json:
return Response.json( return Response.json(
{ {"ok": True, "query": stored_query_to_dict(query)}, status=201
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
},
status=201,
) )
self.ds.add_message(request, "Query saved", self.ds.INFO) self.ds.add_message(request, "Query saved", self.ds.INFO)
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name))) return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
@ -415,20 +398,14 @@ class QueryDefinitionView(BaseView):
query_name = tilde_decode(request.url_vars["query"]) query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name) query = await self.ds.get_query(db.name, query_name)
if query is None: if query is None:
return Response.error([f"Query not found: {query_name}"], 404) return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="view-query", action="view-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied"], 403) return _error(["Permission denied"], 403)
return Response.json( return Response.json({"ok": True, "query": stored_query_to_dict(query)})
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
}
)
class QueryUpdateView(BaseView): class QueryUpdateView(BaseView):
@ -439,17 +416,15 @@ class QueryUpdateView(BaseView):
query_name = tilde_decode(request.url_vars["query"]) query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name) existing = await self.ds.get_query(db.name, query_name)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="update-query", action="update-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied: need update-query"], 403) return _error(["Permission denied: need update-query"], 403)
if existing.is_trusted: if existing.is_trusted:
return Response.error( return _error(["Trusted queries cannot be updated using the API"], 403)
["Trusted queries cannot be updated using the API"], 403
)
try: try:
data, _ = await _json_or_form_payload(request) data, _ = await _json_or_form_payload(request)
@ -475,7 +450,7 @@ class QueryUpdateView(BaseView):
self.ds, request, db, existing, update self.ds, request, db, existing, update
) )
except QueryValidationError as ex: except QueryValidationError as ex:
return Response.error([ex.message], ex.status) return _error([ex.message], ex.status)
await self.ds.update_query(db.name, query_name, **update_kwargs) await self.ds.update_query(db.name, query_name, **update_kwargs)
if data.get("return"): if data.get("return"):
@ -532,32 +507,32 @@ class QueryEditView(BaseView):
async def get(self, request): async def get(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission( await self.ds.ensure_permission(
action="update-query", action="update-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
actor=request.actor, actor=request.actor,
) )
if existing.is_trusted: if existing.is_trusted:
return Response.error(["Trusted queries cannot be edited"], 403) return _error(["Trusted queries cannot be edited"], 403)
return await self._render_form(request, db, existing) return await self._render_form(request, db, existing)
async def post(self, request): async def post(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="update-query", action="update-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied: need update-query"], 403) return _error(["Permission denied: need update-query"], 403)
if existing.is_trusted: if existing.is_trusted:
return Response.error(["Trusted queries cannot be edited"], 403) return _error(["Trusted queries cannot be edited"], 403)
data, _ = await _json_or_form_payload(request) data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict): if not isinstance(data, dict):
return Response.error(["Invalid form submission"], 400) return _error(["Invalid form submission"], 400)
sql = data.get("sql") sql = data.get("sql")
sql = existing.sql if sql is None else sql.strip() sql = existing.sql if sql is None else sql.strip()
title = data.get("title") or "" title = data.get("title") or ""
@ -629,16 +604,12 @@ class QueryDeleteView(BaseView):
async def get(self, request): async def get(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return _error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission( await self.ds.ensure_permission(
action="delete-query", action="delete-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
actor=request.actor, actor=request.actor,
) )
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
return await self.render( return await self.render(
["query_delete.html"], ["query_delete.html"],
request, request,
@ -653,25 +624,21 @@ class QueryDeleteView(BaseView):
async def post(self, request): async def post(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return _error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="delete-query", action="delete-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
actor=request.actor, actor=request.actor,
): ):
return Response.error(["Permission denied: need delete-query"], 403) return _error(["Permission denied: need delete-query"], 403)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
_data, is_json = await _json_or_form_payload(request) data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name) await self.ds.remove_query(db.name, query_name)
if is_json: if is_json:
return Response.json({"ok": True}) return Response.json({"ok": True})
self.ds.add_message( self.ds.add_message(
request, request,
f"Query “{existing.title or query_name}” deleted", "Query “{}” deleted".format(existing.title or query_name),
self.ds.INFO, self.ds.INFO,
) )
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name))) return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))

Some files were not shown because too many files have changed in this diff Show more