mirror of
https://github.com/simonw/datasette.git
synced 2026-09-08 17:44:06 +02:00
Compare commits
1 commit
main
...
primary-ke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364b236771 |
184 changed files with 3042 additions and 10851 deletions
39
.github/actions/setup-sqlite-version/action.yml
vendored
39
.github/actions/setup-sqlite-version/action.yml
vendored
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
2
.github/workflows/deploy-latest.yml
vendored
2
.github/workflows/deploy-latest.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
16
.github/workflows/documentation-links.yml
vendored
Normal file
16
.github/workflows/documentation-links.yml
vendored
Normal 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"
|
||||
6
.github/workflows/playwright.yml
vendored
6
.github/workflows/playwright.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
matrix:
|
||||
browser: [chromium, firefox, webkit]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.14
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -25,14 +25,14 @@ jobs:
|
|||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Cache uv
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py3.14-uv-
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
|
||||
|
|
|
|||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Configure npm caching
|
||||
with:
|
||||
path: ~/.npm
|
||||
|
|
|
|||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
deploy_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/spellcheck.yml
vendored
2
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/stable-docs.yml
vendored
2
.github/workflows/stable-docs.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||
- name: Set up Git user
|
||||
|
|
|
|||
2
.github/workflows/test-coverage.yml
vendored
2
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/test-pyodide.yml
vendored
4
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-browsers
|
||||
|
|
|
|||
4
.github/workflows/test-sqlite-support.yml
vendored
4
.github/workflows/test-sqlite-support.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
#"3.23.1" # 2018-04-10, before UPSERT
|
||||
]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
||||
uses: ./.github/actions/setup-sqlite-version
|
||||
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
|
||||
with:
|
||||
version: ${{ matrix.sqlite-version }}
|
||||
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
||||
|
|
|
|||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
|
|
@ -11,17 +11,16 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
check-latest: true
|
||||
- name: Build extension for --load-extension test
|
||||
run: |-
|
||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||
|
|
|
|||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
|||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
|
|
|
|||
2
.github/workflows/tmate.yml
vendored
2
.github/workflows/tmate.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,8 +5,6 @@ datasets.json
|
|||
|
||||
scratchpad
|
||||
|
||||
ignored/
|
||||
|
||||
.vscode
|
||||
|
||||
uv.lock
|
||||
|
|
|
|||
3
Justfile
3
Justfile
|
|
@ -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 tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Run linters: black, ruff, prettier, cog
|
||||
# Run linters: black, ruff, cog
|
||||
@lint: codespell
|
||||
uv run black datasette tests --check
|
||||
uv run ruff check datasette tests
|
||||
npm run prettier -- --check
|
||||
uv run cog --check README.md docs/*.rst
|
||||
|
||||
# Apply ruff fixes
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
from datasette.permissions import Permission # noqa
|
||||
from datasette.version import __version_info__, __version__ # noqa
|
||||
from datasette.events import Event # noqa
|
||||
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import ( # noqa
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PayloadTooLarge,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
||||
from datasette.utils import actor_matches_allow # noqa
|
||||
from datasette.views import Context # noqa
|
||||
from .hookspecs import hookimpl # noqa
|
||||
|
|
|
|||
|
|
@ -89,8 +89,7 @@ def pytest_runtest_protocol(item, nextitem):
|
|||
continue
|
||||
try:
|
||||
ds.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Surfaced as a pytest warning; teardown must not fail the run
|
||||
except Exception as e:
|
||||
item.warn(
|
||||
pytest.PytestUnraisableExceptionWarning(
|
||||
f"Error closing Datasette instance: {e!r}"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import time
|
||||
|
||||
from itsdangerous import BadSignature
|
||||
|
||||
from datasette import hookimpl
|
||||
from itsdangerous import BadSignature
|
||||
from datasette.utils import baseconv
|
||||
import time
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
|
|||
487
datasette/app.py
487
datasette/app.py
|
|
@ -2,8 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import contextvars
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.permissions import Resource
|
||||
|
|
@ -13,10 +12,11 @@ import dataclasses
|
|||
import datetime
|
||||
import functools
|
||||
import glob
|
||||
import httpx
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
from itsdangerous import BadSignature
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
|
|
@ -28,36 +28,84 @@ import urllib.parse
|
|||
from concurrent import futures
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
from markupsafe import Markup, escape
|
||||
from itsdangerous import URLSafeSerializer
|
||||
from jinja2 import (
|
||||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
PrefixLoader,
|
||||
pass_context,
|
||||
PrefixLoader,
|
||||
)
|
||||
from jinja2.environment import Template
|
||||
from jinja2.exceptions import TemplateNotFound
|
||||
from markupsafe import Markup, escape
|
||||
|
||||
from . import stored_queries, write_sql
|
||||
from .column_types import SQLiteType
|
||||
from .csrf import CrossOriginProtectionMiddleware
|
||||
from .database import Database, QueryInterrupted
|
||||
from .events import Event
|
||||
from .plugins import DEFAULT_PLUGINS, get_plugins, pm
|
||||
from .column_types import SQLiteType
|
||||
from . import stored_queries, write_sql
|
||||
from .views import Context
|
||||
from .views.database import (
|
||||
database_download,
|
||||
DatabaseView,
|
||||
QueryView,
|
||||
)
|
||||
from .views.table_create_alter import (
|
||||
DatabaseForeignKeyTargetsView,
|
||||
TableAlterView,
|
||||
TableCreateView,
|
||||
TableForeignKeySuggestionsView,
|
||||
)
|
||||
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
||||
from .views.stored_queries import (
|
||||
QueryCreateAnalyzeView,
|
||||
QueryDeleteView,
|
||||
QueryDefinitionView,
|
||||
QueryEditView,
|
||||
GlobalQueryListView,
|
||||
QueryListView,
|
||||
QueryParametersView,
|
||||
QueryStoreView,
|
||||
QueryUpdateView,
|
||||
)
|
||||
from .views.index import IndexView
|
||||
from .views.special import (
|
||||
JsonDataView,
|
||||
PatternPortfolioView,
|
||||
AutocompleteDebugView,
|
||||
AuthTokenView,
|
||||
ApiExplorerView,
|
||||
CreateTokenView,
|
||||
LogoutView,
|
||||
AllowDebugView,
|
||||
PermissionsDebugView,
|
||||
MessagesDebugView,
|
||||
AllowedResourcesView,
|
||||
PermissionRulesView,
|
||||
PermissionCheckView,
|
||||
JumpView,
|
||||
InstanceSchemaView,
|
||||
DatabaseSchemaView,
|
||||
TableSchemaView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableAutocompleteView,
|
||||
TableInsertView,
|
||||
TableUpsertView,
|
||||
TableSetColumnTypeView,
|
||||
TableDropView,
|
||||
TableFragmentView,
|
||||
table_view,
|
||||
)
|
||||
from .views.row import RowView, RowDeleteView, RowUpdateView
|
||||
from .renderer import json_renderer
|
||||
from .resources import DatabaseResource, TableResource
|
||||
from .tokens import TokenInvalid
|
||||
from .tracer import AsgiTracer
|
||||
from .url_builder import Urls
|
||||
from .database import Database, QueryInterrupted
|
||||
|
||||
from .utils import (
|
||||
SPATIALITE_FUNCTIONS,
|
||||
PaginatedResources,
|
||||
PrefixedUrlString,
|
||||
SPATIALITE_FUNCTIONS,
|
||||
StartupError,
|
||||
add_cors_headers,
|
||||
async_call_with_supported_arguments,
|
||||
await_me_maybe,
|
||||
baseconv,
|
||||
|
|
@ -72,97 +120,45 @@ from .utils import (
|
|||
move_plugins_and_allow,
|
||||
move_table_config,
|
||||
parse_metadata,
|
||||
redact_keys,
|
||||
resolve_env_secrets,
|
||||
resolve_routes,
|
||||
row_sql_params_pks,
|
||||
sha256_file,
|
||||
tilde_decode,
|
||||
tilde_encode,
|
||||
to_css_class,
|
||||
urlsafe_components,
|
||||
redact_keys,
|
||||
row_sql_params_pks,
|
||||
)
|
||||
from .utils.asgi import (
|
||||
AsgiLifespan,
|
||||
AsgiRunOnFirstRequest,
|
||||
BadRequest,
|
||||
DatabaseNotFound,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
DatabaseNotFound,
|
||||
TableNotFound,
|
||||
RowNotFound,
|
||||
Request,
|
||||
Response,
|
||||
RowNotFound,
|
||||
TableNotFound,
|
||||
AsgiRunOnFirstRequest,
|
||||
asgi_static,
|
||||
asgi_send,
|
||||
asgi_send_file,
|
||||
asgi_send_redirect,
|
||||
asgi_static,
|
||||
)
|
||||
from .csrf import CrossOriginProtectionMiddleware
|
||||
from .utils.internal_db import init_internal_db, populate_schema_tables
|
||||
from .utils.sqlite import (
|
||||
sqlite3,
|
||||
using_pysqlite3,
|
||||
)
|
||||
from .tracer import AsgiTracer
|
||||
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
|
||||
from .version import __version__
|
||||
from .views import Context
|
||||
from .views.database import (
|
||||
DatabaseView,
|
||||
QueryView,
|
||||
database_download,
|
||||
)
|
||||
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
|
||||
from .views.index import IndexView
|
||||
from .views.row import RowDeleteView, RowUpdateView, RowView
|
||||
from .views.special import (
|
||||
AllowDebugView,
|
||||
AllowedResourcesView,
|
||||
ApiExplorerView,
|
||||
AuthTokenView,
|
||||
AutocompleteDebugView,
|
||||
CreateTokenView,
|
||||
DatabaseSchemaView,
|
||||
InstanceSchemaView,
|
||||
JsonDataView,
|
||||
JumpView,
|
||||
LogoutView,
|
||||
MessagesDebugView,
|
||||
PatternPortfolioView,
|
||||
PermissionCheckView,
|
||||
PermissionRulesView,
|
||||
PermissionsDebugView,
|
||||
TableSchemaView,
|
||||
)
|
||||
from .views.stored_queries import (
|
||||
GlobalQueryListView,
|
||||
QueryCreateAnalyzeView,
|
||||
QueryDefinitionView,
|
||||
QueryDeleteView,
|
||||
QueryEditView,
|
||||
QueryListView,
|
||||
QueryParametersView,
|
||||
QueryStoreView,
|
||||
QueryUpdateView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableAutocompleteView,
|
||||
TableDropView,
|
||||
TableFragmentView,
|
||||
TableInsertView,
|
||||
TableSetColumnTypeView,
|
||||
TableUpsertView,
|
||||
table_view,
|
||||
)
|
||||
from .views.table_create_alter import (
|
||||
DatabaseForeignKeyTargetsView,
|
||||
TableAlterView,
|
||||
TableCreateView,
|
||||
TableForeignKeySuggestionsView,
|
||||
)
|
||||
|
||||
from .resources import DatabaseResource, TableResource
|
||||
|
||||
app_root = Path(__file__).parent.parent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Context variable to track when code is executing within a datasette.client request
|
||||
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
|
||||
|
|
@ -185,7 +181,7 @@ class PermissionCheck:
|
|||
"""Represents a logged permission check for debugging purposes."""
|
||||
|
||||
when: str
|
||||
actor: dict[str, Any] | None
|
||||
actor: Dict[str, Any] | None
|
||||
action: str
|
||||
parent: str | None
|
||||
child: str | None
|
||||
|
|
@ -210,11 +206,6 @@ SETTINGS = (
|
|||
100,
|
||||
"Maximum rows that can be inserted at a time using the bulk insert API",
|
||||
),
|
||||
Setting(
|
||||
"max_post_body_bytes",
|
||||
2 * 1024 * 1024,
|
||||
"Maximum size in bytes for a POST body read into memory, e.g. JSON API requests - set 0 to disable this limit",
|
||||
),
|
||||
Setting(
|
||||
"num_sql_threads",
|
||||
3,
|
||||
|
|
@ -435,7 +426,7 @@ class Datasette:
|
|||
if config_dir:
|
||||
db_files = []
|
||||
for ext in ("db", "sqlite", "sqlite3"):
|
||||
db_files.extend(config_dir.glob(f"*.{ext}"))
|
||||
db_files.extend(config_dir.glob("*.{}".format(ext)))
|
||||
self.files += tuple(str(f) for f in db_files)
|
||||
if (
|
||||
config_dir
|
||||
|
|
@ -453,10 +444,8 @@ class Datasette:
|
|||
self.databases = collections.OrderedDict()
|
||||
self.actions = {} # .invoke_startup() will populate this
|
||||
self._column_types = {} # .invoke_startup() will populate this
|
||||
self._setup_db_done = False
|
||||
try:
|
||||
self._refresh_schemas_lock = asyncio.Lock()
|
||||
self._startup_lock = asyncio.Lock()
|
||||
except RuntimeError as rex:
|
||||
# Workaround for intermittent test failure, see:
|
||||
# https://github.com/simonw/datasette/issues/1802
|
||||
|
|
@ -464,7 +453,6 @@ class Datasette:
|
|||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
self._refresh_schemas_lock = asyncio.Lock()
|
||||
self._startup_lock = asyncio.Lock()
|
||||
else:
|
||||
raise
|
||||
self.crossdb = crossdb
|
||||
|
|
@ -679,10 +667,10 @@ class Datasette:
|
|||
def get_jinja_environment(self, request: Request = None) -> Environment:
|
||||
environment = self._jinja_env
|
||||
if request:
|
||||
for hook_environment in pm.hook.jinja2_environment_from_request(
|
||||
for environment in pm.hook.jinja2_environment_from_request(
|
||||
datasette=self, request=request, env=environment
|
||||
):
|
||||
environment = hook_environment
|
||||
pass
|
||||
return environment
|
||||
|
||||
def get_action(self, name_or_abbr: str):
|
||||
|
|
@ -736,7 +724,7 @@ class Datasette:
|
|||
catalog_database_names.update(
|
||||
row["database_name"]
|
||||
for row in await internal_db.execute(
|
||||
f"select distinct database_name from {table}"
|
||||
"select distinct database_name from {}".format(table)
|
||||
)
|
||||
if row["database_name"] is not None
|
||||
)
|
||||
|
|
@ -747,7 +735,7 @@ class Datasette:
|
|||
for stale_db_name in stale_databases:
|
||||
for table in catalog_table_names:
|
||||
conn.execute(
|
||||
f"DELETE FROM {table} WHERE database_name = ?",
|
||||
"DELETE FROM {} WHERE database_name = ?".format(table),
|
||||
[stale_db_name],
|
||||
)
|
||||
|
||||
|
|
@ -757,7 +745,19 @@ class Datasette:
|
|||
# Compare schema versions to see if we should skip it
|
||||
if schema_version == current_schema_versions.get(database_name):
|
||||
continue
|
||||
await populate_schema_tables(internal_db, db, schema_version)
|
||||
placeholders = "(?, ?, ?, ?)"
|
||||
values = [database_name, str(db.path), db.is_memory, schema_version]
|
||||
if db.path is None:
|
||||
placeholders = "(?, null, ?, ?)"
|
||||
values = [database_name, db.is_memory, schema_version]
|
||||
await internal_db.execute_write(
|
||||
"""
|
||||
INSERT OR REPLACE INTO catalog_databases (database_name, path, is_memory, schema_version)
|
||||
VALUES {}
|
||||
""".format(placeholders),
|
||||
values,
|
||||
)
|
||||
await populate_schema_tables(internal_db, db)
|
||||
|
||||
@property
|
||||
def urls(self):
|
||||
|
|
@ -796,13 +796,17 @@ class Datasette:
|
|||
action.name in action_names
|
||||
and action != action_names[action.name]
|
||||
):
|
||||
raise StartupError(f"Duplicate action name: {action.name}")
|
||||
raise StartupError(
|
||||
"Duplicate action name: {}".format(action.name)
|
||||
)
|
||||
if (
|
||||
action.abbr
|
||||
and action.abbr in action_abbrs
|
||||
and action != action_abbrs[action.abbr]
|
||||
):
|
||||
raise StartupError(f"Duplicate action abbr: {action.abbr}")
|
||||
raise StartupError(
|
||||
"Duplicate action abbr: {}".format(action.abbr)
|
||||
)
|
||||
action_names[action.name] = action
|
||||
if action.abbr:
|
||||
action_abbrs[action.abbr] = action
|
||||
|
|
@ -861,7 +865,7 @@ class Datasette:
|
|||
actor_id: str,
|
||||
*,
|
||||
expires_after: int | None = None,
|
||||
restrictions: TokenRestrictions | None = None,
|
||||
restrictions: "TokenRestrictions | None" = None,
|
||||
handler: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
|
|
@ -901,9 +905,7 @@ class Datasette:
|
|||
Verify an API token by trying all registered token handlers.
|
||||
|
||||
Returns an actor dict from the first handler that recognizes the
|
||||
token, or None if no handler accepts it. A handler may raise
|
||||
TokenInvalid for a token it recognizes but rejects (bad signature,
|
||||
expired) - Datasette turns that into a 401 response.
|
||||
token, or None if no handler accepts it.
|
||||
"""
|
||||
for token_handler in self._token_handlers():
|
||||
result = await token_handler.verify_token(self, token)
|
||||
|
|
@ -918,7 +920,7 @@ class Datasette:
|
|||
raise KeyError
|
||||
return matches[0]
|
||||
if name is None:
|
||||
name = next(iter(self.databases.keys()))
|
||||
name = [key for key in self.databases.keys()][0]
|
||||
return self.databases[name]
|
||||
|
||||
def add_database(self, db, name=None, route=None):
|
||||
|
|
@ -931,7 +933,7 @@ class Datasette:
|
|||
suggestion = name
|
||||
i = 2
|
||||
while name in self.databases:
|
||||
name = f"{suggestion}_{i}"
|
||||
name = "{}_{}".format(suggestion, i)
|
||||
i += 1
|
||||
db.name = name
|
||||
db.route = route or name
|
||||
|
|
@ -966,14 +968,13 @@ class Datasette:
|
|||
for db in dbs:
|
||||
try:
|
||||
db.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Collect the first failure and re-raise after every close() has run
|
||||
except Exception as e:
|
||||
if first_exception is None:
|
||||
first_exception = e
|
||||
if self.executor is not None:
|
||||
try:
|
||||
self.executor.shutdown(wait=True, cancel_futures=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
if first_exception is None:
|
||||
first_exception = e
|
||||
if first_exception is not None:
|
||||
|
|
@ -1322,15 +1323,24 @@ class Datasette:
|
|||
actual = (
|
||||
actual_sqlite_type.value
|
||||
if actual_sqlite_type is not None
|
||||
else f"unrecognized {column_detail.type!r}"
|
||||
else "unrecognized {!r}".format(column_detail.type)
|
||||
)
|
||||
raise ValueError(
|
||||
f"Column type {ct_cls.name!r} is only applicable to SQLite types {allowed} but {database}.{resource}.{column} "
|
||||
f"has SQLite type {actual}"
|
||||
"Column type {!r} is only applicable to SQLite types {} but {}.{}.{} "
|
||||
"has SQLite type {}".format(
|
||||
ct_cls.name,
|
||||
allowed,
|
||||
database,
|
||||
resource,
|
||||
column,
|
||||
actual,
|
||||
)
|
||||
)
|
||||
|
||||
async def _apply_column_types_config(self):
|
||||
"""Load column_types from datasette.json config into the internal DB."""
|
||||
import logging
|
||||
|
||||
for db_name, db_conf in (self.config or {}).get("databases", {}).items():
|
||||
for table_name, table_conf in db_conf.get("tables", {}).items():
|
||||
for col_name, ct in table_conf.get("column_types", {}).items():
|
||||
|
|
@ -1340,7 +1350,7 @@ class Datasette:
|
|||
col_type = ct["type"]
|
||||
config = ct.get("config")
|
||||
if col_type not in self._column_types:
|
||||
logger.warning(
|
||||
logging.warning(
|
||||
"column_types config references unknown type %r "
|
||||
"for %s.%s.%s",
|
||||
col_type,
|
||||
|
|
@ -1353,7 +1363,7 @@ class Datasette:
|
|||
db_name, table_name, col_name, col_type, config
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(str(ex))
|
||||
logging.warning(str(ex))
|
||||
|
||||
async def get_column_type(self, database: str, resource: str, column: str):
|
||||
"""
|
||||
|
|
@ -1406,7 +1416,7 @@ class Datasette:
|
|||
resource: str,
|
||||
column: str,
|
||||
column_type: str,
|
||||
config: dict | None = None,
|
||||
config: dict = None,
|
||||
) -> None:
|
||||
"""Assign a column type. Overwrites any existing assignment."""
|
||||
ct_cls = self._column_types.get(column_type)
|
||||
|
|
@ -1490,7 +1500,9 @@ class Datasette:
|
|||
possible_names = {plugin["name"], plugin["name"].replace("-", "_")}
|
||||
if plugin_name in possible_names:
|
||||
return _resolve_static_asset_path(plugin["static_path"], path)
|
||||
raise FileNotFoundError(f"No static assets found for plugin {plugin_name}")
|
||||
raise FileNotFoundError(
|
||||
"No static assets found for plugin {}".format(plugin_name)
|
||||
)
|
||||
|
||||
def _static_mounted_asset(self, mount_name, path):
|
||||
mount_name = mount_name.strip("/")
|
||||
|
|
@ -1500,7 +1512,7 @@ class Datasette:
|
|||
_resolve_static_asset_path(dirname, path),
|
||||
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))),
|
||||
)
|
||||
raise FileNotFoundError(f"No static mount found for {mount_name}")
|
||||
raise FileNotFoundError("No static mount found for {}".format(mount_name))
|
||||
|
||||
def _static_asset_hash(self, filepath):
|
||||
filepath = Path(filepath)
|
||||
|
|
@ -1591,17 +1603,18 @@ class Datasette:
|
|||
if await self.allowed(action="view-instance", actor=actor):
|
||||
crumbs.append({"href": self.urls.instance(), "label": "home"})
|
||||
# Database link
|
||||
if database and await self.allowed(
|
||||
action="view-database",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=actor,
|
||||
):
|
||||
crumbs.append(
|
||||
{
|
||||
"href": self.urls.database(database),
|
||||
"label": database,
|
||||
}
|
||||
)
|
||||
if database:
|
||||
if await self.allowed(
|
||||
action="view-database",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=actor,
|
||||
):
|
||||
crumbs.append(
|
||||
{
|
||||
"href": self.urls.database(database),
|
||||
"label": database,
|
||||
}
|
||||
)
|
||||
# Table link
|
||||
if table:
|
||||
assert database, "table= requires database="
|
||||
|
|
@ -1620,7 +1633,7 @@ class Datasette:
|
|||
|
||||
async def actors_from_ids(
|
||||
self, actor_ids: Iterable[str | int]
|
||||
) -> dict[int | str, dict]:
|
||||
) -> Dict[int | str, Dict]:
|
||||
result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids)
|
||||
if result is None:
|
||||
# Do the default thing
|
||||
|
|
@ -1629,9 +1642,9 @@ class Datasette:
|
|||
return result
|
||||
|
||||
async def track_event(self, event: Event):
|
||||
assert isinstance(
|
||||
event, self.event_classes
|
||||
), f"Invalid event type: {type(event)}"
|
||||
assert isinstance(event, self.event_classes), "Invalid event type: {}".format(
|
||||
type(event)
|
||||
)
|
||||
for hook in pm.hook.track_event(datasette=self, event=event):
|
||||
await await_me_maybe(hook)
|
||||
|
||||
|
|
@ -1668,7 +1681,7 @@ class Datasette:
|
|||
self,
|
||||
actor: dict,
|
||||
action: str,
|
||||
resource: Resource | None = None,
|
||||
resource: "Resource" | None = None,
|
||||
):
|
||||
"""
|
||||
Check if actor can see a resource and if it's private.
|
||||
|
|
@ -1867,7 +1880,10 @@ class Datasette:
|
|||
if truncated and resources:
|
||||
last_resource = resources[-1]
|
||||
# Use tilde-encoding like table pagination
|
||||
next_token = f"{tilde_encode(str(last_resource.parent))},{tilde_encode(str(last_resource.child))}"
|
||||
next_token = "{},{}".format(
|
||||
tilde_encode(str(last_resource.parent)),
|
||||
tilde_encode(str(last_resource.child)),
|
||||
)
|
||||
|
||||
return PaginatedResources(
|
||||
resources=resources,
|
||||
|
|
@ -1885,7 +1901,7 @@ class Datasette:
|
|||
self,
|
||||
*,
|
||||
action: str,
|
||||
resource: Resource = None,
|
||||
resource: "Resource" = None,
|
||||
actor: dict | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
|
|
@ -1916,7 +1932,7 @@ class Datasette:
|
|||
self,
|
||||
*,
|
||||
actions: Sequence[str],
|
||||
resource: Resource = None,
|
||||
resource: "Resource" = None,
|
||||
actor: dict | None = None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
|
|
@ -1937,11 +1953,11 @@ class Datasette:
|
|||
)
|
||||
# {"edit-schema": True, "drop-table": True, "insert-row": False}
|
||||
"""
|
||||
from datasette.utils.actions_sql import check_permissions_for_actions
|
||||
from datasette.permissions import (
|
||||
_permission_check_cache,
|
||||
_skip_permission_checks,
|
||||
)
|
||||
from datasette.utils.actions_sql import check_permissions_for_actions
|
||||
|
||||
# For global actions, resource is None
|
||||
parent = resource.parent if resource else None
|
||||
|
|
@ -2030,7 +2046,7 @@ class Datasette:
|
|||
self,
|
||||
*,
|
||||
action: str,
|
||||
resource: Resource = None,
|
||||
resource: "Resource" = None,
|
||||
actor: dict | None = None,
|
||||
):
|
||||
"""
|
||||
|
|
@ -2084,15 +2100,13 @@ class Datasette:
|
|||
db = self.databases[database]
|
||||
foreign_keys = await db.foreign_keys_for_table(table)
|
||||
# Find the foreign_key for this column
|
||||
fk = next(
|
||||
(
|
||||
try:
|
||||
fk = [
|
||||
foreign_key
|
||||
for foreign_key in foreign_keys
|
||||
if foreign_key["column"] == column
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fk is None:
|
||||
][0]
|
||||
except IndexError:
|
||||
return {}
|
||||
# Ensure user has permission to view the referenced table
|
||||
from datasette.resources import TableResource
|
||||
|
|
@ -2154,18 +2168,6 @@ class Datasette:
|
|||
for name, d in self.databases.items()
|
||||
]
|
||||
|
||||
async def _connected_databases_for_actor(self, actor):
|
||||
page = await self.allowed_resources("view-database", actor)
|
||||
allowed_names = {resource.parent async for resource in page.all()}
|
||||
return [
|
||||
database
|
||||
for database in self._connected_databases()
|
||||
if database["name"] in allowed_names
|
||||
]
|
||||
|
||||
async def _databases_data(self, request):
|
||||
return {"databases": await self._connected_databases_for_actor(request.actor)}
|
||||
|
||||
def _versions(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
self._prepare_connection(conn, "_memory")
|
||||
|
|
@ -2180,17 +2182,16 @@ class Datasette:
|
|||
sqlite_extensions[extension] = result.fetchone()[0]
|
||||
else:
|
||||
sqlite_extensions[extension] = None
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Probing for optional SQLite extensions - absence is the normal case
|
||||
except Exception:
|
||||
pass
|
||||
# More details on SpatiaLite
|
||||
if "spatialite" in sqlite_extensions:
|
||||
spatialite_details = {}
|
||||
for fn in SPATIALITE_FUNCTIONS:
|
||||
try:
|
||||
result = conn.execute(f"select {fn}()")
|
||||
result = conn.execute("select {}()".format(fn))
|
||||
spatialite_details[fn] = result.fetchone()[0]
|
||||
except sqlite3.Error as e:
|
||||
except Exception as e:
|
||||
spatialite_details[fn] = {"error": str(e)}
|
||||
sqlite_extensions["spatialite"] = spatialite_details
|
||||
|
||||
|
|
@ -2198,7 +2199,9 @@ class Datasette:
|
|||
fts_versions = []
|
||||
for fts in ("FTS5", "FTS4", "FTS3"):
|
||||
try:
|
||||
conn.execute(f"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)")
|
||||
conn.execute(
|
||||
"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(fts=fts)
|
||||
)
|
||||
fts_versions.append(fts)
|
||||
except sqlite3.OperationalError:
|
||||
continue
|
||||
|
|
@ -2257,7 +2260,7 @@ class Datasette:
|
|||
"static": p["static_path"] is not None,
|
||||
"templates": p["templates_path"] is not None,
|
||||
"version": p.get("version"),
|
||||
"hooks": sorted(set(p["hooks"])),
|
||||
"hooks": list(sorted(set(p["hooks"]))),
|
||||
}
|
||||
for p in ps
|
||||
]
|
||||
|
|
@ -2333,15 +2336,13 @@ class Datasette:
|
|||
|
||||
async def render_template(
|
||||
self,
|
||||
templates: list[str] | str | Template,
|
||||
context: dict[str, Any] | Context | None = None,
|
||||
templates: List[str] | str | Template,
|
||||
context: Dict[str, Any] | Context | None = None,
|
||||
request: Request | None = None,
|
||||
view_name: str | None = None,
|
||||
):
|
||||
if not self._startup_invoked:
|
||||
raise RuntimeError(
|
||||
"render_template() called before await ds.invoke_startup()"
|
||||
)
|
||||
raise Exception("render_template() called before await ds.invoke_startup()")
|
||||
context = context or {}
|
||||
if isinstance(templates, Template):
|
||||
template = templates
|
||||
|
|
@ -2387,9 +2388,9 @@ class Datasette:
|
|||
datasette=self,
|
||||
):
|
||||
extra_vars = await await_me_maybe(extra_vars)
|
||||
assert isinstance(
|
||||
extra_vars, dict
|
||||
), f"extra_vars is of type {type(extra_vars)}"
|
||||
assert isinstance(extra_vars, dict), "extra_vars is of type {}".format(
|
||||
type(extra_vars)
|
||||
)
|
||||
extra_template_vars.update(extra_vars)
|
||||
|
||||
async def menu_links():
|
||||
|
|
@ -2408,27 +2409,29 @@ class Datasette:
|
|||
# the contract tests fail otherwise
|
||||
template_context = {
|
||||
**context,
|
||||
"request": request,
|
||||
"crumb_items": self._crumb_items,
|
||||
"urls": self.urls,
|
||||
"actor": request.actor if request else None,
|
||||
"menu_links": menu_links,
|
||||
"display_actor": display_actor,
|
||||
"show_logout": request is not None
|
||||
and "ds_actor" in request.cookies
|
||||
and request.actor,
|
||||
"zip": zip,
|
||||
"body_scripts": body_scripts,
|
||||
"format_bytes": format_bytes,
|
||||
"show_messages": lambda: self._show_messages(request),
|
||||
"extra_css_urls": await self._asset_urls(
|
||||
"extra_css_urls", template, context, request, view_name
|
||||
),
|
||||
"extra_js_urls": await self._asset_urls(
|
||||
"extra_js_urls", template, context, request, view_name
|
||||
),
|
||||
"base_url": self.setting("base_url"),
|
||||
"datasette_version": __version__,
|
||||
**{
|
||||
"request": request,
|
||||
"crumb_items": self._crumb_items,
|
||||
"urls": self.urls,
|
||||
"actor": request.actor if request else None,
|
||||
"menu_links": menu_links,
|
||||
"display_actor": display_actor,
|
||||
"show_logout": request is not None
|
||||
and "ds_actor" in request.cookies
|
||||
and request.actor,
|
||||
"zip": zip,
|
||||
"body_scripts": body_scripts,
|
||||
"format_bytes": format_bytes,
|
||||
"show_messages": lambda: self._show_messages(request),
|
||||
"extra_css_urls": await self._asset_urls(
|
||||
"extra_css_urls", template, context, request, view_name
|
||||
),
|
||||
"extra_js_urls": await self._asset_urls(
|
||||
"extra_js_urls", template, context, request, view_name
|
||||
),
|
||||
"base_url": self.setting("base_url"),
|
||||
"datasette_version": __version__,
|
||||
},
|
||||
**extra_template_vars,
|
||||
}
|
||||
if request and request.args.get("_context") and self.setting("template_debug"):
|
||||
|
|
@ -2511,8 +2514,8 @@ class Datasette:
|
|||
def add_route(view, regex):
|
||||
routes.append((regex, view))
|
||||
|
||||
add_route(IndexView.as_view(self), r"/(\.(?P<format>json))?$")
|
||||
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>json))?$")
|
||||
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
|
||||
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
|
||||
add_route(permanent_redirect("/-/"), r"/-$")
|
||||
add_route(favicon, "/favicon.ico")
|
||||
|
||||
|
|
@ -2548,10 +2551,7 @@ class Datasette:
|
|||
)
|
||||
add_route(
|
||||
JsonDataView.as_view(
|
||||
self,
|
||||
"plugins.json",
|
||||
self._plugins,
|
||||
needs_request=True,
|
||||
self, "plugins.json", self._plugins, needs_request=True
|
||||
),
|
||||
r"/-/plugins(\.(?P<format>json))?$",
|
||||
)
|
||||
|
|
@ -2564,18 +2564,11 @@ class Datasette:
|
|||
r"/-/config(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
JsonDataView.as_view(
|
||||
self, "threads.json", self._threads, permission="permissions-debug"
|
||||
),
|
||||
JsonDataView.as_view(self, "threads.json", self._threads),
|
||||
r"/-/threads(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
JsonDataView.as_view(
|
||||
self,
|
||||
"databases.json",
|
||||
self._databases_data,
|
||||
needs_request=True,
|
||||
),
|
||||
JsonDataView.as_view(self, "databases.json", self._connected_databases),
|
||||
r"/-/databases(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
|
|
@ -2588,7 +2581,7 @@ class Datasette:
|
|||
JsonDataView.as_view(
|
||||
self,
|
||||
"actions.json",
|
||||
lambda: {"actions": self._actions()},
|
||||
self._actions,
|
||||
template="debug_actions.html",
|
||||
permission="permissions-debug",
|
||||
),
|
||||
|
|
@ -2796,62 +2789,30 @@ class Datasette:
|
|||
db, table_name, _ = await self.resolve_table(request)
|
||||
pk_values = urlsafe_components(request.url_vars["pks"])
|
||||
sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)
|
||||
if len(pk_values) != len(pks):
|
||||
raise BadRequest(
|
||||
"URL row identifier does not match the primary key for this table"
|
||||
)
|
||||
results = await db.execute(sql, params, truncate=True)
|
||||
row = results.first()
|
||||
if row is None:
|
||||
raise RowNotFound(db.name, table_name, pk_values)
|
||||
return ResolvedRow(db, table_name, sql, params, pks, pk_values, results.first())
|
||||
|
||||
async def _startup_sequence(self):
|
||||
"""Idempotently run the full startup sequence: table counts for
|
||||
immutable databases, then invoke_startup(). Safe to call more than
|
||||
once and safe to call concurrently - callers block until whichever
|
||||
call got there first has finished.
|
||||
|
||||
This is the single entry point used by both AsgiLifespan (so
|
||||
real deployments finish startup before accepting requests) and
|
||||
AsgiRunOnFirstRequest (the fallback for hosts that never send
|
||||
lifespan events, e.g. DatasetteClient's httpx.ASGITransport), and
|
||||
`datasette serve` (cli.py) calls it too. The fast path below checks
|
||||
both `_startup_invoked` and `_setup_db_done` - not just the former -
|
||||
so that a bare `await ds.invoke_startup()` made by a caller ahead of
|
||||
`_startup_sequence()` (which only sets `_startup_invoked`) can't
|
||||
make this method skip the immutable-database table-count precompute.
|
||||
"""
|
||||
if self._startup_invoked and self._setup_db_done:
|
||||
return
|
||||
async with self._startup_lock:
|
||||
if self._startup_invoked and self._setup_db_done:
|
||||
return
|
||||
if not self._setup_db_done:
|
||||
# First time server starts up, calculate table counts for
|
||||
# immutable databases
|
||||
for database in self.databases.values():
|
||||
if not database.is_mutable:
|
||||
await database.table_counts(limit=60 * 60 * 1000)
|
||||
self._setup_db_done = True
|
||||
await self.invoke_startup()
|
||||
|
||||
def app(self):
|
||||
"""Returns an ASGI app function that serves the whole of Datasette"""
|
||||
routes = self._routes()
|
||||
|
||||
async def setup_db():
|
||||
# First time server starts up, calculate table counts for immutable databases
|
||||
for database in self.databases.values():
|
||||
if not database.is_mutable:
|
||||
await database.table_counts(limit=60 * 60 * 1000)
|
||||
|
||||
async def _close_on_shutdown():
|
||||
self.close()
|
||||
|
||||
asgi = CrossOriginProtectionMiddleware(DatasetteRouter(self, routes), self)
|
||||
if self.setting("trace_debug"):
|
||||
asgi = AsgiTracer(asgi)
|
||||
asgi = AsgiLifespan(
|
||||
asgi,
|
||||
on_startup=[self._startup_sequence],
|
||||
on_shutdown=[_close_on_shutdown],
|
||||
)
|
||||
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[self._startup_sequence])
|
||||
asgi = AsgiLifespan(asgi, on_shutdown=[_close_on_shutdown])
|
||||
asgi = AsgiRunOnFirstRequest(asgi, on_startup=[setup_db, self.invoke_startup])
|
||||
for wrapper in pm.hook.asgi_wrapper(datasette=self):
|
||||
asgi = wrapper(asgi)
|
||||
return asgi
|
||||
|
|
@ -2886,11 +2847,7 @@ class DatasetteRouter:
|
|||
if base_url != "/" and path.startswith(base_url):
|
||||
path = "/" + path[len(base_url) :]
|
||||
scope = dict(scope, route_path=path)
|
||||
request = Request(
|
||||
scope,
|
||||
receive,
|
||||
max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
|
||||
)
|
||||
request = Request(scope, receive)
|
||||
# Populate request_messages if ds_messages cookie is present
|
||||
try:
|
||||
request._messages = self.ds.unsign(
|
||||
|
|
@ -2910,24 +2867,13 @@ class DatasetteRouter:
|
|||
# Handle authentication
|
||||
default_actor = scope.get("actor") or None
|
||||
actor = None
|
||||
token_error = None
|
||||
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
|
||||
for result in results:
|
||||
try:
|
||||
result = await await_me_maybe(result)
|
||||
except TokenInvalid as ex:
|
||||
# A presented token was recognized but rejected - fail the
|
||||
# request with a 401 even if another credential is valid,
|
||||
# but keep awaiting the remaining coroutines first
|
||||
if token_error is None:
|
||||
token_error = ex
|
||||
continue
|
||||
result = await await_me_maybe(result)
|
||||
if result and actor is None:
|
||||
actor = result
|
||||
# Don't break — we must await all coroutines to avoid
|
||||
# "coroutine was never awaited" warnings
|
||||
if token_error is not None:
|
||||
return await self.handle_401(request, send, token_error)
|
||||
scope_modifications["actor"] = actor or default_actor
|
||||
scope = dict(scope, **scope_modifications)
|
||||
|
||||
|
|
@ -2956,19 +2902,9 @@ class DatasetteRouter:
|
|||
custom_response
|
||||
), "Default forbidden() hook should have been called"
|
||||
return await custom_response.asgi_send(send)
|
||||
except Exception as exception: # noqa: BLE001
|
||||
# This IS the top-level error handler - it must catch everything
|
||||
except Exception as exception:
|
||||
return await self.handle_exception(request, send, exception)
|
||||
|
||||
async def handle_401(self, request, send, exception):
|
||||
# A presented bearer token was recognized by a handler but rejected.
|
||||
# Bearer tokens are API credentials, so this is always JSON.
|
||||
headers = {"www-authenticate": 'Bearer error="invalid_token"'}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
response = Response.error([str(exception)], 401, headers=headers)
|
||||
await response.asgi_send(send)
|
||||
|
||||
async def handle_404(self, request, send, exception=None):
|
||||
# If path contains % encoding, redirect to tilde encoding
|
||||
if "%" in request.path:
|
||||
|
|
@ -2979,7 +2915,7 @@ class DatasetteRouter:
|
|||
request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E")
|
||||
)
|
||||
if request.query_string:
|
||||
new_path += f"?{request.query_string}"
|
||||
new_path += "?{}".format(request.query_string)
|
||||
await asgi_send_redirect(send, new_path)
|
||||
return
|
||||
# If URL has a trailing slash, redirect to URL without it
|
||||
|
|
@ -3189,7 +3125,8 @@ _curly_re = re.compile(r"({.*?})")
|
|||
|
||||
def route_pattern_from_filepath(filepath):
|
||||
# Drop the ".html" suffix
|
||||
filepath = filepath.removesuffix(".html")
|
||||
if filepath.endswith(".html"):
|
||||
filepath = filepath[: -len(".html")]
|
||||
re_bits = ["/"]
|
||||
for bit in _curly_re.split(filepath):
|
||||
if _curly_re.match(bit):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import hashlib
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.utils.asgi import Response, BadRequest
|
||||
from datasette.utils import to_css_class
|
||||
from datasette.utils.asgi import BadRequest, Response
|
||||
import hashlib
|
||||
|
||||
_BLOB_COLUMN = "_blob_column"
|
||||
_BLOB_HASH = "_blob_hash"
|
||||
|
|
|
|||
179
datasette/cli.py
179
datasette/cli.py
|
|
@ -1,45 +1,43 @@
|
|||
import asyncio
|
||||
import uvicorn
|
||||
import click
|
||||
from click import formatting
|
||||
from click.types import CompositeParamType
|
||||
from click_default_group import DefaultGroup
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from runpy import run_module
|
||||
import shutil
|
||||
from subprocess import call
|
||||
import sys
|
||||
import textwrap
|
||||
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 (
|
||||
Datasette,
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS,
|
||||
SQLITE_LIMIT_ATTACHED,
|
||||
Datasette,
|
||||
pm,
|
||||
)
|
||||
from .inspect import inspect_tables
|
||||
from .utils import (
|
||||
ConnectionProblem,
|
||||
LoadExtension,
|
||||
SpatialiteConnectionProblem,
|
||||
SpatialiteNotFound,
|
||||
StartupError,
|
||||
StaticMount,
|
||||
ValueAsBooleanError,
|
||||
check_connection,
|
||||
deep_dict_update,
|
||||
find_spatialite,
|
||||
parse_metadata,
|
||||
ConnectionProblem,
|
||||
SpatialiteConnectionProblem,
|
||||
initial_path_for_datasette,
|
||||
pairs_to_nested_config,
|
||||
parse_metadata,
|
||||
temporary_docker_directory,
|
||||
value_as_boolean,
|
||||
SpatialiteNotFound,
|
||||
StaticMount,
|
||||
ValueAsBooleanError,
|
||||
)
|
||||
from .utils.sqlite import sqlite3
|
||||
from .utils.testing import TestClient
|
||||
|
|
@ -77,7 +75,7 @@ class Setting(CompositeParamType):
|
|||
# Datasette 1.0, we turn bare setting names into setting.name
|
||||
# Type checking for those older settings
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
name = f"settings.{name}"
|
||||
name = "settings.{}".format(name)
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, "true" if value_as_boolean(value) else "false"
|
||||
|
|
@ -173,6 +171,7 @@ async def inspect_(files, sqlite_extensions):
|
|||
@cli.group()
|
||||
def publish():
|
||||
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
||||
pass
|
||||
|
||||
|
||||
# Register publish plugins
|
||||
|
|
@ -579,27 +578,27 @@ def serve(
|
|||
# https://github.com/simonw/datasette/issues/2389
|
||||
deep_dict_update(config_data, settings_updates)
|
||||
|
||||
kwargs = {
|
||||
"immutables": immutable,
|
||||
"cache_headers": not reload,
|
||||
"cors": cors,
|
||||
"inspect_data": inspect_data,
|
||||
"config": config_data,
|
||||
"metadata": metadata_data,
|
||||
"sqlite_extensions": sqlite_extensions,
|
||||
"template_dir": template_dir,
|
||||
"plugins_dir": plugins_dir,
|
||||
"static_mounts": static,
|
||||
"settings": None, # These are passed in config= now
|
||||
"memory": memory,
|
||||
"secret": secret,
|
||||
"version_note": version_note,
|
||||
"pdb": pdb,
|
||||
"crossdb": crossdb,
|
||||
"nolock": nolock,
|
||||
"internal": internal,
|
||||
"default_deny": default_deny,
|
||||
}
|
||||
kwargs = dict(
|
||||
immutables=immutable,
|
||||
cache_headers=not reload,
|
||||
cors=cors,
|
||||
inspect_data=inspect_data,
|
||||
config=config_data,
|
||||
metadata=metadata_data,
|
||||
sqlite_extensions=sqlite_extensions,
|
||||
template_dir=template_dir,
|
||||
plugins_dir=plugins_dir,
|
||||
static_mounts=static,
|
||||
settings=None, # These are passed in config= now
|
||||
memory=memory,
|
||||
secret=secret,
|
||||
version_note=version_note,
|
||||
pdb=pdb,
|
||||
crossdb=crossdb,
|
||||
nolock=nolock,
|
||||
internal=internal,
|
||||
default_deny=default_deny,
|
||||
)
|
||||
|
||||
# Separate directories from files
|
||||
directories = [f for f in files if os.path.isdir(f)]
|
||||
|
|
@ -622,7 +621,9 @@ def serve(
|
|||
conn.close()
|
||||
else:
|
||||
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
|
||||
|
|
@ -663,6 +664,16 @@ def serve(
|
|||
# Private utility mechanism for writing unit tests
|
||||
return ds
|
||||
|
||||
# Run async soundness checks before startup hooks, since invoke_startup
|
||||
# now populates internal tables which requires querying each database
|
||||
run_sync(lambda: check_databases(ds))
|
||||
|
||||
# Run the "startup" plugin hooks
|
||||
try:
|
||||
run_sync(ds.invoke_startup)
|
||||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
|
||||
if headers and not get:
|
||||
raise click.ClickException("--headers can only be used with --get")
|
||||
|
||||
|
|
@ -670,18 +681,10 @@ def serve(
|
|||
raise click.ClickException("--token can only be used with --get")
|
||||
|
||||
if get:
|
||||
# --get means we don't run Uvicorn at all
|
||||
run_sync(lambda: check_databases(ds))
|
||||
|
||||
try:
|
||||
run_sync(ds.invoke_startup)
|
||||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
|
||||
client = TestClient(ds)
|
||||
request_headers = {}
|
||||
if token:
|
||||
request_headers["Authorization"] = f"Bearer {token}"
|
||||
request_headers["Authorization"] = "Bearer {}".format(token)
|
||||
cookies = {}
|
||||
if actor:
|
||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||
|
|
@ -702,54 +705,30 @@ def serve(
|
|||
sys.exit(exit_code)
|
||||
return
|
||||
|
||||
# check_databases, invoke_startup() and the uvicorn server all run on a
|
||||
# single event loop, so that anything a plugin's "startup" hook schedules
|
||||
# on the loop (asyncio.create_task, Lock/Queue/Event objects, ...) is
|
||||
# still alive when the server starts handling requests.
|
||||
async def _serve_async():
|
||||
# Populate internal catalog tables before invoke_startup
|
||||
await check_databases(ds)
|
||||
|
||||
# Run the full startup sequence (immutable-database table-count
|
||||
# precompute + the "startup" plugin hooks) via the same entry point
|
||||
# AsgiLifespan/AsgiRunOnFirstRequest use, so it's not skipped when
|
||||
# uvicorn's lifespan.startup fires moments later.
|
||||
try:
|
||||
await ds._startup_sequence()
|
||||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
|
||||
# Start the server
|
||||
url = None
|
||||
if root:
|
||||
ds.root_enabled = True
|
||||
url = "http://{}:{}{}?token={}".format(
|
||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||
)
|
||||
click.echo(url)
|
||||
if open_browser:
|
||||
if url is None:
|
||||
# Figure out most convenient URL - to table, database or homepage
|
||||
path = await initial_path_for_datasette(ds)
|
||||
url = f"http://{host}:{port}{path}"
|
||||
webbrowser.open(url)
|
||||
uvicorn_kwargs = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"log_level": "info",
|
||||
"lifespan": "on",
|
||||
"workers": 1,
|
||||
}
|
||||
if uds:
|
||||
uvicorn_kwargs["uds"] = uds
|
||||
if ssl_keyfile:
|
||||
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||
if ssl_certfile:
|
||||
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
||||
server = uvicorn.Server(uvicorn.Config(ds.app(), **uvicorn_kwargs))
|
||||
await server.serve()
|
||||
|
||||
asyncio.run(_serve_async())
|
||||
# Start the server
|
||||
url = None
|
||||
if root:
|
||||
ds.root_enabled = True
|
||||
url = "http://{}:{}{}?token={}".format(
|
||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||
)
|
||||
click.echo(url)
|
||||
if open_browser:
|
||||
if url is None:
|
||||
# Figure out most convenient URL - to table, database or homepage
|
||||
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||
url = f"http://{host}:{port}{path}"
|
||||
webbrowser.open(url)
|
||||
uvicorn_kwargs = dict(
|
||||
host=host, port=port, log_level="info", lifespan="on", workers=1
|
||||
)
|
||||
if uds:
|
||||
uvicorn_kwargs["uds"] = uds
|
||||
if ssl_keyfile:
|
||||
uvicorn_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||
if ssl_certfile:
|
||||
uvicorn_kwargs["ssl_certfile"] = ssl_certfile
|
||||
uvicorn.run(ds.app(), **uvicorn_kwargs)
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
|
@ -906,7 +885,7 @@ async def check_databases(ds):
|
|||
)
|
||||
except ConnectionProblem as e:
|
||||
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 (
|
||||
|
|
@ -914,5 +893,9 @@ async def check_databases(ds):
|
|||
and len([db for db in ds.databases.values() if not db.is_memory])
|
||||
> 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)
|
||||
|
|
|
|||
|
|
@ -64,14 +64,14 @@ class ColumnType:
|
|||
Return an HTML string to render this cell value, or None to
|
||||
fall through to the default render_cell plugin hook chain.
|
||||
"""
|
||||
return
|
||||
return None
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
"""
|
||||
Validate a value before it is written. Return None if valid,
|
||||
or a string error message if invalid.
|
||||
"""
|
||||
return
|
||||
return None
|
||||
|
||||
async def transform_value(self, value, datasette):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ def _origin_tuple(value):
|
|||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
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
|
||||
if port is None:
|
||||
port = DEFAULT_PORTS.get(scheme)
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -125,7 +125,9 @@ class CrossOriginProtectionMiddleware:
|
|||
return
|
||||
await self._forbid(
|
||||
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
|
||||
|
||||
|
|
@ -139,11 +141,11 @@ class CrossOriginProtectionMiddleware:
|
|||
request_scheme = self._request_scheme(scope)
|
||||
try:
|
||||
origin_tuple = _origin_tuple(origin)
|
||||
expected_tuple = _origin_tuple(f"{request_scheme}://{host}")
|
||||
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
|
||||
except ValueError:
|
||||
await self._forbid(
|
||||
send,
|
||||
f"Malformed Origin {origin!r} or Host {host!r}",
|
||||
"Malformed Origin {!r} or Host {!r}".format(origin, host),
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -153,7 +155,7 @@ class CrossOriginProtectionMiddleware:
|
|||
|
||||
await self._forbid(
|
||||
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):
|
||||
|
|
@ -161,8 +163,7 @@ class CrossOriginProtectionMiddleware:
|
|||
try:
|
||||
if self.datasette.setting("force_https_urls"):
|
||||
return "https"
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Settings may not be readable this early; fall back to the ASGI scheme
|
||||
except Exception:
|
||||
pass
|
||||
return scope.get("scheme") or "http"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +1,33 @@
|
|||
import asyncio
|
||||
import atexit
|
||||
from collections import namedtuple
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import sqlite_utils
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
|
||||
import sqlite_utils
|
||||
|
||||
from .inspect import inspect_hash
|
||||
from .tracer import trace
|
||||
from .utils import (
|
||||
call_with_supported_arguments,
|
||||
detect_fts,
|
||||
detect_primary_keys,
|
||||
detect_spatialite,
|
||||
escape_sqlite,
|
||||
get_all_foreign_keys,
|
||||
get_outbound_foreign_keys,
|
||||
md5_not_usedforsecurity,
|
||||
sqlite3,
|
||||
sqlite_timelimit,
|
||||
table_column_details,
|
||||
sqlite3,
|
||||
table_columns,
|
||||
table_column_details,
|
||||
)
|
||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||
from .utils.sqlite import sqlite_hidden_table_names
|
||||
from .inspect import inspect_hash
|
||||
|
||||
connections = threading.local()
|
||||
|
||||
|
|
@ -100,7 +98,9 @@ class Database:
|
|||
|
||||
def _check_not_closed(self):
|
||||
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):
|
||||
with self._pending_execute_futures_lock:
|
||||
|
|
@ -139,7 +139,7 @@ class Database:
|
|||
if write:
|
||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||
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(
|
||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||
)
|
||||
|
|
@ -192,20 +192,21 @@ class Database:
|
|||
write_thread.join(timeout=10)
|
||||
if write_thread.is_alive():
|
||||
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()
|
||||
for future in pending_execute_futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Shutdown teardown - a failed pending write must not block close()
|
||||
except Exception:
|
||||
pass
|
||||
# Close anything still tracked in _all_file_connections
|
||||
for connection in self._all_file_connections:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
pass
|
||||
self._all_file_connections = []
|
||||
# Drop per-thread cached read connections we can reach
|
||||
|
|
@ -217,13 +218,13 @@ class Database:
|
|||
if self._read_connection is not None:
|
||||
try:
|
||||
self._read_connection.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
pass
|
||||
self._read_connection = None
|
||||
if self._write_connection is not None:
|
||||
try:
|
||||
self._write_connection.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
pass
|
||||
self._write_connection = None
|
||||
if self.is_temp_disk:
|
||||
|
|
@ -245,7 +246,6 @@ class Database:
|
|||
request=None,
|
||||
return_all=False,
|
||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||
transaction=True,
|
||||
):
|
||||
self._check_not_closed()
|
||||
if returning_limit < 0:
|
||||
|
|
@ -258,9 +258,7 @@ class Database:
|
|||
)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(
|
||||
_inner, block=block, request=request, transaction=transaction
|
||||
)
|
||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||
return results
|
||||
|
||||
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)
|
||||
if transaction:
|
||||
with self._write_connection:
|
||||
self._write_connection.execute("BEGIN IMMEDIATE")
|
||||
result = fn(self._write_connection)
|
||||
else:
|
||||
result = fn(self._write_connection)
|
||||
|
|
@ -369,8 +366,7 @@ class Database:
|
|||
async def _dispatch_events_after_write():
|
||||
try:
|
||||
await reply_future
|
||||
except Exception: # noqa: BLE001
|
||||
# The write failed; skip success events regardless of why
|
||||
except Exception:
|
||||
# if the write failed, don't emit success events
|
||||
return
|
||||
for event in pending_events:
|
||||
|
|
@ -423,7 +419,9 @@ class Database:
|
|||
self._write_thread = threading.Thread(
|
||||
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()
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
loop = asyncio.get_running_loop()
|
||||
|
|
@ -444,8 +442,7 @@ class Database:
|
|||
try:
|
||||
conn = self.connect(write=True)
|
||||
self.ds._prepare_connection(conn, self.name)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Stored and re-raised to whoever queues the next write
|
||||
except Exception as e:
|
||||
conn_exception = e
|
||||
while True:
|
||||
task = self._write_queue.get()
|
||||
|
|
@ -453,8 +450,7 @@ class Database:
|
|||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Best-effort close as the write thread exits
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
exception = None
|
||||
|
|
@ -473,21 +469,19 @@ class Database:
|
|||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Write thread must survive any task failure or the database wedges
|
||||
sys.stderr.write(f"{e}\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.stderr.write(f"{e}\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
_deliver_write_result(task, result, exception)
|
||||
|
|
@ -554,7 +548,9 @@ class Database:
|
|||
raise QueryInterrupted(e, sql, params)
|
||||
if log_sql_errors:
|
||||
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()
|
||||
raise
|
||||
|
|
@ -607,7 +603,7 @@ class Database:
|
|||
try:
|
||||
table_count = (
|
||||
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,
|
||||
)
|
||||
).rows[0][0]
|
||||
|
|
@ -711,9 +707,9 @@ class Database:
|
|||
column_names
|
||||
and len(column_names) == 2
|
||||
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:
|
||||
return None
|
||||
|
||||
|
|
@ -855,10 +851,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
|||
class WriteTask:
|
||||
__slots__ = (
|
||||
"fn",
|
||||
"isolated_connection",
|
||||
"task_id",
|
||||
"loop",
|
||||
"reply_future",
|
||||
"task_id",
|
||||
"isolated_connection",
|
||||
"transaction",
|
||||
)
|
||||
|
||||
|
|
@ -899,7 +895,7 @@ class QueryInterrupted(Exception):
|
|||
self.params = params
|
||||
|
||||
def __str__(self):
|
||||
return f"QueryInterrupted: {self.e}"
|
||||
return "QueryInterrupted: {}".format(self.e)
|
||||
|
||||
|
||||
class MultipleValues(Exception):
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ from datasette import hookimpl
|
|||
from datasette.permissions import Action
|
||||
from datasette.resources import (
|
||||
DatabaseResource,
|
||||
QueryResource,
|
||||
TableResource,
|
||||
QueryResource,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -61,12 +61,6 @@ def register_actions():
|
|||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="create-view",
|
||||
abbr="cv",
|
||||
description="Create views",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="store-query",
|
||||
abbr="sq",
|
||||
|
|
@ -117,12 +111,6 @@ def register_actions():
|
|||
description="Drop tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-view",
|
||||
abbr="dv",
|
||||
description="Drop views",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Query-level actions (child-level)
|
||||
Action(
|
||||
name="view-query",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
from datasette import hookimpl
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
def header(key, request):
|
||||
key = key.replace("_", "-").encode("utf-8")
|
||||
|
|
|
|||
|
|
@ -17,29 +17,18 @@ UNION/INTERSECT operations. The order of evaluation is:
|
|||
|
||||
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
|
||||
from .restrictions import (
|
||||
actor_restrictions_sql as actor_restrictions_sql,
|
||||
)
|
||||
from .restrictions import (
|
||||
restrictions_allow_action as restrictions_allow_action,
|
||||
ActorRestrictions as ActorRestrictions,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
):
|
||||
self.datasette = datasette
|
||||
|
|
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
|
|||
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||
|
||||
# Pre-compute restriction info for efficiency
|
||||
self.restricted_databases: set[str] = set()
|
||||
self.restricted_tables: set[tuple[str, str]] = set()
|
||||
self.restricted_databases: Set[str] = set()
|
||||
self.restricted_tables: Set[Tuple[str, str]] = set()
|
||||
|
||||
if self.has_restrictions:
|
||||
self.restricted_databases = {
|
||||
|
|
@ -92,20 +92,16 @@ class ConfigPermissionProcessor:
|
|||
# Tables implicitly reference their parent databases
|
||||
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."""
|
||||
if allow_block is 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)
|
||||
|
||||
def is_in_restriction_allowlist(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
) -> bool:
|
||||
"""Check if resource is allowed by actor restrictions."""
|
||||
if not self.has_restrictions:
|
||||
|
|
@ -147,9 +143,9 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def add_permissions_rule(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
permissions_block: dict | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
permissions_block: Optional[dict],
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""Add a rule from a permissions:{action} block."""
|
||||
|
|
@ -169,8 +165,8 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def add_allow_block_rule(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow_block: Any,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
|
|
@ -202,8 +198,8 @@ class ConfigPermissionProcessor:
|
|||
|
||||
def _add_restriction_gate_denies(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
is_allowed: bool,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
|
|
@ -235,7 +231,7 @@ class ConfigPermissionProcessor:
|
|||
if db_name == parent:
|
||||
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."""
|
||||
self._process_root_permissions()
|
||||
self._process_databases()
|
||||
|
|
@ -425,10 +421,10 @@ class ConfigPermissionProcessor:
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def config_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> list[PermissionSQL] | None:
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Apply permission rules from datasette.yaml configuration.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Provides default allow rules for standard view/execute actions.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -29,28 +29,29 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_allow_sql_check(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> PermissionSQL | None:
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Enforce the default_allow_sql setting.
|
||||
|
||||
When default_allow_sql is false (the default), execute-sql is denied
|
||||
unless explicitly allowed by config or other rules.
|
||||
"""
|
||||
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||
if action == "execute-sql":
|
||||
if not datasette.setting("default_allow_sql"):
|
||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_action_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> PermissionSQL | None:
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Provide default allow rules for standard view/execute actions.
|
||||
|
||||
|
|
@ -70,10 +71,10 @@ async def default_action_permissions_sql(
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_query_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> PermissionSQL | None:
|
||||
) -> Optional[PermissionSQL]:
|
||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
if action not in {"view-query", "update-query", "delete-query"}:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, List, Optional, Set
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|||
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).
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
|
|||
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."""
|
||||
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:
|
||||
"""A single permission rule row."""
|
||||
|
||||
parent: str | None
|
||||
child: str | None
|
||||
parent: Optional[str]
|
||||
child: Optional[str]
|
||||
allow: bool
|
||||
reason: str
|
||||
|
||||
|
|
@ -46,14 +46,14 @@ class PermissionRowCollector:
|
|||
"""Collects permission rows and converts them to PermissionSQL."""
|
||||
|
||||
def __init__(self, prefix: str = "row"):
|
||||
self.rows: list[PermissionRow] = []
|
||||
self.rows: List[PermissionRow] = []
|
||||
self.prefix = prefix
|
||||
|
||||
def add(
|
||||
self,
|
||||
parent: str | None,
|
||||
child: str | None,
|
||||
allow: bool | None,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow: Optional[bool],
|
||||
reason: str,
|
||||
if_not_none: bool = False,
|
||||
) -> None:
|
||||
|
|
@ -62,7 +62,7 @@ class PermissionRowCollector:
|
|||
return
|
||||
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."""
|
||||
if not self.rows:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
|
|||
class ActorRestrictions:
|
||||
"""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]}
|
||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||
|
||||
@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."""
|
||||
if not actor:
|
||||
return None
|
||||
|
|
@ -44,11 +44,11 @@ class ActorRestrictions:
|
|||
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."""
|
||||
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."""
|
||||
allowed = set()
|
||||
for db_name, db_actions in self.database_actions.items():
|
||||
|
|
@ -57,8 +57,8 @@ class ActorRestrictions:
|
|||
return allowed
|
||||
|
||||
def get_allowed_tables(
|
||||
self, datasette: Datasette, action: str
|
||||
) -> set[tuple[str, str]]:
|
||||
self, datasette: "Datasette", action: str
|
||||
) -> Set[Tuple[str, str]]:
|
||||
"""Get (database, table) pairs where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, tables in self.table_actions.items():
|
||||
|
|
@ -70,10 +70,10 @@ class ActorRestrictions:
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def actor_restrictions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> list[PermissionSQL] | None:
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Handle actor restriction-based permission rules.
|
||||
|
||||
|
|
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
|
|||
|
||||
|
||||
def restrictions_allow_action(
|
||||
datasette: Datasette,
|
||||
datasette: "Datasette",
|
||||
restrictions: dict,
|
||||
action: str,
|
||||
resource: str | tuple[str, str] | None,
|
||||
resource: Optional[str | Tuple[str, str]],
|
||||
) -> bool:
|
||||
"""
|
||||
Check if restrictions allow the requested action on the requested resource.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
|
|||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def root_user_permissions_sql(
|
||||
datasette: Datasette,
|
||||
actor: dict | None,
|
||||
) -> PermissionSQL | None:
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Grant root user full permissions when --root flag is used.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -17,13 +17,15 @@ from datasette.tokens import SignedTokenHandler
|
|||
|
||||
|
||||
@hookimpl
|
||||
def register_token_handler(datasette: Datasette):
|
||||
def register_token_handler(datasette: "Datasette"):
|
||||
"""Register the default signed token handler."""
|
||||
return SignedTokenHandler()
|
||||
|
||||
|
||||
@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
|
||||
token handlers via datasette.verify_token().
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ def table_actions(datasette, actor, database, table, request):
|
|||
"label": "Alter table",
|
||||
"description": "Change columns and primary key for this table.",
|
||||
"attrs": {
|
||||
"aria-label": f"Alter table {table}",
|
||||
"aria-label": "Alter table {}".format(table),
|
||||
"data-table-action": "alter-table",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
from abc import ABC, abstractproperty
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datasette.hookspecs import hookimpl
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ from typing import ClassVar
|
|||
|
||||
from asyncinject import Registry
|
||||
|
||||
from datasette.utils.asgi import BadRequest
|
||||
|
||||
|
||||
def extra_names_from_request(request):
|
||||
extra_bits = request.args.getlist("_extra")
|
||||
|
|
@ -115,17 +113,6 @@ class ExtraRegistry:
|
|||
self._allowed_names[key] = 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):
|
||||
allowed_names = self._allowed_names_for_scope(scope, include_internal)
|
||||
requested_names = [name for name in requested if name in allowed_names]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import json
|
||||
import urllib
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.utils import (
|
||||
detect_json1,
|
||||
escape_sqlite,
|
||||
path_with_added_args,
|
||||
path_with_removed_args,
|
||||
detect_json1,
|
||||
sqlite3,
|
||||
)
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ def load_facet_configs(request, table_config):
|
|||
assert (
|
||||
len(facet_config.values()) == 1
|
||||
), "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):
|
||||
facet_config = {"simple": facet_config}
|
||||
facet_configs.setdefault(type, []).append(
|
||||
|
|
@ -86,7 +85,7 @@ class Facet:
|
|||
self.database = database
|
||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||
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.table_config = table_config
|
||||
# row_count can be None, in which case we calculate it ourselves:
|
||||
|
|
@ -161,13 +160,18 @@ class ColumnFacet(Facet):
|
|||
for column in columns:
|
||||
if column in already_enabled:
|
||||
continue
|
||||
suggested_facet_sql = f"""
|
||||
with limited as (select * from ({self.sql}) limit {self.suggest_consider})
|
||||
select {escape_sqlite(column)} as value, count(*) as n from limited
|
||||
suggested_facet_sql = """
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select {column} as value, count(*) as n from limited
|
||||
where value is not null
|
||||
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
|
||||
try:
|
||||
distinct_values = await self.ds.execute(
|
||||
|
|
@ -263,7 +267,7 @@ class ColumnFacet(Facet):
|
|||
for row in facet_rows:
|
||||
column_qs = column
|
||||
if column.startswith("_"):
|
||||
column_qs = f"{column}__exact"
|
||||
column_qs = "{}__exact".format(column)
|
||||
selected = (column_qs, str(row["value"])) in qs_pairs
|
||||
if selected:
|
||||
toggle_path = path_with_removed_args(
|
||||
|
|
@ -338,12 +342,12 @@ class ArrayFacet(Facet):
|
|||
for v in await self.ds.execute(
|
||||
self.database,
|
||||
(
|
||||
f"select {escape_sqlite(column)} from ({self.sql}) "
|
||||
f"where {escape_sqlite(column)} is not null "
|
||||
f"and {escape_sqlite(column)} != '' "
|
||||
f"and json_array_length({escape_sqlite(column)}) > 0 "
|
||||
"select {column} from ({sql}) "
|
||||
"where {column} is not null "
|
||||
"and {column} != '' "
|
||||
"and json_array_length({column}) > 0 "
|
||||
"limit 100"
|
||||
),
|
||||
).format(column=escape_sqlite(column), sql=self.sql),
|
||||
self.params,
|
||||
truncate=False,
|
||||
custom_time_limit=self.ds.setting(
|
||||
|
|
@ -384,14 +388,14 @@ class ArrayFacet(Facet):
|
|||
source = source_and_config["source"]
|
||||
column = config.get("column") or config["simple"]
|
||||
# https://github.com/simonw/datasette/issues/448
|
||||
facet_sql = f"""
|
||||
with inner as ({self.sql}),
|
||||
facet_sql = """
|
||||
with inner as ({sql}),
|
||||
deduped_array_items as (
|
||||
select
|
||||
distinct j.value,
|
||||
inner.*
|
||||
from
|
||||
json_each([inner].{escape_sqlite(column)}) j
|
||||
json_each([inner].{col}) j
|
||||
join inner
|
||||
)
|
||||
select
|
||||
|
|
@ -402,8 +406,12 @@ class ArrayFacet(Facet):
|
|||
group by
|
||||
value
|
||||
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:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import json
|
||||
from typing import ClassVar
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.utils.asgi import BadRequest
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -102,9 +99,9 @@ def search_filters(request, database, table, datasette):
|
|||
fts_table=escape_sqlite(fts_table),
|
||||
search_col=escape_sqlite(search_col),
|
||||
match_clause=(
|
||||
f":search_{i}"
|
||||
":search_{}".format(i)
|
||||
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"]
|
||||
db = datasette.get_database(database)
|
||||
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
||||
fk_to_us = next(
|
||||
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table),
|
||||
None,
|
||||
)
|
||||
if fk_to_us is None:
|
||||
try:
|
||||
fk_to_us = [
|
||||
fk for fk in outgoing_foreign_keys if fk["other_table"] == table
|
||||
][0]
|
||||
except IndexError:
|
||||
raise DatasetteError(
|
||||
"Invalid _through - could not find corresponding foreign key"
|
||||
)
|
||||
|
|
@ -209,14 +206,10 @@ class TemplatedFilter(Filter):
|
|||
if self.numeric and converted.isdigit():
|
||||
converted = int(converted)
|
||||
if self.no_argument:
|
||||
kwargs = {"c": _quote_sqlite_identifier(column)}
|
||||
kwargs = {"c": column}
|
||||
converted = None
|
||||
else:
|
||||
kwargs = {
|
||||
"c": _quote_sqlite_identifier(column),
|
||||
"p": f"p{param_counter}",
|
||||
"t": _quote_sqlite_identifier(table),
|
||||
}
|
||||
kwargs = {"c": column, "p": f"p{param_counter}", "t": table}
|
||||
return self.sql_template.format(**kwargs), converted
|
||||
|
||||
def human_clause(self, column, value):
|
||||
|
|
@ -230,14 +223,6 @@ class TemplatedFilter(Filter):
|
|||
return template.format(c=column, v=value)
|
||||
|
||||
|
||||
def _quote_sqlite_identifier(identifier):
|
||||
# Preserve the historic always-quoted SQL generated by TemplatedFilter.
|
||||
escaped = escape_sqlite(identifier)
|
||||
if escaped == identifier:
|
||||
return f'"{identifier}"'
|
||||
return escaped
|
||||
|
||||
|
||||
class InFilter(Filter):
|
||||
key = "in"
|
||||
display = "in"
|
||||
|
|
@ -279,56 +264,56 @@ class Filters:
|
|||
TemplatedFilter(
|
||||
"exact",
|
||||
"=",
|
||||
"{c} = :{p}",
|
||||
'"{c}" = :{p}',
|
||||
lambda c, v: "{c} = {v}" if v.isdigit() else '{c} = "{v}"',
|
||||
),
|
||||
TemplatedFilter(
|
||||
"not",
|
||||
"!=",
|
||||
"{c} != :{p}",
|
||||
'"{c}" != :{p}',
|
||||
lambda c, v: "{c} != {v}" if v.isdigit() else '{c} != "{v}"',
|
||||
),
|
||||
TemplatedFilter(
|
||||
"contains",
|
||||
"contains",
|
||||
"{c} like :{p}",
|
||||
'"{c}" like :{p}',
|
||||
'{c} contains "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"notcontains",
|
||||
"does not contain",
|
||||
"{c} not like :{p}",
|
||||
'"{c}" not like :{p}',
|
||||
'{c} does not contain "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"endswith",
|
||||
"ends with",
|
||||
"{c} like :{p}",
|
||||
'"{c}" like :{p}',
|
||||
'{c} ends with "{v}"',
|
||||
format="%{}",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"startswith",
|
||||
"starts with",
|
||||
"{c} like :{p}",
|
||||
'"{c}" like :{p}',
|
||||
'{c} starts with "{v}"',
|
||||
format="{}%",
|
||||
),
|
||||
TemplatedFilter("gt", ">", "{c} > :{p}", "{c} > {v}", numeric=True),
|
||||
TemplatedFilter("gt", ">", '"{c}" > :{p}', "{c} > {v}", numeric=True),
|
||||
TemplatedFilter(
|
||||
"gte", "\u2265", "{c} >= :{p}", "{c} \u2265 {v}", numeric=True
|
||||
"gte", "\u2265", '"{c}" >= :{p}', "{c} \u2265 {v}", numeric=True
|
||||
),
|
||||
TemplatedFilter("lt", "<", "{c} < :{p}", "{c} < {v}", numeric=True),
|
||||
TemplatedFilter("lt", "<", '"{c}" < :{p}', "{c} < {v}", numeric=True),
|
||||
TemplatedFilter(
|
||||
"lte", "\u2264", "{c} <= :{p}", "{c} \u2264 {v}", numeric=True
|
||||
"lte", "\u2264", '"{c}" <= :{p}', "{c} \u2264 {v}", numeric=True
|
||||
),
|
||||
TemplatedFilter("like", "like", "{c} like :{p}", '{c} like "{v}"'),
|
||||
TemplatedFilter("like", "like", '"{c}" like :{p}', '{c} like "{v}"'),
|
||||
TemplatedFilter(
|
||||
"notlike", "not like", "{c} not like :{p}", '{c} not like "{v}"'
|
||||
"notlike", "not like", '"{c}" not like :{p}', '{c} not like "{v}"'
|
||||
),
|
||||
TemplatedFilter("glob", "glob", "{c} glob :{p}", '{c} glob "{v}"'),
|
||||
TemplatedFilter("glob", "glob", '"{c}" glob :{p}', '{c} glob "{v}"'),
|
||||
InFilter(),
|
||||
NotInFilter(),
|
||||
]
|
||||
|
|
@ -337,13 +322,13 @@ class Filters:
|
|||
TemplatedFilter(
|
||||
"arraycontains",
|
||||
"array contains",
|
||||
""":{p} in (select value from json_each({t}.{c}))""",
|
||||
""":{p} in (select value from json_each([{t}].[{c}]))""",
|
||||
'{c} contains "{v}"',
|
||||
),
|
||||
TemplatedFilter(
|
||||
"arraynotcontains",
|
||||
"array does not contain",
|
||||
""":{p} not in (select value from json_each({t}.{c}))""",
|
||||
""":{p} not in (select value from json_each([{t}].[{c}]))""",
|
||||
'{c} does not contain "{v}"',
|
||||
),
|
||||
]
|
||||
|
|
@ -351,34 +336,36 @@ class Filters:
|
|||
else []
|
||||
)
|
||||
+ [
|
||||
TemplatedFilter("date", "date", "date({c}) = :{p}", '"{c}" is on date {v}'),
|
||||
TemplatedFilter(
|
||||
"isnull", "is null", "{c} is null", "{c} is null", no_argument=True
|
||||
"date", "date", 'date("{c}") = :{p}', '"{c}" is on date {v}'
|
||||
),
|
||||
TemplatedFilter(
|
||||
"isnull", "is null", '"{c}" is null', "{c} is null", no_argument=True
|
||||
),
|
||||
TemplatedFilter(
|
||||
"notnull",
|
||||
"is not null",
|
||||
"{c} is not null",
|
||||
'"{c}" is not null',
|
||||
"{c} is not null",
|
||||
no_argument=True,
|
||||
),
|
||||
TemplatedFilter(
|
||||
"isblank",
|
||||
"is blank",
|
||||
"({c} is null or {c} = '')",
|
||||
'("{c}" is null or "{c}" = "")',
|
||||
"{c} is blank",
|
||||
no_argument=True,
|
||||
),
|
||||
TemplatedFilter(
|
||||
"notblank",
|
||||
"is not blank",
|
||||
"({c} is not null and {c} != '')",
|
||||
'("{c}" is not null and "{c}" != "")',
|
||||
"{c} is not blank",
|
||||
no_argument=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
_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):
|
||||
self.pairs = pairs
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils import documented
|
||||
import itertools
|
||||
import random
|
||||
import string
|
||||
|
||||
from datasette.utils import documented
|
||||
from datasette.utils.sqlite import sqlite3
|
||||
|
||||
__all__ = [
|
||||
"EXTRA_DATABASE_SQL",
|
||||
"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'
|
||||
+ "\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)
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,9 @@
|
|||
from datasette import Response, hookimpl
|
||||
|
||||
from .utils import add_cors_headers
|
||||
from datasette import hookimpl, Response
|
||||
|
||||
|
||||
@hookimpl(trylast=True)
|
||||
def forbidden(datasette, request, message):
|
||||
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(
|
||||
await datasette.render_template(
|
||||
"error.html",
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
import traceback
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from datasette import Response, hookimpl
|
||||
|
||||
from .utils import add_cors_headers, error_body
|
||||
from datasette import hookimpl, Response
|
||||
from .utils import add_cors_headers
|
||||
from .utils.asgi import (
|
||||
Base400,
|
||||
)
|
||||
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:
|
||||
import ipdb as pdb # noqa: T100
|
||||
import ipdb as pdb
|
||||
except ImportError:
|
||||
import pdb # noqa: T100
|
||||
import pdb
|
||||
|
||||
try:
|
||||
import rich
|
||||
|
|
@ -33,7 +28,6 @@ def handle_exception(datasette, request, exception):
|
|||
rich.get_console().print_exception(show_locals=True)
|
||||
|
||||
title = None
|
||||
plain_message = None
|
||||
if isinstance(exception, Base400):
|
||||
status = exception.status
|
||||
info = {}
|
||||
|
|
@ -42,7 +36,6 @@ def handle_exception(datasette, request, exception):
|
|||
status = exception.status
|
||||
info = exception.error_dict
|
||||
message = exception.message
|
||||
plain_message = exception.plain_message
|
||||
if exception.message_is_html:
|
||||
message = Markup(message)
|
||||
title = exception.title
|
||||
|
|
@ -52,13 +45,6 @@ def handle_exception(datasette, request, exception):
|
|||
message = str(exception)
|
||||
traceback.print_exc()
|
||||
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(
|
||||
{
|
||||
"ok": False,
|
||||
|
|
@ -67,18 +53,24 @@ def handle_exception(datasette, request, exception):
|
|||
"title": title,
|
||||
}
|
||||
)
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
info,
|
||||
urls=datasette.urls,
|
||||
menu_links=list,
|
||||
)
|
||||
),
|
||||
status=status,
|
||||
headers=headers,
|
||||
)
|
||||
headers = {}
|
||||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
if request.path.split("?")[0].endswith(".json"):
|
||||
return Response.json(info, status=status, headers=headers)
|
||||
else:
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
info,
|
||||
urls=datasette.urls,
|
||||
menu_links=lambda: [],
|
||||
)
|
||||
),
|
||||
status=status,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from pluggy import HookimplMarker, HookspecMarker
|
||||
from pluggy import HookimplMarker
|
||||
from pluggy import HookspecMarker
|
||||
|
||||
hookspec = HookspecMarker("datasette")
|
||||
hookimpl = HookimplMarker("datasette")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import hashlib
|
||||
|
||||
from .utils import (
|
||||
detect_spatialite,
|
||||
detect_fts,
|
||||
detect_primary_keys,
|
||||
detect_spatialite,
|
||||
escape_sqlite,
|
||||
get_all_foreign_keys,
|
||||
sqlite3,
|
||||
table_columns,
|
||||
sqlite3,
|
||||
)
|
||||
|
||||
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:
|
||||
if t == hidden_table or t.startswith(hidden_table):
|
||||
table_info["hidden"] = True
|
||||
tables[t]["hidden"] = True
|
||||
continue
|
||||
|
||||
return tables
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class JumpSQL:
|
|||
search_text: str | None = None,
|
||||
display_name: str | None = None,
|
||||
item_type: str = "menu",
|
||||
) -> JumpSQL:
|
||||
) -> "JumpSQL":
|
||||
if search_text is None:
|
||||
search_text = " ".join(
|
||||
text for text in (label, display_name, description) if text is not None
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import contextvars
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NamedTuple
|
||||
import contextvars
|
||||
|
||||
# Context variable to track when permission checks should be skipped
|
||||
_skip_permission_checks = contextvars.ContextVar(
|
||||
|
|
@ -72,8 +72,8 @@ class Resource(ABC):
|
|||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})"
|
||||
return "{}(parent={!r}, child={!r})".format(
|
||||
self.__class__.__name__, self.parent, self.child
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
@ -129,6 +129,7 @@ class Resource(ABC):
|
|||
|
||||
Must return two columns: parent, child
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AllowedResource(NamedTuple):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import importlib
|
||||
import importlib.metadata as importlib_metadata
|
||||
import importlib.resources as importlib_resources
|
||||
import os
|
||||
import sys
|
||||
from pprint import pprint
|
||||
|
||||
import pluggy
|
||||
|
||||
from pprint import pprint
|
||||
import sys
|
||||
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 = (
|
||||
"datasette.publish.heroku",
|
||||
"datasette.publish.cloudrun",
|
||||
|
|
@ -79,7 +85,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
|
|||
# Ensure name can be found in plugin_to_distinfo later:
|
||||
pm._plugin_distinfo.append((mod, distribution))
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
from datasette import hookimpl
|
||||
import click
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from subprocess import CalledProcessError, check_call, check_output
|
||||
|
||||
import click
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
from ..utils import temporary_docker_directory
|
||||
from .common import (
|
||||
add_common_publish_arguments_and_options,
|
||||
fail_if_publish_binary_not_installed,
|
||||
)
|
||||
from ..utils import temporary_docker_directory
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
@ -221,7 +219,7 @@ def publish_subcommand(publish):
|
|||
|
||||
check_call(
|
||||
"gcloud builds submit --tag {}{}".format(
|
||||
image_id, f" --timeout {timeout}" if timeout else ""
|
||||
image_id, " --timeout {}".format(timeout) if timeout else ""
|
||||
),
|
||||
shell=True,
|
||||
)
|
||||
|
|
@ -233,7 +231,7 @@ def publish_subcommand(publish):
|
|||
("--min-instances", min_instances),
|
||||
):
|
||||
if value is not None:
|
||||
extra_deploy_options.append(f"{option} {value}")
|
||||
extra_deploy_options.append("{} {}".format(option, value))
|
||||
check_call(
|
||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||
image_id,
|
||||
|
|
@ -260,16 +258,24 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
|
|||
) from exc
|
||||
|
||||
describe_cmd = (
|
||||
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} "
|
||||
f"--location {artifact_region} --quiet"
|
||||
"gcloud artifacts repositories describe {repo} --project {project} "
|
||||
"--location {location} --quiet"
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
project=artifact_project,
|
||||
location=artifact_region,
|
||||
)
|
||||
try:
|
||||
check_call(describe_cmd, shell=True)
|
||||
return
|
||||
except CalledProcessError:
|
||||
create_cmd = (
|
||||
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker "
|
||||
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet'
|
||||
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
||||
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
location=artifact_region,
|
||||
project=artifact_project,
|
||||
)
|
||||
try:
|
||||
check_call(create_cmd, shell=True)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
from ..utils import StaticMount
|
||||
import click
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from ..utils import StaticMount
|
||||
|
||||
|
||||
def add_common_publish_arguments_and_options(subcommand):
|
||||
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"""
|
||||
if not shutil.which(binary):
|
||||
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",
|
||||
fg="white",
|
||||
bold=True,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
from contextlib import contextmanager
|
||||
from datasette import hookimpl
|
||||
import click
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from subprocess import call, check_output
|
||||
|
||||
import click
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
||||
import tempfile
|
||||
|
||||
from .common import (
|
||||
add_common_publish_arguments_and_options,
|
||||
fail_if_publish_binary_not_installed,
|
||||
)
|
||||
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
@ -236,7 +234,7 @@ def temporary_heroku_directory(
|
|||
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
||||
|
||||
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(
|
||||
quoted_files=quoted_files, extras=" ".join(extras)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import json
|
||||
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.utils import (
|
||||
CustomJSONEncoder,
|
||||
error_body,
|
||||
path_from_row_pks,
|
||||
remove_infinites,
|
||||
sqlite3,
|
||||
value_as_boolean,
|
||||
remove_infinites,
|
||||
CustomJSONEncoder,
|
||||
path_from_row_pks,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
|
|
@ -54,7 +52,8 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
if error:
|
||||
shape = "objects"
|
||||
status_code = 400
|
||||
data.update(error_body(error, status_code))
|
||||
data["error"] = error
|
||||
data["ok"] = False
|
||||
|
||||
if truncated is not None:
|
||||
data["truncated"] = truncated
|
||||
|
|
@ -88,8 +87,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
|||
object_rows[pk_string] = row
|
||||
data = object_rows
|
||||
if shape_error:
|
||||
status_code = 400
|
||||
data = error_body(shape_error, status_code)
|
||||
data = {"ok": False, "error": shape_error}
|
||||
elif shape == "array":
|
||||
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"]]
|
||||
else:
|
||||
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
|
||||
# https://github.com/simonw/datasette/issues/2136
|
||||
|
|
|
|||
|
|
@ -1641,11 +1641,6 @@ dialog.row-edit-dialog::backdrop {
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-fields[hidden],
|
||||
.row-edit-bulk[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
|
|
@ -1705,118 +1700,6 @@ textarea.row-edit-input {
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-binary-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.row-edit-binary-control:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.row-edit-binary-preview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-binary-preview img {
|
||||
display: block;
|
||||
max-width: min(240px, 100%);
|
||||
max-height: 180px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-binary-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-edit-binary-size {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.row-edit-binary-name {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-binary-name[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-binary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-edit-binary-file-button,
|
||||
.row-edit-binary-clear {
|
||||
appearance: none;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.2;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.row-edit-binary-file-button:hover,
|
||||
.row-edit-binary-file-button:focus-within,
|
||||
.row-edit-binary-clear:hover,
|
||||
.row-edit-binary-clear:focus {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.row-edit-binary-file-button:focus-within,
|
||||
.row-edit-binary-clear:focus {
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row-edit-binary-file-button input[type="file"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row-edit-binary-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-binary-drop-target {
|
||||
border: 1px dashed var(--rule);
|
||||
border-radius: 4px;
|
||||
padding: 7px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.row-edit-binary-dragover .row-edit-binary-drop-target {
|
||||
border-color: var(--accent);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-default {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7.25rem;
|
||||
|
|
@ -1915,207 +1798,6 @@ textarea.row-edit-input {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-bulk-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-editor[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.row-edit-bulk-actions .btn {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-label {
|
||||
color: var(--ink);
|
||||
font-size: 0.82rem;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-control {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-help {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-copy-template-label-narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-textarea {
|
||||
min-height: 16rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.row-edit-bulk-textarea.row-edit-bulk-drop-target {
|
||||
border-color: var(--accent);
|
||||
background: #f8fbff;
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.row-edit-bulk-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-note label,
|
||||
.row-edit-bulk-note .button-as-link {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row-edit-copy-template-label-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-copy-template-label-narrow {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-narrow {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-summary {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table-wrap {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th,
|
||||
.row-edit-bulk-preview-table td {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule);
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th:last-child,
|
||||
.row-edit-bulk-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-null,
|
||||
.row-edit-bulk-preview-auto {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress-status {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
datasette-autocomplete {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
|
@ -2194,16 +1876,6 @@ datasette-autocomplete input[type="text"],
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-mode-link {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.row-edit-mode-link[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
|
@ -2400,120 +2072,6 @@ select.table-create-input {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-create-columns[hidden],
|
||||
.table-create-data[hidden],
|
||||
.table-create-data-editor[hidden],
|
||||
.table-create-data-preview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-create-data,
|
||||
.table-create-data-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-create-data-label {
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.table-create-data-textarea {
|
||||
min-height: 16rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.table-create-data-textarea.table-create-data-drop-target {
|
||||
border-color: var(--accent);
|
||||
background: #f8fbff;
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.table-create-data-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-create-data-note label,
|
||||
.table-create-data-note .button-as-link {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.table-create-data-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-create-data-preview-summary {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-create-data-pk-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table-wrap {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th,
|
||||
.table-create-data-preview-table td {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule);
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th:last-child,
|
||||
.table-create-data-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.table-create-data-preview-null {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.table-create-column-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
@ -2741,16 +2299,6 @@ select.table-create-input {
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.table-create-mode-link {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.table-create-mode-link[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-create-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
|
@ -3464,8 +3012,7 @@ select.table-alter-input {
|
|||
.row-edit-dialog .modal-header,
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-fields,
|
||||
.row-edit-bulk {
|
||||
.row-edit-fields {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
|
@ -3484,15 +3031,6 @@ select.table-alter-input {
|
|||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
|
|
@ -3524,11 +3062,6 @@ select.table-alter-input {
|
|||
padding-top: 0;
|
||||
}
|
||||
|
||||
.table-create-data-pk-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.table-create-column-headings {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
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,
|
||||
"hide_sql": query.hide_sql,
|
||||
"fragment": query.fragment,
|
||||
"params": list(query.parameters),
|
||||
"parameters": list(query.parameters),
|
||||
"is_write": query.is_write,
|
||||
"is_private": query.is_private,
|
||||
|
|
@ -84,6 +84,7 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
|
|||
return {
|
||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
||||
"next": page.next,
|
||||
"has_more": page.has_more,
|
||||
"limit": page.limit,
|
||||
}
|
||||
|
||||
|
|
@ -387,7 +388,7 @@ async def count_queries(
|
|||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = f"%{q}%"
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
|
|
@ -463,7 +464,7 @@ async def list_queries(
|
|||
except ValueError:
|
||||
components = []
|
||||
if database is None and len(components) == 3:
|
||||
where_clauses.append(f"""
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.database_name > :cursor_database
|
||||
OR (
|
||||
|
|
@ -477,12 +478,12 @@ async def list_queries(
|
|||
)
|
||||
)
|
||||
)
|
||||
""")
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_database"] = components[0]
|
||||
params["cursor_sort_key"] = components[1]
|
||||
params["cursor_name"] = components[2]
|
||||
elif database is not None and len(components) == 2:
|
||||
where_clauses.append(f"""
|
||||
where_clauses.append("""
|
||||
(
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
|
|
@ -490,7 +491,7 @@ async def list_queries(
|
|||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
""")
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_sort_key"] = components[0]
|
||||
params["cursor_name"] = components[1]
|
||||
|
||||
|
|
@ -503,7 +504,7 @@ async def list_queries(
|
|||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = f"%{q}%"
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
|
|
|
|||
|
|
@ -6,20 +6,8 @@
|
|||
padding: 1.5em;
|
||||
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 {
|
||||
margin-bottom: 1.25em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
|
|
@ -27,51 +15,22 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
.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 {
|
||||
height: 3rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.permission-textarea {
|
||||
font-family: monospace;
|
||||
min-height: 12rem;
|
||||
padding: 0.75rem;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section input[type="number"]:focus,
|
||||
.form-section select:focus,
|
||||
.permission-textarea:focus {
|
||||
.form-section select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
|
||||
outline: none;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.45em;
|
||||
margin-top: 0.3em;
|
||||
color: #666;
|
||||
}
|
||||
.form-actions {
|
||||
|
|
@ -183,9 +142,4 @@
|
|||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.permission-form-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -44,10 +44,10 @@
|
|||
</style>
|
||||
|
||||
<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('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Access map</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rule explorer</a>
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</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('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,29 @@
|
|||
{% block title %}Debug allow rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
<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 {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% 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>
|
||||
|
||||
<div class="permission-form">
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
|
||||
<div class="permission-form-grid">
|
||||
<div class="form-section">
|
||||
<label for="allow-block">Allow block</label>
|
||||
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="allow-actor">Actor</label>
|
||||
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">Apply allow block to actor</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||
<div class="two-col">
|
||||
<p><label>Allow block</label></p>
|
||||
<textarea name="allow">{{ allow_input }}</textarea>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<p><label>Actor</label></p>
|
||||
<textarea name="actor">{{ actor_input }}</textarea>
|
||||
</div>
|
||||
<div style="margin-top: 1em;">
|
||||
<input type="submit" value="Apply allow block to actor">
|
||||
</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 %}
|
||||
</div>
|
||||
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for action in data.actions %}
|
||||
{% for action in data %}
|
||||
<tr>
|
||||
<td><strong>{{ action.name }}</strong></td>
|
||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
<div class="form-section">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('_page');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== '_size' && key !== '_page') {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('_page', page.toString());
|
||||
params.append('_size', pageSize);
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Explain a permission decision{% endblock %}
|
||||
{% block title %}Permission Check{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||
|
|
@ -13,35 +13,29 @@
|
|||
border-radius: 5px;
|
||||
}
|
||||
#output.allowed {
|
||||
background-color: #f3fbf4;
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
#output.denied {
|
||||
background-color: #fff7f7;
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
#output h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
#output h3 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
#output .result-badge,
|
||||
.effect-badge,
|
||||
.rule-status {
|
||||
#output .result-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2em 0.5em;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
#output .allowed-badge,
|
||||
.effect-allow {
|
||||
background-color: #2e7d32;
|
||||
#output .allowed-badge {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
#output .denied-badge,
|
||||
.effect-deny {
|
||||
background-color: #c62828;
|
||||
#output .denied-badge {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.details-section {
|
||||
|
|
@ -54,130 +48,70 @@
|
|||
.details-section dd {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Explain a permission decision</h1>
|
||||
<h1>Permission check</h1>
|
||||
|
||||
{% set current_tab = "check" %}
|
||||
{% 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">
|
||||
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
|
||||
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
||||
<div class="form-section">
|
||||
<label for="actor">Actor JSON:</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>
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action in actions %}
|
||||
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small id="action-help">The operation to evaluate</small>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section" id="parent-section">
|
||||
<label for="parent">Parent resource:</label>
|
||||
<div class="form-section">
|
||||
<label for="parent">Parent resource (optional):</label>
|
||||
<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 class="form-section" id="child-section">
|
||||
<label for="child">Child resource:</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
|
||||
<small>The table, query or other child resource</small>
|
||||
<div class="form-section">
|
||||
<label for="child">Child resource (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="output" style="display: none;">
|
||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||
<p id="result-summary"></p>
|
||||
|
||||
<dl class="details-section">
|
||||
<dt>Actor:</dt>
|
||||
<dd><code id="result-actor"></code></dd>
|
||||
<dt>Action:</dt>
|
||||
<dd><code id="result-action"></code></dd>
|
||||
<dt>Resource:</dt>
|
||||
<dd><code id="result-resource"></code></dd>
|
||||
<dd id="result-action"></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>
|
||||
|
||||
<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;">
|
||||
<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>
|
||||
|
|
@ -185,134 +119,152 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
|
||||
const form = document.getElementById('check-form');
|
||||
const output = document.getElementById('output');
|
||||
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() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Explaining...';
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
submitBtn.textContent = 'Checking...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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();
|
||||
|
||||
if (response.ok) {
|
||||
displayResult(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({error: error.message});
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
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) {
|
||||
output.style.display = 'block';
|
||||
|
||||
// Set badge and styling
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
output.className = data.allowed ? 'allowed' : 'denied';
|
||||
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
|
||||
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
||||
document.getElementById('result-summary').textContent = data.explanation.summary;
|
||||
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
|
||||
document.getElementById('result-action').textContent = data.action;
|
||||
document.getElementById('result-resource').textContent = data.resource.path;
|
||||
displayRules(data.explanation);
|
||||
displayRestrictions(data.explanation.restrictions);
|
||||
displayRequirements(data.explanation.required_actions);
|
||||
if (data.allowed) {
|
||||
output.className = 'allowed';
|
||||
resultBadge.className = 'result-badge allowed-badge';
|
||||
resultBadge.textContent = 'ALLOWED ✓';
|
||||
} else {
|
||||
output.className = 'denied';
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'DENIED ✗';
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
function displayRules(explanation) {
|
||||
const container = document.getElementById('matching-rules');
|
||||
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('');
|
||||
// Scroll to output
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
output.style.display = 'block';
|
||||
output.className = 'denied';
|
||||
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'ERROR';
|
||||
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
|
||||
document.getElementById('result-actor').textContent = '—';
|
||||
document.getElementById('result-action').textContent = '—';
|
||||
document.getElementById('result-resource').textContent = '—';
|
||||
document.getElementById('matching-rules').innerHTML = '';
|
||||
document.getElementById('restrictions-section').style.display = 'none';
|
||||
document.getElementById('requirements-section').style.display = 'none';
|
||||
|
||||
document.getElementById('result-action').textContent = 'N/A';
|
||||
document.getElementById('result-resource').textContent = 'N/A';
|
||||
document.getElementById('result-actor').textContent = 'N/A';
|
||||
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
performCheck();
|
||||
});
|
||||
actionSelect.addEventListener('change', updateResourceFields);
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
(function initializeFromUrl() {
|
||||
const params = populateFormFromURL();
|
||||
updateResourceFields();
|
||||
if (params.get('action')) {
|
||||
performCheck();
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before adding a child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission activity{% endblock %}
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
|
|
@ -20,45 +20,60 @@
|
|||
.check-action, .check-when, .check-result {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission activity</h1>
|
||||
<h1>Permission playground</h1>
|
||||
|
||||
{% set current_tab = "permissions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<h2>Raw simulator</h2>
|
||||
|
||||
<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>
|
||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||
<div class="permission-form-grid">
|
||||
<div>
|
||||
<div class="form-section">
|
||||
<label for="activity-actor">Actor</label>
|
||||
<textarea class="permission-textarea" id="activity-actor" name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<div class="form-section">
|
||||
<label>Actor</label>
|
||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col" style="vertical-align: top">
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
|
|
@ -110,7 +125,7 @@ debugPost.addEventListener('submit', function(ev) {
|
|||
});
|
||||
</script>
|
||||
|
||||
<h2>Recent permission checks</h2>
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
<p>
|
||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<div class="form-section">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
|
|||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('_page');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
|
|
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
|
|||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== '_size' && key !== '_page') {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('_page', page.toString());
|
||||
params.append('_size', pageSize);
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||
|
||||
import dataclasses
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import itsdangerous
|
||||
|
||||
|
|
@ -18,21 +18,6 @@ if TYPE_CHECKING:
|
|||
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
|
||||
class TokenRestrictions:
|
||||
"""
|
||||
|
|
@ -50,24 +35,24 @@ class TokenRestrictions:
|
|||
database: 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."""
|
||||
self.all.append(action)
|
||||
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."""
|
||||
self.database.setdefault(database, []).append(action)
|
||||
return self
|
||||
|
||||
def allow_resource(
|
||||
self, database: str, resource: str, action: str
|
||||
) -> TokenRestrictions:
|
||||
) -> "TokenRestrictions":
|
||||
"""Allow an action on a specific resource within a database."""
|
||||
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
||||
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
|
||||
restrictions, using action abbreviations registered with ``datasette``.
|
||||
|
|
@ -112,23 +97,19 @@ class TokenHandler:
|
|||
|
||||
async def create_token(
|
||||
self,
|
||||
datasette: Datasette,
|
||||
datasette: "Datasette",
|
||||
actor_id: str,
|
||||
*,
|
||||
expires_after: int | None = None,
|
||||
restrictions: TokenRestrictions | None = None,
|
||||
expires_after: Optional[int] = None,
|
||||
restrictions: Optional[TokenRestrictions] = None,
|
||||
) -> str:
|
||||
"""Create and return a token string for the given actor."""
|
||||
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.
|
||||
|
||||
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.
|
||||
Verify a token and return an actor dict, or None if this handler
|
||||
does not recognize the token.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
|
@ -142,11 +123,11 @@ class SignedTokenHandler(TokenHandler):
|
|||
|
||||
async def create_token(
|
||||
self,
|
||||
datasette: Datasette,
|
||||
datasette: "Datasette",
|
||||
actor_id: str,
|
||||
*,
|
||||
expires_after: int | None = None,
|
||||
restrictions: TokenRestrictions | None = None,
|
||||
expires_after: Optional[int] = None,
|
||||
restrictions: Optional[TokenRestrictions] = None,
|
||||
) -> str:
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
raise ValueError(
|
||||
|
|
@ -163,35 +144,32 @@ class SignedTokenHandler(TokenHandler):
|
|||
token["_r"] = abbreviated
|
||||
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_"
|
||||
|
||||
if not token.startswith(prefix):
|
||||
# Not one of our tokens - leave it for other handlers
|
||||
if not datasette.setting("allow_signed_tokens"):
|
||||
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")
|
||||
|
||||
if not token.startswith(prefix):
|
||||
return None
|
||||
|
||||
raw = token[len(prefix) :]
|
||||
try:
|
||||
decoded = datasette.unsign(raw, namespace="token")
|
||||
except itsdangerous.BadSignature:
|
||||
raise TokenInvalid("Invalid token signature")
|
||||
return None
|
||||
|
||||
if "t" not in decoded:
|
||||
raise TokenInvalid("Invalid token: no timestamp")
|
||||
return None
|
||||
created = decoded["t"]
|
||||
if not isinstance(created, int):
|
||||
raise TokenInvalid("Invalid token: invalid timestamp")
|
||||
return None
|
||||
|
||||
duration = decoded.get("d")
|
||||
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 (
|
||||
duration is not None
|
||||
|
|
@ -200,8 +178,9 @@ class SignedTokenHandler(TokenHandler):
|
|||
):
|
||||
duration = max_signed_tokens_ttl
|
||||
|
||||
if duration and time.time() - created > duration:
|
||||
raise TokenInvalid("Token has expired")
|
||||
if duration:
|
||||
if time.time() - created > duration:
|
||||
return None
|
||||
|
||||
actor = {"id": decoded["a"], "token": "dstok"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from markupsafe import escape
|
||||
import time
|
||||
import json
|
||||
import traceback
|
||||
|
||||
tracers = {}
|
||||
|
||||
|
|
@ -133,17 +132,17 @@ class AsgiTracer:
|
|||
"num_traces": len(traces),
|
||||
"traces": traces,
|
||||
}
|
||||
content_type = next(
|
||||
(
|
||||
try:
|
||||
content_type = [
|
||||
v.decode("utf8")
|
||||
for k, v in response_headers
|
||||
if k.lower() == b"content-type"
|
||||
),
|
||||
"",
|
||||
)
|
||||
][0]
|
||||
except IndexError:
|
||||
content_type = ""
|
||||
if "text/html" in content_type and b"</body>" in accumulated_body:
|
||||
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)
|
||||
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
||||
data = json.loads(accumulated_body.decode("utf8"))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from .utils import tilde_encode, path_with_format, PrefixedUrlString
|
||||
import urllib
|
||||
|
||||
from .utils import PrefixedUrlString, path_with_format, tilde_encode
|
||||
|
||||
|
||||
class Urls:
|
||||
def __init__(self, ds):
|
||||
|
|
@ -9,7 +8,8 @@ class Urls:
|
|||
|
||||
def path(self, path, format=None):
|
||||
if not isinstance(path, PrefixedUrlString):
|
||||
path = path.removeprefix("/")
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
path = self.ds.setting("base_url") + path
|
||||
if format is not None:
|
||||
path = path_with_format(path=path, format=format)
|
||||
|
|
@ -56,7 +56,6 @@ class Urls:
|
|||
return PrefixedUrlString(path)
|
||||
|
||||
def row_blob(self, database, table, row_path, column):
|
||||
return (
|
||||
self.table(database, table)
|
||||
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
|
||||
return self.table(database, table) + "/{}.blob?_blob_column={}".format(
|
||||
row_path, urllib.parse.quote_plus(column)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,28 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
from contextlib import contextmanager
|
||||
import aiofiles
|
||||
import click
|
||||
from collections import OrderedDict, namedtuple, Counter
|
||||
import copy
|
||||
import dataclasses
|
||||
import base64
|
||||
import hashlib
|
||||
import inspect
|
||||
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 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
|
||||
|
||||
from .shutil_backport import copytree
|
||||
from .sqlite import sqlite3, supports_table_xinfo
|
||||
|
||||
|
|
@ -38,7 +35,7 @@ if typing.TYPE_CHECKING:
|
|||
class PaginatedResources:
|
||||
"""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)
|
||||
_datasette: typing.Any = 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
|
||||
reserved_words = {
|
||||
"abort",
|
||||
"action",
|
||||
"add",
|
||||
"after",
|
||||
"all",
|
||||
"alter",
|
||||
"analyze",
|
||||
"and",
|
||||
"as",
|
||||
"asc",
|
||||
"attach",
|
||||
"autoincrement",
|
||||
"before",
|
||||
"begin",
|
||||
"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",
|
||||
}
|
||||
reserved_words = set(
|
||||
(
|
||||
"abort action add after all alter analyze and as asc attach autoincrement "
|
||||
"before begin 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"
|
||||
).split()
|
||||
)
|
||||
|
||||
APT_GET_DOCKERFILE_EXTRAS = r"""
|
||||
RUN apt-get update && \
|
||||
|
|
@ -270,7 +157,7 @@ functions_marked_as_documented = []
|
|||
|
||||
def documented(fn=None, *, label=None):
|
||||
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)
|
||||
return fn
|
||||
|
||||
|
|
@ -337,71 +224,24 @@ def compound_keys_after_sql(pks, start_index=0):
|
|||
return "({})".format("\n or\n".join(or_clauses))
|
||||
|
||||
|
||||
@documented
|
||||
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):
|
||||
if isinstance(obj, sqlite3.Row):
|
||||
return tuple(obj)
|
||||
if isinstance(obj, sqlite3.Cursor):
|
||||
return list(obj)
|
||||
if isinstance(obj, bytes):
|
||||
return {
|
||||
"$base64": True,
|
||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||
}
|
||||
# Does it encode to utf8?
|
||||
try:
|
||||
return obj.decode("utf8")
|
||||
except UnicodeDecodeError:
|
||||
return {
|
||||
"$base64": True,
|
||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||
}
|
||||
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
|
||||
def sqlite_timelimit(conn, ms):
|
||||
deadline = time.perf_counter() + (ms / 1000)
|
||||
|
|
@ -472,7 +312,7 @@ disallawed_sql_res = [
|
|||
(
|
||||
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
||||
"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)
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -646,7 +486,10 @@ CMD {cmd}""".format(
|
|||
else ""
|
||||
),
|
||||
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),
|
||||
files=" ".join(files),
|
||||
|
|
@ -745,11 +588,11 @@ def detect_primary_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 = []
|
||||
for info in infos:
|
||||
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(
|
||||
{
|
||||
"column": from_,
|
||||
|
|
@ -850,7 +693,7 @@ def detect_json1(conn=None):
|
|||
try:
|
||||
conn.execute("SELECT json('{}')")
|
||||
return True
|
||||
except sqlite3.Error:
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
if close_conn:
|
||||
|
|
@ -930,7 +773,9 @@ def is_url(value):
|
|||
if not value.startswith("http://") and not value.startswith("https://"):
|
||||
return False
|
||||
# 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-]*$")
|
||||
|
|
@ -983,9 +828,7 @@ def module_from_path(path, name):
|
|||
mod.__file__ = path
|
||||
with open(path, "r") as file:
|
||||
code = compile(file.read(), path, "exec", dont_inherit=True)
|
||||
# Executing the file is the whole point - this is how --plugins-dir loads
|
||||
# plugins and how metadata/config .py files are evaluated
|
||||
exec(code, mod.__dict__) # noqa: S102
|
||||
exec(code, mod.__dict__)
|
||||
return mod
|
||||
|
||||
|
||||
|
|
@ -1142,7 +985,9 @@ def escape_fts(query):
|
|||
query += '"'
|
||||
bits = _escape_fts_re.split(query)
|
||||
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:
|
||||
|
|
@ -1154,7 +999,7 @@ class MultiParams:
|
|||
data[key], (list, tuple)
|
||||
), "dictionary data should be a dictionary of key => [list]"
|
||||
self._data = data
|
||||
elif isinstance(data, (list, tuple)):
|
||||
elif isinstance(data, list) or isinstance(data, tuple):
|
||||
new_data = {}
|
||||
for item in data:
|
||||
assert (
|
||||
|
|
@ -1244,7 +1089,9 @@ def _gather_arguments(fn, kwargs):
|
|||
for parameter in parameters:
|
||||
if parameter not in kwargs:
|
||||
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])
|
||||
return call_with
|
||||
|
|
@ -1313,9 +1160,9 @@ def resolve_env_secrets(config, environ):
|
|||
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
||||
if isinstance(config, dict):
|
||||
if list(config.keys()) == ["$env"]:
|
||||
return environ.get(next(iter(config.values())))
|
||||
return environ.get(list(config.values())[0])
|
||||
elif list(config.keys()) == ["$file"]:
|
||||
with open(next(iter(config.values()))) as fp:
|
||||
with open(list(config.values())[0]) as fp:
|
||||
return fp.read()
|
||||
else:
|
||||
return {
|
||||
|
|
@ -1393,38 +1240,29 @@ class StartupError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
# Comments and string literals, matched in a single pass so that whichever
|
||||
# construct starts first "wins" - this ensures a comment marker inside a string
|
||||
# literal (or a quote inside a comment) does not confuse the parameter scan.
|
||||
_comments_and_strings_re = re.compile(
|
||||
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,
|
||||
)
|
||||
_single_line_comment_re = re.compile(r"--.*")
|
||||
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
|
||||
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
|
||||
_named_param_re = re.compile(r":(\w+)")
|
||||
|
||||
|
||||
@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
|
||||
|
||||
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
||||
"""
|
||||
# Strip comments and string literals first so that any ":name" sequences
|
||||
# inside them are not mistaken for named parameters
|
||||
sql = _comments_and_strings_re.sub("", sql)
|
||||
sql = _single_line_comment_re.sub("", sql)
|
||||
sql = _multi_line_comment_re.sub("", sql)
|
||||
sql = _single_quote_re.sub("", sql)
|
||||
sql = _double_quote_re.sub("", sql)
|
||||
# Extract parameters from what is left
|
||||
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
|
||||
with plugins that were using it before it switched to named_parameters()
|
||||
|
|
@ -1432,54 +1270,6 @@ async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
|
|||
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):
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||
|
|
@ -1508,7 +1298,7 @@ class TildeEncoder(dict):
|
|||
elif b == _space:
|
||||
res = "+"
|
||||
else:
|
||||
res = f"~{b:02X}"
|
||||
res = "~{:02X}".format(b)
|
||||
self[b] = res
|
||||
return res
|
||||
|
||||
|
|
@ -1603,7 +1393,7 @@ def _combine(base: dict, update: dict) -> dict:
|
|||
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.
|
||||
"""
|
||||
|
|
@ -1618,7 +1408,7 @@ def make_slot_function(name, datasette, request, **kwargs):
|
|||
from datasette.plugins import pm
|
||||
|
||||
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():
|
||||
html_bits = []
|
||||
|
|
@ -1642,7 +1432,7 @@ def prune_empty_dicts(d: dict):
|
|||
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
|
||||
hierarchy in destination if needed. After moving, recursively remove any keys
|
||||
|
|
|
|||
|
|
@ -252,62 +252,88 @@ async def _build_single_action_sql(
|
|||
]
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# Continue with the cascading logic
|
||||
query_parts.extend(
|
||||
["child_agg AS ("]
|
||||
+ _agg(
|
||||
"parent, child,",
|
||||
"parent IS NOT NULL AND child IS NOT NULL",
|
||||
"parent, child",
|
||||
)
|
||||
+ ["),", "parent_agg AS ("]
|
||||
+ _agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
||||
+ ["),", "global_agg AS ("]
|
||||
+ _agg("", "parent IS NULL AND child IS NULL", None)
|
||||
+ ["),"]
|
||||
[
|
||||
"child_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 = b.child",
|
||||
" 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
|
||||
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(
|
||||
["anon_child_agg AS ("]
|
||||
+ _anon_agg(
|
||||
"parent, child,",
|
||||
"parent IS NOT NULL AND child IS NOT NULL",
|
||||
"parent, child",
|
||||
)
|
||||
+ ["),", "anon_parent_agg AS ("]
|
||||
+ _anon_agg("parent,", "parent IS NOT NULL AND child IS NULL", "parent")
|
||||
+ ["),", "anon_global_agg AS ("]
|
||||
+ _anon_agg("", "parent IS NULL AND child IS NULL", None)
|
||||
+ ["),"]
|
||||
[
|
||||
"anon_child_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 = b.child",
|
||||
" GROUP BY b.parent, b.child",
|
||||
"),",
|
||||
"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
|
||||
|
|
@ -316,28 +342,31 @@ async def _build_single_action_sql(
|
|||
"decisions AS (",
|
||||
" SELECT",
|
||||
" 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:",
|
||||
" -- 1. Child-level deny 2. Child-level allow",
|
||||
" -- 3. Parent-level deny 4. Parent-level allow",
|
||||
" -- 5. Global-level deny 6. Global-level allow",
|
||||
" -- 1. Child-level deny (most specific, blocks access)",
|
||||
" -- 2. Child-level allow (most specific, grants access)",
|
||||
" -- 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)",
|
||||
" CASE",
|
||||
" WHEN ca.any_deny = 1 THEN 0",
|
||||
" WHEN ca.any_allow = 1 THEN 1",
|
||||
" WHEN pa.any_deny = 1 THEN 0",
|
||||
" WHEN pa.any_allow = 1 THEN 1",
|
||||
" WHEN ga.any_deny = 1 THEN 0",
|
||||
" WHEN ga.any_allow = 1 THEN 1",
|
||||
" WHEN cl.any_deny = 1 THEN 0",
|
||||
" WHEN cl.any_allow = 1 THEN 1",
|
||||
" WHEN pl.any_deny = 1 THEN 0",
|
||||
" WHEN pl.any_allow = 1 THEN 1",
|
||||
" WHEN gl.any_deny = 1 THEN 0",
|
||||
" WHEN gl.any_allow = 1 THEN 1",
|
||||
" ELSE 0",
|
||||
" END AS is_allowed,",
|
||||
" CASE",
|
||||
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
|
||||
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
|
||||
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
|
||||
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
|
||||
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
|
||||
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
|
||||
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
|
||||
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
|
||||
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
|
||||
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
|
||||
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
|
||||
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
|
||||
" ELSE '[]'",
|
||||
" END AS reason",
|
||||
]
|
||||
|
|
@ -345,34 +374,21 @@ async def _build_single_action_sql(
|
|||
|
||||
if include_is_private:
|
||||
query_parts.append(
|
||||
" , CASE WHEN ("
|
||||
"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"
|
||||
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
||||
)
|
||||
|
||||
query_parts.extend(
|
||||
[
|
||||
" FROM base b",
|
||||
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
|
||||
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
|
||||
" CROSS JOIN global_agg ga",
|
||||
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
||||
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
||||
" 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:
|
||||
query_parts.extend(
|
||||
[
|
||||
" 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(
|
||||
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
|
||||
)
|
||||
|
||||
query_parts.append(")")
|
||||
|
|
@ -384,28 +400,8 @@ async def _build_single_action_sql(
|
|||
restriction_intersect = "\nINTERSECT\n".join(
|
||||
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(
|
||||
[
|
||||
",",
|
||||
"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",
|
||||
")",
|
||||
]
|
||||
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
|
||||
)
|
||||
|
||||
# Final SELECT
|
||||
|
|
@ -420,11 +416,10 @@ async def _build_single_action_sql(
|
|||
# Add restriction filter if there are restrictions
|
||||
if restriction_sqls:
|
||||
query_parts.append("""
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM restriction_all)
|
||||
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
|
||||
OR EXISTS (SELECT 1 FROM restriction_child_any r WHERE r.child = decisions.child)
|
||||
OR EXISTS (SELECT 1 FROM restriction_exact r WHERE r.parent = decisions.parent AND r.child = decisions.child)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM restriction_list r
|
||||
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
|
||||
AND (r.child = decisions.child OR r.child IS NULL)
|
||||
)""")
|
||||
|
||||
# Add parent filter if specified
|
||||
|
|
@ -678,239 +673,3 @@ async def check_permission_for_resource(
|
|||
child=child,
|
||||
)
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -1,30 +1,28 @@
|
|||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from http.cookies import Morsel, SimpleCookie
|
||||
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 typing import Optional
|
||||
from datasette.utils import MultiParams, calculate_etag, sha256_file
|
||||
from datasette.utils.multipart import (
|
||||
DEFAULT_MAX_FIELD_SIZE,
|
||||
DEFAULT_MAX_FIELDS,
|
||||
parse_form_data,
|
||||
MultipartParseError,
|
||||
FormData,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_REQUEST_SIZE,
|
||||
DEFAULT_MAX_FIELDS,
|
||||
DEFAULT_MAX_FILES,
|
||||
DEFAULT_MAX_PARTS,
|
||||
DEFAULT_MAX_FIELD_SIZE,
|
||||
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||
DEFAULT_MAX_PART_HEADER_BYTES,
|
||||
DEFAULT_MAX_PART_HEADER_LINES,
|
||||
DEFAULT_MAX_PARTS,
|
||||
DEFAULT_MAX_REQUEST_SIZE,
|
||||
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
|
||||
Morsel._reserved["samesite"] = "SameSite"
|
||||
|
|
@ -69,28 +67,16 @@ class BadRequest(Base400):
|
|||
status = 400
|
||||
|
||||
|
||||
class PayloadTooLarge(Base400):
|
||||
status = 413
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
||||
def __init__(self, scope, receive):
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.max_post_body_bytes = max_post_body_bytes
|
||||
|
||||
def __repr__(self):
|
||||
return f'<asgi.Request method="{self.method}" url="{self.url}">'
|
||||
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
|
|
@ -155,43 +141,15 @@ class Request:
|
|||
def actor(self):
|
||||
return self.scope.get("actor", None)
|
||||
|
||||
async def post_body(self, max_bytes=None):
|
||||
"""
|
||||
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
|
||||
async def post_body(self):
|
||||
body = b""
|
||||
more_body = True
|
||||
while more_body:
|
||||
message = await self.receive()
|
||||
assert message["type"] == "http.request", message
|
||||
chunk = message.get("body", b"")
|
||||
received += len(chunk)
|
||||
if max_bytes and received > max_bytes:
|
||||
raise too_large
|
||||
chunks.append(chunk)
|
||||
body += message.get("body", b"")
|
||||
more_body = message.get("more_body", False)
|
||||
return b"".join(chunks)
|
||||
return body
|
||||
|
||||
async def post_vars(self):
|
||||
body = await self.post_body()
|
||||
|
|
@ -208,7 +166,7 @@ class Request:
|
|||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||
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_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||
|
|
@ -301,24 +259,12 @@ class AsgiLifespan:
|
|||
while True:
|
||||
message = await receive()
|
||||
if message["type"] == "lifespan.startup":
|
||||
try:
|
||||
for fn in self.on_startup:
|
||||
await fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
await send(
|
||||
{"type": "lifespan.startup.failed", "message": str(e)}
|
||||
)
|
||||
return
|
||||
for fn in self.on_startup:
|
||||
await fn()
|
||||
await send({"type": "lifespan.startup.complete"})
|
||||
elif message["type"] == "lifespan.shutdown":
|
||||
try:
|
||||
for fn in self.on_shutdown:
|
||||
await fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
await send(
|
||||
{"type": "lifespan.shutdown.failed", "message": str(e)}
|
||||
)
|
||||
return
|
||||
for fn in self.on_shutdown:
|
||||
await fn()
|
||||
await send({"type": "lifespan.shutdown.complete"})
|
||||
return
|
||||
else:
|
||||
|
|
@ -543,9 +489,9 @@ class Response:
|
|||
httponly=False,
|
||||
samesite="lax",
|
||||
):
|
||||
assert (
|
||||
samesite in SAMESITE_VALUES
|
||||
), f"samesite should be one of {SAMESITE_VALUES}"
|
||||
assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
|
||||
SAMESITE_VALUES
|
||||
)
|
||||
cookie = SimpleCookie()
|
||||
cookie[key] = value
|
||||
for prop_name, prop_value in (
|
||||
|
|
@ -589,18 +535,6 @@ class Response:
|
|||
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
|
||||
def redirect(cls, path, status=302, headers=None):
|
||||
headers = headers or {}
|
||||
|
|
@ -637,23 +571,10 @@ class AsgiRunOnFirstRequest:
|
|||
self.asgi = asgi
|
||||
self.on_startup = on_startup
|
||||
self._started = False
|
||||
# Guards against concurrent early requests interleaving with startup:
|
||||
# without this, several requests could all observe `_started is
|
||||
# False` and proceed before any of them finish running the hooks.
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
# Leave "lifespan" scope events alone - this shim only exists as a
|
||||
# fallback for hosts that never send them. It wraps AsgiLifespan, so
|
||||
# if it ran on_startup here too, a startup exception would escape
|
||||
# before AsgiLifespan's own try/except got a chance to turn it into
|
||||
# a lifespan.startup.failed message.
|
||||
if scope["type"] != "lifespan" and not self._started:
|
||||
async with self._lock:
|
||||
# Re-check: another request may have finished startup while
|
||||
# we were waiting for the lock.
|
||||
if not self._started:
|
||||
for hook in self.on_startup:
|
||||
await hook()
|
||||
self._started = True
|
||||
if not self._started:
|
||||
self._started = True
|
||||
for hook in self.on_startup:
|
||||
await hook()
|
||||
return await self.asgi(scope, receive, send)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
|
|||
"""
|
||||
|
||||
|
||||
class BaseConverter:
|
||||
class BaseConverter(object):
|
||||
decimal_digits = "0123456789"
|
||||
|
||||
def __init__(self, digits):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import inspect
|
||||
import types
|
||||
from typing import Any, NamedTuple
|
||||
from typing import NamedTuple, Any
|
||||
|
||||
|
||||
class CallableStatus(NamedTuple):
|
||||
|
|
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
|
|||
if isinstance(obj, types.FunctionType):
|
||||
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
||||
|
||||
if callable(obj):
|
||||
if hasattr(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))
|
||||
|
|
|
|||
|
|
@ -1,30 +1,9 @@
|
|||
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
|
||||
|
||||
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("""
|
||||
async def init_internal_db(db):
|
||||
create_tables_sql = textwrap.dedent("""
|
||||
CREATE TABLE IF NOT EXISTS catalog_databases (
|
||||
database_name TEXT PRIMARY KEY,
|
||||
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, 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()
|
||||
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):
|
||||
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):
|
||||
async def populate_schema_tables(internal_db, db):
|
||||
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
|
||||
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_to_insert.extend(
|
||||
{
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
**{"database_name": database_name, "table_name": table_name},
|
||||
**column._asdict(),
|
||||
}
|
||||
for column in columns
|
||||
)
|
||||
foreign_keys = conn.execute(
|
||||
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
|
||||
f"PRAGMA foreign_key_list([{table_name}])"
|
||||
).fetchall()
|
||||
foreign_keys_to_insert.extend(
|
||||
{
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
**{"database_name": database_name, "table_name": table_name},
|
||||
**dict(foreign_key),
|
||||
}
|
||||
for foreign_key in foreign_keys
|
||||
)
|
||||
indexes = conn.execute(
|
||||
f"PRAGMA index_list({escape_sqlite(table_name)})"
|
||||
).fetchall()
|
||||
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
|
||||
indexes_to_insert.extend(
|
||||
{
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
**{"database_name": database_name, "table_name": table_name},
|
||||
**dict(index),
|
||||
}
|
||||
for index in indexes
|
||||
|
|
@ -251,76 +223,47 @@ async def populate_schema_tables(internal_db, db, schema_version):
|
|||
indexes_to_insert,
|
||||
) = await db.execute_fn(collect_info)
|
||||
|
||||
def replace_catalog(conn):
|
||||
# Delete child rows before their catalog_tables parents so this also
|
||||
# works if a prepare_connection plugin enables foreign key enforcement.
|
||||
for table in (
|
||||
"catalog_columns",
|
||||
"catalog_foreign_keys",
|
||||
"catalog_indexes",
|
||||
"catalog_views",
|
||||
"catalog_tables",
|
||||
):
|
||||
conn.execute(
|
||||
f"DELETE FROM {table} WHERE database_name = ?",
|
||||
[database_name],
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO catalog_databases (
|
||||
database_name, path, is_memory, schema_version
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
database_name,
|
||||
str(db.path) if db.path is not None else None,
|
||||
db.is_memory,
|
||||
schema_version,
|
||||
],
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
tables_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
views_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
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
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
tables_to_insert,
|
||||
""",
|
||||
columns_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
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(
|
||||
"""
|
||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||
values (?, ?, ?, ?)
|
||||
""",
|
||||
views_to_insert,
|
||||
""",
|
||||
foreign_keys_to_insert,
|
||||
)
|
||||
await internal_db.execute_write_many(
|
||||
"""
|
||||
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(
|
||||
"""
|
||||
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)
|
||||
""",
|
||||
indexes_to_insert,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,10 +11,15 @@ Supports:
|
|||
import asyncio
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
|
|
@ -24,7 +29,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
|
|||
DEFAULT_MAX_FIELDS = 1000
|
||||
DEFAULT_MAX_FILES = 100
|
||||
# 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_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
|
||||
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
|
||||
|
|
@ -35,6 +40,8 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
|
|||
class MultipartParseError(Exception):
|
||||
"""Raised when multipart parsing fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UploadedFile:
|
||||
|
|
@ -50,7 +57,7 @@ class UploadedFile:
|
|||
|
||||
name: str
|
||||
filename: str
|
||||
content_type: str | None
|
||||
content_type: Optional[str]
|
||||
size: int
|
||||
_file: tempfile.SpooledTemporaryFile = field(repr=False)
|
||||
|
||||
|
|
@ -79,8 +86,7 @@ class UploadedFile:
|
|||
def __del__(self):
|
||||
try:
|
||||
self._file.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# __del__ must never raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -92,27 +98,27 @@ class FormData:
|
|||
"""
|
||||
|
||||
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."""
|
||||
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."""
|
||||
for k, v in self._data:
|
||||
if k == key:
|
||||
return v
|
||||
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."""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
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."""
|
||||
return [v for k, v in self._data if k == key]
|
||||
|
||||
|
|
@ -136,15 +142,15 @@ class FormData:
|
|||
"""Return unique keys."""
|
||||
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 list(self._data)
|
||||
|
||||
def values(self) -> list[str | UploadedFile]:
|
||||
def values(self) -> List[Union[str, UploadedFile]]:
|
||||
"""Return all values."""
|
||||
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 [v for _, v in self._data if isinstance(v, UploadedFile)]
|
||||
|
||||
|
|
@ -157,7 +163,7 @@ class FormData:
|
|||
for uploaded in self._uploaded_files():
|
||||
try:
|
||||
uploaded.close_sync()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
# Best-effort cleanup; ignore close errors
|
||||
pass
|
||||
|
||||
|
|
@ -166,7 +172,7 @@ class FormData:
|
|||
for uploaded in self._uploaded_files():
|
||||
try:
|
||||
await uploaded.close()
|
||||
except Exception: # noqa: BLE001, S110
|
||||
except Exception:
|
||||
# Best-effort cleanup; ignore close errors
|
||||
pass
|
||||
|
||||
|
|
@ -183,13 +189,13 @@ class FormData:
|
|||
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.
|
||||
|
||||
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
|
||||
parts = []
|
||||
|
|
@ -232,8 +238,7 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
|
|||
from urllib.parse import unquote
|
||||
|
||||
result["filename"] = unquote(encoded, encoding="utf-8")
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Malformed RFC 5987 filename* - fall back to the plain filename
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
|
|
@ -245,19 +250,20 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
|
|||
|
||||
if key == "name":
|
||||
result["name"] = value
|
||||
# Only set filename if filename* hasn't already set it
|
||||
elif key == "filename" and result["filename"] is None:
|
||||
# Strip path components (security)
|
||||
# Handle both Unix and Windows paths
|
||||
value = value.replace("\\", "/")
|
||||
if "/" in value:
|
||||
value = value.rsplit("/", 1)[-1]
|
||||
result["filename"] = value
|
||||
elif key == "filename":
|
||||
# Only set if filename* hasn't already set it
|
||||
if result["filename"] is None:
|
||||
# Strip path components (security)
|
||||
# Handle both Unix and Windows paths
|
||||
value = value.replace("\\", "/")
|
||||
if "/" in value:
|
||||
value = value.rsplit("/", 1)[-1]
|
||||
result["filename"] = value
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -301,7 +307,7 @@ class MultipartParser:
|
|||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||
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_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||
|
|
@ -342,12 +348,12 @@ class MultipartParser:
|
|||
self._tempdir = tempfile.gettempdir()
|
||||
|
||||
# Current part state
|
||||
self.current_headers: dict[str, str] = {}
|
||||
self.current_file: tempfile.SpooledTemporaryFile | None = None
|
||||
self.current_headers: Dict[str, str] = {}
|
||||
self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
|
||||
self.current_body = bytearray()
|
||||
self.current_name: str | None = None
|
||||
self.current_filename: str | None = None
|
||||
self.current_content_type: str | None = None
|
||||
self.current_name: Optional[str] = None
|
||||
self.current_filename: Optional[str] = None
|
||||
self.current_content_type: Optional[str] = None
|
||||
|
||||
def feed(self, chunk: bytes) -> None:
|
||||
"""Feed a chunk of data to the parser."""
|
||||
|
|
@ -448,7 +454,7 @@ class MultipartParser:
|
|||
# Parse header
|
||||
try:
|
||||
line_str = line.decode("utf-8", errors="replace")
|
||||
except UnicodeDecodeError:
|
||||
except Exception:
|
||||
line_str = line.decode("latin-1")
|
||||
|
||||
if ":" in line_str:
|
||||
|
|
@ -475,9 +481,7 @@ class MultipartParser:
|
|||
if self.file_count > self.max_files:
|
||||
raise MultipartParseError("Too many files")
|
||||
if self.handle_files:
|
||||
# Outlives this method - it is filled in across parser callbacks
|
||||
# and then handed to the UploadedFile the caller consumes
|
||||
self.current_file = tempfile.SpooledTemporaryFile( # noqa: SIM115
|
||||
self.current_file = tempfile.SpooledTemporaryFile(
|
||||
max_size=self.max_memory_file_size
|
||||
)
|
||||
else:
|
||||
|
|
@ -640,7 +644,7 @@ async def parse_form_data(
|
|||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||
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_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.plugins import pm
|
||||
|
|
@ -16,7 +15,7 @@ SKIP_PERMISSION_CHECKS = object()
|
|||
|
||||
async def gather_permission_sql_from_hooks(
|
||||
*, datasette, actor: dict | None, action: str
|
||||
) -> list[PermissionSQL] | object:
|
||||
) -> List[PermissionSQL] | object:
|
||||
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
||||
|
||||
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()
|
||||
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_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
|
|
@ -72,7 +71,7 @@ def _iter_permission_sql_from_result(
|
|||
if isinstance(result, PermissionSQL):
|
||||
return [result]
|
||||
if isinstance(result, (list, tuple)):
|
||||
collected: list[PermissionSQL] = []
|
||||
collected: List[PermissionSQL] = []
|
||||
for item in result:
|
||||
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
||||
return collected
|
||||
|
|
@ -91,7 +90,7 @@ def _iter_permission_sql_from_result(
|
|||
|
||||
def build_rules_union(
|
||||
actor: dict | None, plugins: Sequence[PermissionSQL]
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
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
|
||||
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_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:
|
||||
# No namespacing - just use plugin params as-is
|
||||
|
|
@ -142,10 +141,10 @@ async def resolve_permissions_from_catalog(
|
|||
plugins: Sequence[Any],
|
||||
action: str,
|
||||
candidate_sql: str,
|
||||
candidate_params: dict[str, Any] | None = None,
|
||||
candidate_params: Dict[str, Any] | None = None,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
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
|
||||
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||
"""
|
||||
resolved_plugins: list[PermissionSQL] = []
|
||||
restriction_sqls: list[str] = []
|
||||
resolved_plugins: List[PermissionSQL] = []
|
||||
restriction_sqls: List[str] = []
|
||||
|
||||
for plugin in plugins:
|
||||
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
||||
|
|
@ -399,11 +398,11 @@ async def resolve_permissions_with_candidates(
|
|||
db,
|
||||
actor: dict | None,
|
||||
plugins: Sequence[Any],
|
||||
candidates: list[tuple[str, str | None]],
|
||||
candidates: List[Tuple[str, str | None]],
|
||||
action: str,
|
||||
*,
|
||||
implicit_deny: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve permissions without any external candidate table by embedding
|
||||
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
|
||||
"""
|
||||
# Build a small CTE for candidates.
|
||||
cand_rows_sql: list[str] = []
|
||||
cand_params: dict[str, Any] = {}
|
||||
cand_rows_sql: List[str] = []
|
||||
cand_params: Dict[str, Any] = {}
|
||||
for i, (parent, child) in enumerate(candidates):
|
||||
pkey = f"cand_p_{i}"
|
||||
ckey = f"cand_c_{i}"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ https://github.com/python/cpython/blob/v3.8.3/LICENSE
|
|||
"""
|
||||
|
||||
import os
|
||||
from shutil import Error, copy, copy2, copystat
|
||||
from shutil import copy, copy2, copystat, Error
|
||||
|
||||
|
||||
def _copytree(
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ _SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
|
|||
"sqlite_rename_test",
|
||||
"substr",
|
||||
}
|
||||
|
||||
_AUTHORIZER_ACTION_NAMES = {
|
||||
getattr(sqlite3, name): name
|
||||
for name in (
|
||||
|
|
@ -390,10 +391,6 @@ def analyze_sql_tables(
|
|||
)
|
||||
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:
|
||||
record(
|
||||
"function",
|
||||
|
|
@ -413,12 +410,12 @@ def analyze_sql_tables(
|
|||
database=None,
|
||||
table=None,
|
||||
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,
|
||||
)
|
||||
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(
|
||||
"unknown",
|
||||
"unknown",
|
||||
|
|
@ -488,17 +485,17 @@ def analyze_sql_tables(
|
|||
and key.operation in {"create", "alter", "drop"}
|
||||
for key in operations
|
||||
)
|
||||
dropped_tables_and_views = {
|
||||
dropped_tables = {
|
||||
(key.database, key.table)
|
||||
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:
|
||||
return (
|
||||
key.operation == "delete"
|
||||
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(
|
||||
|
|
@ -521,7 +518,9 @@ def analyze_sql_tables(
|
|||
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
|
||||
):
|
||||
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:
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]
|
|||
schema_table = _sqlite_schema_table(schema)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"select name, sql from {schema_table} where type = 'table'"
|
||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
||||
).fetchall()
|
||||
except sqlite3.DatabaseError:
|
||||
return []
|
||||
|
|
@ -127,7 +127,7 @@ def _sqlite_table_type_from_schema(
|
|||
schema_table = _sqlite_schema_table(schema)
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"select type, sql from {schema_table} where name = ?",
|
||||
"select type, sql from {} where name = ?".format(schema_table),
|
||||
(table,),
|
||||
).fetchone()
|
||||
except sqlite3.DatabaseError:
|
||||
|
|
@ -155,7 +155,7 @@ def _is_known_shadow_table(
|
|||
schema_table = _sqlite_schema_table(schema)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"select name, sql from {schema_table} where type = 'table'"
|
||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
||||
).fetchall()
|
||||
except sqlite3.DatabaseError:
|
||||
return False
|
||||
|
|
@ -174,7 +174,7 @@ def _sqlite_schema_table(schema: str | None) -> str:
|
|||
return "sqlite_master"
|
||||
if schema == "temp":
|
||||
return "sqlite_temp_master"
|
||||
return f"{_quote_identifier(schema)}.sqlite_master"
|
||||
return "{}.sqlite_master".format(_quote_identifier(schema))
|
||||
|
||||
|
||||
def _quote_identifier(value: str) -> str:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from urllib.parse import urlencode
|
||||
import json
|
||||
|
||||
# These wrapper classes pre-date the introduction of
|
||||
# datasette.client and httpx to Datasette. They could
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
__version__ = "1.0a38"
|
||||
__version__ = "1.0a35"
|
||||
__version_info__ = tuple(__version__.split("."))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from dataclasses import dataclass
|
||||
import dataclasses
|
||||
import types
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -74,14 +74,16 @@ class Context:
|
|||
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"
|
||||
"{}.{} is declared with from_extra() but there is no "
|
||||
"registered extra of that name".format(cls.__name__, 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}"
|
||||
"{}.{} is declared with from_extra() but the {} extra is "
|
||||
"not available for scope {}".format(
|
||||
cls.__name__, name, name, cls.extras_scope
|
||||
)
|
||||
)
|
||||
return extra_class.description or ""
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@ import csv
|
|||
import hashlib
|
||||
import sys
|
||||
|
||||
from datasette.utils.asgi import Request
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
EscapeHtmlWriter,
|
||||
InvalidSql,
|
||||
LimitedWriter,
|
||||
add_cors_headers,
|
||||
path_from_row_pks,
|
||||
path_with_format,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import (
|
||||
AsgiStream,
|
||||
BadRequest,
|
||||
Request,
|
||||
Response,
|
||||
BadRequest,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -28,15 +28,12 @@ class DatasetteError(Exception):
|
|||
status=500,
|
||||
template=None,
|
||||
message_is_html=False,
|
||||
plain_message=None,
|
||||
):
|
||||
self.message = message
|
||||
self.title = title
|
||||
self.error_dict = error_dict or {}
|
||||
self.status = status
|
||||
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:
|
||||
|
|
@ -52,7 +49,9 @@ class View:
|
|||
request.path.endswith(".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:
|
||||
response = Response.text("Method not allowed", status=405)
|
||||
return response
|
||||
|
|
@ -91,7 +90,9 @@ class BaseView:
|
|||
request.path.endswith(".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:
|
||||
response = Response.text("Method not allowed", status=405)
|
||||
return response
|
||||
|
|
@ -129,10 +130,12 @@ class BaseView:
|
|||
template = environment.select_template(templates)
|
||||
template_context = {
|
||||
**context,
|
||||
"select_templates": [
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
],
|
||||
**{
|
||||
"select_templates": [
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
],
|
||||
},
|
||||
}
|
||||
headers = {}
|
||||
if self.has_json_alternate:
|
||||
|
|
@ -149,7 +152,9 @@ class BaseView:
|
|||
template_context["alternate_url_json"] = alternate_url_json
|
||||
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(
|
||||
|
|
@ -175,12 +180,18 @@ class BaseView:
|
|||
return view
|
||||
|
||||
|
||||
def _error(messages, status=400):
|
||||
return Response.json({"ok": False, "errors": messages}, status=status)
|
||||
|
||||
|
||||
async def stream_csv(datasette, fetch_data, request, database):
|
||||
kwargs = {}
|
||||
stream = request.args.get("_stream")
|
||||
# Do not calculate facets or counts:
|
||||
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:
|
||||
# Replace request object with a new one with modified scope
|
||||
|
|
@ -210,6 +221,9 @@ async def stream_csv(datasette, fetch_data, request, database):
|
|||
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
|
||||
|
||||
|
|
@ -316,9 +330,8 @@ async def stream_csv(datasette, fetch_data, request, database):
|
|||
else:
|
||||
new_row.append(cell)
|
||||
await writer.writerow(new_row)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
# Streaming CSV: report the error into the response body and stop
|
||||
sys.stderr.write(f"Caught this error: {ex}\n")
|
||||
except Exception as ex:
|
||||
sys.stderr.write("Caught this error: {}\n".format(ex))
|
||||
sys.stderr.flush()
|
||||
await r.write(str(ex))
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,52 +1,48 @@
|
|||
from dataclasses import asdict, dataclass, field
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
import asyncio
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import markupsafe
|
||||
import os
|
||||
import textwrap
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import markupsafe
|
||||
|
||||
from datasette.extras import extra_names_from_request
|
||||
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.stored_queries import StoredQuery, stored_query_to_dict
|
||||
from datasette.write_sql import QueryWriteRejected
|
||||
from datasette.utils import (
|
||||
InvalidSql,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
call_with_supported_arguments,
|
||||
error_body,
|
||||
named_parameters as derive_named_parameters,
|
||||
format_bytes,
|
||||
is_url,
|
||||
make_slot_function,
|
||||
tilde_decode,
|
||||
to_css_class,
|
||||
validate_sql_select,
|
||||
is_url,
|
||||
path_with_added_args,
|
||||
path_with_format,
|
||||
path_with_removed_args,
|
||||
sqlite3,
|
||||
tilde_decode,
|
||||
to_css_class,
|
||||
truncate_url,
|
||||
validate_sql_select,
|
||||
InvalidSql,
|
||||
)
|
||||
from datasette.utils import (
|
||||
named_parameters as derive_named_parameters,
|
||||
)
|
||||
from datasette.utils.asgi import AsgiFileDownload, Forbidden, NotFound, Response
|
||||
from datasette.write_sql import QueryWriteRejected
|
||||
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
||||
from datasette.plugins import pm
|
||||
|
||||
from . import Context
|
||||
from .base import DatasetteError, View, stream_csv
|
||||
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
||||
from .table_create_alter import _create_table_ui_context
|
||||
from .table_extras import (
|
||||
QueryExtraContext,
|
||||
resolve_query_extras,
|
||||
table_extra_registry,
|
||||
)
|
||||
from .table_create_alter import _create_table_ui_context
|
||||
from . import Context
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -103,7 +99,7 @@ class DatabaseView(View):
|
|||
return response
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -167,7 +163,7 @@ class DatabaseView(View):
|
|||
"label": "Create table",
|
||||
"description": "Create a new table in this database.",
|
||||
"attrs": {
|
||||
"aria-label": f"Create table in {database}",
|
||||
"aria-label": "Create table in {}".format(database),
|
||||
"data-database-action": "create-table",
|
||||
},
|
||||
}
|
||||
|
|
@ -274,7 +270,9 @@ class DatabaseView(View):
|
|||
view_name="database",
|
||||
),
|
||||
headers={
|
||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||
alternate_url_json
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -327,7 +325,7 @@ class DatabaseContext(Context):
|
|||
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``.'
|
||||
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
|
||||
}
|
||||
)
|
||||
database_actions: callable = field(
|
||||
|
|
@ -557,7 +555,7 @@ async def database_download(request, datasette):
|
|||
if datasette.cors:
|
||||
add_cors_headers(headers)
|
||||
if db.hash:
|
||||
etag = f'"{db.hash}"'
|
||||
etag = '"{}"'.format(db.hash)
|
||||
headers["Etag"] = etag
|
||||
# Has user seen this already?
|
||||
if_none_match = request.headers.get("if-none-match")
|
||||
|
|
@ -609,7 +607,11 @@ class QueryView(View):
|
|||
"_json"
|
||||
):
|
||||
return Response.json(
|
||||
dict(error_body([ex.message], 403), redirect=None),
|
||||
{
|
||||
"ok": False,
|
||||
"message": ex.message,
|
||||
"redirect": None,
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
datasette.add_message(request, ex.message, datasette.ERROR)
|
||||
|
|
@ -644,15 +646,8 @@ class QueryView(View):
|
|||
ok = None
|
||||
redirect_url = None
|
||||
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(
|
||||
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
|
||||
message = None
|
||||
|
|
@ -665,9 +660,8 @@ class QueryView(View):
|
|||
).first()
|
||||
if message_result:
|
||||
message = message_result[0]
|
||||
except Exception as ex: # noqa: BLE001
|
||||
# Stored-query on_success_message_sql is user-authored
|
||||
message = f"Error running on_success_message_sql: {ex}"
|
||||
except Exception as ex:
|
||||
message = "Error running on_success_message_sql: {}".format(ex)
|
||||
message_type = datasette.ERROR
|
||||
if not message:
|
||||
if stored_query.on_success_message:
|
||||
|
|
@ -681,24 +675,18 @@ class QueryView(View):
|
|||
|
||||
redirect_url = stored_query.on_success_redirect
|
||||
ok = True
|
||||
except Exception as ex: # noqa: BLE001
|
||||
# Stored-query execution is user-authored SQL
|
||||
except Exception as ex:
|
||||
message = stored_query.on_error_message or str(ex)
|
||||
message_type = datasette.ERROR
|
||||
redirect_url = stored_query.on_error_redirect
|
||||
ok = False
|
||||
if should_return_json:
|
||||
if ok:
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"message": message,
|
||||
"redirect": redirect_url,
|
||||
}
|
||||
)
|
||||
return Response.json(
|
||||
dict(error_body([message], 400), redirect=redirect_url),
|
||||
status=400,
|
||||
{
|
||||
"ok": ok,
|
||||
"message": message,
|
||||
"redirect": redirect_url,
|
||||
}
|
||||
)
|
||||
else:
|
||||
datasette.add_message(request, message, message_type)
|
||||
|
|
@ -816,23 +804,19 @@ class QueryView(View):
|
|||
rows = results.rows
|
||||
except QueryInterrupted as ex:
|
||||
raise DatasetteError(
|
||||
textwrap.dedent(f"""
|
||||
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%">{markupsafe.escape(ex.sql)}</textarea>
|
||||
<textarea style="width: 90%">{}</textarea>
|
||||
<script>
|
||||
let ta = document.querySelector("textarea");
|
||||
ta.style.height = ta.scrollHeight + "px";
|
||||
</script>
|
||||
""").strip(),
|
||||
""".format(markupsafe.escape(ex.sql))).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.DatabaseError as ex:
|
||||
query_error = str(ex)
|
||||
|
|
@ -841,6 +825,8 @@ class QueryView(View):
|
|||
columns = []
|
||||
except (sqlite3.OperationalError, InvalidSql) as ex:
|
||||
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
|
||||
except sqlite3.OperationalError as ex:
|
||||
raise DatasetteError(str(ex))
|
||||
except DatasetteError:
|
||||
raise
|
||||
|
||||
|
|
@ -862,12 +848,9 @@ class QueryView(View):
|
|||
return data, None, None
|
||||
|
||||
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
||||
elif format_ in datasette.renderers:
|
||||
if not sql:
|
||||
raise DatasetteError("?sql= is required", status=400)
|
||||
elif format_ in datasette.renderers.keys():
|
||||
data = {"ok": True, "rows": rows, "columns": columns}
|
||||
extras = extra_names_from_request(request)
|
||||
table_extra_registry.validate_requested(extras, ExtraScope.QUERY)
|
||||
if extras:
|
||||
query_extra_context = QueryExtraContext(
|
||||
datasette=datasette,
|
||||
|
|
@ -954,7 +937,9 @@ class QueryView(View):
|
|||
}
|
||||
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()
|
||||
|
|
@ -1035,7 +1020,9 @@ class QueryView(View):
|
|||
+ "?"
|
||||
+ urlencode(
|
||||
{
|
||||
"sql": sql,
|
||||
**{
|
||||
"sql": sql,
|
||||
},
|
||||
**named_parameter_values,
|
||||
}
|
||||
)
|
||||
|
|
@ -1137,7 +1124,7 @@ class QueryView(View):
|
|||
headers=headers,
|
||||
)
|
||||
else:
|
||||
assert False, f"Invalid format: {format_}"
|
||||
assert False, "Invalid format: {}".format(format_)
|
||||
if datasette.cors:
|
||||
add_cors_headers(r.headers)
|
||||
return r
|
||||
|
|
@ -1238,7 +1225,7 @@ async def display_rows(datasette, database, request, rows, columns):
|
|||
'<a class="blob-download" href="{}"{}><Binary: {:,} byte{}></a>'.format(
|
||||
blob_url,
|
||||
(
|
||||
f' title="{formatted}"'
|
||||
' title="{}"'.format(formatted)
|
||||
if "bytes" not in formatted
|
||||
else ""
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ import re
|
|||
from urllib.parse import urlencode
|
||||
|
||||
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 .base import BaseView
|
||||
from .base import BaseView, _error
|
||||
from .database import display_rows as display_query_rows
|
||||
from .query_helpers import (
|
||||
SQL_PARAMETER_FORM_PREFIX,
|
||||
QueryValidationError,
|
||||
SQL_PARAMETER_FORM_PREFIX,
|
||||
_analysis_is_write,
|
||||
_analysis_rows,
|
||||
_analysis_rows_with_permissions,
|
||||
|
|
@ -31,7 +31,15 @@ WRITE_TEMPLATE_LABELS = {
|
|||
"delete": "Delete rows",
|
||||
}
|
||||
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)"
|
||||
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
|
||||
(
|
||||
"create table new_table (",
|
||||
" id integer primary key,",
|
||||
" name text",
|
||||
" -- created text default (datetime('now'))",
|
||||
")",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _parameter_names(columns):
|
||||
|
|
@ -41,11 +49,11 @@ def _parameter_names(columns):
|
|||
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
||||
base = base.strip("_") or "value"
|
||||
if base[0].isdigit():
|
||||
base = f"p_{base}"
|
||||
base = "p_{}".format(base)
|
||||
name = base
|
||||
index = 2
|
||||
while name in seen:
|
||||
name = f"{base}_{index}"
|
||||
name = "{}_{}".format(base, index)
|
||||
index += 1
|
||||
seen.add(name)
|
||||
names[column] = name
|
||||
|
|
@ -57,7 +65,7 @@ def _quote_identifier(identifier):
|
|||
|
||||
|
||||
def _preferred_where_column(table, columns):
|
||||
lower_table_id = f"{table.lower()}_id"
|
||||
lower_table_id = "{}_id".format(table.lower())
|
||||
return (
|
||||
next((column for column in columns if column.lower() == "id"), None)
|
||||
or next(
|
||||
|
|
@ -82,15 +90,17 @@ def _insert_template_sql(table, columns):
|
|||
auto_pk = _auto_incrementing_primary_key(columns)
|
||||
insert_columns = [column for column in column_names if column != auto_pk]
|
||||
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)
|
||||
return "\n".join(
|
||||
(
|
||||
f"insert into {_quote_identifier(table)} (",
|
||||
",\n".join(f" {_quote_identifier(column)}" for column in insert_columns),
|
||||
"insert into {} (".format(_quote_identifier(table)),
|
||||
",\n".join(
|
||||
" {}".format(_quote_identifier(column)) for column in insert_columns
|
||||
),
|
||||
")",
|
||||
"values (",
|
||||
",\n".join(f" :{names[column]}" for column in insert_columns),
|
||||
",\n".join(" :{}".format(names[column]) for column in insert_columns),
|
||||
")",
|
||||
)
|
||||
)
|
||||
|
|
@ -104,14 +114,18 @@ def _update_template_sql(table, columns):
|
|||
if not set_columns:
|
||||
return "\n".join(
|
||||
(
|
||||
f"update {_quote_identifier(table)}",
|
||||
f"set {_quote_identifier(where_column)} = :new_{names[where_column]}",
|
||||
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
||||
"update {}".format(_quote_identifier(table)),
|
||||
"set {} = :new_{}".format(
|
||||
_quote_identifier(where_column), names[where_column]
|
||||
),
|
||||
"where {} = :{}".format(
|
||||
_quote_identifier(where_column), names[where_column]
|
||||
),
|
||||
)
|
||||
)
|
||||
return "\n".join(
|
||||
(
|
||||
f"update {_quote_identifier(table)}",
|
||||
"update {}".format(_quote_identifier(table)),
|
||||
"set "
|
||||
+ ",\n".join(
|
||||
"{}{} = :{}".format(
|
||||
|
|
@ -121,7 +135,9 @@ def _update_template_sql(table, 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 +148,10 @@ def _delete_template_sql(table, columns):
|
|||
where_column = _preferred_where_column(table, column_names)
|
||||
return "\n".join(
|
||||
(
|
||||
f"delete from {_quote_identifier(table)}",
|
||||
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
||||
"delete from {}".format(_quote_identifier(table)),
|
||||
"where {} = :{}".format(
|
||||
_quote_identifier(where_column), names[where_column]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -330,7 +348,7 @@ class ExecuteWriteView(BaseView):
|
|||
)
|
||||
if not db.is_mutable:
|
||||
return _block_framing(
|
||||
Response.error(
|
||||
_error(
|
||||
["Cannot execute write SQL because this database is immutable."],
|
||||
403,
|
||||
)
|
||||
|
|
@ -349,10 +367,10 @@ class ExecuteWriteView(BaseView):
|
|||
actor=request.actor,
|
||||
):
|
||||
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:
|
||||
return _block_framing(Response.error(["Database is immutable"], 403))
|
||||
return _block_framing(_error(["Database is immutable"], 403))
|
||||
|
||||
data = {}
|
||||
is_json = request.headers.get("content-type", "").startswith("application/json")
|
||||
|
|
@ -366,7 +384,7 @@ class ExecuteWriteView(BaseView):
|
|||
)
|
||||
except QueryValidationError as ex:
|
||||
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:
|
||||
self.ds.add_message(request, ex.message, self.ds.ERROR)
|
||||
return await self._render_form(
|
||||
|
|
@ -387,7 +405,7 @@ class ExecuteWriteView(BaseView):
|
|||
except sqlite3.DatabaseError as ex:
|
||||
message = str(ex)
|
||||
if wants_json:
|
||||
return _block_framing(Response.error([message], 400))
|
||||
return _block_framing(_error([message], 400))
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
|
|
@ -470,18 +488,20 @@ class ExecuteWriteAnalyzeView(BaseView):
|
|||
actor=request.actor,
|
||||
):
|
||||
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"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
Response.error(
|
||||
_error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
||||
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
||||
return _block_framing(Response.json(analysis))
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import json
|
|||
|
||||
from datasette.plugins import pm
|
||||
from datasette.utils import (
|
||||
UNSTABLE_API_MESSAGE,
|
||||
CustomJSONEncoder,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
make_slot_function,
|
||||
CustomJSONEncoder,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
from datasette.version import __version__
|
||||
|
|
@ -46,15 +45,15 @@ class IndexView(BaseView):
|
|||
|
||||
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]
|
||||
database_private = allowed_db.private
|
||||
database_private = allowed_db_dict[name].private
|
||||
|
||||
# Get allowed tables/views for this database
|
||||
allowed_for_db = tables_by_db.get(name, {})
|
||||
|
||||
# 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())
|
||||
|
||||
|
|
@ -99,7 +98,7 @@ class IndexView(BaseView):
|
|||
# We will be sorting by number of relationships, so populate that field
|
||||
all_foreign_keys = await db.get_all_foreign_keys()
|
||||
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"])
|
||||
tables[table]["num_relationships_for_sorting"] = count
|
||||
|
||||
|
|
@ -121,7 +120,8 @@ class IndexView(BaseView):
|
|||
# Only add views if this is less than TRUNCATE_AT
|
||||
if len(tables_and_views_truncated) < TRUNCATE_AT:
|
||||
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(
|
||||
{
|
||||
|
|
@ -151,9 +151,7 @@ class IndexView(BaseView):
|
|||
return Response(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"databases": databases,
|
||||
"databases": {db["name"]: db for db in databases},
|
||||
"metadata": await self.ds.get_instance_metadata(),
|
||||
},
|
||||
cls=CustomJSONEncoder,
|
||||
|
|
|
|||
|
|
@ -5,19 +5,6 @@ from datasette.resources import DatabaseResource
|
|||
from datasette.stored_queries import (
|
||||
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 (
|
||||
IgnoreWriteSqlOperation,
|
||||
QueryWriteRejected,
|
||||
|
|
@ -25,6 +12,16 @@ from datasette.write_sql import (
|
|||
decision_for_write_sql_operation,
|
||||
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]+$")
|
||||
|
||||
|
|
@ -35,6 +32,7 @@ _query_fields = {
|
|||
"hide_sql",
|
||||
"fragment",
|
||||
"parameters",
|
||||
"params",
|
||||
"is_private",
|
||||
"on_success_message",
|
||||
"on_success_redirect",
|
||||
|
|
@ -93,14 +91,16 @@ def _as_optional_bool(value, name):
|
|||
return True
|
||||
if lowered in {"0", "false", "f", "no", "off"}:
|
||||
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:
|
||||
return parse_size_limit(value, default, maximum)
|
||||
return min(max(1, int(value)), 1000)
|
||||
except ValueError as ex:
|
||||
raise QueryValidationError(str(ex)) from ex
|
||||
raise QueryValidationError("_size must be an integer") from ex
|
||||
|
||||
|
||||
def _derived_query_parameters(sql):
|
||||
|
|
@ -173,7 +173,7 @@ async def _json_or_form_payload(request):
|
|||
try:
|
||||
return json.loads(body or b"{}"), True
|
||||
except json.JSONDecodeError as e:
|
||||
raise QueryValidationError(f"Invalid JSON: {e}")
|
||||
raise QueryValidationError("Invalid JSON: {}".format(e))
|
||||
return await request.post_vars(), False
|
||||
|
||||
|
||||
|
|
@ -194,7 +194,7 @@ async def _analyze_user_query(datasette, db, sql, *, actor):
|
|||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
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)
|
||||
if is_write:
|
||||
|
|
@ -295,7 +295,8 @@ def _coerce_execute_write_payload(data, is_json):
|
|||
for key, value in data.items():
|
||||
if key in {"sql", "csrftoken", "_json"}:
|
||||
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
|
||||
if not isinstance(params, dict):
|
||||
raise QueryValidationError("params must be a dictionary")
|
||||
|
|
@ -315,7 +316,7 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
|
|||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
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):
|
||||
raise QueryValidationError(
|
||||
"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:
|
||||
result = await db.execute(
|
||||
f"select {select} from {escape_sqlite(table)} where rowid = ?",
|
||||
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
|
||||
[lastrowid],
|
||||
)
|
||||
except sqlite3.DatabaseError:
|
||||
|
|
@ -540,7 +541,7 @@ async def _prepare_query_create(datasette, request, db, data):
|
|||
raise QueryValidationError("Writable query fields require writable SQL")
|
||||
|
||||
parameters = _coerce_query_parameters(
|
||||
data.get("parameters"),
|
||||
data.get("parameters", data.get("params")),
|
||||
derived,
|
||||
)
|
||||
return {
|
||||
|
|
@ -585,9 +586,9 @@ async def _prepare_query_update(datasette, request, db, existing: StoredQuery, u
|
|||
actor=request.actor,
|
||||
)
|
||||
|
||||
if "parameters" in update:
|
||||
if "parameters" in update or "params" in update:
|
||||
parameters = _coerce_query_parameters(
|
||||
update.get("parameters"),
|
||||
update.get("parameters", update.get("params")),
|
||||
derived,
|
||||
)
|
||||
elif "sql" in update:
|
||||
|
|
|
|||
|
|
@ -8,35 +8,32 @@ from dataclasses import dataclass, field
|
|||
import markupsafe
|
||||
import sqlite_utils
|
||||
|
||||
from datasette.utils.asgi import NotFound, Forbidden, Response
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.events import DeleteRowEvent, UpdateRowEvent
|
||||
from datasette.extras import ExtraScope, extra_names_from_request
|
||||
from datasette.plugins import pm
|
||||
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
||||
from datasette.resources import TableResource
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
from datasette.utils import (
|
||||
CustomJSONEncoder,
|
||||
CustomRow,
|
||||
InvalidSql,
|
||||
WriteJsonValueError,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
call_with_supported_arguments,
|
||||
decode_write_json_row,
|
||||
escape_sqlite,
|
||||
CustomRow,
|
||||
InvalidSql,
|
||||
make_slot_function,
|
||||
path_from_row_pks,
|
||||
path_with_added_args,
|
||||
path_with_format,
|
||||
path_with_removed_args,
|
||||
sqlite3,
|
||||
to_css_class,
|
||||
escape_sqlite,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response
|
||||
|
||||
from datasette.plugins import pm
|
||||
from datasette.extras import extra_names_from_request, ExtraScope
|
||||
from . import Context, from_extra
|
||||
from .base import BaseView, DatasetteError, stream_csv
|
||||
from .table import (
|
||||
_table_page_data,
|
||||
display_columns_and_rows,
|
||||
_table_page_data,
|
||||
row_label_from_label_column,
|
||||
)
|
||||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
||||
|
|
@ -188,33 +185,43 @@ class RowView(BaseView):
|
|||
data, extra_template_data, templates = response_or_template_contexts
|
||||
except QueryInterrupted as ex:
|
||||
raise DatasetteError(
|
||||
textwrap.dedent(f"""
|
||||
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%">{markupsafe.escape(ex.sql)}</textarea>
|
||||
<textarea style="width: 90%">{}</textarea>
|
||||
<script>
|
||||
let ta = document.querySelector("textarea");
|
||||
ta.style.height = ta.scrollHeight + "px";
|
||||
</script>
|
||||
""").strip(),
|
||||
""".format(markupsafe.escape(ex.sql))).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 sqlite3.OperationalError as e:
|
||||
raise DatasetteError(str(e))
|
||||
except DatasetteError:
|
||||
raise
|
||||
|
||||
end = time.perf_counter()
|
||||
data["query_ms"] = (end - start) * 1000
|
||||
|
||||
if format_ in self.ds.renderers:
|
||||
# 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(
|
||||
|
|
@ -257,7 +264,7 @@ class RowView(BaseView):
|
|||
if status_code is not None:
|
||||
response.status = status_code
|
||||
else:
|
||||
raise NotFound(f"Invalid format: {format_}")
|
||||
raise NotFound("Invalid format: {}".format(format_))
|
||||
|
||||
ttl = request.args.get("_ttl", None)
|
||||
if ttl is None or not ttl.isdigit():
|
||||
|
|
@ -372,7 +379,9 @@ class RowView(BaseView):
|
|||
view_name=self.name,
|
||||
),
|
||||
headers={
|
||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||
alternate_url_json
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -497,7 +506,7 @@ class RowView(BaseView):
|
|||
|
||||
row_action_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 = {}
|
||||
if is_table and db.is_mutable:
|
||||
|
|
@ -510,7 +519,7 @@ class RowView(BaseView):
|
|||
row_actions = []
|
||||
if row_action_permissions.get("update-row"):
|
||||
attrs = {
|
||||
"aria-label": f"Edit row {row_action_label}",
|
||||
"aria-label": "Edit row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "edit",
|
||||
}
|
||||
|
|
@ -526,7 +535,7 @@ class RowView(BaseView):
|
|||
)
|
||||
if row_action_permissions.get("delete-row"):
|
||||
attrs = {
|
||||
"aria-label": f"Delete row {row_action_label}",
|
||||
"aria-label": "Delete row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "delete",
|
||||
}
|
||||
|
|
@ -600,9 +609,6 @@ class RowView(BaseView):
|
|||
}
|
||||
|
||||
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
|
||||
row_extra_context = RowExtraContext(
|
||||
|
|
@ -676,7 +682,7 @@ class RowView(BaseView):
|
|||
key,
|
||||
",".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
|
||||
|
||||
|
||||
|
|
@ -702,21 +708,21 @@ async def _row_flash_message(db, action, resolved, row=None):
|
|||
if label:
|
||||
label = _truncated_row_flash_label(label)
|
||||
if label and label != pk_label:
|
||||
return f"{action} row {pk_label} ({label})"
|
||||
return f"{action} row {pk_label}"
|
||||
return "{} row {} ({})".format(action, pk_label, label)
|
||||
return "{} row {}".format(action, pk_label)
|
||||
|
||||
|
||||
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:
|
||||
resolved = await datasette.resolve_row(request)
|
||||
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:
|
||||
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:
|
||||
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
|
||||
if not await datasette.allowed(
|
||||
|
|
@ -724,7 +730,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
|
|||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||
actor=request.actor,
|
||||
):
|
||||
return False, Response.error(["Permission denied"], 403)
|
||||
return False, _error(["Permission denied"], 403)
|
||||
|
||||
return True, resolved
|
||||
|
||||
|
|
@ -748,9 +754,8 @@ class RowDeleteView(BaseView):
|
|||
|
||||
try:
|
||||
await resolved.db.execute_write_fn(delete_row, request=request)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||
return Response.error([str(e)], 400)
|
||||
except Exception as e:
|
||||
return _error([str(e)], 500)
|
||||
|
||||
await self.ds.track_event(
|
||||
DeleteRowEvent(
|
||||
|
|
@ -789,24 +794,18 @@ class RowUpdateView(BaseView):
|
|||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return Response.error([f"Invalid JSON: {e}"])
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
return _error(["Invalid JSON: {}".format(e)])
|
||||
|
||||
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):
|
||||
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"}
|
||||
if invalid_keys:
|
||||
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
||||
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
||||
|
||||
update = data["update"]
|
||||
try:
|
||||
update = decode_write_json_row(update)
|
||||
except WriteJsonValueError as e:
|
||||
return Response.error([str(e)], 400)
|
||||
|
||||
# Validate column types
|
||||
from datasette.views.table import _validate_column_types
|
||||
|
|
@ -815,7 +814,7 @@ class RowUpdateView(BaseView):
|
|||
self.ds, resolved.db.name, resolved.table, [update]
|
||||
)
|
||||
if ct_errors:
|
||||
return Response.error(ct_errors, 400)
|
||||
return _error(ct_errors, 400)
|
||||
|
||||
alter = data.get("alter")
|
||||
if alter and not await self.ds.allowed(
|
||||
|
|
@ -823,7 +822,7 @@ class RowUpdateView(BaseView):
|
|||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied for alter-table"], 403)
|
||||
return _error(["Permission denied for alter-table"], 403)
|
||||
|
||||
def update_row(conn):
|
||||
sqlite_utils.Database(conn)[resolved.table].update(
|
||||
|
|
@ -832,9 +831,8 @@ class RowUpdateView(BaseView):
|
|||
|
||||
try:
|
||||
await resolved.db.execute_write_fn(update_row, request=request)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||
return Response.error([str(e)], 400)
|
||||
except Exception as e:
|
||||
return _error([str(e)], 400)
|
||||
|
||||
result = {"ok": True}
|
||||
returned_row = None
|
||||
|
|
@ -843,7 +841,7 @@ class RowUpdateView(BaseView):
|
|||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
returned_row = results.dicts()[0]
|
||||
result["rows"] = [returned_row]
|
||||
result["row"] = returned_row
|
||||
|
||||
await self.ds.track_event(
|
||||
UpdateRowEvent(
|
||||
|
|
@ -869,4 +867,4 @@ class RowUpdateView(BaseView):
|
|||
self.ds.INFO,
|
||||
)
|
||||
|
||||
return Response.json(result, status=200, default=CustomJSONEncoder().default)
|
||||
return Response.json(result, status=200)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import urllib
|
||||
|
||||
from datasette.events import CreateTokenEvent, LoginEvent, LogoutEvent
|
||||
from datasette.jump import JumpSQL, namespace_sql_params
|
||||
from datasette.plugins import pm
|
||||
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
||||
from datasette.resources import DatabaseResource, TableResource
|
||||
from datasette.utils.asgi import Response, Forbidden
|
||||
from datasette.utils import (
|
||||
UNSTABLE_API_MESSAGE,
|
||||
actor_matches_allow,
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
error_body,
|
||||
parse_size_limit,
|
||||
tilde_decode,
|
||||
tilde_encode,
|
||||
tilde_decode,
|
||||
)
|
||||
from datasette.utils.asgi import Forbidden, Response
|
||||
|
||||
from .base import BaseView, View
|
||||
import secrets
|
||||
import urllib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -57,9 +52,9 @@ class JsonDataView(BaseView):
|
|||
if self.permission:
|
||||
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
|
||||
if self.needs_request:
|
||||
data = await await_me_maybe(self.data_callback(request))
|
||||
data = self.data_callback(request)
|
||||
else:
|
||||
data = await await_me_maybe(self.data_callback())
|
||||
data = self.data_callback()
|
||||
|
||||
# Return JSON or HTML depending on format parameter
|
||||
as_format = request.url_vars.get("format")
|
||||
|
|
@ -67,8 +62,6 @@ class JsonDataView(BaseView):
|
|||
headers = {}
|
||||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
if isinstance(data, dict):
|
||||
data = {"ok": True, **data}
|
||||
return Response.json(data, headers=headers)
|
||||
else:
|
||||
context = {
|
||||
|
|
@ -181,7 +174,9 @@ class AutocompleteDebugView(BaseView):
|
|||
)
|
||||
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),
|
||||
}
|
||||
)
|
||||
|
|
@ -297,12 +292,6 @@ class PermissionsDebugView(BaseView):
|
|||
response, status = await _check_permission_for_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)
|
||||
|
||||
|
||||
|
|
@ -359,32 +348,29 @@ class AllowedResourcesView(BaseView):
|
|||
async def _allowed_payload(self, request, has_debug_permission):
|
||||
action = request.args.get("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:
|
||||
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_id = actor.get("id") if actor else None
|
||||
parent_filter = request.args.get("parent")
|
||||
child_filter = request.args.get("child")
|
||||
if child_filter and not parent_filter:
|
||||
return (
|
||||
error_body("parent must be provided when child is specified", 400),
|
||||
400,
|
||||
)
|
||||
return {"error": "parent must be provided when child is specified"}, 400
|
||||
|
||||
try:
|
||||
page = int(request.args.get("_page", "1"))
|
||||
if page < 1:
|
||||
raise ValueError
|
||||
page = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("page_size", "50"))
|
||||
except ValueError:
|
||||
return error_body("_page must be a positive integer", 400), 400
|
||||
try:
|
||||
page_size = parse_size_limit(
|
||||
request.args.get("_size"), default=50, maximum=200
|
||||
)
|
||||
except ValueError as ex:
|
||||
return error_body(str(ex), 400), 400
|
||||
return {"error": "page and page_size must be integers"}, 400
|
||||
if page < 1:
|
||||
return {"error": "page must be >= 1"}, 400
|
||||
if page_size < 1:
|
||||
return {"error": "page_size must be >= 1"}, 400
|
||||
max_page_size = 200
|
||||
if page_size > max_page_size:
|
||||
page_size = max_page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Use the simplified allowed_resources method
|
||||
|
|
@ -420,14 +406,10 @@ class AllowedResourcesView(BaseView):
|
|||
row["reason"] = resource.reasons
|
||||
|
||||
allowed_rows.append(row)
|
||||
except Exception: # noqa: BLE001
|
||||
# Returns empty results if the catalog tables don't exist yet, but
|
||||
# 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
|
||||
except Exception:
|
||||
# If catalog tables don't exist yet, return empty results
|
||||
return (
|
||||
{
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"actor_id": actor_id,
|
||||
"page": page,
|
||||
|
|
@ -452,17 +434,16 @@ class AllowedResourcesView(BaseView):
|
|||
def build_page_url(page_number):
|
||||
pairs = []
|
||||
for key in request.args:
|
||||
if key in {"_page", "_size"}:
|
||||
if key in {"page", "page_size"}:
|
||||
continue
|
||||
for value in request.args.getlist(key):
|
||||
pairs.append((key, value))
|
||||
pairs.append(("_page", str(page_number)))
|
||||
pairs.append(("_size", str(page_size)))
|
||||
pairs.append(("page", str(page_number)))
|
||||
pairs.append(("page_size", str(page_size)))
|
||||
query = urllib.parse.urlencode(pairs)
|
||||
return f"{request.path}?{query}"
|
||||
|
||||
response = {
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"actor_id": actor_id,
|
||||
"page": page,
|
||||
|
|
@ -504,29 +485,31 @@ class PermissionRulesView(BaseView):
|
|||
# JSON API - action parameter is required
|
||||
action = request.args.get("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:
|
||||
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
|
||||
|
||||
try:
|
||||
page = int(request.args.get("_page", "1"))
|
||||
if page < 1:
|
||||
raise ValueError
|
||||
page = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("page_size", "50"))
|
||||
except ValueError:
|
||||
return Response.error("_page must be a positive integer", 400)
|
||||
try:
|
||||
page_size = parse_size_limit(
|
||||
request.args.get("_size"), default=50, maximum=200
|
||||
return Response.json(
|
||||
{"error": "page and page_size must be integers"}, status=400
|
||||
)
|
||||
except ValueError as ex:
|
||||
return Response.error(str(ex), 400)
|
||||
if page < 1:
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
await self.ds.refresh_schemas()
|
||||
|
|
@ -572,17 +555,16 @@ class PermissionRulesView(BaseView):
|
|||
def build_page_url(page_number):
|
||||
pairs = []
|
||||
for key in request.args:
|
||||
if key in {"_page", "_size"}:
|
||||
if key in {"page", "page_size"}:
|
||||
continue
|
||||
for value in request.args.getlist(key):
|
||||
pairs.append((key, value))
|
||||
pairs.append(("_page", str(page_number)))
|
||||
pairs.append(("_size", str(page_size)))
|
||||
pairs.append(("page", str(page_number)))
|
||||
pairs.append(("page_size", str(page_size)))
|
||||
query = urllib.parse.urlencode(pairs)
|
||||
return f"{request.path}?{query}"
|
||||
|
||||
response = {
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"actor_id": (actor or {}).get("id") if actor else None,
|
||||
"page": page,
|
||||
|
|
@ -603,17 +585,17 @@ class PermissionRulesView(BaseView):
|
|||
|
||||
|
||||
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:
|
||||
return error_body(f"Unknown action: {action}", 404), 404
|
||||
return {"error": f"Unknown action: {action}"}, 404
|
||||
|
||||
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
|
||||
action_obj = ds.actions.get(action)
|
||||
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
|
||||
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)
|
||||
else:
|
||||
# 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)
|
||||
|
||||
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 = {
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"action": action,
|
||||
"allowed": bool(allowed),
|
||||
"actor": actor,
|
||||
"resource": {
|
||||
"parent": parent,
|
||||
"child": child,
|
||||
"path": _resource_path(parent, child),
|
||||
},
|
||||
"explanation": explanation,
|
||||
}
|
||||
|
||||
if actor and "id" in actor:
|
||||
|
|
@ -671,25 +639,11 @@ class PermissionCheckView(BaseView):
|
|||
as_format = request.url_vars.get("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(
|
||||
["debug_check.html"],
|
||||
request,
|
||||
{
|
||||
"actions": actions,
|
||||
"actor_json": request.args.get("actor")
|
||||
or json.dumps(request.actor, indent=2),
|
||||
"sorted_actions": sorted(self.ds.actions.keys()),
|
||||
"has_debug_permission": True,
|
||||
},
|
||||
)
|
||||
|
|
@ -697,22 +651,13 @@ class PermissionCheckView(BaseView):
|
|||
# JSON API - action parameter is required
|
||||
action = request.args.get("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")
|
||||
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(
|
||||
self.ds, action, parent, child, actor
|
||||
self.ds, action, parent, child, request.actor
|
||||
)
|
||||
return Response.json(response, status=status)
|
||||
|
||||
|
|
@ -939,7 +884,7 @@ class ApiExplorerView(BaseView):
|
|||
tables.append({"name": table, "links": table_links})
|
||||
table_links.append(
|
||||
{
|
||||
"label": f"Get rows for {table}",
|
||||
"label": "Get rows for {}".format(table),
|
||||
"method": "GET",
|
||||
"path": self.ds.urls.table(name, table, format="json"),
|
||||
}
|
||||
|
|
@ -959,7 +904,7 @@ class ApiExplorerView(BaseView):
|
|||
{
|
||||
"path": self.ds.urls.table(name, table) + "/-/insert",
|
||||
"method": "POST",
|
||||
"label": f"Insert rows into {table}",
|
||||
"label": "Insert rows into {}".format(table),
|
||||
"json": {
|
||||
"rows": [
|
||||
{
|
||||
|
|
@ -973,7 +918,7 @@ class ApiExplorerView(BaseView):
|
|||
{
|
||||
"path": self.ds.urls.table(name, table) + "/-/upsert",
|
||||
"method": "POST",
|
||||
"label": f"Upsert rows into {table}",
|
||||
"label": "Upsert rows into {}".format(table),
|
||||
"json": {
|
||||
"rows": [
|
||||
{
|
||||
|
|
@ -1003,7 +948,7 @@ class ApiExplorerView(BaseView):
|
|||
table_links.append(
|
||||
{
|
||||
"path": self.ds.urls.table(name, table) + "/-/drop",
|
||||
"label": f"Drop table {table}",
|
||||
"label": "Drop table {}".format(table),
|
||||
"json": {"confirm": False},
|
||||
"method": "POST",
|
||||
}
|
||||
|
|
@ -1020,7 +965,7 @@ class ApiExplorerView(BaseView):
|
|||
database_links.append(
|
||||
{
|
||||
"path": self.ds.urls.database(name) + "/-/create",
|
||||
"label": f"Create table in {name}",
|
||||
"label": "Create table in {}".format(name),
|
||||
"json": {
|
||||
"table": "new_table",
|
||||
"columns": [
|
||||
|
|
@ -1253,7 +1198,7 @@ class JumpView(BaseView):
|
|||
match["display_name"] = row["display_name"]
|
||||
matches.append(match)
|
||||
|
||||
return Response.json({"ok": True, "matches": matches, "truncated": truncated})
|
||||
return Response.json({"matches": matches, "truncated": truncated})
|
||||
|
||||
|
||||
class SchemaBaseView(BaseView):
|
||||
|
|
@ -1275,7 +1220,7 @@ class SchemaBaseView(BaseView):
|
|||
headers = {}
|
||||
if self.ds.cors:
|
||||
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):
|
||||
"""Format error response based on requested format."""
|
||||
|
|
@ -1284,7 +1229,7 @@ class SchemaBaseView(BaseView):
|
|||
if self.ds.cors:
|
||||
add_cors_headers(headers)
|
||||
return Response.json(
|
||||
error_body(error_message, status), status=status, headers=headers
|
||||
{"ok": False, "error": error_message}, status=status, headers=headers
|
||||
)
|
||||
else:
|
||||
return Response.text(error_message, status=status)
|
||||
|
|
@ -1360,17 +1305,17 @@ class DatabaseSchemaView(SchemaBaseView):
|
|||
database_name = request.url_vars["database"]
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
||||
# Permission check comes first, so actors without view-database
|
||||
# cannot distinguish existing databases from missing ones
|
||||
# Check if database exists
|
||||
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(
|
||||
action="view-database",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
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)
|
||||
|
||||
if format_ == "json":
|
||||
|
|
@ -1404,9 +1349,6 @@ class TableSchemaView(SchemaBaseView):
|
|||
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
|
||||
db = self.ds.databases[database_name]
|
||||
result = await db.execute(
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ from urllib.parse import parse_qsl, urlencode
|
|||
|
||||
from datasette.resources import DatabaseResource, QueryResource
|
||||
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 .base import BaseView
|
||||
from .base import BaseView, _error
|
||||
from .query_helpers import (
|
||||
QueryValidationError,
|
||||
_as_bool,
|
||||
|
|
@ -34,14 +34,12 @@ class QueryParametersView(BaseView):
|
|||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(
|
||||
Response.error(["Permission denied: need execute-sql"], 403)
|
||||
)
|
||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
Response.error(
|
||||
_error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
|
|
@ -49,16 +47,8 @@ class QueryParametersView(BaseView):
|
|||
try:
|
||||
parameters = _derived_query_parameters(request.args.get("sql") or "")
|
||||
except QueryValidationError as ex:
|
||||
return _block_framing(Response.error([ex.message], ex.status))
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"parameters": parameters,
|
||||
}
|
||||
)
|
||||
)
|
||||
return _block_framing(_error([ex.message], ex.status))
|
||||
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
|
||||
|
||||
|
||||
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
|
||||
|
|
@ -92,12 +82,11 @@ class QueryListView(BaseView):
|
|||
limit = _query_list_limit(
|
||||
request.args.get("_size"),
|
||||
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_private = _as_optional_bool(request.args.get("is_private"), "is_private")
|
||||
except QueryValidationError as ex:
|
||||
return Response.error([ex.message], ex.status)
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
page = await self.ds.list_queries(
|
||||
database,
|
||||
|
|
@ -122,9 +111,9 @@ class QueryListView(BaseView):
|
|||
if key != "_next"
|
||||
]
|
||||
pairs.append(("_next", page.next))
|
||||
next_url = self.ds.absolute_url(
|
||||
request,
|
||||
f"{request.path}?{urlencode(pairs)}",
|
||||
next_url = "{}?{}".format(
|
||||
query_list_path,
|
||||
urlencode(pairs),
|
||||
)
|
||||
|
||||
current_filters = {
|
||||
|
|
@ -210,6 +199,7 @@ class QueryListView(BaseView):
|
|||
"queries": page.queries,
|
||||
"next": page.next,
|
||||
"next_url": next_url,
|
||||
"has_more": page.has_more,
|
||||
"limit": page.limit,
|
||||
"show_private_note": any(query.is_private 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),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(
|
||||
Response.error(["Permission denied: need execute-sql"], 403)
|
||||
)
|
||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
||||
if not await self.ds.allowed(
|
||||
action="store-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(
|
||||
Response.error(["Permission denied: need store-query"], 403)
|
||||
)
|
||||
return _block_framing(_error(["Permission denied: need store-query"], 403))
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
Response.error(
|
||||
_error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
analysis = await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
||||
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
||||
return _block_framing(Response.json(analysis))
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class QueryStoreView(QueryCreateView):
|
||||
|
|
@ -358,13 +346,13 @@ class QueryStoreView(QueryCreateView):
|
|||
resource=DatabaseResource(db.name),
|
||||
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(
|
||||
action="store-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need store-query"], 403)
|
||||
return _error(["Permission denied: need store-query"], 403)
|
||||
|
||||
is_json = False
|
||||
query_data = {}
|
||||
|
|
@ -381,7 +369,7 @@ class QueryStoreView(QueryCreateView):
|
|||
return await self._error_response(
|
||||
request, db, query_data, ex.message, ex.status
|
||||
)
|
||||
return Response.error([ex.message], ex.status)
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
prepared.pop("analysis")
|
||||
name = prepared.pop("name")
|
||||
|
|
@ -390,18 +378,13 @@ class QueryStoreView(QueryCreateView):
|
|||
except sqlite3.IntegrityError as ex:
|
||||
if not is_json and isinstance(query_data, dict):
|
||||
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)
|
||||
assert query is not None
|
||||
if is_json:
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"query": stored_query_to_dict(query),
|
||||
},
|
||||
status=201,
|
||||
{"ok": True, "query": stored_query_to_dict(query)}, status=201
|
||||
)
|
||||
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
||||
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 = await self.ds.get_query(db.name, query_name)
|
||||
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(
|
||||
action="view-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied"], 403)
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"unstable": UNSTABLE_API_MESSAGE,
|
||||
"query": stored_query_to_dict(query),
|
||||
}
|
||||
)
|
||||
return _error(["Permission denied"], 403)
|
||||
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
|
||||
|
||||
|
||||
class QueryUpdateView(BaseView):
|
||||
|
|
@ -439,17 +416,15 @@ class QueryUpdateView(BaseView):
|
|||
query_name = tilde_decode(request.url_vars["query"])
|
||||
existing = await self.ds.get_query(db.name, query_name)
|
||||
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(
|
||||
action="update-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need update-query"], 403)
|
||||
return _error(["Permission denied: need update-query"], 403)
|
||||
if existing.is_trusted:
|
||||
return Response.error(
|
||||
["Trusted queries cannot be updated using the API"], 403
|
||||
)
|
||||
return _error(["Trusted queries cannot be updated using the API"], 403)
|
||||
|
||||
try:
|
||||
data, _ = await _json_or_form_payload(request)
|
||||
|
|
@ -475,7 +450,7 @@ class QueryUpdateView(BaseView):
|
|||
self.ds, request, db, existing, update
|
||||
)
|
||||
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)
|
||||
if data.get("return"):
|
||||
|
|
@ -532,32 +507,32 @@ class QueryEditView(BaseView):
|
|||
async def get(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
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(
|
||||
action="update-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
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)
|
||||
|
||||
async def post(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
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(
|
||||
action="update-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need update-query"], 403)
|
||||
return _error(["Permission denied: need update-query"], 403)
|
||||
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)
|
||||
if not isinstance(data, dict):
|
||||
return Response.error(["Invalid form submission"], 400)
|
||||
return _error(["Invalid form submission"], 400)
|
||||
sql = data.get("sql")
|
||||
sql = existing.sql if sql is None else sql.strip()
|
||||
title = data.get("title") or ""
|
||||
|
|
@ -629,16 +604,12 @@ class QueryDeleteView(BaseView):
|
|||
async def get(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
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(
|
||||
action="delete-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
if existing.is_trusted:
|
||||
return Response.error(
|
||||
["Trusted queries cannot be deleted using the API"], 403
|
||||
)
|
||||
return await self.render(
|
||||
["query_delete.html"],
|
||||
request,
|
||||
|
|
@ -653,25 +624,21 @@ class QueryDeleteView(BaseView):
|
|||
async def post(self, request):
|
||||
db, query_name, existing = await self._load(request)
|
||||
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(
|
||||
action="delete-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need delete-query"], 403)
|
||||
if existing.is_trusted:
|
||||
return Response.error(
|
||||
["Trusted queries cannot be deleted using the API"], 403
|
||||
)
|
||||
return _error(["Permission denied: need delete-query"], 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)
|
||||
if is_json:
|
||||
return Response.json({"ok": True})
|
||||
self.ds.add_message(
|
||||
request,
|
||||
f"Query “{existing.title or query_name}” deleted",
|
||||
"Query “{}” deleted".format(existing.title or query_name),
|
||||
self.ds.INFO,
|
||||
)
|
||||
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))
|
||||
|
|
|
|||
|
|
@ -3,63 +3,54 @@ import itertools
|
|||
import json
|
||||
import urllib
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import markupsafe
|
||||
import sqlite_utils
|
||||
|
||||
from datasette import tracer
|
||||
from datasette.column_types import SQLiteType
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.plugins import pm
|
||||
from datasette.events import (
|
||||
AlterTableEvent,
|
||||
DropTableEvent,
|
||||
InsertRowsEvent,
|
||||
UpsertRowsEvent,
|
||||
)
|
||||
from datasette.extras import ExtraScope, extra_names_from_request
|
||||
from datasette.filters import Filters
|
||||
from datasette.plugins import pm
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette import tracer
|
||||
from datasette.resources import DatabaseResource, TableResource
|
||||
from datasette.utils import (
|
||||
CustomJSONEncoder,
|
||||
CustomRow,
|
||||
InvalidSql,
|
||||
WriteJsonValueError,
|
||||
add_cors_headers,
|
||||
append_querystring,
|
||||
await_me_maybe,
|
||||
call_with_supported_arguments,
|
||||
CustomRow,
|
||||
append_querystring,
|
||||
compound_keys_after_sql,
|
||||
decode_write_json_rows,
|
||||
format_bytes,
|
||||
make_slot_function,
|
||||
tilde_encode,
|
||||
escape_sqlite,
|
||||
filters_should_redirect,
|
||||
format_bytes,
|
||||
is_url,
|
||||
make_slot_function,
|
||||
path_from_row_pks,
|
||||
path_with_added_args,
|
||||
path_with_format,
|
||||
path_with_removed_args,
|
||||
path_with_replaced_args,
|
||||
sqlite3,
|
||||
tilde_encode,
|
||||
to_css_class,
|
||||
truncate_url,
|
||||
urlsafe_components,
|
||||
value_as_boolean,
|
||||
InvalidSql,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import (
|
||||
BadRequest,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
PayloadTooLarge,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
|
||||
from datasette.filters import Filters
|
||||
import sqlite_utils
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from datasette.extras import ExtraScope
|
||||
from . import Context, from_extra
|
||||
from .base import BaseView, DatasetteError, stream_csv
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
from .database import QueryView
|
||||
from .table_create_alter import (
|
||||
ALTER_TABLE_COLUMN_TYPES,
|
||||
|
|
@ -71,7 +62,6 @@ from .table_create_alter import (
|
|||
from .table_extras import (
|
||||
TABLE_EXTRA_BUNDLES,
|
||||
TableExtraContext,
|
||||
count_is_truncated,
|
||||
precompute_database_action_permissions,
|
||||
precompute_table_action_permissions,
|
||||
resolve_table_extras,
|
||||
|
|
@ -106,6 +96,7 @@ class TableContext(Context):
|
|||
human_description_en: str = from_extra()
|
||||
is_view: bool = from_extra()
|
||||
metadata: dict = from_extra()
|
||||
next_url: str = from_extra()
|
||||
primary_keys: list = from_extra()
|
||||
private: bool = from_extra()
|
||||
query: dict = from_extra()
|
||||
|
|
@ -122,11 +113,6 @@ class TableContext(Context):
|
|||
metadata={"help": "True if the data for this page was retrieved without errors"}
|
||||
)
|
||||
next: str = field(metadata={"help": "Pagination token for the next page, or None"})
|
||||
next_url: str = field(
|
||||
metadata={
|
||||
"help": "Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`."
|
||||
}
|
||||
)
|
||||
count_truncated: bool = field(
|
||||
metadata={
|
||||
"help": "True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit."
|
||||
|
|
@ -219,7 +205,7 @@ class TableContext(Context):
|
|||
)
|
||||
table_insert_ui: dict = field(
|
||||
metadata={
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
}
|
||||
)
|
||||
table_alter_ui: dict = field(
|
||||
|
|
@ -494,15 +480,8 @@ async def _table_insert_ui(
|
|||
):
|
||||
return None
|
||||
|
||||
can_update = await datasette.allowed(
|
||||
action="update-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
column_types_map = await datasette.get_column_types(database_name, table_name)
|
||||
columns = []
|
||||
bulk_columns = []
|
||||
column_details = await db.table_column_details(table_name)
|
||||
for column in column_details:
|
||||
if column.hidden:
|
||||
|
|
@ -513,40 +492,32 @@ async def _table_insert_ui(
|
|||
and len(pks) == 1
|
||||
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
|
||||
)
|
||||
column_type = column_types_map.get(column.name)
|
||||
column_data = {
|
||||
"name": column.name,
|
||||
"sqlite_type": _column_sqlite_type_for_insert_form(column),
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"is_auto_pk": is_auto_pk,
|
||||
"value_kind": _column_value_kind_for_insert_form(column),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
bulk_columns.append(column_data)
|
||||
if is_auto_pk:
|
||||
continue
|
||||
columns.append(column_data)
|
||||
column_type = column_types_map.get(column.name)
|
||||
columns.append(
|
||||
{
|
||||
"name": column.name,
|
||||
"sqlite_type": _column_sqlite_type_for_insert_form(column),
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"value_kind": _column_value_kind_for_insert_form(column),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
data = {
|
||||
"path": f"{datasette.urls.table(database_name, table_name)}/-/insert",
|
||||
return {
|
||||
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"bulkColumns": bulk_columns,
|
||||
"primaryKeys": pks,
|
||||
"maxInsertRows": datasette.setting("max_insert_rows"),
|
||||
}
|
||||
if can_update:
|
||||
data["upsertPath"] = (
|
||||
f"{datasette.urls.table(database_name, table_name)}/-/upsert"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _table_alter_ui(
|
||||
|
|
@ -603,7 +574,7 @@ async def _table_alter_ui(
|
|||
columns.append(column_data)
|
||||
|
||||
data = {
|
||||
"path": f"{datasette.urls.table(database_name, table_name)}/-/alter",
|
||||
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"primaryKeys": pks,
|
||||
|
|
@ -629,7 +600,9 @@ async def _table_alter_ui(
|
|||
actor=request.actor,
|
||||
)
|
||||
if can_drop_table:
|
||||
data["dropPath"] = f"{datasette.urls.table(database_name, table_name)}/-/drop"
|
||||
data["dropPath"] = "{}/-/drop".format(
|
||||
datasette.urls.table(database_name, table_name)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -725,10 +698,12 @@ async def display_columns_and_rows(
|
|||
row_label = row_label_from_label_column(row, label_column)
|
||||
row_action_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)
|
||||
table_path = datasette.urls.table(database_name, table_name)
|
||||
row_link = (
|
||||
f'<a href="{table_path}/{row_path}">{markupsafe.escape(pk_path)!s}</a>'
|
||||
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
|
||||
table_path=table_path,
|
||||
flat_pks=str(markupsafe.escape(pk_path)),
|
||||
flat_pks_quoted=row_path,
|
||||
)
|
||||
edit_icon = (
|
||||
'<svg class="row-inline-action-icon" aria-hidden="true" '
|
||||
|
|
@ -755,16 +730,22 @@ async def display_columns_and_rows(
|
|||
if row_action_permissions.get("update-row"):
|
||||
row_actions.append(
|
||||
'<button type="button" class="row-inline-action row-inline-action-edit" '
|
||||
f'aria-label="Edit row {markupsafe.escape(row_action_label)}" title="Edit row" '
|
||||
'aria-label="Edit row {row_label}" title="Edit row" '
|
||||
'data-row-action="edit">'
|
||||
f"{edit_icon}</button>"
|
||||
"{edit_icon}</button>".format(
|
||||
edit_icon=edit_icon,
|
||||
row_label=markupsafe.escape(row_action_label),
|
||||
)
|
||||
)
|
||||
if row_action_permissions.get("delete-row"):
|
||||
row_actions.append(
|
||||
'<button type="button" class="row-inline-action row-inline-action-delete" '
|
||||
f'aria-label="Delete row {markupsafe.escape(row_action_label)}" title="Delete row" '
|
||||
'aria-label="Delete row {row_label}" title="Delete row" '
|
||||
'data-row-action="delete">'
|
||||
f"{delete_icon}</button>"
|
||||
"{delete_icon}</button>".format(
|
||||
delete_icon=delete_icon,
|
||||
row_label=markupsafe.escape(row_action_label),
|
||||
)
|
||||
)
|
||||
if row_actions:
|
||||
row_link = (
|
||||
|
|
@ -832,7 +813,11 @@ async def display_columns_and_rows(
|
|||
path_from_row_pks(row, pks, not pks),
|
||||
column,
|
||||
),
|
||||
(f' title="{formatted}"' if "bytes" not in formatted else ""),
|
||||
(
|
||||
' title="{}"'.format(formatted)
|
||||
if "bytes" not in formatted
|
||||
else ""
|
||||
),
|
||||
len(value),
|
||||
"" if len(value) == 1 else "s",
|
||||
)
|
||||
|
|
@ -940,11 +925,13 @@ class TableInsertView(BaseView):
|
|||
def _errors(errors):
|
||||
return None, errors, {}
|
||||
|
||||
# The body is parsed as JSON regardless of the Content-Type header
|
||||
if not request.headers.get("content-type").startswith("application/json"):
|
||||
# TODO: handle form-encoded data
|
||||
return _errors(["Invalid content-type, must be application/json"])
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return _errors([f"Invalid JSON: {e}"])
|
||||
return _errors(["Invalid JSON: {}".format(e)])
|
||||
if not isinstance(data, dict):
|
||||
return _errors(["JSON must be a dictionary"])
|
||||
keys = data.keys()
|
||||
|
|
@ -972,7 +959,9 @@ class TableInsertView(BaseView):
|
|||
# Does this exceed max_insert_rows?
|
||||
max_insert_rows = self.ds.setting("max_insert_rows")
|
||||
if len(rows) > max_insert_rows:
|
||||
return _errors([f"Too many rows, maximum allowed is {max_insert_rows}"])
|
||||
return _errors(
|
||||
["Too many rows, maximum allowed is {}".format(max_insert_rows)]
|
||||
)
|
||||
|
||||
# Validate other parameters
|
||||
extras = {
|
||||
|
|
@ -1022,7 +1011,7 @@ class TableInsertView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return Response.error([e.args[0]], 404)
|
||||
return _error([e.args[0]], 404)
|
||||
db = resolved.db
|
||||
database_name = db.name
|
||||
table_name = resolved.table
|
||||
|
|
@ -1030,7 +1019,7 @@ class TableInsertView(BaseView):
|
|||
# Table must exist (may handle table creation in the future)
|
||||
db = self.ds.get_database(database_name)
|
||||
if not await db.table_exists(table_name):
|
||||
return Response.error([f"Table not found: {table_name}"], 404)
|
||||
return _error(["Table not found: {}".format(table_name)], 404)
|
||||
|
||||
if upsert:
|
||||
# Must have insert-row AND upsert-row permissions
|
||||
|
|
@ -1046,7 +1035,7 @@ class TableInsertView(BaseView):
|
|||
actor=request.actor,
|
||||
)
|
||||
):
|
||||
return Response.error(
|
||||
return _error(
|
||||
["Permission denied: need both insert-row and update-row"], 403
|
||||
)
|
||||
else:
|
||||
|
|
@ -1056,32 +1045,25 @@ class TableInsertView(BaseView):
|
|||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied"], 403)
|
||||
return _error(["Permission denied"], 403)
|
||||
|
||||
if not db.is_mutable:
|
||||
return Response.error(["Database is immutable"], 403)
|
||||
return _error(["Database is immutable"], 403)
|
||||
|
||||
pks = await db.primary_keys(table_name)
|
||||
|
||||
try:
|
||||
rows, errors, extras = await self._validate_data(
|
||||
request, db, table_name, pks, upsert
|
||||
)
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
rows, errors, extras = await self._validate_data(
|
||||
request, db, table_name, pks, upsert
|
||||
)
|
||||
if errors:
|
||||
return Response.error(errors, 400)
|
||||
try:
|
||||
rows = decode_write_json_rows(rows)
|
||||
except WriteJsonValueError as e:
|
||||
return Response.error([str(e)], 400)
|
||||
return _error(errors, 400)
|
||||
|
||||
# Validate column types
|
||||
ct_errors = await _validate_column_types(
|
||||
self.ds, database_name, table_name, rows
|
||||
)
|
||||
if ct_errors:
|
||||
return Response.error(ct_errors, 400)
|
||||
return _error(ct_errors, 400)
|
||||
|
||||
num_rows = len(rows)
|
||||
|
||||
|
|
@ -1095,16 +1077,14 @@ class TableInsertView(BaseView):
|
|||
alter = extras.get("alter")
|
||||
|
||||
if upsert and (ignore or replace):
|
||||
return Response.error(["Upsert does not support ignore or replace"], 400)
|
||||
return _error(["Upsert does not support ignore or replace"], 400)
|
||||
|
||||
if replace and not await self.ds.allowed(
|
||||
action="update-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(
|
||||
['Permission denied: need update-row to use "replace"'], 403
|
||||
)
|
||||
return _error(['Permission denied: need update-row to use "replace"'], 403)
|
||||
|
||||
initial_schema = None
|
||||
if alter:
|
||||
|
|
@ -1114,7 +1094,7 @@ class TableInsertView(BaseView):
|
|||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied for alter-table"], 403)
|
||||
return _error(["Permission denied for alter-table"], 403)
|
||||
# Track initial schema to check if it changed later
|
||||
initial_schema = await db.execute_fn(
|
||||
lambda conn: sqlite_utils.Database(conn)[table_name].schema
|
||||
|
|
@ -1153,15 +1133,14 @@ class TableInsertView(BaseView):
|
|||
|
||||
try:
|
||||
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||
return Response.error([str(e)])
|
||||
except Exception as e:
|
||||
return _error([str(e)])
|
||||
result = {"ok": True}
|
||||
if should_return:
|
||||
if upsert:
|
||||
# Fetch based on initial input IDs
|
||||
where_clause = " OR ".join(
|
||||
["({})".format(" AND ".join(f"{pk} = ?" for pk in pks))]
|
||||
["({})".format(" AND ".join("{} = ?".format(pk) for pk in pks))]
|
||||
* len(row_pk_values_for_later)
|
||||
)
|
||||
args = list(itertools.chain.from_iterable(row_pk_values_for_later))
|
||||
|
|
@ -1212,11 +1191,7 @@ class TableInsertView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
return Response.json(
|
||||
result,
|
||||
status=200 if upsert else 201,
|
||||
default=CustomJSONEncoder().default,
|
||||
)
|
||||
return Response.json(result, status=200 if upsert else 201)
|
||||
|
||||
|
||||
class TableUpsertView(TableInsertView):
|
||||
|
|
@ -1236,7 +1211,7 @@ class TableSetColumnTypeView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return Response.error([e.args[0]], 404)
|
||||
return _error([e.args[0]], 404)
|
||||
|
||||
database_name = resolved.db.name
|
||||
table_name = resolved.table
|
||||
|
|
@ -1246,39 +1221,41 @@ class TableSetColumnTypeView(BaseView):
|
|||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied"], 403)
|
||||
return _error(["Permission denied"], 403)
|
||||
|
||||
content_type = request.headers.get("content-type") or ""
|
||||
if not content_type.startswith("application/json"):
|
||||
return _error(["Invalid content-type, must be application/json"], 400)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return Response.error([f"Invalid JSON: {e}"], 400)
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
return _error(["Invalid JSON: {}".format(e)], 400)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return Response.error(["JSON must be a dictionary"], 400)
|
||||
return _error(["JSON must be a dictionary"], 400)
|
||||
|
||||
invalid_keys = set(data.keys()) - {"column", "column_type"}
|
||||
if invalid_keys:
|
||||
return Response.error(
|
||||
return _error(
|
||||
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
|
||||
if "column" not in data:
|
||||
return Response.error(['"column" is required'], 400)
|
||||
return _error(['"column" is required'], 400)
|
||||
column = data["column"]
|
||||
if not isinstance(column, str):
|
||||
return Response.error(['"column" must be a string'], 400)
|
||||
return _error(['"column" must be a string'], 400)
|
||||
|
||||
if "column_type" not in data:
|
||||
return Response.error(['"column_type" is required'], 400)
|
||||
return _error(['"column_type" is required'], 400)
|
||||
|
||||
column_details = await self.ds._get_resource_column_details(
|
||||
database_name, table_name
|
||||
)
|
||||
if column not in column_details:
|
||||
return Response.error([f"Column not found: {column}"], 400)
|
||||
return _error(["Column not found: {}".format(column)], 400)
|
||||
|
||||
column_type_data = data["column_type"]
|
||||
if column_type_data is None:
|
||||
|
|
@ -1295,11 +1272,11 @@ class TableSetColumnTypeView(BaseView):
|
|||
)
|
||||
|
||||
if not isinstance(column_type_data, dict):
|
||||
return Response.error(['"column_type" must be an object or null'], 400)
|
||||
return _error(['"column_type" must be an object or null'], 400)
|
||||
|
||||
invalid_column_type_keys = set(column_type_data.keys()) - {"type", "config"}
|
||||
if invalid_column_type_keys:
|
||||
return Response.error(
|
||||
return _error(
|
||||
[
|
||||
'Invalid column_type parameter: "{}"'.format(
|
||||
'", "'.join(sorted(invalid_column_type_keys))
|
||||
|
|
@ -1309,24 +1286,24 @@ class TableSetColumnTypeView(BaseView):
|
|||
)
|
||||
|
||||
if "type" not in column_type_data:
|
||||
return Response.error(['"column_type.type" is required'], 400)
|
||||
return _error(['"column_type.type" is required'], 400)
|
||||
column_type = column_type_data["type"]
|
||||
if not isinstance(column_type, str):
|
||||
return Response.error(['"column_type.type" must be a string'], 400)
|
||||
return _error(['"column_type.type" must be a string'], 400)
|
||||
|
||||
config = column_type_data.get("config")
|
||||
if config is not None and not isinstance(config, dict):
|
||||
return Response.error(['"column_type.config" must be a dictionary'], 400)
|
||||
return _error(['"column_type.config" must be a dictionary'], 400)
|
||||
|
||||
if column_type not in self.ds._column_types:
|
||||
return Response.error([f"Unknown column type: {column_type}"], 400)
|
||||
return _error(["Unknown column type: {}".format(column_type)], 400)
|
||||
|
||||
try:
|
||||
await self.ds.set_column_type(
|
||||
database_name, table_name, column, column_type, config
|
||||
)
|
||||
except ValueError as e:
|
||||
return Response.error([str(e)], 400)
|
||||
return _error([str(e)], 400)
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
|
|
@ -1350,30 +1327,28 @@ class TableDropView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return Response.error([e.args[0]], 404)
|
||||
return _error([e.args[0]], 404)
|
||||
db = resolved.db
|
||||
database_name = db.name
|
||||
table_name = resolved.table
|
||||
# Table must exist
|
||||
db = self.ds.get_database(database_name)
|
||||
if not await db.table_exists(table_name):
|
||||
return Response.error([f"Table not found: {table_name}"], 404)
|
||||
return _error(["Table not found: {}".format(table_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="drop-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied"], 403)
|
||||
return _error(["Permission denied"], 403)
|
||||
if not db.is_mutable:
|
||||
return Response.error(["Database is immutable"], 403)
|
||||
return _error(["Database is immutable"], 403)
|
||||
confirm = False
|
||||
try:
|
||||
data = await request.json()
|
||||
confirm = data.get("confirm")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
|
||||
if not confirm:
|
||||
return Response.json(
|
||||
|
|
@ -1382,7 +1357,7 @@ class TableDropView(BaseView):
|
|||
"database": database_name,
|
||||
"table": table_name,
|
||||
"row_count": (
|
||||
await db.execute(f"select count(*) from [{table_name}]")
|
||||
await db.execute("select count(*) from [{}]".format(table_name))
|
||||
).single_value(),
|
||||
"message": 'Pass "confirm": true to confirm',
|
||||
},
|
||||
|
|
@ -1391,9 +1366,7 @@ class TableDropView(BaseView):
|
|||
|
||||
# Drop table
|
||||
def drop_table(conn):
|
||||
table = sqlite_utils.Database(conn)[table_name]
|
||||
table.disable_fts()
|
||||
table.drop()
|
||||
sqlite_utils.Database(conn)[table_name].drop()
|
||||
|
||||
await db.execute_write_fn(drop_table, request=request)
|
||||
await self.ds.track_event(
|
||||
|
|
@ -1403,7 +1376,7 @@ class TableDropView(BaseView):
|
|||
)
|
||||
self.ds.add_message(
|
||||
request,
|
||||
f"Table {table_name} dropped",
|
||||
"Table {} dropped".format(table_name),
|
||||
self.ds.WARNING,
|
||||
)
|
||||
return Response.json({"ok": True}, status=200)
|
||||
|
|
@ -1463,28 +1436,32 @@ def _prefix_range_end(value):
|
|||
|
||||
|
||||
def _autocomplete_like(column):
|
||||
return f"{escape_sqlite(column)} like :like escape char(92)"
|
||||
return "{} like :like escape char(92)".format(escape_sqlite(column))
|
||||
|
||||
|
||||
def _autocomplete_prefix_like(column):
|
||||
return f"{escape_sqlite(column)} like :prefix escape char(92)"
|
||||
return "{} like :prefix escape char(92)".format(escape_sqlite(column))
|
||||
|
||||
|
||||
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
|
||||
clauses = []
|
||||
if exact_pk:
|
||||
clauses.append(
|
||||
f"case when cast({escape_sqlite(pks[0])} as text) = :q then 0 else 1 end"
|
||||
"case when cast({} as text) = :q then 0 else 1 end".format(
|
||||
escape_sqlite(pks[0])
|
||||
)
|
||||
)
|
||||
if label_column:
|
||||
label_like = _autocomplete_like(label_column)
|
||||
if label_matches_first:
|
||||
clauses.append(f"case when {label_like} then 0 else 1 end")
|
||||
clauses.append("case when {} then 0 else 1 end".format(label_like))
|
||||
clauses.append(
|
||||
f"case when {label_like} then length(cast({escape_sqlite(label_column)} as text)) end"
|
||||
"case when {} then length(cast({} as text)) end".format(
|
||||
label_like, escape_sqlite(label_column)
|
||||
)
|
||||
)
|
||||
else:
|
||||
clauses.append(f"length(cast({escape_sqlite(pks[0])} as text))")
|
||||
clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0])))
|
||||
clauses.extend(escape_sqlite(pk) for pk in pks)
|
||||
return ", ".join(clauses)
|
||||
|
||||
|
|
@ -1548,11 +1525,11 @@ class TableAutocompleteView(BaseView):
|
|||
and value_as_boolean(initial_arg)
|
||||
)
|
||||
if not q and not initial:
|
||||
return Response.json({"ok": True, "rows": []})
|
||||
return Response.json({"rows": []})
|
||||
params = {
|
||||
"q": q,
|
||||
"like": f"%{_escape_like(q)}%",
|
||||
"prefix": f"{_escape_like(q)}%",
|
||||
"like": "%{}%".format(_escape_like(q)),
|
||||
"prefix": "{}%".format(_escape_like(q)),
|
||||
}
|
||||
|
||||
like_columns = pks[:]
|
||||
|
|
@ -1566,13 +1543,18 @@ class TableAutocompleteView(BaseView):
|
|||
where_sql = "1 = 1"
|
||||
order_by = _autocomplete_initial_order_by(pks)
|
||||
|
||||
sql = f"""
|
||||
sql = """
|
||||
select {select_sql}
|
||||
from {escape_sqlite(table_name)}
|
||||
where {where_sql}
|
||||
from {table}
|
||||
where {where}
|
||||
order by {order_by}
|
||||
limit 10
|
||||
"""
|
||||
""".format(
|
||||
select_sql=select_sql,
|
||||
table=escape_sqlite(table_name),
|
||||
where=where_sql,
|
||||
order_by=order_by,
|
||||
)
|
||||
|
||||
try:
|
||||
results = await db.execute(
|
||||
|
|
@ -1584,14 +1566,21 @@ class TableAutocompleteView(BaseView):
|
|||
if prefix_end:
|
||||
params["prefix_end"] = prefix_end
|
||||
first_pk = escape_sqlite(pks[0])
|
||||
fallback_where = f"{first_pk} >= :q and {first_pk} < :prefix_end and {fallback_where}"
|
||||
fallback_sql = f"""
|
||||
fallback_where = (
|
||||
"{first_pk} >= :q and {first_pk} < :prefix_end and {like}"
|
||||
).format(first_pk=first_pk, like=fallback_where)
|
||||
fallback_sql = """
|
||||
select {select_sql}
|
||||
from {escape_sqlite(table_name)}
|
||||
where {fallback_where}
|
||||
order by {_autocomplete_pk_order_by(pks)}
|
||||
from {table}
|
||||
where {where}
|
||||
order by {order_by}
|
||||
limit 10
|
||||
"""
|
||||
""".format(
|
||||
select_sql=select_sql,
|
||||
table=escape_sqlite(table_name),
|
||||
where=fallback_where,
|
||||
order_by=_autocomplete_pk_order_by(pks),
|
||||
)
|
||||
try:
|
||||
results = await db.execute(
|
||||
fallback_sql,
|
||||
|
|
@ -1599,13 +1588,10 @@ class TableAutocompleteView(BaseView):
|
|||
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
|
||||
)
|
||||
except QueryInterrupted:
|
||||
return Response.json({"ok": True, "rows": []})
|
||||
return Response.json({"rows": []})
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"rows": _autocomplete_response_rows(results.rows, pks, label_column),
|
||||
}
|
||||
{"rows": _autocomplete_response_rows(results.rows, pks, label_column)}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1747,7 +1733,7 @@ async def table_view_traced(datasette, request):
|
|||
)
|
||||
if isinstance(view_data, Response):
|
||||
return view_data
|
||||
data, rows, columns, _expanded_columns, sql, next_url = view_data
|
||||
data, rows, columns, expanded_columns, sql, next_url = view_data
|
||||
|
||||
# Handle formats from plugins
|
||||
if format_ == "csv":
|
||||
|
|
@ -1758,8 +1744,8 @@ async def table_view_traced(datasette, request):
|
|||
rows,
|
||||
columns,
|
||||
expanded_columns,
|
||||
_sql,
|
||||
_next_url,
|
||||
sql,
|
||||
next_url,
|
||||
) = await table_view_data(
|
||||
datasette,
|
||||
request,
|
||||
|
|
@ -1776,7 +1762,7 @@ async def table_view_traced(datasette, request):
|
|||
return data, None, None
|
||||
|
||||
return await stream_csv(datasette, fetch_data, request, resolved.db.name)
|
||||
elif format_ in datasette.renderers:
|
||||
elif format_ in datasette.renderers.keys():
|
||||
# Dispatch request to the correct output format renderer
|
||||
# (CSV is not handled here due to streaming)
|
||||
result = call_with_supported_arguments(
|
||||
|
|
@ -1834,7 +1820,9 @@ async def table_view_traced(datasette, request):
|
|||
)
|
||||
headers.update(
|
||||
{
|
||||
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||
alternate_url_json
|
||||
)
|
||||
}
|
||||
)
|
||||
table_context = TableContext(
|
||||
|
|
@ -1919,7 +1907,7 @@ async def table_view_traced(datasette, request):
|
|||
headers=headers,
|
||||
)
|
||||
else:
|
||||
assert False, f"Invalid format: {format_}"
|
||||
assert False, "Invalid format: {}".format(format_)
|
||||
if next_url:
|
||||
r.headers["link"] = f'<{next_url}>; rel="next"'
|
||||
return r
|
||||
|
|
@ -2110,7 +2098,9 @@ async def table_view_data(
|
|||
extra_desc_only=(
|
||||
""
|
||||
if sort
|
||||
else f" or {escape_sqlite(sort or sort_desc)} is null"
|
||||
else " or {column2} is null".format(
|
||||
column2=escape_sqlite(sort or sort_desc)
|
||||
)
|
||||
),
|
||||
next_clauses=" and ".join(next_by_pk_clauses),
|
||||
)
|
||||
|
|
@ -2152,11 +2142,22 @@ async def table_view_data(
|
|||
|
||||
# Facets are calculated against SQL without order by or limit
|
||||
sql_no_order_no_limit = (
|
||||
f"select {select_all_columns} from {escape_sqlite(table_name)} {where_clause}"
|
||||
"select {select_all_columns} from {table_name} {where}".format(
|
||||
select_all_columns=select_all_columns,
|
||||
table_name=escape_sqlite(table_name),
|
||||
where=where_clause,
|
||||
)
|
||||
)
|
||||
|
||||
# This is the SQL that populates the main table on the page
|
||||
sql = f"select {select_specified_columns} from {escape_sqlite(table_name)} {where_clause}{order_by} limit {page_size + 1}{offset}"
|
||||
sql = "select {select_specified_columns} from {table_name} {where}{order_by} limit {page_size}{offset}".format(
|
||||
select_specified_columns=select_specified_columns,
|
||||
table_name=escape_sqlite(table_name),
|
||||
where=where_clause,
|
||||
order_by=order_by,
|
||||
page_size=page_size + 1,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
if request.args.get("_timelimit"):
|
||||
extra_args["custom_time_limit"] = int(request.args.get("_timelimit"))
|
||||
|
|
@ -2167,6 +2168,9 @@ async def table_view_data(
|
|||
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
raise DatasetteError(str(e))
|
||||
|
||||
columns = [r[0] for r in results.description]
|
||||
rows = list(results.rows)
|
||||
|
||||
|
|
@ -2213,8 +2217,7 @@ async def table_view_data(
|
|||
new_rows = []
|
||||
for row in rows:
|
||||
new_row = CustomRow(columns)
|
||||
# CustomRow/sqlite3.Row iterate over values, so .keys() is required
|
||||
for column in row.keys(): # noqa: SIM118
|
||||
for column in row.keys():
|
||||
value = row[column]
|
||||
if (column, value) in expanded_labels and value is not None:
|
||||
new_row[column] = {
|
||||
|
|
@ -2247,16 +2250,10 @@ async def table_view_data(
|
|||
|
||||
# Resolve extras
|
||||
extras = extra_names_from_request(request)
|
||||
if not extra_extras:
|
||||
# Data formats reject unknown extras; the HTML path (which passes
|
||||
# extra_extras={"_html"}) resolves internal extras of its own
|
||||
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
|
||||
if any(k for k in request.args if k == "_facet" or k.startswith("_facet_")):
|
||||
if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")):
|
||||
extras.add("facet_results")
|
||||
if request.args.get("_shape") == "object":
|
||||
extras.add("primary_keys")
|
||||
if "count" in extras:
|
||||
extras.add("count_truncated")
|
||||
if extra_extras:
|
||||
extras.update(extra_extras)
|
||||
|
||||
|
|
@ -2311,7 +2308,6 @@ async def table_view_data(
|
|||
data = {
|
||||
"ok": True,
|
||||
"next": next_value and str(next_value) or None,
|
||||
"next_url": next_url,
|
||||
}
|
||||
data.update(
|
||||
await resolve_table_extras(
|
||||
|
|
@ -2336,7 +2332,7 @@ async def table_view_data(
|
|||
data["rows"] = transformed_rows
|
||||
|
||||
if context_for_html_hack:
|
||||
data["count_truncated"] = count_is_truncated(
|
||||
data["count_truncated"] = _count_truncated_for_table_page(
|
||||
datasette, db, database_name, table_name, count_sql, data.get("count")
|
||||
)
|
||||
data.update(extra_context_from_filters)
|
||||
|
|
@ -2404,6 +2400,24 @@ async def table_view_data(
|
|||
return data, rows[:page_size], columns, expanded_columns, sql, next_url
|
||||
|
||||
|
||||
def _count_truncated_for_table_page(
|
||||
datasette, db, database_name, table_name, count_sql, count
|
||||
):
|
||||
if count != db.count_limit + 1:
|
||||
return False
|
||||
if (
|
||||
not db.is_mutable
|
||||
and datasette.inspect_data
|
||||
and count_sql == f"select count(*) from {table_name} "
|
||||
):
|
||||
try:
|
||||
datasette.inspect_data[database_name]["tables"][table_name]["count"]
|
||||
return False
|
||||
except KeyError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
async def _next_value_and_url(
|
||||
datasette,
|
||||
db,
|
||||
|
|
@ -2432,13 +2446,20 @@ async def _next_value_and_url(
|
|||
except IndexError:
|
||||
# sort/sort_desc column missing from SELECT - look up value by PK instead
|
||||
prefix_where_clause = " and ".join(
|
||||
f"[{pk}] = :pk{i}" for i, pk in enumerate(pks)
|
||||
"[{}] = :pk{}".format(pk, i) for i, pk in enumerate(pks)
|
||||
)
|
||||
prefix_lookup_sql = "select [{}] from [{}] where {}".format(
|
||||
sort or sort_desc, table_name, prefix_where_clause
|
||||
)
|
||||
prefix_lookup_sql = f"select [{sort or sort_desc}] from [{table_name}] where {prefix_where_clause}"
|
||||
prefix = (
|
||||
await db.execute(
|
||||
prefix_lookup_sql,
|
||||
{**{f"pk{i}": rows[-2][pk] for i, pk in enumerate(pks)}},
|
||||
{
|
||||
**{
|
||||
"pk{}".format(i): rows[-2][pk]
|
||||
for i, pk in enumerate(pks)
|
||||
}
|
||||
},
|
||||
)
|
||||
).single_value()
|
||||
if isinstance(prefix, dict) and "value" in prefix:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Annotated, Any, Literal
|
||||
from typing import Annotated, Any, Literal, Union
|
||||
|
||||
import sqlite_utils
|
||||
from datasette.database import QueryInterrupted
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
|
|
@ -13,23 +13,21 @@ from pydantic import (
|
|||
model_validator,
|
||||
)
|
||||
from pydantic_core import PydanticCustomError
|
||||
import sqlite_utils
|
||||
from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT
|
||||
|
||||
from datasette.column_types import SQLiteType
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
||||
from datasette.resources import DatabaseResource, TableResource
|
||||
from datasette.utils import (
|
||||
WriteJsonValueError,
|
||||
decode_write_json_rows,
|
||||
escape_sqlite,
|
||||
get_outbound_foreign_keys,
|
||||
table_column_details,
|
||||
)
|
||||
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
|
||||
from datasette.utils.asgi import NotFound, Response
|
||||
from datasette.utils.sqlite import sqlite_hidden_table_names
|
||||
|
||||
from .base import BaseView
|
||||
from .base import BaseView, _error
|
||||
|
||||
CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"]
|
||||
CREATE_TABLE_SQLITE_TYPES = {
|
||||
|
|
@ -136,14 +134,14 @@ def _foreign_key_name_reasons(source_column, target):
|
|||
singular_table = _singular(table)
|
||||
column = target["fk_column"].lower()
|
||||
possible_names = {
|
||||
f"{table}_{column}",
|
||||
f"{singular_table}_{column}",
|
||||
"{}_{}".format(table, column),
|
||||
"{}_{}".format(singular_table, column),
|
||||
}
|
||||
if column == "id":
|
||||
possible_names.update(
|
||||
{
|
||||
f"{table}_id",
|
||||
f"{singular_table}_id",
|
||||
"{}_id".format(table),
|
||||
"{}_id".format(singular_table),
|
||||
}
|
||||
)
|
||||
return ["name_match"] if source in possible_names else []
|
||||
|
|
@ -262,16 +260,13 @@ async def _create_table_ui_context(
|
|||
if not database_action_permissions.get("create-table"):
|
||||
return None
|
||||
data = {
|
||||
"path": f"{datasette.urls.database(database_name)}/-/create",
|
||||
"foreignKeyTargetsPath": f"{datasette.urls.database(database_name)}/-/foreign-key-targets",
|
||||
"path": "{}/-/create".format(datasette.urls.database(database_name)),
|
||||
"foreignKeyTargetsPath": "{}/-/foreign-key-targets".format(
|
||||
datasette.urls.database(database_name)
|
||||
),
|
||||
"databaseName": database_name,
|
||||
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
|
||||
"defaultExpressions": default_expression_options(),
|
||||
"canInsertRows": await datasette.allowed(
|
||||
action="insert-row",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
),
|
||||
}
|
||||
can_set_column_type = await datasette.allowed(
|
||||
action="set-column-type",
|
||||
|
|
@ -396,15 +391,15 @@ def default_expr_for_sql(expression):
|
|||
|
||||
def _quoted_options(options):
|
||||
if len(options) == 1:
|
||||
return f"'{options[0]}'"
|
||||
return "'{}'".format(options[0])
|
||||
return "{} or '{}'".format(
|
||||
", ".join(f"'{option}'" for option in options[:-1]),
|
||||
", ".join("'{}'".format(option) for option in options[:-1]),
|
||||
options[-1],
|
||||
)
|
||||
|
||||
|
||||
def _default_expr_error_message():
|
||||
return f"Input should be {_quoted_options(list(DEFAULT_EXPRESSIONS))}"
|
||||
return "Input should be {}".format(_quoted_options(list(DEFAULT_EXPRESSIONS)))
|
||||
|
||||
|
||||
def default_expression_options():
|
||||
|
|
@ -713,16 +708,18 @@ class SetForeignKeysOperation(_StrictPydanticModel):
|
|||
|
||||
|
||||
AlterTableOperation = Annotated[
|
||||
AddColumnOperation
|
||||
| RenameColumnOperation
|
||||
| RenameTableOperation
|
||||
| AlterColumnOperation
|
||||
| DropColumnOperation
|
||||
| SetPrimaryKeyOperation
|
||||
| ReorderColumnsOperation
|
||||
| AddForeignKeyOperation
|
||||
| DropForeignKeyOperation
|
||||
| SetForeignKeysOperation,
|
||||
Union[
|
||||
AddColumnOperation,
|
||||
RenameColumnOperation,
|
||||
RenameTableOperation,
|
||||
AlterColumnOperation,
|
||||
DropColumnOperation,
|
||||
SetPrimaryKeyOperation,
|
||||
ReorderColumnsOperation,
|
||||
AddForeignKeyOperation,
|
||||
DropForeignKeyOperation,
|
||||
SetForeignKeysOperation,
|
||||
],
|
||||
Field(discriminator="op"),
|
||||
]
|
||||
|
||||
|
|
@ -736,7 +733,7 @@ def _pydantic_errors(validation_error):
|
|||
for error in validation_error.errors():
|
||||
location = ".".join(str(item) for item in error["loc"])
|
||||
message = error["msg"]
|
||||
errors.append(f"{location}: {message}" if location else message)
|
||||
errors.append("{}: {}".format(location, message) if location else message)
|
||||
return errors
|
||||
|
||||
|
||||
|
|
@ -757,7 +754,7 @@ def _create_table_pydantic_errors(validation_error):
|
|||
output.append(message)
|
||||
continue
|
||||
location = ".".join(str(item) for item in error["loc"])
|
||||
output.append(f"{location}: {message}" if location else message)
|
||||
output.append("{}: {}".format(location, message) if location else message)
|
||||
return output
|
||||
|
||||
|
||||
|
|
@ -775,6 +772,14 @@ def _primary_key_value(columns):
|
|||
return tuple(columns)
|
||||
|
||||
|
||||
def _primary_key_columns(pk, pks):
|
||||
if pks:
|
||||
return list(pks)
|
||||
if pk:
|
||||
return [pk]
|
||||
return []
|
||||
|
||||
|
||||
def _default_expression_sql(default_expr):
|
||||
return DEFAULT_EXPR_SQL[default_expr]
|
||||
|
||||
|
|
@ -801,33 +806,32 @@ class TableCreateView(BaseView):
|
|||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied"], 403)
|
||||
return _error(["Permission denied"], 403)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return Response.error([f"Invalid JSON: {e}"])
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
return _error(["Invalid JSON: {}".format(e)])
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return Response.error(["JSON must be an object"])
|
||||
return _error(["JSON must be an object"])
|
||||
|
||||
try:
|
||||
create_request = CreateTableRequest.model_validate(data)
|
||||
except ValidationError as e:
|
||||
return Response.error(_create_table_pydantic_errors(e))
|
||||
return _error(_create_table_pydantic_errors(e))
|
||||
|
||||
ignore = create_request.ignore
|
||||
replace = create_request.replace
|
||||
|
||||
# Replacing rows requires update-row permission
|
||||
if replace and not await self.ds.allowed(
|
||||
action="update-row",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need update-row"], 403)
|
||||
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 = create_request.table
|
||||
table_exists = await db.table_exists(table_name)
|
||||
|
|
@ -841,11 +845,7 @@ class TableCreateView(BaseView):
|
|||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need insert-row"], 403)
|
||||
try:
|
||||
rows = decode_write_json_rows(rows)
|
||||
except WriteJsonValueError as e:
|
||||
return Response.error([str(e)], 400)
|
||||
return _error(["Permission denied: need insert-row"], 403)
|
||||
|
||||
alter = False
|
||||
if rows:
|
||||
|
|
@ -860,9 +860,7 @@ class TableCreateView(BaseView):
|
|||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(
|
||||
["Permission denied: need alter-table"], 403
|
||||
)
|
||||
return _error(["Permission denied: need alter-table"], 403)
|
||||
alter = True
|
||||
|
||||
pk = create_request.pk
|
||||
|
|
@ -873,19 +871,16 @@ class TableCreateView(BaseView):
|
|||
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 pk
|
||||
and pk != actual_pks[0]
|
||||
or len(actual_pks) > 1
|
||||
and pks
|
||||
and set(pks) != set(actual_pks)
|
||||
):
|
||||
if len(actual_pks) == 1 and pk and pk != actual_pks[0]:
|
||||
bad_pks = True
|
||||
elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks):
|
||||
bad_pks = True
|
||||
if bad_pks:
|
||||
return Response.error(["pk cannot be changed for existing table"])
|
||||
return _error(["pk cannot be changed for existing table"])
|
||||
pks = actual_pks
|
||||
|
||||
pk_columns = _primary_key_columns(pk, pks)
|
||||
|
||||
initial_schema = None
|
||||
if table_exists:
|
||||
initial_schema = await db.execute_fn(
|
||||
|
|
@ -896,11 +891,41 @@ class TableCreateView(BaseView):
|
|||
db_for_write = sqlite_utils.Database(conn)
|
||||
table = db_for_write[table_name]
|
||||
if rows:
|
||||
row_columns = set()
|
||||
pk_columns_set = set(pk_columns)
|
||||
insert_kwargs = {
|
||||
"pk": pks or pk,
|
||||
"ignore": ignore,
|
||||
"replace": replace,
|
||||
"alter": alter,
|
||||
}
|
||||
if not table_exists and pk_columns:
|
||||
row_columns = {key for row in rows for key in row}
|
||||
if pk_columns_set.issubset(row_columns):
|
||||
insert_kwargs["not_null"] = pk_columns
|
||||
table.insert_all(
|
||||
rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter
|
||||
rows,
|
||||
**insert_kwargs,
|
||||
)
|
||||
if (
|
||||
not table_exists
|
||||
and pk_columns
|
||||
and not pk_columns_set.issubset(row_columns)
|
||||
):
|
||||
table.transform(
|
||||
not_null={column: True for column in pk_columns},
|
||||
)
|
||||
else:
|
||||
not_null = [column.name for column in columns if column.not_null]
|
||||
column_definitions = {column.name: column.type for column in columns}
|
||||
if pk and pk not in column_definitions:
|
||||
column_definitions = {pk: "integer", **column_definitions}
|
||||
not_null = [
|
||||
column.name
|
||||
for column in columns
|
||||
if column.not_null or column.name in pk_columns
|
||||
]
|
||||
if pk and pk not in not_null:
|
||||
not_null.insert(0, pk)
|
||||
defaults = {}
|
||||
for column in columns:
|
||||
if "default_expr" in column.model_fields_set:
|
||||
|
|
@ -915,7 +940,7 @@ class TableCreateView(BaseView):
|
|||
db_for_write, column.default
|
||||
)
|
||||
table.create(
|
||||
{column.name: column.type for column in columns},
|
||||
column_definitions,
|
||||
pk=pks or pk,
|
||||
foreign_keys=create_request.foreign_keys,
|
||||
not_null=not_null or None,
|
||||
|
|
@ -925,9 +950,8 @@ class TableCreateView(BaseView):
|
|||
|
||||
try:
|
||||
schema = await db.execute_write_fn(create_table, request=request)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||
return Response.error([str(e)])
|
||||
except Exception as e:
|
||||
return _error([str(e)])
|
||||
|
||||
if initial_schema is not None and initial_schema != schema:
|
||||
await self.ds.track_event(
|
||||
|
|
@ -1002,7 +1026,7 @@ class DatabaseForeignKeyTargetsView(BaseView):
|
|||
actor=request.actor,
|
||||
)
|
||||
if not (can_create_table or can_alter_table):
|
||||
return Response.error(["Permission denied: need create-table"], 403)
|
||||
return _error(["Permission denied: need create-table"], 403)
|
||||
|
||||
hidden_tables = await db.execute_fn(
|
||||
lambda conn: set(sqlite_hidden_table_names(conn))
|
||||
|
|
@ -1031,21 +1055,21 @@ class TableForeignKeySuggestionsView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return Response.error([e.args[0]], 404)
|
||||
return _error([e.args[0]], 404)
|
||||
|
||||
db = resolved.db
|
||||
database_name = db.name
|
||||
table_name = resolved.table
|
||||
|
||||
if resolved.is_view:
|
||||
return Response.error(["Cannot suggest foreign keys for a view"], 400)
|
||||
return _error(["Cannot suggest foreign keys for a view"], 400)
|
||||
|
||||
if not await self.ds.allowed(
|
||||
action="alter-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need alter-table"], 403)
|
||||
return _error(["Permission denied: need alter-table"], 403)
|
||||
|
||||
source_columns, targets, current_by_column = await db.execute_fn(
|
||||
lambda conn: _foreign_key_suggestion_metadata(conn, table_name)
|
||||
|
|
@ -1153,7 +1177,7 @@ class TableAlterView(BaseView):
|
|||
try:
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
except NotFound as e:
|
||||
return Response.error([e.args[0]], 404)
|
||||
return _error([e.args[0]], 404)
|
||||
|
||||
db = resolved.db
|
||||
database_name = db.name
|
||||
|
|
@ -1164,25 +1188,27 @@ class TableAlterView(BaseView):
|
|||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return Response.error(["Permission denied: need alter-table"], 403)
|
||||
return _error(["Permission denied: need alter-table"], 403)
|
||||
|
||||
if not db.is_mutable:
|
||||
return Response.error(["Database is immutable"], 403)
|
||||
return _error(["Database is immutable"], 403)
|
||||
|
||||
content_type = request.headers.get("content-type") or ""
|
||||
if not content_type.startswith("application/json"):
|
||||
return _error(["Invalid content-type, must be application/json"], 400)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError as e:
|
||||
return Response.error([f"Invalid JSON: {e}"], 400)
|
||||
except PayloadTooLarge as e:
|
||||
return Response.error([str(e)], 413)
|
||||
return _error(["Invalid JSON: {}".format(e)], 400)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return Response.error(["JSON must be a dictionary"], 400)
|
||||
return _error(["JSON must be a dictionary"], 400)
|
||||
|
||||
try:
|
||||
alter_request = AlterTableRequest.model_validate(data)
|
||||
except ValidationError as e:
|
||||
return Response.error(_pydantic_errors(e), 400)
|
||||
return _error(_pydantic_errors(e), 400)
|
||||
|
||||
def alter_table(conn):
|
||||
before_schema = _table_schema_from_conn(conn, table_name)
|
||||
|
|
@ -1201,6 +1227,7 @@ class TableAlterView(BaseView):
|
|||
defaults = {}
|
||||
column_order = None
|
||||
pk = SQLITE_UTILS_DEFAULT
|
||||
pk_columns = []
|
||||
add_foreign_keys = []
|
||||
drop_foreign_keys = []
|
||||
foreign_keys = None
|
||||
|
|
@ -1253,6 +1280,7 @@ class TableAlterView(BaseView):
|
|||
drop.add(args.name)
|
||||
elif operation.op == "set_primary_key":
|
||||
pk = _primary_key_value(args.columns)
|
||||
pk_columns = args.columns
|
||||
elif operation.op == "reorder_columns":
|
||||
column_order = args.columns
|
||||
elif operation.op == "add_foreign_key":
|
||||
|
|
@ -1295,6 +1323,8 @@ class TableAlterView(BaseView):
|
|||
)
|
||||
)
|
||||
if should_transform:
|
||||
for column in pk_columns:
|
||||
not_null[column] = True
|
||||
table.transform(
|
||||
types=types or None,
|
||||
rename=rename or None,
|
||||
|
|
@ -1312,7 +1342,10 @@ class TableAlterView(BaseView):
|
|||
and rename_table_to != current_table_name
|
||||
):
|
||||
operation_conn.execute(
|
||||
f"alter table {escape_sqlite(current_table_name)} rename to {escape_sqlite(rename_table_to)}"
|
||||
"alter table {} rename to {}".format(
|
||||
escape_sqlite(current_table_name),
|
||||
escape_sqlite(rename_table_to),
|
||||
)
|
||||
)
|
||||
current_table_name = rename_table_to
|
||||
|
||||
|
|
@ -1327,9 +1360,8 @@ class TableAlterView(BaseView):
|
|||
before_schema, after_schema, after_table_name = await db.execute_write_fn(
|
||||
alter_table, request=request
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||
return Response.error([str(e)], 400)
|
||||
except Exception as e:
|
||||
return _error([str(e)], 400)
|
||||
|
||||
altered = before_schema != after_schema
|
||||
if altered:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import itertools
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from datasette.column_types import SQLiteType
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.extras import Extra, ExtraExample, ExtraRegistry, ExtraScope, Provider
|
||||
from datasette.plugins import pm
|
||||
|
|
@ -102,7 +100,7 @@ class QueryExtraContext:
|
|||
class CountSqlExtra(Extra):
|
||||
description = "SQL query string used to calculate the total count for the current table view, including active filters."
|
||||
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.count_sql
|
||||
|
|
@ -111,7 +109,7 @@ class CountSqlExtra(Extra):
|
|||
class CountExtra(Extra):
|
||||
description = "Total count of rows matching these filters"
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=count")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
expensive = True
|
||||
|
||||
async def resolve(self, context):
|
||||
|
|
@ -129,7 +127,9 @@ class CountExtra(Extra):
|
|||
pass
|
||||
|
||||
if context.count_sql and count is None and not context.nocount:
|
||||
count_sql_limited = f"select count(*) from (select * {context.from_sql} limit {context.db.count_limit + 1})"
|
||||
count_sql_limited = "select count(*) from (select * {} limit {})".format(
|
||||
context.from_sql, context.db.count_limit + 1
|
||||
)
|
||||
try:
|
||||
count_rows = list(
|
||||
await context.db.execute(count_sql_limited, context.from_sql_params)
|
||||
|
|
@ -140,41 +140,8 @@ class CountExtra(Extra):
|
|||
return count
|
||||
|
||||
|
||||
def count_is_truncated(datasette, db, database_name, table_name, count_sql, count):
|
||||
if count != db.count_limit + 1:
|
||||
return False
|
||||
if (
|
||||
not db.is_mutable
|
||||
and datasette.inspect_data
|
||||
and count_sql == f"select count(*) from {table_name} "
|
||||
):
|
||||
try:
|
||||
datasette.inspect_data[database_name]["tables"][table_name]["count"]
|
||||
return False
|
||||
except KeyError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
class CountTruncatedExtra(Extra):
|
||||
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
expensive = True
|
||||
|
||||
async def resolve(self, context, count):
|
||||
return count_is_truncated(
|
||||
context.datasette,
|
||||
context.db,
|
||||
context.database_name,
|
||||
context.table_name,
|
||||
context.count_sql,
|
||||
count,
|
||||
)
|
||||
|
||||
|
||||
class FacetInstancesProvider(Provider):
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context, count):
|
||||
facet_instances = []
|
||||
|
|
@ -215,7 +182,7 @@ class FacetResultsExtra(Extra):
|
|||
},
|
||||
note="Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.",
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
expensive = True
|
||||
docs_note = "See :ref:`facets` for details of how facets work."
|
||||
|
||||
|
|
@ -258,7 +225,7 @@ class FacetsTimedOutExtra(Extra):
|
|||
"if every facet calculation completed."
|
||||
),
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context, facet_results):
|
||||
return facet_results["timed_out"]
|
||||
|
|
@ -275,7 +242,7 @@ class SuggestedFacetsExtra(Extra):
|
|||
],
|
||||
note="Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.",
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
expensive = True
|
||||
docs_note = (
|
||||
"Suggestions are controlled by the :ref:`setting_suggest_facets` setting."
|
||||
|
|
@ -303,7 +270,7 @@ class HumanDescriptionEnExtra(Extra):
|
|||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?state=CA&_sort=pk&_extra=human_description_en"
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
human_description_en = context.filters.human_description_en(
|
||||
|
|
@ -320,10 +287,25 @@ class HumanDescriptionEnExtra(Extra):
|
|||
return human_description_en
|
||||
|
||||
|
||||
class NextUrlExtra(Extra):
|
||||
description = "Full URL for the next page of results"
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_size=1&_extra=next_url",
|
||||
note=(
|
||||
"``null`` if there are no more pages of results. "
|
||||
"See :ref:`json_api_pagination`."
|
||||
),
|
||||
)
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.next_url
|
||||
|
||||
|
||||
class ColumnsExtra(Extra):
|
||||
description = "List of column names returned by this table, row or query."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=columns"
|
||||
),
|
||||
|
|
@ -331,11 +313,7 @@ class ColumnsExtra(Extra):
|
|||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=columns"
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.columns
|
||||
|
|
@ -344,7 +322,7 @@ class ColumnsExtra(Extra):
|
|||
class AllColumnsExtra(Extra):
|
||||
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
|
||||
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return list(context.table_columns)
|
||||
|
|
@ -353,69 +331,20 @@ class AllColumnsExtra(Extra):
|
|||
class PrimaryKeysExtra(Extra):
|
||||
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=primary_keys"
|
||||
)
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.pks
|
||||
|
||||
|
||||
def column_detail_as_json(column):
|
||||
return {
|
||||
"type": column.type,
|
||||
"sqlite_type": SQLiteType.from_declared_type(column.type).value,
|
||||
"notnull": bool(column.notnull),
|
||||
"default": column.default_value,
|
||||
"is_pk": bool(column.is_pk),
|
||||
"pk_position": column.is_pk,
|
||||
"hidden": column.hidden,
|
||||
}
|
||||
|
||||
|
||||
class ColumnDetailsExtra(Extra):
|
||||
description = (
|
||||
"SQLite schema details for columns in this table. The dictionary maps "
|
||||
"column names to objects describing the schema for each column."
|
||||
)
|
||||
docs_note = (
|
||||
"Each object has ``type`` as the declared type string returned by "
|
||||
'SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the '
|
||||
"normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, "
|
||||
"``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` "
|
||||
'as the raw SQL default expression string, such as ``"42"``, '
|
||||
"``\"'hello'\"`` or ``\"datetime('now')\"``, or ``null`` if there is "
|
||||
"no default; ``is_pk`` as a boolean; ``pk_position`` as the integer "
|
||||
"primary key position reported by SQLite, or ``0`` for columns that "
|
||||
"are not part of the primary key; and ``hidden`` as the integer value "
|
||||
"reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for "
|
||||
"normal columns, ``1`` for hidden virtual table columns, ``2`` for "
|
||||
"virtual generated columns and ``3`` for stored generated columns."
|
||||
)
|
||||
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/binary_data/1.json?_extra=column_details"
|
||||
)
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
|
||||
async def resolve(self, context):
|
||||
column_details = await context.datasette._get_resource_column_details(
|
||||
context.database_name, context.table_name
|
||||
)
|
||||
return {
|
||||
column_name: column_detail_as_json(column)
|
||||
for column_name, column in column_details.items()
|
||||
}
|
||||
|
||||
|
||||
class ActionsExtra(Extra):
|
||||
description = 'Async callable returning table or view 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`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Returns an async function for the HTML templates - not JSON serializable
|
||||
public = False
|
||||
|
||||
|
|
@ -487,7 +416,7 @@ async def precompute_database_action_permissions(datasette, actor, database_name
|
|||
class IsViewExtra(Extra):
|
||||
description = "Whether this resource is a view instead of a table"
|
||||
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.is_view
|
||||
|
|
@ -500,7 +429,7 @@ class DebugExtra(Extra):
|
|||
"API and may change without warning."
|
||||
)
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=debug")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=debug"
|
||||
),
|
||||
|
|
@ -508,11 +437,7 @@ class DebugExtra(Extra):
|
|||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=debug"
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
debug = {
|
||||
|
|
@ -536,7 +461,7 @@ class DebugExtra(Extra):
|
|||
class RequestExtra(Extra):
|
||||
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=request")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=request"
|
||||
),
|
||||
|
|
@ -544,11 +469,7 @@ class RequestExtra(Extra):
|
|||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=request"
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
return {
|
||||
|
|
@ -561,7 +482,7 @@ class RequestExtra(Extra):
|
|||
|
||||
|
||||
class DisplayColumnsAndRowsProvider(Provider):
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
display_columns, display_rows = await context.display_columns_and_rows(
|
||||
|
|
@ -605,7 +526,7 @@ class DisplayColumnsExtra(Extra):
|
|||
],
|
||||
note="Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.",
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context, display_columns_and_rows):
|
||||
return display_columns_and_rows["columns"]
|
||||
|
|
@ -613,7 +534,7 @@ class DisplayColumnsExtra(Extra):
|
|||
|
||||
class DisplayRowsExtra(Extra):
|
||||
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Contains markupsafe/sqlite3.Row values - not JSON serializable
|
||||
public = False
|
||||
|
||||
|
|
@ -644,7 +565,7 @@ class RenderCellExtra(Extra):
|
|||
"whose rendered value differs from the default are included."
|
||||
),
|
||||
)
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
value={
|
||||
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
|
||||
|
|
@ -669,11 +590,7 @@ class RenderCellExtra(Extra):
|
|||
),
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
table_name = context.table_name
|
||||
|
|
@ -727,7 +644,7 @@ class RenderCellExtra(Extra):
|
|||
class QueryExtra(Extra):
|
||||
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
|
||||
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=query"
|
||||
),
|
||||
|
|
@ -736,11 +653,7 @@ class QueryExtra(Extra):
|
|||
ExtraExample("/fixtures/neighborhood_search.json?text=town&_extra=query"),
|
||||
],
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
return {
|
||||
|
|
@ -764,7 +677,7 @@ class ColumnTypesExtra(Extra):
|
|||
"been assigned the ``json`` column type."
|
||||
),
|
||||
)
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/facetable/1.json?_extra=column_types",
|
||||
note=(
|
||||
|
|
@ -773,7 +686,7 @@ class ColumnTypesExtra(Extra):
|
|||
),
|
||||
)
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
|
||||
async def resolve(self, context):
|
||||
ct_map = await context.datasette.get_column_types(
|
||||
|
|
@ -823,7 +736,7 @@ class SetColumnTypeUiExtra(Extra):
|
|||
"types that could be assigned to it."
|
||||
),
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
if context.is_view:
|
||||
|
|
@ -865,7 +778,9 @@ class SetColumnTypeUiExtra(Extra):
|
|||
],
|
||||
}
|
||||
return {
|
||||
"path": f"{context.datasette.urls.table(context.database_name, context.table_name)}/-/set-column-type",
|
||||
"path": "{}/-/set-column-type".format(
|
||||
context.datasette.urls.table(context.database_name, context.table_name)
|
||||
),
|
||||
"columns": columns,
|
||||
}
|
||||
|
||||
|
|
@ -883,7 +798,7 @@ class MetadataExtra(Extra):
|
|||
"descriptions."
|
||||
),
|
||||
)
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=metadata",
|
||||
note=(
|
||||
|
|
@ -901,11 +816,7 @@ class MetadataExtra(Extra):
|
|||
),
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
if context.scope == ExtraScope.QUERY:
|
||||
|
|
@ -934,7 +845,7 @@ class MetadataExtra(Extra):
|
|||
class DatabaseExtra(Extra):
|
||||
description = "Database name"
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=database")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=database"
|
||||
),
|
||||
|
|
@ -942,11 +853,7 @@ class DatabaseExtra(Extra):
|
|||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database"
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.database_name
|
||||
|
|
@ -955,10 +862,10 @@ class DatabaseExtra(Extra):
|
|||
class TableExtra(Extra):
|
||||
description = "Table name"
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=table")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table")
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.table_name
|
||||
|
|
@ -971,7 +878,7 @@ class DatabaseColorExtra(Extra):
|
|||
"a hash of the database name and used in the Datasette interface."
|
||||
)
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=database_color"
|
||||
),
|
||||
|
|
@ -979,11 +886,7 @@ class DatabaseColorExtra(Extra):
|
|||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database_color"
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.db.color
|
||||
|
|
@ -994,7 +897,7 @@ class FormHiddenArgsExtra(Extra):
|
|||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
form_hidden_args = []
|
||||
|
|
@ -1011,7 +914,7 @@ class FormHiddenArgsExtra(Extra):
|
|||
|
||||
class FiltersExtra(Extra):
|
||||
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Returns a Filters instance for the HTML templates - not JSON serializable
|
||||
public = False
|
||||
|
||||
|
|
@ -1027,7 +930,7 @@ class CustomTableTemplatesExtra(Extra):
|
|||
":ref:`customization_custom_templates`."
|
||||
)
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return [
|
||||
|
|
@ -1048,7 +951,7 @@ class SortedFacetResultsExtra(Extra):
|
|||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results"
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context, facet_results):
|
||||
facet_configs = context.table_metadata.get("facets", [])
|
||||
|
|
@ -1058,7 +961,7 @@ class SortedFacetResultsExtra(Extra):
|
|||
if isinstance(fc, str):
|
||||
metadata_facet_names.append(fc)
|
||||
elif isinstance(fc, dict):
|
||||
metadata_facet_names.append(next(iter(fc.values())))
|
||||
metadata_facet_names.append(list(fc.values())[0])
|
||||
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
|
||||
metadata_facets = []
|
||||
request_facets = []
|
||||
|
|
@ -1084,7 +987,7 @@ class SortedFacetResultsExtra(Extra):
|
|||
class TableDefinitionExtra(Extra):
|
||||
description = "SQL definition for this table"
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=table_definition")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return await context.db.get_table_definition(context.table_name)
|
||||
|
|
@ -1093,7 +996,7 @@ class TableDefinitionExtra(Extra):
|
|||
class ViewDefinitionExtra(Extra):
|
||||
description = "SQL definition for this view"
|
||||
example = ExtraExample("/fixtures/simple_view.json?_extra=view_definition")
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
return await context.db.get_view_definition(context.table_name)
|
||||
|
|
@ -1110,7 +1013,7 @@ class RenderersExtra(Extra):
|
|||
"<plugin_register_output_renderer>`."
|
||||
),
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context, expandable_columns, query):
|
||||
renderers = {}
|
||||
|
|
@ -1152,7 +1055,7 @@ class PrivateExtra(Extra):
|
|||
"anonymous user could not. See :ref:`authentication_permissions`."
|
||||
)
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=private")
|
||||
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = {
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=private"
|
||||
),
|
||||
|
|
@ -1160,11 +1063,7 @@ class PrivateExtra(Extra):
|
|||
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private"
|
||||
),
|
||||
}
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
return context.private
|
||||
|
|
@ -1181,7 +1080,7 @@ class ExpandableColumnsExtra(Extra):
|
|||
"that would be used as the label for each expanded value."
|
||||
),
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE}
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
async def resolve(self, context):
|
||||
expandables = []
|
||||
|
|
@ -1201,7 +1100,7 @@ class ForeignKeyTablesExtra(Extra):
|
|||
"reference this row, and ``link`` is a URL to browse those rows."
|
||||
),
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.ROW}
|
||||
scopes = {ExtraScope.ROW}
|
||||
expensive = True
|
||||
|
||||
async def resolve(self, context):
|
||||
|
|
@ -1235,11 +1134,7 @@ class ExtrasExtra(Extra):
|
|||
"the current request."
|
||||
),
|
||||
)
|
||||
scopes: ClassVar[set[ExtraScope]] = {
|
||||
ExtraScope.TABLE,
|
||||
ExtraScope.ROW,
|
||||
ExtraScope.QUERY,
|
||||
}
|
||||
scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
|
||||
|
||||
async def resolve(self, context):
|
||||
all_extras = [
|
||||
|
|
@ -1272,6 +1167,7 @@ TABLE_EXTRA_BUNDLES = {
|
|||
"count",
|
||||
"count_sql",
|
||||
"human_description_en",
|
||||
"next_url",
|
||||
"metadata",
|
||||
"query",
|
||||
"columns",
|
||||
|
|
@ -1300,17 +1196,16 @@ TABLE_EXTRA_BUNDLES = {
|
|||
|
||||
TABLE_EXTRA_CLASSES = [
|
||||
CountExtra,
|
||||
CountTruncatedExtra,
|
||||
CountSqlExtra,
|
||||
FacetResultsExtra,
|
||||
FacetsTimedOutExtra,
|
||||
SuggestedFacetsExtra,
|
||||
FacetInstancesProvider,
|
||||
HumanDescriptionEnExtra,
|
||||
NextUrlExtra,
|
||||
ColumnsExtra,
|
||||
AllColumnsExtra,
|
||||
PrimaryKeysExtra,
|
||||
ColumnDetailsExtra,
|
||||
DisplayColumnsAndRowsProvider,
|
||||
DisplayColumnsExtra,
|
||||
DisplayRowsExtra,
|
||||
|
|
|
|||
|
|
@ -138,33 +138,6 @@ def decision_for_write_sql_operation(
|
|||
),
|
||||
)
|
||||
)
|
||||
if operation.operation == "create" and operation.target_type == "view":
|
||||
if operation.database is None:
|
||||
return UnsupportedWriteSqlOperation(unsupported_message)
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="create-view",
|
||||
resource=DatabaseResource(database=operation.database),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation == "drop"
|
||||
and operation.target_type == "view"
|
||||
and operation.database is not None
|
||||
and operation.table is not None
|
||||
):
|
||||
return RequireWriteSqlPermissions(
|
||||
(
|
||||
PermissionRequirement(
|
||||
action="drop-view",
|
||||
resource=TableResource(
|
||||
database=operation.database, table=operation.table
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
operation.operation == "alter"
|
||||
and operation.target_type == "table"
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ help:
|
|||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
livehtml:
|
||||
sphinx-autobuild -b html --watch ../datasette "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)
|
||||
sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)
|
||||
|
|
|
|||
|
|
@ -45,12 +45,12 @@ Using the "root" actor
|
|||
|
||||
Datasette currently leaves almost all forms of authentication to plugins - `datasette-auth-github <https://github.com/simonw/datasette-auth-github>`__ for example.
|
||||
|
||||
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user starts with **all permissions**: Datasette contributes a global allow rule for every action. More specific deny rules can still override that global rule.
|
||||
The one exception is the "root" account, which you can sign into while using Datasette on your local machine. The root user has **all permissions** - they can perform any action regardless of other permission rules.
|
||||
|
||||
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
|
||||
|
||||
* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
|
||||
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``)
|
||||
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``)
|
||||
* Debug permissions (``permissions-debug``, ``debug-menu``)
|
||||
* Any custom permissions defined by plugins
|
||||
|
||||
|
|
@ -84,12 +84,12 @@ Click on that link and then visit ``http://127.0.0.1:8001/-/actor`` to confirm t
|
|||
Permissions
|
||||
===========
|
||||
|
||||
Datasette's permissions system is built around SQL queries. Datasette and its plugins construct SQL queries to resolve the list of resources that an actor cas access.
|
||||
|
||||
The key question the permissions system answers is this:
|
||||
|
||||
Is this **actor** allowed to perform this **action**, optionally against this particular **resource**?
|
||||
|
||||
Every permission decision can be understood in terms of those three values. Datasette implements the decisions using SQL, but you do not need to understand the generated SQL to configure or debug permissions.
|
||||
|
||||
**Actors** are :ref:`described above <authentication_actor>`.
|
||||
|
||||
An **action** is a string describing the action the actor would like to perform. A full list is :ref:`provided below <actions>` - examples include ``view-table`` and ``execute-sql``.
|
||||
|
|
@ -138,51 +138,7 @@ This configuration will deny access to everyone except the user with ``id`` of `
|
|||
How permissions are resolved
|
||||
----------------------------
|
||||
|
||||
Permission rules describe an effect (``allow`` or ``deny``) at one of three levels:
|
||||
|
||||
``resource``
|
||||
A specific child resource, such as the ``analytics/sales`` table.
|
||||
|
||||
``parent``
|
||||
A parent resource, such as the ``analytics`` database. A parent rule also applies to its child resources.
|
||||
|
||||
``global``
|
||||
Every resource for that action.
|
||||
|
||||
Datasette resolves matching rules from most specific to least specific:
|
||||
|
||||
#. Resource rules take precedence over parent and global rules.
|
||||
#. Parent rules take precedence over global rules.
|
||||
#. If both allow and deny rules match at the same level, deny takes precedence.
|
||||
#. If no rule matches, access is denied.
|
||||
|
||||
This means a resource-level allow can provide an exception to a parent-level deny. It also means that two plugins which disagree at the same level resolve to deny.
|
||||
|
||||
.. list-table:: Permission rule examples
|
||||
:header-rows: 1
|
||||
|
||||
* - Matching rules
|
||||
- Result
|
||||
- Explanation
|
||||
* - Global allow
|
||||
- Allow
|
||||
- The global rule is the most specific matching rule.
|
||||
* - Global allow, parent deny
|
||||
- Deny
|
||||
- The parent rule is more specific.
|
||||
* - Parent deny, resource allow
|
||||
- Allow
|
||||
- The resource rule is more specific.
|
||||
* - Resource allow and resource deny
|
||||
- Deny
|
||||
- Deny takes precedence at the same level.
|
||||
* - No matching rules
|
||||
- Deny
|
||||
- Permissions default to deny when no rule applies.
|
||||
|
||||
The built-in public defaults are global allow rules for actions such as ``view-instance``, ``view-database`` and ``view-table``. They follow the same precedence rules as configuration and plugin rules. The ``--default-deny`` option prevents Datasette from contributing those default allow rules.
|
||||
|
||||
Datasette performs checks using :ref:`datasette_allowed`, which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``.
|
||||
|
||||
``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified.
|
||||
|
||||
|
|
@ -193,12 +149,12 @@ resources were allowed or denied. The combined sources are:
|
|||
|
||||
* ``allow`` blocks configured in :ref:`datasette.yaml <authentication_permissions_config>`.
|
||||
* :ref:`Actor restrictions <authentication_cli_create_token_restrict>` encoded into the actor dictionary or API token.
|
||||
* The "root" user rule when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active. This is a global allow rule, so a more specific configuration deny can override it.
|
||||
* The "root" user shortcut when ``--root`` (or :attr:`Datasette.root_enabled <datasette.app.Datasette.root_enabled>`) is active, replying ``True`` to all permission chucks unless configuration rules deny them at a more specific level.
|
||||
* Any additional SQL provided by plugins implementing :ref:`plugin_hook_permission_resources_sql`.
|
||||
|
||||
Actor restrictions are applied after the allow/deny rules. They act as an additional allowlist: a restriction can remove access but cannot grant access that the actor did not already have. See :ref:`authentication_cli_create_token_restrict`.
|
||||
|
||||
Some actions have dependencies on other actions. These are evaluated as an ``AND`` condition. For example, ``execute-sql`` also requires ``view-database``: both decisions must be allowed for the final result to be allowed.
|
||||
Datasette evaluates the SQL to determine if the requested ``resource`` is
|
||||
included. Explicit deny rules returned by configuration or plugins will block
|
||||
access even if other rules allowed it.
|
||||
|
||||
.. _authentication_permissions_allow:
|
||||
|
||||
|
|
@ -1035,9 +991,7 @@ The ``/-/create-token`` page cannot be accessed by actors that are authenticated
|
|||
|
||||
Datasette plugins that implement their own form of API token authentication should follow this convention.
|
||||
|
||||
If a request presents a token that a token handler recognizes but rejects - an invalid signature, a malformed payload or an expired token - Datasette responds with a ``401`` status, the :ref:`standard JSON error format <json_api_errors>` and a ``WWW-Authenticate: Bearer error="invalid_token"`` header. This means API clients can distinguish "your token needs to be renewed" (``401``) from "your token does not grant this permission" (``403``). A ``Bearer`` token that no registered handler recognizes at all is ignored, since it may be intended for an authentication plugin.
|
||||
|
||||
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting. Requests presenting a ``dstok_`` token while the feature is disabled receive a ``401``.
|
||||
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting.
|
||||
|
||||
.. _authentication_cli_create_token:
|
||||
|
||||
|
|
@ -1189,23 +1143,11 @@ The debug tool at ``/-/permissions`` is available to any actor with the ``permis
|
|||
|
||||
datasette -s permissions.permissions-debug true data.db
|
||||
|
||||
The permission debug tools answer four different questions:
|
||||
The page shows the permission checks that have been carried out by the Datasette instance.
|
||||
|
||||
Why was this decision allowed or denied?
|
||||
Use :ref:`PermissionCheckView`. It shows every matching rule, identifies the winning specificity level, applies actor restrictions and evaluates any required actions.
|
||||
It also provides an interface for running hypothetical permission checks against a hypothetical actor. This is a useful way of confirming that your configured permissions work in the way you expect.
|
||||
|
||||
Which resources can the current actor access?
|
||||
Use :ref:`AllowedResourcesView` to view an access map for a selected action.
|
||||
|
||||
Which raw rules did Datasette and its plugins contribute?
|
||||
Use :ref:`PermissionRulesView` to inspect the rules before they are resolved into decisions.
|
||||
|
||||
Which checks has this Datasette instance performed recently?
|
||||
Use ``/-/permissions`` to view recent permission activity.
|
||||
|
||||
These tools are designed to help administrators and plugin authors understand and confirm the effective permissions configuration.
|
||||
|
||||
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
|
||||
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
|
||||
|
||||
.. _AllowedResourcesView:
|
||||
|
||||
|
|
@ -1216,7 +1158,7 @@ The ``/-/allowed`` endpoint displays resources that the current actor can access
|
|||
|
||||
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead.
|
||||
|
||||
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair. Results are paginated: ``?_size=`` sets the page size (default 50, maximum 200, ``max`` for the maximum) and ``?_page=`` selects a page.
|
||||
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair.
|
||||
|
||||
This endpoint is publicly accessible to help users understand their own permissions. The potentially sensitive ``reason`` field is only shown to users with the ``permissions-debug`` permission - it shows the plugins and explanatory reasons that were responsible for each decision.
|
||||
|
||||
|
|
@ -1229,7 +1171,7 @@ The ``/-/rules`` endpoint displays all permission rules (both allow and deny) fo
|
|||
|
||||
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead.
|
||||
|
||||
Pass ``?action=`` as a query parameter to specify which action to check. The ``?_size=`` and ``?_page=`` pagination parameters work the same as on ``/-/allowed``.
|
||||
Pass ``?action=`` as a query parameter to specify which action to check.
|
||||
|
||||
This endpoint requires the ``permissions-debug`` permission.
|
||||
|
||||
|
|
@ -1238,20 +1180,11 @@ This endpoint requires the ``permissions-debug`` permission.
|
|||
Permission check view
|
||||
---------------------
|
||||
|
||||
The ``/-/check`` endpoint evaluates and explains a single actor, action and resource decision. The explanation includes:
|
||||
|
||||
* Every matching allow and deny rule, with its source and reason.
|
||||
* The winning resource, parent or global scope.
|
||||
* Rules ignored because a more specific rule matched, or because a deny won at the same scope.
|
||||
* Actor restriction allowlists that included or excluded the resource.
|
||||
* Additional actions required by the requested action.
|
||||
* An explicit default-deny explanation when no rule matched.
|
||||
The ``/-/check`` endpoint evaluates a single action/resource pair and returns information indicating whether the access was allowed along with diagnostic information.
|
||||
|
||||
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/check.json?action=view-instance``) to get the raw JSON response instead.
|
||||
|
||||
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource. The interactive form also accepts actor JSON, allowing a hypothetical actor to be tested without signing in as that actor. The JSON endpoint accepts the same value using the ``actor`` query string parameter. Use ``actor=null`` to represent an anonymous actor.
|
||||
|
||||
This endpoint requires the ``permissions-debug`` permission. The hypothetical actor is used only for the decision being explained; access to the debug tool is checked against the actor who is actually signed in.
|
||||
Pass ``?action=`` to specify the action to check, and optional ``?parent=`` and ``?child=`` parameters to specify the resource.
|
||||
|
||||
.. _authentication_ds_actor:
|
||||
|
||||
|
|
@ -1453,16 +1386,6 @@ create-table
|
|||
|
||||
Actor is allowed to create a database table.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
.. _actions_create_view:
|
||||
|
||||
create-view
|
||||
-----------
|
||||
|
||||
Actor is allowed to create a database view.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
|
|
@ -1502,18 +1425,6 @@ Actor is allowed to drop a database table.
|
|||
|
||||
``table`` is the name of the table (string)
|
||||
|
||||
.. _actions_drop_view:
|
||||
|
||||
drop-view
|
||||
---------
|
||||
|
||||
Actor is allowed to drop a database view.
|
||||
|
||||
``resource`` - ``datasette.resources.TableResource(database, table)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``table`` is the name of the view (string)
|
||||
|
||||
.. _actions_execute_sql:
|
||||
|
||||
execute-sql
|
||||
|
|
|
|||
|
|
@ -12,12 +12,7 @@ Datasette includes special handling for these binary values. The Datasette inter
|
|||
:width: 311px
|
||||
:alt: Screenshot showing download links next to binary data in the table view
|
||||
|
||||
.. _binary_json_format:
|
||||
|
||||
Binary values in JSON
|
||||
---------------------
|
||||
|
||||
Binary data is represented in ``.json`` exports using Base64 encoding. Datasette uses this representation for every ``BLOB`` value, including binary values that could also be decoded as UTF-8 text.
|
||||
Binary data is represented in ``.json`` exports using Base64 encoding.
|
||||
|
||||
https://latest.datasette.io/fixtures/binary_data.json?_shape=array
|
||||
|
||||
|
|
@ -44,48 +39,6 @@ https://latest.datasette.io/fixtures/binary_data.json?_shape=array
|
|||
}
|
||||
]
|
||||
|
||||
The same format can be used with the :ref:`JSON write API <json_api_write>`.
|
||||
If a column value in a ``row``, ``rows`` or ``update`` object is a JSON object with exactly ``"$base64"`` set to ``true`` and an ``"encoded"`` string, Datasette will decode that Base64 string and store the resulting bytes:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"$base64": true,
|
||||
"encoded": "FRwCx60F/g=="
|
||||
}
|
||||
}
|
||||
|
||||
This works for inserts, upserts and updates. It also works when creating a table from example ``row`` or ``rows`` data: Datasette decodes the value before inferring the schema, allowing that column to be created as a ``BLOB`` column.
|
||||
|
||||
To store a JSON object with that exact shape literally, wrap it in a ``"$raw"`` object:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"$raw": {
|
||||
"$base64": true,
|
||||
"encoded": "FRwCx60F/g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
``"$raw"`` unwraps exactly one layer. To store a literal ``"$raw"`` object containing a Base64 object, wrap it again:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"$raw": {
|
||||
"$raw": {
|
||||
"$base64": true,
|
||||
"encoded": "FRwCx60F/g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.. _binary_linking:
|
||||
|
||||
Linking to binary downloads
|
||||
|
|
|
|||
|
|
@ -4,88 +4,6 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
.. _v1_0_a38:
|
||||
|
||||
1.0a38 (2026-08-06)
|
||||
-------------------
|
||||
|
||||
This release fixes a **SQL injection** security issue that affects Datasette instances that serve a **mixture of public and private tables** in the same database, with access configured using the :ref:`Datasette permissions system <authentication>`.
|
||||
|
||||
Site administrators who serve private tables in this way are advised to disable the :ref:`execute-sql permission <actions_execute_sql>` on that database to prevent users from accessing private tables using raw SQL queries. The bug that has been fixed would have allowed users with access to any public table to execute SQL injection attacks despite that restriction, giving them read-only access to data in private tables in the same database.
|
||||
|
||||
This fix is also available in Datasette 0.65.3.
|
||||
|
||||
.. _v1_0_a37:
|
||||
|
||||
1.0a37 (2026-07-14)
|
||||
-------------------
|
||||
|
||||
Performance improvement for SQL-backed permission checks, plus an improved permission debugging interface.
|
||||
|
||||
- SQL used to resolve permission checks now aggregates permission rules before joining them to resources, improving performance on instances with large schemas. (:issue:`2832`)
|
||||
- The :ref:`PermissionCheckView` permission debugger now explains why a decision was allowed or denied, including the matching rules. The interactive form can also test a hypothetical actor supplied as JSON, and the :ref:`permissions documentation <authentication_permissions_explained>` now describes resolution rules in more detail. (:issue:`2841`)
|
||||
- :ref:`db.execute_write(sql, ..., transaction=True) <database_execute_write>` has a new ``transaction=`` parameter, which can be set to ``False`` for statements such as ``VACUUM`` that cannot run inside a transaction. Write tasks now start their transactions using ``BEGIN IMMEDIATE``, which also ensures that writes are rolled back if the task fails. (:issue:`2831`)
|
||||
- Refreshing a database's schema in Datasette's internal catalog is now performed as a single atomic operation. (:issue:`2831`)
|
||||
- Fixed schema introspection, table pages, facets and table counts for tables with names containing a ``]`` character. Thanks, `TowyTowy <https://github.com/TowyTowy>`__. (:issue:`2431`, :pr:`2846`)
|
||||
- ``/-/plugins.json`` once again returns a top-level JSON array of plugin objects, reverting the object envelope introduced in 1.0a36. This should fix a large number of trivial test failures in existing plugins. (:issue:`2842`, :pr:`2843`)
|
||||
|
||||
.. _v1_0_a36:
|
||||
|
||||
1.0a36 (2026-07-07)
|
||||
-------------------
|
||||
|
||||
The signature features of this alpha are new UIs for **inserting multiple rows at once** (from TSV, CSV or JSON) and for **creating a table from rows**, plus a large number of small **JSON API consistency fixes** in preparation for a 1.0 stable release.
|
||||
|
||||
- Table pages now offer an "Insert multiple rows" mode in the row insertion dialog. This accepts pasted TSV, CSV or JSON, previews the parsed rows before inserting them, validates unknown columns as data is pasted and displays omitted auto integer primary keys as ``auto`` in the preview. (:pr:`2813`)
|
||||
- The bulk insert UI can skip rows with existing primary keys, or update existing rows and insert new rows using the existing ``/<database>/<table>/-/upsert`` API when the actor has both :ref:`insert-row <actions_insert_row>` and :ref:`update-row <actions_update_row>` permissions. (:pr:`2813`)
|
||||
- The "Create table" dialog now includes a "Create table from data" mode. Paste TSV, CSV or JSON rows to preview inferred columns and types, choose the table name and primary key, then create the table and insert those rows in one step. (:pr:`2813`)
|
||||
- Datasette's JSON APIs now consistently encode every ``BLOB`` value using the documented :ref:`binary value JSON format <binary_json_format>`, even when the bytes could be decoded as UTF-8 text. (:issue:`2806`, :pr:`2822`)
|
||||
- The insert and edit row dialogs now provide a dedicated control for ``BLOB`` values. Existing binary values are shown by byte size, image values under 10MB are previewed as thumbnails, and replacements can be attached, dropped or pasted into the control. (:issue:`2806`, :pr:`2822`)
|
||||
- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata.
|
||||
- POST bodies that Datasette reads fully into memory - such as JSON submitted to the write API - are now capped by the new :ref:`setting_max_post_body_bytes` setting, defaulting to 2MB. Oversized requests are rejected with an HTTP 413 error as soon as the limit is exceeded, protecting smaller servers from memory exhaustion. File uploads are unaffected - ``request.form()`` streams those to disk and has its own separate limits. (:issue:`2823`)
|
||||
- Row pages for tables with compound primary keys now return a ``400`` error instead of a ``500`` error when the URL row identifier does not contain the correct number of primary key values. Thanks, `Zain Dana Harper <https://github.com/HarperZ9>`__. (:issue:`2811`, :pr:`2815`)
|
||||
- The :ref:`execute-write-sql <actions_execute_write_sql>` interface now supports ``CREATE VIEW`` and ``DROP VIEW`` statements, gated by the new :ref:`create-view <actions_create_view>` and :ref:`drop-view <actions_drop_view>` permissions. (:issue:`2819`, :pr:`2818`)
|
||||
- Saved-query SQL analysis now handles recursive CTEs, fixing a bug where storing a valid read-only recursive query could be disabled by SQLite's internal ``SQLITE_RECURSIVE`` authorizer callback. (:issue:`2809`, :pr:`2812`)
|
||||
- ``named_parameters()`` now correctly ignores SQLite comment markers that appear inside string literals, so query forms no longer drop later ``:named`` parameters from SQL such as ``select '--' || :name``. Thanks, `JSap0914 <https://github.com/JSap0914>`__. (:pr:`2783`)
|
||||
- Datasette's internal database schema is now managed using `sqlite-utils migrations <https://sqlite-utils.datasette.io/en/stable/python-api.html#migrations>`__, using the new dependency on ``sqlite-utils>=4.0``. (:issue:`2827`)
|
||||
- ``datasette.utils.CustomJSONEncoder`` is now documented as a public API for plugins that need to serialize Datasette values to JSON. Thanks, `Chris Amico <https://github.com/eyeseast>`__. (:issue:`1983`, :pr:`1996`)
|
||||
|
||||
This release also includes the results of a `detailed consistency review <https://github.com/simonw/datasette/pull/2824>`__ of Datasette's JSON API in preparation for the 1.0 stable release. Several of these changes are backwards-incompatible with previous 1.0 alphas. The new :ref:`API stability documentation <json_api_stability>` describes exactly which parts of the JSON API are covered by the 1.0 stability promise.
|
||||
|
||||
JSON API: breaking changes
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- JSON error responses now use a single canonical format across every endpoint: ``{"ok": false, "error": "...", "errors": [...], "status": 400}``. The ``error`` key joins all error messages together, ``errors`` is the full list of messages and ``status`` always matches the HTTP status code. The legacy ``title`` key is no longer included in JSON errors (it remains available to the HTML error template), and endpoints that previously returned bare ``{"error": ...}`` objects have been updated. See :ref:`json_api_errors`.
|
||||
- Every JSON object success response now includes ``"ok": true``, including introspection endpoints such as ``/-/versions`` and ``/-/settings``.
|
||||
- ``/-/plugins.json``, ``/-/databases.json`` and ``/-/actions.json`` now return objects - ``{"ok": true, "plugins": [...]}`` and equivalents - instead of top-level JSON arrays, so these responses can gain additional keys in the future without a breaking change. The ``datasette plugins`` CLI command still outputs a plain array.
|
||||
- ``/-/databases`` now only lists databases the current actor is allowed to view. It previously listed every attached database, including their filesystem paths, to any actor with ``view-instance``.
|
||||
- Requests with an invalid or expired ``Authorization: Bearer`` token now receive a ``401`` status with the standard error body and a ``WWW-Authenticate: Bearer error="invalid_token"`` header, instead of being silently treated as unauthenticated. Bearer tokens that no registered token handler recognizes are still ignored, so authentication plugins with their own token formats keep working. Plugin :ref:`token handlers <plugin_hook_register_token_handler>` can raise the new ``datasette.TokenInvalid`` exception to trigger the same behavior.
|
||||
- Permission errors for JSON requests now return the standard JSON error format with a ``403`` status. The default forbidden handling previously rendered an HTML error page even for ``.json`` requests.
|
||||
- ``POST`` to a write canned query now returns a ``400`` error when the SQL fails to execute, instead of a ``200`` status with ``"ok": false`` in the body. The error response includes the standard error keys plus a ``"redirect"`` key.
|
||||
- The :ref:`row update API <RowUpdateView>` with ``"return": true`` now responds with a ``"rows"`` list, matching insert and upsert, instead of a singular ``"row"`` object.
|
||||
- Row delete write failures - such as a constraint violation raised by a trigger - now return ``400`` instead of ``500``, matching the other write endpoints.
|
||||
- ``/<database>/-/query.json`` with a missing or blank ``?sql=`` parameter now returns a ``400`` error, as the CSV format already did, instead of a ``200`` with empty rows.
|
||||
- Unknown ``?_extra=`` names now return a ``400`` error for JSON and other data formats, instead of being silently ignored. HTML pages continue to ignore unknown names.
|
||||
- Table JSON responses now include ``next_url`` alongside ``next`` by default - both are ``null`` on the final page. The now-redundant ``?_extra=next_url`` parameter has been removed.
|
||||
- The stored query list JSON no longer includes ``has_more`` - ``"next": null`` is the end-of-results signal across the whole API. This change also uncovered and fixed a bug where the query list ``next_url`` pointed at the HTML page and was a relative path; it is now an absolute URL that preserves the requested format.
|
||||
- Stored query JSON objects no longer duplicate the list of parameter names as both ``params`` and ``parameters`` - only ``parameters`` remains. The query create and update APIs no longer accept ``params`` as an input alias either; ``params`` is still the documented key for :ref:`queries defined in configuration <queries_named_parameters>`.
|
||||
- Page size parameters are now consistent across the API: the stored query lists accept ``?_size=max`` and return a ``400`` error for values over the maximum instead of silently clamping them, and the ``/-/allowed`` and ``/-/rules`` permission debug endpoints renamed their ``page`` and ``page_size`` parameters to ``_page`` and ``_size``, matching the underscore grammar used by every other Datasette system parameter.
|
||||
- ``/-/threads`` now requires the ``permissions-debug`` permission, since it exposes runtime internals such as file paths. It previously only required ``view-instance``.
|
||||
- Trusted stored queries - those defined in configuration - can no longer be deleted through the JSON API or web interface, matching the existing restriction on editing them.
|
||||
- The ``/<database>/-/schema`` endpoints now check the ``view-database`` permission before checking whether the database exists, so unauthorized actors can no longer probe for the existence of databases.
|
||||
- SQL time limit errors in JSON responses are now a plain text message. The error string previously embedded an HTML fragment.
|
||||
- The undocumented homepage JSON at ``/.json`` now returns ``databases`` as a list of objects rather than an object keyed by database name, matching every other collection in the API.
|
||||
- The legacy ``.jsono`` format extension, long since superseded by ``?_shape=``, has been removed.
|
||||
|
||||
JSON API: other improvements
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- The :ref:`write API <json_api_write>` endpoints now parse the request body as JSON regardless of the ``Content-Type`` header, so ``curl -d`` invocations work without remembering to set it. Invalid JSON is a ``400`` error. Cross-site request forgery remains prevented by Datasette's ``Origin`` and ``Sec-Fetch-Site`` checks. This also fixes a ``500`` error from the insert API when the ``Content-Type`` header was missing entirely.
|
||||
- New ``Response.error(messages, status=400)`` helper for plugins that need to return a JSON error in Datasette's standard format. See :ref:`internals_response`.
|
||||
- New ``count_truncated`` extra for table JSON, included automatically whenever ``count`` is requested. ``true`` means the count reached Datasette's counting limit and the real number of rows may be higher. See :ref:`json_api_extra`.
|
||||
- JSON endpoints that are not part of the documented stable API now declare themselves with an ``"unstable"`` key in their responses.
|
||||
- New documentation covering the grammar for :ref:`boolean query string arguments <json_api_table_arguments>`, the reason :ref:`upsert <TableUpsertView>` returns ``200`` where insert returns ``201``, and advice for plugin authors on :ref:`naming secret configuration keys <plugins_configuration_secret>` so that ``/-/config`` redacts them automatically.
|
||||
|
||||
.. _v1_0_a35:
|
||||
|
||||
1.0a35 (2026-06-23)
|
||||
|
|
|
|||
|
|
@ -244,9 +244,6 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam
|
|||
custom query (default=1000)
|
||||
max_insert_rows Maximum rows that can be inserted at a time using
|
||||
the bulk insert API (default=100)
|
||||
max_post_body_bytes Maximum size in bytes for a POST body read into
|
||||
memory, e.g. JSON API requests - set 0 to disable
|
||||
this limit (default=2097152)
|
||||
num_sql_threads Number of threads in the thread pool for
|
||||
executing SQLite queries (default=3)
|
||||
sql_time_limit_ms Time limit for a SQL query in milliseconds
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Datasette documentation build configuration file, created by
|
||||
# sphinx-quickstart on Thu Nov 16 06:50:13 2017.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ If you want to start making contributions to the Datasette project by installing
|
|||
Basic installation
|
||||
==================
|
||||
|
||||
.. _installation_datasette_desktop:
|
||||
|
||||
Datasette Desktop for Mac
|
||||
-------------------------
|
||||
|
||||
`Datasette Desktop <https://datasette.io/desktop>`__ is a packaged Mac application which bundles Datasette together with Python and allows you to install and run Datasette directly on your laptop. This is the best option for local installation if you are not comfortable using the command line.
|
||||
|
||||
.. _installation_homebrew:
|
||||
|
||||
Using Homebrew
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue