mirror of
https://github.com/simonw/datasette.git
synced 2026-07-26 17:34:35 +02:00
Compare commits
179 commits
insert-dia
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e889403d3b |
||
|
|
481df7ff6d | ||
|
|
2ffd8a860e | ||
|
|
8b7c942d5e | ||
|
|
591b909a4d |
||
|
|
9cfc252394 |
||
|
|
7f0a8b38ae |
||
|
|
10088dfa1d |
||
|
|
ccace40e5a |
||
|
|
db82123108 | ||
|
|
52ae7d1b6d | ||
|
|
a31673c90b | ||
|
|
54597f22fa | ||
|
|
bf3e277c98 |
||
|
|
211e70d4e1 |
||
|
|
617acedd38 | ||
|
|
7f37205e76 | ||
|
|
a926ab392e | ||
|
|
db7ba1d30c | ||
|
|
96e8b85523 | ||
|
|
6f27aa112a |
||
|
|
d2695a0c2f |
||
|
|
ebd013c6ef | ||
|
|
27a5be1326 |
||
|
|
b7bbde04be |
||
|
|
be25d6e3e4 |
||
|
|
4a853cb10c |
||
|
|
57ce1a059f |
||
|
|
b83b12dd7a |
||
|
|
b23fc4ec48 |
||
|
|
610c24d59a |
||
|
|
4874c29286 |
||
|
|
8b159144a5 |
||
|
|
53ccca5e15 |
||
|
|
0d962deb05 |
||
|
|
60bac9439d |
||
|
|
87cd695ca3 |
||
|
|
022eb6d3a0 |
||
|
|
8154f7149f |
||
|
|
3322e1f528 |
||
|
|
6e17c51361 |
||
|
|
e892c686c2 |
||
|
|
5c418efd7f |
||
|
|
194ee95ae2 |
||
|
|
f4dfd6e0f7 |
||
|
|
b6f5fd5cd0 | ||
|
|
9a0b78b76c | ||
|
|
c833217401 | ||
|
|
557e08c6ef | ||
|
|
6d253d10c8 | ||
|
|
3a0ea58557 |
||
|
|
404ee4c3a7 |
||
|
|
afa7b1ba0d |
||
|
|
5cb2bc6909 |
||
|
|
13dc7a08b7 |
||
|
|
e5e9aca871 |
||
|
|
0bf3a54716 |
||
|
|
e0ba8b3c6a |
||
|
|
9ee95cab3d |
||
|
|
b958d03c0f |
||
|
|
b09dceea88 |
||
|
|
6488b7a30e |
||
|
|
f3f5e891c9 |
||
|
|
ea9c1b1524 |
||
|
|
aaaffe45b8 |
||
|
|
b2cdc81d34 |
||
|
|
e8048e023f |
||
|
|
ae10a99811 |
||
|
|
f091b6dab1 |
||
|
|
23ccdaeffc |
||
|
|
19e54b10d4 |
||
|
|
b74a8e5b12 |
||
|
|
089e96a437 |
||
|
|
bc51c00724 |
||
|
|
0679e04bd3 |
||
|
|
58c07cc264 | ||
|
|
5bcf191e60 |
||
|
|
7ab8b644a7 | ||
|
|
ae26c3372a | ||
|
|
81d6ee69cd | ||
|
|
5f05d33ef7 | ||
|
|
19dde1c860 | ||
|
|
b476218edb | ||
|
|
8856914be8 | ||
|
|
3b24c88e93 | ||
|
|
c9975c1fe1 |
||
|
|
2bcabd8e1f | ||
|
|
8f32a8f134 | ||
|
|
b1d990ceba | ||
|
|
3d67168ee5 | ||
|
|
3aea678eab | ||
|
|
c864bc866d | ||
|
|
ed6235e59a | ||
|
|
141fe194c6 | ||
|
|
4a39e44eb6 | ||
|
|
9c033b7ce9 |
||
|
|
4bd9d41c43 | ||
|
|
2f84ab77f2 |
||
|
|
8985ecf438 |
||
|
|
b759ea5486 | ||
|
|
9ec42b2dad | ||
|
|
e0a138ffbd | ||
|
|
34ab85e664 |
||
|
|
cb622a3dd6 | ||
|
|
7ae601588e | ||
|
|
b7a896a803 | ||
|
|
d621bdfbfe | ||
|
|
fc2922a300 |
||
|
|
488c9cf3d3 | ||
|
|
753fa3b316 | ||
|
|
85be50ac71 | ||
|
|
463eea2bd0 |
||
|
|
a913ba372a | ||
|
|
a5931594de |
||
|
|
22ccd8a087 | ||
|
|
39f1df5997 | ||
|
|
5eca46a4bc |
||
|
|
a4f74d1d2b | ||
|
|
e3ff63b0f9 | ||
|
|
e0cdd38786 |
||
|
|
0c523dda20 | ||
|
|
8276879997 | ||
|
|
0d1c097396 | ||
|
|
34d9a3bf33 | ||
|
|
59ab0c0ca0 | ||
|
|
a43e76c31a | ||
|
|
cda8f7bbef | ||
|
|
17ec88503e | ||
|
|
2680e3c4bd | ||
|
|
4ac795e20c | ||
|
|
29971d9729 | ||
|
|
4d031c8562 | ||
|
|
49b1adba7b | ||
|
|
86ea1d4722 | ||
|
|
f831352551 | ||
|
|
f0645c6ddf |
||
|
|
b3b5c25df8 | ||
|
|
b932d0dc78 | ||
|
|
1717ab02f7 | ||
|
|
2ebae5ed71 | ||
|
|
fa43aba309 | ||
|
|
063b04ad83 | ||
|
|
dada4de172 | ||
|
|
4b219be8bd | ||
|
|
87354cf94e | ||
|
|
a2e75967ce | ||
|
|
084df1fba2 | ||
|
|
c4aead65ee | ||
|
|
17876ccf45 | ||
|
|
b02999bca6 | ||
|
|
a87c4ac555 | ||
|
|
354780a136 | ||
|
|
e834008075 | ||
|
|
c77dad910b | ||
|
|
1f863def5e | ||
|
|
21c156dfb1 | ||
|
|
a6ef65f90d | ||
|
|
2900efb32d | ||
|
|
9d9a2d3ff3 | ||
|
|
9766a9c087 | ||
|
|
1972ba8952 | ||
|
|
8cec528eeb | ||
|
|
4115213e17 | ||
|
|
c9c79fdfc8 | ||
|
|
15a3ac58cc | ||
|
|
fdd1b61a3e | ||
|
|
b40665dd14 | ||
|
|
2d3c85dfc0 | ||
|
|
57e7bba38f | ||
|
|
bccb7f17e5 | ||
|
|
387f4dd4bc | ||
|
|
8e01542fe9 | ||
|
|
3cc0fc07b4 | ||
|
|
8b89a3aca8 | ||
|
|
63995ce823 | ||
|
|
3ea7ed8606 | ||
|
|
a55ae2adfc | ||
|
|
6a1b237b39 | ||
|
|
435ff7fa88 |
201 changed files with 21603 additions and 5514 deletions
39
.github/actions/setup-sqlite-version/action.yml
vendored
Normal file
39
.github/actions/setup-sqlite-version/action.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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 }}
|
||||||
144
.github/actions/setup-sqlite-version/setup-sqlite-version.sh
vendored
Normal file
144
.github/actions/setup-sqlite-version/setup-sqlite-version.sh
vendored
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
#!/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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out datasette
|
- name: Check out datasette
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
16
.github/workflows/documentation-links.yml
vendored
16
.github/workflows/documentation-links.yml
vendored
|
|
@ -1,16 +0,0 @@
|
||||||
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:
|
matrix:
|
||||||
browser: [chromium, firefox, webkit]
|
browser: [chromium, firefox, webkit]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python 3.14
|
- name: Set up Python 3.14
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -25,14 +25,14 @@ jobs:
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: pyproject.toml
|
cache-dependency-path: pyproject.toml
|
||||||
- name: Cache uv
|
- name: Cache uv
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/uv
|
path: ~/.cache/uv
|
||||||
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
|
key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-py3.14-uv-
|
${{ runner.os }}-py3.14-uv-
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright/
|
path: ~/.cache/ms-playwright/
|
||||||
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
|
key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }}
|
||||||
|
|
|
||||||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Configure npm caching
|
name: Configure npm caching
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
|
|
|
||||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -35,7 +35,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
if: "!github.event.release.prerelease"
|
if: "!github.event.release.prerelease"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -92,7 +92,7 @@ jobs:
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
if: "!github.event.release.prerelease"
|
if: "!github.event.release.prerelease"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Build and push to Docker Hub
|
- name: Build and push to Docker Hub
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
|
|
||||||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
deploy_docker:
|
deploy_docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Build and push to Docker Hub
|
- name: Build and push to Docker Hub
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||||
|
|
|
||||||
2
.github/workflows/spellcheck.yml
vendored
2
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
||||||
spellcheck:
|
spellcheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/stable-docs.yml
vendored
2
.github/workflows/stable-docs.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||||
- name: Set up Git user
|
- name: Set up Git user
|
||||||
|
|
|
||||||
2
.github/workflows/test-coverage.yml
vendored
2
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out datasette
|
- name: Check out datasette
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
4
.github/workflows/test-pyodide.yml
vendored
4
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
cache-dependency-path: '**/pyproject.toml'
|
cache-dependency-path: '**/pyproject.toml'
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright/
|
path: ~/.cache/ms-playwright/
|
||||||
key: ${{ runner.os }}-browsers
|
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
|
#"3.23.1" # 2018-04-10, before UPSERT
|
||||||
]
|
]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: pyproject.toml
|
cache-dependency-path: pyproject.toml
|
||||||
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
||||||
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
|
uses: ./.github/actions/setup-sqlite-version
|
||||||
with:
|
with:
|
||||||
version: ${{ matrix.sqlite-version }}
|
version: ${{ matrix.sqlite-version }}
|
||||||
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
||||||
|
|
|
||||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
|
@ -9,10 +9,11 @@ jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
|
|
|
||||||
2
.github/workflows/tmate.yml
vendored
2
.github/workflows/tmate.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@v3
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,6 +5,8 @@ datasets.json
|
||||||
|
|
||||||
scratchpad
|
scratchpad
|
||||||
|
|
||||||
|
ignored/
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
uv.lock
|
uv.lock
|
||||||
|
|
|
||||||
3
Justfile
3
Justfile
|
|
@ -33,10 +33,11 @@ export DATASETTE_SECRET := "not_a_secret"
|
||||||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||||
|
|
||||||
# Run linters: black, ruff, cog
|
# Run linters: black, ruff, prettier, cog
|
||||||
@lint: codespell
|
@lint: codespell
|
||||||
uv run black datasette tests --check
|
uv run black datasette tests --check
|
||||||
uv run ruff check datasette tests
|
uv run ruff check datasette tests
|
||||||
|
npm run prettier -- --check
|
||||||
uv run cog --check README.md docs/*.rst
|
uv run cog --check README.md docs/*.rst
|
||||||
|
|
||||||
# Apply ruff fixes
|
# Apply ruff fixes
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
from datasette.permissions import Permission # noqa
|
from datasette.permissions import Permission # noqa
|
||||||
from datasette.version import __version_info__, __version__ # noqa
|
from datasette.version import __version_info__, __version__ # noqa
|
||||||
from datasette.events import Event # noqa
|
from datasette.events import Event # noqa
|
||||||
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
|
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
|
||||||
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
from datasette.utils.asgi import ( # noqa
|
||||||
|
Forbidden,
|
||||||
|
NotFound,
|
||||||
|
PayloadTooLarge,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
from datasette.utils import actor_matches_allow # noqa
|
from datasette.utils import actor_matches_allow # noqa
|
||||||
from datasette.views import Context # noqa
|
from datasette.views import Context # noqa
|
||||||
from .hookspecs import hookimpl # noqa
|
from .hookspecs import hookimpl # noqa
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,8 @@ def pytest_runtest_protocol(item, nextitem):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
ds.close()
|
ds.close()
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
|
# Surfaced as a pytest warning; teardown must not fail the run
|
||||||
item.warn(
|
item.warn(
|
||||||
pytest.PytestUnraisableExceptionWarning(
|
pytest.PytestUnraisableExceptionWarning(
|
||||||
f"Error closing Datasette instance: {e!r}"
|
f"Error closing Datasette instance: {e!r}"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
from datasette import hookimpl
|
|
||||||
from itsdangerous import BadSignature
|
|
||||||
from datasette.utils import baseconv
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from itsdangerous import BadSignature
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
from datasette.utils import baseconv
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def actor_from_request(datasette, request):
|
def actor_from_request(datasette, request):
|
||||||
|
|
|
||||||
588
datasette/app.py
588
datasette/app.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,9 @@
|
||||||
from datasette import hookimpl
|
|
||||||
from datasette.utils.asgi import Response, BadRequest
|
|
||||||
from datasette.utils import to_css_class
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
from datasette.utils import to_css_class
|
||||||
|
from datasette.utils.asgi import BadRequest, Response
|
||||||
|
|
||||||
_BLOB_COLUMN = "_blob_column"
|
_BLOB_COLUMN = "_blob_column"
|
||||||
_BLOB_HASH = "_blob_hash"
|
_BLOB_HASH = "_blob_hash"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,45 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import uvicorn
|
|
||||||
import click
|
|
||||||
from click import formatting
|
|
||||||
from click.types import CompositeParamType
|
|
||||||
from click_default_group import DefaultGroup
|
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
from runpy import run_module
|
|
||||||
import shutil
|
import shutil
|
||||||
from subprocess import call
|
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
from runpy import run_module
|
||||||
|
from subprocess import call
|
||||||
|
|
||||||
|
import click
|
||||||
|
import uvicorn
|
||||||
|
from click import formatting
|
||||||
|
from click.types import CompositeParamType
|
||||||
|
from click_default_group import DefaultGroup
|
||||||
|
|
||||||
from .app import (
|
from .app import (
|
||||||
Datasette,
|
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SQLITE_LIMIT_ATTACHED,
|
SQLITE_LIMIT_ATTACHED,
|
||||||
|
Datasette,
|
||||||
pm,
|
pm,
|
||||||
)
|
)
|
||||||
from .inspect import inspect_tables
|
from .inspect import inspect_tables
|
||||||
from .utils import (
|
from .utils import (
|
||||||
|
ConnectionProblem,
|
||||||
LoadExtension,
|
LoadExtension,
|
||||||
|
SpatialiteConnectionProblem,
|
||||||
|
SpatialiteNotFound,
|
||||||
StartupError,
|
StartupError,
|
||||||
|
StaticMount,
|
||||||
|
ValueAsBooleanError,
|
||||||
check_connection,
|
check_connection,
|
||||||
deep_dict_update,
|
deep_dict_update,
|
||||||
find_spatialite,
|
find_spatialite,
|
||||||
parse_metadata,
|
|
||||||
ConnectionProblem,
|
|
||||||
SpatialiteConnectionProblem,
|
|
||||||
initial_path_for_datasette,
|
initial_path_for_datasette,
|
||||||
pairs_to_nested_config,
|
pairs_to_nested_config,
|
||||||
|
parse_metadata,
|
||||||
temporary_docker_directory,
|
temporary_docker_directory,
|
||||||
value_as_boolean,
|
value_as_boolean,
|
||||||
SpatialiteNotFound,
|
|
||||||
StaticMount,
|
|
||||||
ValueAsBooleanError,
|
|
||||||
)
|
)
|
||||||
from .utils.sqlite import sqlite3
|
from .utils.sqlite import sqlite3
|
||||||
from .utils.testing import TestClient
|
from .utils.testing import TestClient
|
||||||
|
|
@ -75,7 +77,7 @@ class Setting(CompositeParamType):
|
||||||
# Datasette 1.0, we turn bare setting names into setting.name
|
# Datasette 1.0, we turn bare setting names into setting.name
|
||||||
# Type checking for those older settings
|
# Type checking for those older settings
|
||||||
default = DEFAULT_SETTINGS[name]
|
default = DEFAULT_SETTINGS[name]
|
||||||
name = "settings.{}".format(name)
|
name = f"settings.{name}"
|
||||||
if isinstance(default, bool):
|
if isinstance(default, bool):
|
||||||
try:
|
try:
|
||||||
return name, "true" if value_as_boolean(value) else "false"
|
return name, "true" if value_as_boolean(value) else "false"
|
||||||
|
|
@ -171,7 +173,6 @@ async def inspect_(files, sqlite_extensions):
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def publish():
|
def publish():
|
||||||
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Register publish plugins
|
# Register publish plugins
|
||||||
|
|
@ -578,27 +579,27 @@ def serve(
|
||||||
# https://github.com/simonw/datasette/issues/2389
|
# https://github.com/simonw/datasette/issues/2389
|
||||||
deep_dict_update(config_data, settings_updates)
|
deep_dict_update(config_data, settings_updates)
|
||||||
|
|
||||||
kwargs = dict(
|
kwargs = {
|
||||||
immutables=immutable,
|
"immutables": immutable,
|
||||||
cache_headers=not reload,
|
"cache_headers": not reload,
|
||||||
cors=cors,
|
"cors": cors,
|
||||||
inspect_data=inspect_data,
|
"inspect_data": inspect_data,
|
||||||
config=config_data,
|
"config": config_data,
|
||||||
metadata=metadata_data,
|
"metadata": metadata_data,
|
||||||
sqlite_extensions=sqlite_extensions,
|
"sqlite_extensions": sqlite_extensions,
|
||||||
template_dir=template_dir,
|
"template_dir": template_dir,
|
||||||
plugins_dir=plugins_dir,
|
"plugins_dir": plugins_dir,
|
||||||
static_mounts=static,
|
"static_mounts": static,
|
||||||
settings=None, # These are passed in config= now
|
"settings": None, # These are passed in config= now
|
||||||
memory=memory,
|
"memory": memory,
|
||||||
secret=secret,
|
"secret": secret,
|
||||||
version_note=version_note,
|
"version_note": version_note,
|
||||||
pdb=pdb,
|
"pdb": pdb,
|
||||||
crossdb=crossdb,
|
"crossdb": crossdb,
|
||||||
nolock=nolock,
|
"nolock": nolock,
|
||||||
internal=internal,
|
"internal": internal,
|
||||||
default_deny=default_deny,
|
"default_deny": default_deny,
|
||||||
)
|
}
|
||||||
|
|
||||||
# Separate directories from files
|
# Separate directories from files
|
||||||
directories = [f for f in files if os.path.isdir(f)]
|
directories = [f for f in files if os.path.isdir(f)]
|
||||||
|
|
@ -621,9 +622,7 @@ def serve(
|
||||||
conn.close()
|
conn.close()
|
||||||
else:
|
else:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
|
f"Invalid value for '[FILES]...': Path '{file}' does not exist."
|
||||||
file
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicate files by resolving all paths to their absolute forms
|
# Check for duplicate files by resolving all paths to their absolute forms
|
||||||
|
|
@ -684,7 +683,7 @@ def serve(
|
||||||
client = TestClient(ds)
|
client = TestClient(ds)
|
||||||
request_headers = {}
|
request_headers = {}
|
||||||
if token:
|
if token:
|
||||||
request_headers["Authorization"] = "Bearer {}".format(token)
|
request_headers["Authorization"] = f"Bearer {token}"
|
||||||
cookies = {}
|
cookies = {}
|
||||||
if actor:
|
if actor:
|
||||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||||
|
|
@ -719,9 +718,13 @@ def serve(
|
||||||
path = run_sync(lambda: initial_path_for_datasette(ds))
|
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||||
url = f"http://{host}:{port}{path}"
|
url = f"http://{host}:{port}{path}"
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
uvicorn_kwargs = dict(
|
uvicorn_kwargs = {
|
||||||
host=host, port=port, log_level="info", lifespan="on", workers=1
|
"host": host,
|
||||||
)
|
"port": port,
|
||||||
|
"log_level": "info",
|
||||||
|
"lifespan": "on",
|
||||||
|
"workers": 1,
|
||||||
|
}
|
||||||
if uds:
|
if uds:
|
||||||
uvicorn_kwargs["uds"] = uds
|
uvicorn_kwargs["uds"] = uds
|
||||||
if ssl_keyfile:
|
if ssl_keyfile:
|
||||||
|
|
@ -885,7 +888,7 @@ async def check_databases(ds):
|
||||||
)
|
)
|
||||||
except ConnectionProblem as e:
|
except ConnectionProblem as e:
|
||||||
raise click.UsageError(
|
raise click.UsageError(
|
||||||
f"Connection to {database.path} failed check: {str(e.args[0])}"
|
f"Connection to {database.path} failed check: {e.args[0]!s}"
|
||||||
)
|
)
|
||||||
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
|
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
|
||||||
if (
|
if (
|
||||||
|
|
@ -893,9 +896,5 @@ async def check_databases(ds):
|
||||||
and len([db for db in ds.databases.values() if not db.is_memory])
|
and len([db for db in ds.databases.values() if not db.is_memory])
|
||||||
> SQLITE_LIMIT_ATTACHED
|
> SQLITE_LIMIT_ATTACHED
|
||||||
):
|
):
|
||||||
msg = (
|
msg = f"Warning: --crossdb only works with the first {SQLITE_LIMIT_ATTACHED} attached databases"
|
||||||
"Warning: --crossdb only works with the first {} attached databases".format(
|
|
||||||
SQLITE_LIMIT_ATTACHED
|
|
||||||
)
|
|
||||||
)
|
|
||||||
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)
|
click.echo(click.style(msg, bold=True, fg="yellow"), err=True)
|
||||||
|
|
|
||||||
|
|
@ -64,14 +64,14 @@ class ColumnType:
|
||||||
Return an HTML string to render this cell value, or None to
|
Return an HTML string to render this cell value, or None to
|
||||||
fall through to the default render_cell plugin hook chain.
|
fall through to the default render_cell plugin hook chain.
|
||||||
"""
|
"""
|
||||||
return None
|
return
|
||||||
|
|
||||||
async def validate(self, value, datasette):
|
async def validate(self, value, datasette):
|
||||||
"""
|
"""
|
||||||
Validate a value before it is written. Return None if valid,
|
Validate a value before it is written. Return None if valid,
|
||||||
or a string error message if invalid.
|
or a string error message if invalid.
|
||||||
"""
|
"""
|
||||||
return None
|
return
|
||||||
|
|
||||||
async def transform_value(self, value, datasette):
|
async def transform_value(self, value, datasette):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@ def _origin_tuple(value):
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
if not scheme or not host:
|
if not scheme or not host:
|
||||||
raise ValueError("missing scheme or host in {!r}".format(value))
|
raise ValueError(f"missing scheme or host in {value!r}")
|
||||||
port = parsed.port # may raise ValueError on bad ports
|
port = parsed.port # may raise ValueError on bad ports
|
||||||
if port is None:
|
if port is None:
|
||||||
port = DEFAULT_PORTS.get(scheme)
|
port = DEFAULT_PORTS.get(scheme)
|
||||||
if port is None:
|
if port is None:
|
||||||
raise ValueError("unknown default port for scheme {!r}".format(scheme))
|
raise ValueError(f"unknown default port for scheme {scheme!r}")
|
||||||
return scheme, host, port
|
return scheme, host, port
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -125,9 +125,7 @@ class CrossOriginProtectionMiddleware:
|
||||||
return
|
return
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
send,
|
||||||
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
|
f"Sec-Fetch-Site was {sec_fetch_site!r}, expected 'same-origin' or 'none'",
|
||||||
sec_fetch_site
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -141,11 +139,11 @@ class CrossOriginProtectionMiddleware:
|
||||||
request_scheme = self._request_scheme(scope)
|
request_scheme = self._request_scheme(scope)
|
||||||
try:
|
try:
|
||||||
origin_tuple = _origin_tuple(origin)
|
origin_tuple = _origin_tuple(origin)
|
||||||
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
|
expected_tuple = _origin_tuple(f"{request_scheme}://{host}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
send,
|
||||||
"Malformed Origin {!r} or Host {!r}".format(origin, host),
|
f"Malformed Origin {origin!r} or Host {host!r}",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -155,7 +153,7 @@ class CrossOriginProtectionMiddleware:
|
||||||
|
|
||||||
await self._forbid(
|
await self._forbid(
|
||||||
send,
|
send,
|
||||||
"Origin {!r} does not match Host {!r}".format(origin, host),
|
f"Origin {origin!r} does not match Host {host!r}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request_scheme(self, scope):
|
def _request_scheme(self, scope):
|
||||||
|
|
@ -163,7 +161,8 @@ class CrossOriginProtectionMiddleware:
|
||||||
try:
|
try:
|
||||||
if self.datasette.setting("force_https_urls"):
|
if self.datasette.setting("force_https_urls"):
|
||||||
return "https"
|
return "https"
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
|
# Settings may not be readable this early; fall back to the ASGI scheme
|
||||||
pass
|
pass
|
||||||
return scope.get("scheme") or "http"
|
return scope.get("scheme") or "http"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,35 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
from collections import namedtuple
|
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
import queue
|
import queue
|
||||||
import sqlite_utils
|
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import namedtuple
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlite_utils
|
||||||
|
|
||||||
|
from .inspect import inspect_hash
|
||||||
from .tracer import trace
|
from .tracer import trace
|
||||||
from .utils import (
|
from .utils import (
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
detect_fts,
|
detect_fts,
|
||||||
detect_primary_keys,
|
detect_primary_keys,
|
||||||
detect_spatialite,
|
detect_spatialite,
|
||||||
|
escape_sqlite,
|
||||||
get_all_foreign_keys,
|
get_all_foreign_keys,
|
||||||
get_outbound_foreign_keys,
|
get_outbound_foreign_keys,
|
||||||
md5_not_usedforsecurity,
|
md5_not_usedforsecurity,
|
||||||
sqlite_timelimit,
|
|
||||||
sqlite3,
|
sqlite3,
|
||||||
table_columns,
|
sqlite_timelimit,
|
||||||
table_column_details,
|
table_column_details,
|
||||||
|
table_columns,
|
||||||
)
|
)
|
||||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||||
from .utils.sqlite import sqlite_hidden_table_names
|
from .utils.sqlite import sqlite_hidden_table_names
|
||||||
from .inspect import inspect_hash
|
|
||||||
|
|
||||||
connections = threading.local()
|
connections = threading.local()
|
||||||
|
|
||||||
|
|
@ -98,9 +100,7 @@ class Database:
|
||||||
|
|
||||||
def _check_not_closed(self):
|
def _check_not_closed(self):
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise DatasetteClosedError(
|
raise DatasetteClosedError(f"Database {self.name!r} has been closed")
|
||||||
"Database {!r} has been closed".format(self.name)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _remove_pending_execute_future(self, future):
|
def _remove_pending_execute_future(self, future):
|
||||||
with self._pending_execute_futures_lock:
|
with self._pending_execute_futures_lock:
|
||||||
|
|
@ -139,7 +139,7 @@ class Database:
|
||||||
if write:
|
if write:
|
||||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||||
if self.memory_name:
|
if self.memory_name:
|
||||||
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
|
uri = f"file:{self.memory_name}?mode=memory&cache=shared"
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||||
)
|
)
|
||||||
|
|
@ -192,21 +192,20 @@ class Database:
|
||||||
write_thread.join(timeout=10)
|
write_thread.join(timeout=10)
|
||||||
if write_thread.is_alive():
|
if write_thread.is_alive():
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"Datasette: write thread for {!r} did not exit within 10s\n".format(
|
f"Datasette: write thread for {self.name!r} did not exit within 10s\n"
|
||||||
self.name
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
for future in pending_execute_futures:
|
for future in pending_execute_futures:
|
||||||
try:
|
try:
|
||||||
future.result()
|
future.result()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
|
# Shutdown teardown - a failed pending write must not block close()
|
||||||
pass
|
pass
|
||||||
# Close anything still tracked in _all_file_connections
|
# Close anything still tracked in _all_file_connections
|
||||||
for connection in self._all_file_connections:
|
for connection in self._all_file_connections:
|
||||||
try:
|
try:
|
||||||
connection.close()
|
connection.close()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
pass
|
pass
|
||||||
self._all_file_connections = []
|
self._all_file_connections = []
|
||||||
# Drop per-thread cached read connections we can reach
|
# Drop per-thread cached read connections we can reach
|
||||||
|
|
@ -218,13 +217,13 @@ class Database:
|
||||||
if self._read_connection is not None:
|
if self._read_connection is not None:
|
||||||
try:
|
try:
|
||||||
self._read_connection.close()
|
self._read_connection.close()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
pass
|
pass
|
||||||
self._read_connection = None
|
self._read_connection = None
|
||||||
if self._write_connection is not None:
|
if self._write_connection is not None:
|
||||||
try:
|
try:
|
||||||
self._write_connection.close()
|
self._write_connection.close()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
pass
|
pass
|
||||||
self._write_connection = None
|
self._write_connection = None
|
||||||
if self.is_temp_disk:
|
if self.is_temp_disk:
|
||||||
|
|
@ -246,6 +245,7 @@ class Database:
|
||||||
request=None,
|
request=None,
|
||||||
return_all=False,
|
return_all=False,
|
||||||
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
returning_limit=EXECUTE_WRITE_RETURNING_LIMIT,
|
||||||
|
transaction=True,
|
||||||
):
|
):
|
||||||
self._check_not_closed()
|
self._check_not_closed()
|
||||||
if returning_limit < 0:
|
if returning_limit < 0:
|
||||||
|
|
@ -258,7 +258,9 @@ class Database:
|
||||||
)
|
)
|
||||||
|
|
||||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
results = await self.execute_write_fn(
|
||||||
|
_inner, block=block, request=request, transaction=transaction
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def execute_write_script(self, sql, block=True, request=None):
|
async def execute_write_script(self, sql, block=True, request=None):
|
||||||
|
|
@ -348,6 +350,7 @@ class Database:
|
||||||
self.ds._prepare_connection(self._write_connection, self.name)
|
self.ds._prepare_connection(self._write_connection, self.name)
|
||||||
if transaction:
|
if transaction:
|
||||||
with self._write_connection:
|
with self._write_connection:
|
||||||
|
self._write_connection.execute("BEGIN IMMEDIATE")
|
||||||
result = fn(self._write_connection)
|
result = fn(self._write_connection)
|
||||||
else:
|
else:
|
||||||
result = fn(self._write_connection)
|
result = fn(self._write_connection)
|
||||||
|
|
@ -366,7 +369,8 @@ class Database:
|
||||||
async def _dispatch_events_after_write():
|
async def _dispatch_events_after_write():
|
||||||
try:
|
try:
|
||||||
await reply_future
|
await reply_future
|
||||||
except Exception:
|
except Exception: # noqa: BLE001
|
||||||
|
# The write failed; skip success events regardless of why
|
||||||
# if the write failed, don't emit success events
|
# if the write failed, don't emit success events
|
||||||
return
|
return
|
||||||
for event in pending_events:
|
for event in pending_events:
|
||||||
|
|
@ -419,9 +423,7 @@ class Database:
|
||||||
self._write_thread = threading.Thread(
|
self._write_thread = threading.Thread(
|
||||||
target=self._execute_writes, daemon=True
|
target=self._execute_writes, daemon=True
|
||||||
)
|
)
|
||||||
self._write_thread.name = "_execute_writes for database {}".format(
|
self._write_thread.name = f"_execute_writes for database {self.name}"
|
||||||
self.name
|
|
||||||
)
|
|
||||||
self._write_thread.start()
|
self._write_thread.start()
|
||||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
@ -442,7 +444,8 @@ class Database:
|
||||||
try:
|
try:
|
||||||
conn = self.connect(write=True)
|
conn = self.connect(write=True)
|
||||||
self.ds._prepare_connection(conn, self.name)
|
self.ds._prepare_connection(conn, self.name)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
|
# Stored and re-raised to whoever queues the next write
|
||||||
conn_exception = e
|
conn_exception = e
|
||||||
while True:
|
while True:
|
||||||
task = self._write_queue.get()
|
task = self._write_queue.get()
|
||||||
|
|
@ -450,7 +453,8 @@ class Database:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
|
# Best-effort close as the write thread exits
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
exception = None
|
exception = None
|
||||||
|
|
@ -469,19 +473,21 @@ class Database:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Was probably a memory connection
|
# Was probably a memory connection
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
sys.stderr.write("{}\n".format(e))
|
# Write thread must survive any task failure or the database wedges
|
||||||
|
sys.stderr.write(f"{e}\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
exception = e
|
exception = e
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
if task.transaction:
|
if task.transaction:
|
||||||
with conn:
|
with conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
result = task.fn(conn)
|
result = task.fn(conn)
|
||||||
else:
|
else:
|
||||||
result = task.fn(conn)
|
result = task.fn(conn)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
sys.stderr.write("{}\n".format(e))
|
sys.stderr.write(f"{e}\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
exception = e
|
exception = e
|
||||||
_deliver_write_result(task, result, exception)
|
_deliver_write_result(task, result, exception)
|
||||||
|
|
@ -548,9 +554,7 @@ class Database:
|
||||||
raise QueryInterrupted(e, sql, params)
|
raise QueryInterrupted(e, sql, params)
|
||||||
if log_sql_errors:
|
if log_sql_errors:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"ERROR: conn={}, sql = {}, params = {}: {}\n".format(
|
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n"
|
||||||
conn, repr(sql), params, e
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
raise
|
raise
|
||||||
|
|
@ -603,7 +607,7 @@ class Database:
|
||||||
try:
|
try:
|
||||||
table_count = (
|
table_count = (
|
||||||
await self.execute(
|
await self.execute(
|
||||||
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
|
f"select count(*) from (select * from {escape_sqlite(table)} limit {self.count_limit + 1})",
|
||||||
custom_time_limit=limit,
|
custom_time_limit=limit,
|
||||||
)
|
)
|
||||||
).rows[0][0]
|
).rows[0][0]
|
||||||
|
|
@ -707,9 +711,9 @@ class Database:
|
||||||
column_names
|
column_names
|
||||||
and len(column_names) == 2
|
and len(column_names) == 2
|
||||||
and ("id" in column_names or "pk" in column_names)
|
and ("id" in column_names or "pk" in column_names)
|
||||||
and not set(column_names) == {"id", "pk"}
|
and set(column_names) != {"id", "pk"}
|
||||||
):
|
):
|
||||||
return [c for c in column_names if c not in ("id", "pk")][0]
|
return next(c for c in column_names if c not in ("id", "pk"))
|
||||||
# Couldn't find a label:
|
# Couldn't find a label:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -851,10 +855,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||||
class WriteTask:
|
class WriteTask:
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"fn",
|
"fn",
|
||||||
"task_id",
|
"isolated_connection",
|
||||||
"loop",
|
"loop",
|
||||||
"reply_future",
|
"reply_future",
|
||||||
"isolated_connection",
|
"task_id",
|
||||||
"transaction",
|
"transaction",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -895,7 +899,7 @@ class QueryInterrupted(Exception):
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "QueryInterrupted: {}".format(self.e)
|
return f"QueryInterrupted: {self.e}"
|
||||||
|
|
||||||
|
|
||||||
class MultipleValues(Exception):
|
class MultipleValues(Exception):
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ from datasette import hookimpl
|
||||||
from datasette.permissions import Action
|
from datasette.permissions import Action
|
||||||
from datasette.resources import (
|
from datasette.resources import (
|
||||||
DatabaseResource,
|
DatabaseResource,
|
||||||
TableResource,
|
|
||||||
QueryResource,
|
QueryResource,
|
||||||
|
TableResource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -61,6 +61,12 @@ def register_actions():
|
||||||
description="Create tables",
|
description="Create tables",
|
||||||
resource_class=DatabaseResource,
|
resource_class=DatabaseResource,
|
||||||
),
|
),
|
||||||
|
Action(
|
||||||
|
name="create-view",
|
||||||
|
abbr="cv",
|
||||||
|
description="Create views",
|
||||||
|
resource_class=DatabaseResource,
|
||||||
|
),
|
||||||
Action(
|
Action(
|
||||||
name="store-query",
|
name="store-query",
|
||||||
abbr="sq",
|
abbr="sq",
|
||||||
|
|
@ -111,6 +117,12 @@ def register_actions():
|
||||||
description="Drop tables",
|
description="Drop tables",
|
||||||
resource_class=TableResource,
|
resource_class=TableResource,
|
||||||
),
|
),
|
||||||
|
Action(
|
||||||
|
name="drop-view",
|
||||||
|
abbr="dv",
|
||||||
|
description="Drop views",
|
||||||
|
resource_class=TableResource,
|
||||||
|
),
|
||||||
# Query-level actions (child-level)
|
# Query-level actions (child-level)
|
||||||
Action(
|
Action(
|
||||||
name="view-query",
|
name="view-query",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from datasette import hookimpl
|
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
def header(key, request):
|
def header(key, request):
|
||||||
key = key.replace("_", "-").encode("utf-8")
|
key = key.replace("_", "-").encode("utf-8")
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,29 @@ UNION/INTERSECT operations. The order of evaluation is:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Re-export all hooks and public utilities
|
|
||||||
from .restrictions import (
|
|
||||||
actor_restrictions_sql as actor_restrictions_sql,
|
|
||||||
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 .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 (
|
from .defaults import (
|
||||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
||||||
default_allow_sql_check as default_allow_sql_check,
|
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,
|
|
||||||
)
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor: Optional[dict],
|
actor: dict | None,
|
||||||
action: str,
|
action: str,
|
||||||
):
|
):
|
||||||
self.datasette = datasette
|
self.datasette = datasette
|
||||||
|
|
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
|
||||||
self.restrictions = actor.get("_r", {}) if actor else {}
|
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||||
|
|
||||||
# Pre-compute restriction info for efficiency
|
# Pre-compute restriction info for efficiency
|
||||||
self.restricted_databases: Set[str] = set()
|
self.restricted_databases: set[str] = set()
|
||||||
self.restricted_tables: Set[Tuple[str, str]] = set()
|
self.restricted_tables: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
if self.has_restrictions:
|
if self.has_restrictions:
|
||||||
self.restricted_databases = {
|
self.restricted_databases = {
|
||||||
|
|
@ -92,16 +92,20 @@ class ConfigPermissionProcessor:
|
||||||
# Tables implicitly reference their parent databases
|
# Tables implicitly reference their parent databases
|
||||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
||||||
|
|
||||||
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
|
def evaluate_allow_block(self, allow_block: Any) -> bool | None:
|
||||||
"""Evaluate an allow block against the current actor."""
|
"""Evaluate an allow block against the current actor."""
|
||||||
if allow_block is None:
|
if allow_block is None:
|
||||||
return None
|
return None
|
||||||
|
# Values passed using ``-s permissions.* 1`` or ``0`` are parsed as
|
||||||
|
# integers, but should retain the CLI's boolean 1/0 behavior.
|
||||||
|
if isinstance(allow_block, int) and allow_block in (0, 1):
|
||||||
|
return bool(allow_block)
|
||||||
return actor_matches_allow(self.actor, allow_block)
|
return actor_matches_allow(self.actor, allow_block)
|
||||||
|
|
||||||
def is_in_restriction_allowlist(
|
def is_in_restriction_allowlist(
|
||||||
self,
|
self,
|
||||||
parent: Optional[str],
|
parent: str | None,
|
||||||
child: Optional[str],
|
child: str | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if resource is allowed by actor restrictions."""
|
"""Check if resource is allowed by actor restrictions."""
|
||||||
if not self.has_restrictions:
|
if not self.has_restrictions:
|
||||||
|
|
@ -143,9 +147,9 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def add_permissions_rule(
|
def add_permissions_rule(
|
||||||
self,
|
self,
|
||||||
parent: Optional[str],
|
parent: str | None,
|
||||||
child: Optional[str],
|
child: str | None,
|
||||||
permissions_block: Optional[dict],
|
permissions_block: dict | None,
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a rule from a permissions:{action} block."""
|
"""Add a rule from a permissions:{action} block."""
|
||||||
|
|
@ -165,8 +169,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def add_allow_block_rule(
|
def add_allow_block_rule(
|
||||||
self,
|
self,
|
||||||
parent: Optional[str],
|
parent: str | None,
|
||||||
child: Optional[str],
|
child: str | None,
|
||||||
allow_block: Any,
|
allow_block: Any,
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -198,8 +202,8 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
def _add_restriction_gate_denies(
|
def _add_restriction_gate_denies(
|
||||||
self,
|
self,
|
||||||
parent: Optional[str],
|
parent: str | None,
|
||||||
child: Optional[str],
|
child: str | None,
|
||||||
is_allowed: bool,
|
is_allowed: bool,
|
||||||
scope_desc: str,
|
scope_desc: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -231,7 +235,7 @@ class ConfigPermissionProcessor:
|
||||||
if db_name == parent:
|
if db_name == parent:
|
||||||
self.collector.add(db_name, table_name, False, reason)
|
self.collector.add(db_name, table_name, False, reason)
|
||||||
|
|
||||||
def process(self) -> Optional[PermissionSQL]:
|
def process(self) -> PermissionSQL | None:
|
||||||
"""Process all config rules and return combined PermissionSQL."""
|
"""Process all config rules and return combined PermissionSQL."""
|
||||||
self._process_root_permissions()
|
self._process_root_permissions()
|
||||||
self._process_databases()
|
self._process_databases()
|
||||||
|
|
@ -421,10 +425,10 @@ class ConfigPermissionProcessor:
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def config_permissions_sql(
|
async def config_permissions_sql(
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor: Optional[dict],
|
actor: dict | None,
|
||||||
action: str,
|
action: str,
|
||||||
) -> Optional[List[PermissionSQL]]:
|
) -> list[PermissionSQL] | None:
|
||||||
"""
|
"""
|
||||||
Apply permission rules from datasette.yaml configuration.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -29,29 +29,28 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_allow_sql_check(
|
async def default_allow_sql_check(
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor: Optional[dict],
|
actor: dict | None,
|
||||||
action: str,
|
action: str,
|
||||||
) -> Optional[PermissionSQL]:
|
) -> PermissionSQL | None:
|
||||||
"""
|
"""
|
||||||
Enforce the default_allow_sql setting.
|
Enforce the default_allow_sql setting.
|
||||||
|
|
||||||
When default_allow_sql is false (the default), execute-sql is denied
|
When default_allow_sql is false (the default), execute-sql is denied
|
||||||
unless explicitly allowed by config or other rules.
|
unless explicitly allowed by config or other rules.
|
||||||
"""
|
"""
|
||||||
if action == "execute-sql":
|
if action == "execute-sql" and not datasette.setting("default_allow_sql"):
|
||||||
if not datasette.setting("default_allow_sql"):
|
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_action_permissions_sql(
|
async def default_action_permissions_sql(
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor: Optional[dict],
|
actor: dict | None,
|
||||||
action: str,
|
action: str,
|
||||||
) -> Optional[PermissionSQL]:
|
) -> PermissionSQL | None:
|
||||||
"""
|
"""
|
||||||
Provide default allow rules for standard view/execute actions.
|
Provide default allow rules for standard view/execute actions.
|
||||||
|
|
||||||
|
|
@ -71,10 +70,10 @@ async def default_action_permissions_sql(
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def default_query_permissions_sql(
|
async def default_query_permissions_sql(
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor: Optional[dict],
|
actor: dict | None,
|
||||||
action: str,
|
action: str,
|
||||||
) -> Optional[PermissionSQL]:
|
) -> PermissionSQL | None:
|
||||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||||
|
|
||||||
if action not in {"view-query", "update-query", "delete-query"}:
|
if action not in {"view-query", "update-query", "delete-query"}:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, List, Optional, Set
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||||
from datasette.permissions import PermissionSQL
|
from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
|
|
||||||
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
|
def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
|
||||||
"""
|
"""
|
||||||
Get all name variants for an action (full name and abbreviation).
|
Get all name variants for an action (full name and abbreviation).
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
|
||||||
return variants
|
return variants
|
||||||
|
|
||||||
|
|
||||||
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
|
def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool:
|
||||||
"""Check if an action (or its abbreviation) is in a list."""
|
"""Check if an action (or its abbreviation) is in a list."""
|
||||||
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
||||||
|
|
||||||
|
|
@ -36,8 +36,8 @@ def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bo
|
||||||
class PermissionRow:
|
class PermissionRow:
|
||||||
"""A single permission rule row."""
|
"""A single permission rule row."""
|
||||||
|
|
||||||
parent: Optional[str]
|
parent: str | None
|
||||||
child: Optional[str]
|
child: str | None
|
||||||
allow: bool
|
allow: bool
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
@ -46,14 +46,14 @@ class PermissionRowCollector:
|
||||||
"""Collects permission rows and converts them to PermissionSQL."""
|
"""Collects permission rows and converts them to PermissionSQL."""
|
||||||
|
|
||||||
def __init__(self, prefix: str = "row"):
|
def __init__(self, prefix: str = "row"):
|
||||||
self.rows: List[PermissionRow] = []
|
self.rows: list[PermissionRow] = []
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
|
|
||||||
def add(
|
def add(
|
||||||
self,
|
self,
|
||||||
parent: Optional[str],
|
parent: str | None,
|
||||||
child: Optional[str],
|
child: str | None,
|
||||||
allow: Optional[bool],
|
allow: bool | None,
|
||||||
reason: str,
|
reason: str,
|
||||||
if_not_none: bool = False,
|
if_not_none: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -62,7 +62,7 @@ class PermissionRowCollector:
|
||||||
return
|
return
|
||||||
self.rows.append(PermissionRow(parent, child, allow, reason))
|
self.rows.append(PermissionRow(parent, child, allow, reason))
|
||||||
|
|
||||||
def to_permission_sql(self) -> Optional[PermissionSQL]:
|
def to_permission_sql(self) -> PermissionSQL | None:
|
||||||
"""Convert collected rows to a PermissionSQL object."""
|
"""Convert collected rows to a PermissionSQL object."""
|
||||||
if not self.rows:
|
if not self.rows:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
|
||||||
class ActorRestrictions:
|
class ActorRestrictions:
|
||||||
"""Parsed actor restrictions from the _r key."""
|
"""Parsed actor restrictions from the _r key."""
|
||||||
|
|
||||||
global_actions: List[str] # _r.a - globally allowed actions
|
global_actions: list[str] # _r.a - globally allowed actions
|
||||||
database_actions: dict # _r.d - {db_name: [actions]}
|
database_actions: dict # _r.d - {db_name: [actions]}
|
||||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
|
def from_actor(cls, actor: dict | None) -> ActorRestrictions | None:
|
||||||
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
||||||
if not actor:
|
if not actor:
|
||||||
return None
|
return None
|
||||||
|
|
@ -44,11 +44,11 @@ class ActorRestrictions:
|
||||||
table_actions=restrictions.get("r", {}),
|
table_actions=restrictions.get("r", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
|
def is_action_globally_allowed(self, datasette: Datasette, action: str) -> bool:
|
||||||
"""Check if action is in the global allowlist."""
|
"""Check if action is in the global allowlist."""
|
||||||
return action_in_list(datasette, action, self.global_actions)
|
return action_in_list(datasette, action, self.global_actions)
|
||||||
|
|
||||||
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
|
def get_allowed_databases(self, datasette: Datasette, action: str) -> set[str]:
|
||||||
"""Get database names where this action is allowed."""
|
"""Get database names where this action is allowed."""
|
||||||
allowed = set()
|
allowed = set()
|
||||||
for db_name, db_actions in self.database_actions.items():
|
for db_name, db_actions in self.database_actions.items():
|
||||||
|
|
@ -57,8 +57,8 @@ class ActorRestrictions:
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
def get_allowed_tables(
|
def get_allowed_tables(
|
||||||
self, datasette: "Datasette", action: str
|
self, datasette: Datasette, action: str
|
||||||
) -> Set[Tuple[str, str]]:
|
) -> set[tuple[str, str]]:
|
||||||
"""Get (database, table) pairs where this action is allowed."""
|
"""Get (database, table) pairs where this action is allowed."""
|
||||||
allowed = set()
|
allowed = set()
|
||||||
for db_name, tables in self.table_actions.items():
|
for db_name, tables in self.table_actions.items():
|
||||||
|
|
@ -70,10 +70,10 @@ class ActorRestrictions:
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def actor_restrictions_sql(
|
async def actor_restrictions_sql(
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor: Optional[dict],
|
actor: dict | None,
|
||||||
action: str,
|
action: str,
|
||||||
) -> Optional[List[PermissionSQL]]:
|
) -> list[PermissionSQL] | None:
|
||||||
"""
|
"""
|
||||||
Handle actor restriction-based permission rules.
|
Handle actor restriction-based permission rules.
|
||||||
|
|
||||||
|
|
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
|
||||||
|
|
||||||
|
|
||||||
def restrictions_allow_action(
|
def restrictions_allow_action(
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
restrictions: dict,
|
restrictions: dict,
|
||||||
action: str,
|
action: str,
|
||||||
resource: Optional[str | Tuple[str, str]],
|
resource: str | tuple[str, str] | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if restrictions allow the requested action on the requested resource.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
|
||||||
|
|
||||||
@hookimpl(specname="permission_resources_sql")
|
@hookimpl(specname="permission_resources_sql")
|
||||||
async def root_user_permissions_sql(
|
async def root_user_permissions_sql(
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor: Optional[dict],
|
actor: dict | None,
|
||||||
) -> Optional[PermissionSQL]:
|
) -> PermissionSQL | None:
|
||||||
"""
|
"""
|
||||||
Grant root user full permissions when --root flag is used.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
@ -17,15 +17,13 @@ from datasette.tokens import SignedTokenHandler
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def register_token_handler(datasette: "Datasette"):
|
def register_token_handler(datasette: Datasette):
|
||||||
"""Register the default signed token handler."""
|
"""Register the default signed token handler."""
|
||||||
return SignedTokenHandler()
|
return SignedTokenHandler()
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(specname="actor_from_request")
|
@hookimpl(specname="actor_from_request")
|
||||||
async def actor_from_signed_api_token(
|
async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None:
|
||||||
datasette: "Datasette", request
|
|
||||||
) -> Optional[dict]:
|
|
||||||
"""
|
"""
|
||||||
Authenticate requests using API tokens by delegating to all registered
|
Authenticate requests using API tokens by delegating to all registered
|
||||||
token handlers via datasette.verify_token().
|
token handlers via datasette.verify_token().
|
||||||
|
|
|
||||||
29
datasette/default_table_actions.py
Normal file
29
datasette/default_table_actions.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from datasette import hookimpl
|
||||||
|
from datasette.resources import TableResource
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def table_actions(datasette, actor, database, table, request):
|
||||||
|
async def inner():
|
||||||
|
db = datasette.get_database(database)
|
||||||
|
if not db.is_mutable:
|
||||||
|
return []
|
||||||
|
if not await datasette.allowed(
|
||||||
|
action="alter-table",
|
||||||
|
resource=TableResource(database=database, table=table),
|
||||||
|
actor=actor,
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"label": "Alter table",
|
||||||
|
"description": "Change columns and primary key for this table.",
|
||||||
|
"attrs": {
|
||||||
|
"aria-label": f"Alter table {table}",
|
||||||
|
"data-table-action": "alter-table",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from abc import ABC, abstractproperty
|
from abc import ABC, abstractproperty
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datasette.hookspecs import hookimpl
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from datasette.hookspecs import hookimpl
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Event(ABC):
|
class Event(ABC):
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from typing import ClassVar
|
||||||
|
|
||||||
from asyncinject import Registry
|
from asyncinject import Registry
|
||||||
|
|
||||||
|
from datasette.utils.asgi import BadRequest
|
||||||
|
|
||||||
|
|
||||||
def extra_names_from_request(request):
|
def extra_names_from_request(request):
|
||||||
extra_bits = request.args.getlist("_extra")
|
extra_bits = request.args.getlist("_extra")
|
||||||
|
|
@ -81,6 +83,16 @@ class ExtraRegistry:
|
||||||
def public_classes_for_scope(self, scope):
|
def public_classes_for_scope(self, scope):
|
||||||
return self.classes_for_scope(scope, include_internal=False)
|
return self.classes_for_scope(scope, include_internal=False)
|
||||||
|
|
||||||
|
def internal_classes_for_scope(self, scope):
|
||||||
|
# Extras that are available to HTML templates but excluded from
|
||||||
|
# JSON responses - plain Providers are dependency plumbing and
|
||||||
|
# never surface as keys, so they are not included
|
||||||
|
return [
|
||||||
|
cls
|
||||||
|
for cls in self.classes_for_scope(scope)
|
||||||
|
if issubclass(cls, Extra) and not cls.public
|
||||||
|
]
|
||||||
|
|
||||||
def _registry_for_scope(self, scope):
|
def _registry_for_scope(self, scope):
|
||||||
registry = self._scope_registries.get(scope)
|
registry = self._scope_registries.get(scope)
|
||||||
if registry is None:
|
if registry is None:
|
||||||
|
|
@ -103,6 +115,17 @@ class ExtraRegistry:
|
||||||
self._allowed_names[key] = names
|
self._allowed_names[key] = names
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
def validate_requested(self, requested, scope):
|
||||||
|
"""
|
||||||
|
Raise BadRequest if any requested extra name is not a public extra
|
||||||
|
for this scope. Used by data formats such as .json - HTML pages
|
||||||
|
silently ignore unknown names instead.
|
||||||
|
"""
|
||||||
|
allowed = self._allowed_names_for_scope(scope, include_internal=False)
|
||||||
|
unknown = sorted(name for name in requested if name not in allowed)
|
||||||
|
if unknown:
|
||||||
|
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
|
||||||
|
|
||||||
async def resolve(self, requested, context, scope, include_internal=False):
|
async def resolve(self, requested, context, scope, include_internal=False):
|
||||||
allowed_names = self._allowed_names_for_scope(scope, include_internal)
|
allowed_names = self._allowed_names_for_scope(scope, include_internal)
|
||||||
requested_names = [name for name in requested if name in allowed_names]
|
requested_names = [name for name in requested if name in allowed_names]
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import json
|
import json
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
detect_json1,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
detect_json1,
|
|
||||||
sqlite3,
|
sqlite3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,7 +31,7 @@ def load_facet_configs(request, table_config):
|
||||||
assert (
|
assert (
|
||||||
len(facet_config.values()) == 1
|
len(facet_config.values()) == 1
|
||||||
), "Metadata config dicts should be {type: config}"
|
), "Metadata config dicts should be {type: config}"
|
||||||
type, facet_config = list(facet_config.items())[0]
|
type, facet_config = next(iter(facet_config.items()))
|
||||||
if isinstance(facet_config, str):
|
if isinstance(facet_config, str):
|
||||||
facet_config = {"simple": facet_config}
|
facet_config = {"simple": facet_config}
|
||||||
facet_configs.setdefault(type, []).append(
|
facet_configs.setdefault(type, []).append(
|
||||||
|
|
@ -85,7 +86,7 @@ class Facet:
|
||||||
self.database = database
|
self.database = database
|
||||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||||
self.table = table
|
self.table = table
|
||||||
self.sql = sql or f"select * from [{table}]"
|
self.sql = sql or f"select * from {escape_sqlite(table)}"
|
||||||
self.params = params or []
|
self.params = params or []
|
||||||
self.table_config = table_config
|
self.table_config = table_config
|
||||||
# row_count can be None, in which case we calculate it ourselves:
|
# row_count can be None, in which case we calculate it ourselves:
|
||||||
|
|
@ -160,18 +161,13 @@ class ColumnFacet(Facet):
|
||||||
for column in columns:
|
for column in columns:
|
||||||
if column in already_enabled:
|
if column in already_enabled:
|
||||||
continue
|
continue
|
||||||
suggested_facet_sql = """
|
suggested_facet_sql = f"""
|
||||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
with limited as (select * from ({self.sql}) limit {self.suggest_consider})
|
||||||
select {column} as value, count(*) as n from limited
|
select {escape_sqlite(column)} as value, count(*) as n from limited
|
||||||
where value is not null
|
where value is not null
|
||||||
group by value
|
group by value
|
||||||
limit {limit}
|
limit {facet_size + 1}
|
||||||
""".format(
|
"""
|
||||||
column=escape_sqlite(column),
|
|
||||||
sql=self.sql,
|
|
||||||
limit=facet_size + 1,
|
|
||||||
suggest_consider=self.suggest_consider,
|
|
||||||
)
|
|
||||||
distinct_values = None
|
distinct_values = None
|
||||||
try:
|
try:
|
||||||
distinct_values = await self.ds.execute(
|
distinct_values = await self.ds.execute(
|
||||||
|
|
@ -267,7 +263,7 @@ class ColumnFacet(Facet):
|
||||||
for row in facet_rows:
|
for row in facet_rows:
|
||||||
column_qs = column
|
column_qs = column
|
||||||
if column.startswith("_"):
|
if column.startswith("_"):
|
||||||
column_qs = "{}__exact".format(column)
|
column_qs = f"{column}__exact"
|
||||||
selected = (column_qs, str(row["value"])) in qs_pairs
|
selected = (column_qs, str(row["value"])) in qs_pairs
|
||||||
if selected:
|
if selected:
|
||||||
toggle_path = path_with_removed_args(
|
toggle_path = path_with_removed_args(
|
||||||
|
|
@ -342,12 +338,12 @@ class ArrayFacet(Facet):
|
||||||
for v in await self.ds.execute(
|
for v in await self.ds.execute(
|
||||||
self.database,
|
self.database,
|
||||||
(
|
(
|
||||||
"select {column} from ({sql}) "
|
f"select {escape_sqlite(column)} from ({self.sql}) "
|
||||||
"where {column} is not null "
|
f"where {escape_sqlite(column)} is not null "
|
||||||
"and {column} != '' "
|
f"and {escape_sqlite(column)} != '' "
|
||||||
"and json_array_length({column}) > 0 "
|
f"and json_array_length({escape_sqlite(column)}) > 0 "
|
||||||
"limit 100"
|
"limit 100"
|
||||||
).format(column=escape_sqlite(column), sql=self.sql),
|
),
|
||||||
self.params,
|
self.params,
|
||||||
truncate=False,
|
truncate=False,
|
||||||
custom_time_limit=self.ds.setting(
|
custom_time_limit=self.ds.setting(
|
||||||
|
|
@ -388,14 +384,14 @@ class ArrayFacet(Facet):
|
||||||
source = source_and_config["source"]
|
source = source_and_config["source"]
|
||||||
column = config.get("column") or config["simple"]
|
column = config.get("column") or config["simple"]
|
||||||
# https://github.com/simonw/datasette/issues/448
|
# https://github.com/simonw/datasette/issues/448
|
||||||
facet_sql = """
|
facet_sql = f"""
|
||||||
with inner as ({sql}),
|
with inner as ({self.sql}),
|
||||||
deduped_array_items as (
|
deduped_array_items as (
|
||||||
select
|
select
|
||||||
distinct j.value,
|
distinct j.value,
|
||||||
inner.*
|
inner.*
|
||||||
from
|
from
|
||||||
json_each([inner].{col}) j
|
json_each([inner].{escape_sqlite(column)}) j
|
||||||
join inner
|
join inner
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
|
|
@ -406,12 +402,8 @@ class ArrayFacet(Facet):
|
||||||
group by
|
group by
|
||||||
value
|
value
|
||||||
order by
|
order by
|
||||||
count(*) desc, value limit {limit}
|
count(*) desc, value limit {facet_size + 1}
|
||||||
""".format(
|
"""
|
||||||
col=escape_sqlite(column),
|
|
||||||
sql=self.sql,
|
|
||||||
limit=facet_size + 1,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
facet_rows_results = await self.ds.execute(
|
facet_rows_results = await self.ds.execute(
|
||||||
self.database,
|
self.database,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import json
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
from datasette import hookimpl
|
from datasette import hookimpl
|
||||||
from datasette.resources import DatabaseResource
|
from datasette.resources import DatabaseResource
|
||||||
from datasette.views.base import DatasetteError
|
|
||||||
from datasette.utils.asgi import BadRequest
|
from datasette.utils.asgi import BadRequest
|
||||||
import json
|
from datasette.views.base import DatasetteError
|
||||||
|
|
||||||
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,9 +102,9 @@ def search_filters(request, database, table, datasette):
|
||||||
fts_table=escape_sqlite(fts_table),
|
fts_table=escape_sqlite(fts_table),
|
||||||
search_col=escape_sqlite(search_col),
|
search_col=escape_sqlite(search_col),
|
||||||
match_clause=(
|
match_clause=(
|
||||||
":search_{}".format(i)
|
f":search_{i}"
|
||||||
if search_mode_raw
|
if search_mode_raw
|
||||||
else "escape_fts(:search_{})".format(i)
|
else f"escape_fts(:search_{i})"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -134,11 +137,11 @@ def through_filters(request, database, table, datasette):
|
||||||
value = through_data["value"]
|
value = through_data["value"]
|
||||||
db = datasette.get_database(database)
|
db = datasette.get_database(database)
|
||||||
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
|
||||||
try:
|
fk_to_us = next(
|
||||||
fk_to_us = [
|
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table),
|
||||||
fk for fk in outgoing_foreign_keys if fk["other_table"] == table
|
None,
|
||||||
][0]
|
)
|
||||||
except IndexError:
|
if fk_to_us is None:
|
||||||
raise DatasetteError(
|
raise DatasetteError(
|
||||||
"Invalid _through - could not find corresponding foreign key"
|
"Invalid _through - could not find corresponding foreign key"
|
||||||
)
|
)
|
||||||
|
|
@ -365,7 +368,7 @@ class Filters:
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
_filters_by_key = {f.key: f for f in _filters}
|
_filters_by_key: ClassVar[dict[str, Filter]] = {f.key: f for f in _filters}
|
||||||
|
|
||||||
def __init__(self, pairs):
|
def __init__(self, pairs):
|
||||||
self.pairs = pairs
|
self.pairs = pairs
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from datasette.utils.sqlite import sqlite3
|
|
||||||
from datasette.utils import documented
|
|
||||||
import itertools
|
import itertools
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
|
||||||
|
from datasette.utils import documented
|
||||||
|
from datasette.utils.sqlite import sqlite3
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EXTRA_DATABASE_SQL",
|
"EXTRA_DATABASE_SQL",
|
||||||
"TABLES",
|
"TABLES",
|
||||||
|
|
@ -346,9 +347,7 @@ CREATE VIEW searchable_view_configured_by_metadata AS
|
||||||
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
||||||
+ "\n".join(
|
+ "\n".join(
|
||||||
[
|
[
|
||||||
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
|
f'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'
|
||||||
a=a, b=b, c=c, content=content
|
|
||||||
)
|
|
||||||
for a, b, c, content in generate_compound_rows(1001)
|
for a, b, c, content in generate_compound_rows(1001)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
from datasette import hookimpl, Response
|
from datasette import Response, hookimpl
|
||||||
|
|
||||||
|
from .utils import add_cors_headers
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(trylast=True)
|
@hookimpl(trylast=True)
|
||||||
def forbidden(datasette, request, message):
|
def forbidden(datasette, request, message):
|
||||||
async def inner():
|
async def inner():
|
||||||
|
if (
|
||||||
|
request.path.split("?")[0].endswith(".json")
|
||||||
|
or "application/json" in (request.headers.get("accept") or "")
|
||||||
|
or request.headers.get("content-type") == "application/json"
|
||||||
|
):
|
||||||
|
headers = {}
|
||||||
|
if datasette.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
return Response.error(message, 403, headers=headers)
|
||||||
return Response.html(
|
return Response.html(
|
||||||
await datasette.render_template(
|
await datasette.render_template(
|
||||||
"error.html",
|
"error.html",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
from datasette import hookimpl, Response
|
import traceback
|
||||||
from .utils import add_cors_headers
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from datasette import Response, hookimpl
|
||||||
|
|
||||||
|
from .utils import add_cors_headers, error_body
|
||||||
from .utils.asgi import (
|
from .utils.asgi import (
|
||||||
Base400,
|
Base400,
|
||||||
)
|
)
|
||||||
from .views.base import DatasetteError
|
from .views.base import DatasetteError
|
||||||
from markupsafe import Markup
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
|
# Debugger imports are deliberate - they back the "pdb" setting, which drops
|
||||||
|
# into a debugger on unhandled exceptions
|
||||||
try:
|
try:
|
||||||
import ipdb as pdb
|
import ipdb as pdb # noqa: T100
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import pdb
|
import pdb # noqa: T100
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import rich
|
import rich
|
||||||
|
|
@ -28,6 +33,7 @@ def handle_exception(datasette, request, exception):
|
||||||
rich.get_console().print_exception(show_locals=True)
|
rich.get_console().print_exception(show_locals=True)
|
||||||
|
|
||||||
title = None
|
title = None
|
||||||
|
plain_message = None
|
||||||
if isinstance(exception, Base400):
|
if isinstance(exception, Base400):
|
||||||
status = exception.status
|
status = exception.status
|
||||||
info = {}
|
info = {}
|
||||||
|
|
@ -36,6 +42,7 @@ def handle_exception(datasette, request, exception):
|
||||||
status = exception.status
|
status = exception.status
|
||||||
info = exception.error_dict
|
info = exception.error_dict
|
||||||
message = exception.message
|
message = exception.message
|
||||||
|
plain_message = exception.plain_message
|
||||||
if exception.message_is_html:
|
if exception.message_is_html:
|
||||||
message = Markup(message)
|
message = Markup(message)
|
||||||
title = exception.title
|
title = exception.title
|
||||||
|
|
@ -45,6 +52,13 @@ def handle_exception(datasette, request, exception):
|
||||||
message = str(exception)
|
message = str(exception)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
templates = [f"{status}.html", "error.html"]
|
templates = [f"{status}.html", "error.html"]
|
||||||
|
headers = {}
|
||||||
|
if datasette.cors:
|
||||||
|
add_cors_headers(headers)
|
||||||
|
if request.path.split("?")[0].endswith(".json"):
|
||||||
|
body = dict(info)
|
||||||
|
body.update(error_body(plain_message or message, status))
|
||||||
|
return Response.json(body, status=status, headers=headers)
|
||||||
info.update(
|
info.update(
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
|
|
@ -53,21 +67,18 @@ def handle_exception(datasette, request, exception):
|
||||||
"title": title,
|
"title": title,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
headers = {}
|
environment = datasette.get_jinja_environment(request)
|
||||||
if datasette.cors:
|
template = environment.select_template(templates)
|
||||||
add_cors_headers(headers)
|
return Response.html(
|
||||||
if request.path.split("?")[0].endswith(".json"):
|
await template.render_async(
|
||||||
return Response.json(info, status=status, headers=headers)
|
dict(
|
||||||
else:
|
|
||||||
return Response.html(
|
|
||||||
await datasette.render_template(
|
|
||||||
templates,
|
|
||||||
info,
|
info,
|
||||||
request=request,
|
urls=datasette.urls,
|
||||||
view_name="error",
|
menu_links=list,
|
||||||
),
|
)
|
||||||
status=status,
|
),
|
||||||
headers=headers,
|
status=status,
|
||||||
)
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from pluggy import HookimplMarker
|
from pluggy import HookimplMarker, HookspecMarker
|
||||||
from pluggy import HookspecMarker
|
|
||||||
|
|
||||||
hookspec = HookspecMarker("datasette")
|
hookspec = HookspecMarker("datasette")
|
||||||
hookimpl = HookimplMarker("datasette")
|
hookimpl = HookimplMarker("datasette")
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
detect_spatialite,
|
|
||||||
detect_fts,
|
detect_fts,
|
||||||
detect_primary_keys,
|
detect_primary_keys,
|
||||||
|
detect_spatialite,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
get_all_foreign_keys,
|
get_all_foreign_keys,
|
||||||
table_columns,
|
|
||||||
sqlite3,
|
sqlite3,
|
||||||
|
table_columns,
|
||||||
)
|
)
|
||||||
|
|
||||||
HASH_BLOCK_SIZE = 1024 * 1024
|
HASH_BLOCK_SIZE = 1024 * 1024
|
||||||
|
|
@ -95,10 +95,10 @@ def inspect_tables(conn, database_metadata):
|
||||||
""")
|
""")
|
||||||
]
|
]
|
||||||
|
|
||||||
for t in tables.keys():
|
for t, table_info in tables.items():
|
||||||
for hidden_table in hidden_tables:
|
for hidden_table in hidden_tables:
|
||||||
if t == hidden_table or t.startswith(hidden_table):
|
if t == hidden_table or t.startswith(hidden_table):
|
||||||
tables[t]["hidden"] = True
|
table_info["hidden"] = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return tables
|
return tables
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class JumpSQL:
|
||||||
search_text: str | None = None,
|
search_text: str | None = None,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
item_type: str = "menu",
|
item_type: str = "menu",
|
||||||
) -> "JumpSQL":
|
) -> JumpSQL:
|
||||||
if search_text is None:
|
if search_text is None:
|
||||||
search_text = " ".join(
|
search_text = " ".join(
|
||||||
text for text in (label, display_name, description) if text is not None
|
text for text in (label, display_name, description) if text is not None
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
import contextvars
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
import contextvars
|
|
||||||
|
|
||||||
# Context variable to track when permission checks should be skipped
|
# Context variable to track when permission checks should be skipped
|
||||||
_skip_permission_checks = contextvars.ContextVar(
|
_skip_permission_checks = contextvars.ContextVar(
|
||||||
|
|
@ -72,8 +72,8 @@ class Resource(ABC):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "{}(parent={!r}, child={!r})".format(
|
return (
|
||||||
self.__class__.__name__, self.parent, self.child
|
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -129,7 +129,6 @@ class Resource(ABC):
|
||||||
|
|
||||||
Must return two columns: parent, child
|
Must return two columns: parent, child
|
||||||
"""
|
"""
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class AllowedResource(NamedTuple):
|
class AllowedResource(NamedTuple):
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
import importlib
|
import importlib
|
||||||
|
import importlib.metadata as importlib_metadata
|
||||||
|
import importlib.resources as importlib_resources
|
||||||
import os
|
import os
|
||||||
import pluggy
|
|
||||||
from pprint import pprint
|
|
||||||
import sys
|
import sys
|
||||||
|
from pprint import pprint
|
||||||
|
|
||||||
|
import pluggy
|
||||||
|
|
||||||
from . import hookspecs
|
from . import hookspecs
|
||||||
|
|
||||||
if sys.version_info >= (3, 9):
|
|
||||||
import importlib.resources as importlib_resources
|
|
||||||
else:
|
|
||||||
import importlib_resources
|
|
||||||
if sys.version_info >= (3, 10):
|
|
||||||
import importlib.metadata as importlib_metadata
|
|
||||||
else:
|
|
||||||
import importlib_metadata
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_PLUGINS = (
|
DEFAULT_PLUGINS = (
|
||||||
"datasette.publish.heroku",
|
"datasette.publish.heroku",
|
||||||
"datasette.publish.cloudrun",
|
"datasette.publish.cloudrun",
|
||||||
|
|
@ -31,6 +25,7 @@ DEFAULT_PLUGINS = (
|
||||||
"datasette.default_debug_menu",
|
"datasette.default_debug_menu",
|
||||||
"datasette.default_jump_items",
|
"datasette.default_jump_items",
|
||||||
"datasette.default_database_actions",
|
"datasette.default_database_actions",
|
||||||
|
"datasette.default_table_actions",
|
||||||
"datasette.default_query_actions",
|
"datasette.default_query_actions",
|
||||||
"datasette.handle_exception",
|
"datasette.handle_exception",
|
||||||
"datasette.forbidden",
|
"datasette.forbidden",
|
||||||
|
|
@ -84,7 +79,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
|
||||||
# Ensure name can be found in plugin_to_distinfo later:
|
# Ensure name can be found in plugin_to_distinfo later:
|
||||||
pm._plugin_distinfo.append((mod, distribution))
|
pm._plugin_distinfo.append((mod, distribution))
|
||||||
except importlib_metadata.PackageNotFoundError:
|
except importlib_metadata.PackageNotFoundError:
|
||||||
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
|
sys.stderr.write(f"Plugin {package_name} could not be found\n")
|
||||||
|
|
||||||
|
|
||||||
# Load default plugins
|
# Load default plugins
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
from datasette import hookimpl
|
|
||||||
import click
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from subprocess import CalledProcessError, check_call, check_output
|
from subprocess import CalledProcessError, check_call, check_output
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
from ..utils import temporary_docker_directory
|
||||||
from .common import (
|
from .common import (
|
||||||
add_common_publish_arguments_and_options,
|
add_common_publish_arguments_and_options,
|
||||||
fail_if_publish_binary_not_installed,
|
fail_if_publish_binary_not_installed,
|
||||||
)
|
)
|
||||||
from ..utils import temporary_docker_directory
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -219,7 +221,7 @@ def publish_subcommand(publish):
|
||||||
|
|
||||||
check_call(
|
check_call(
|
||||||
"gcloud builds submit --tag {}{}".format(
|
"gcloud builds submit --tag {}{}".format(
|
||||||
image_id, " --timeout {}".format(timeout) if timeout else ""
|
image_id, f" --timeout {timeout}" if timeout else ""
|
||||||
),
|
),
|
||||||
shell=True,
|
shell=True,
|
||||||
)
|
)
|
||||||
|
|
@ -231,7 +233,7 @@ def publish_subcommand(publish):
|
||||||
("--min-instances", min_instances),
|
("--min-instances", min_instances),
|
||||||
):
|
):
|
||||||
if value is not None:
|
if value is not None:
|
||||||
extra_deploy_options.append("{} {}".format(option, value))
|
extra_deploy_options.append(f"{option} {value}")
|
||||||
check_call(
|
check_call(
|
||||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||||
image_id,
|
image_id,
|
||||||
|
|
@ -258,24 +260,16 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
describe_cmd = (
|
describe_cmd = (
|
||||||
"gcloud artifacts repositories describe {repo} --project {project} "
|
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} "
|
||||||
"--location {location} --quiet"
|
f"--location {artifact_region} --quiet"
|
||||||
).format(
|
|
||||||
repo=artifact_repository,
|
|
||||||
project=artifact_project,
|
|
||||||
location=artifact_region,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
check_call(describe_cmd, shell=True)
|
check_call(describe_cmd, shell=True)
|
||||||
return
|
return
|
||||||
except CalledProcessError:
|
except CalledProcessError:
|
||||||
create_cmd = (
|
create_cmd = (
|
||||||
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker "
|
||||||
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet'
|
||||||
).format(
|
|
||||||
repo=artifact_repository,
|
|
||||||
location=artifact_region,
|
|
||||||
project=artifact_project,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
check_call(create_cmd, shell=True)
|
check_call(create_cmd, shell=True)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
from ..utils import StaticMount
|
|
||||||
import click
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from ..utils import StaticMount
|
||||||
|
|
||||||
|
|
||||||
def add_common_publish_arguments_and_options(subcommand):
|
def add_common_publish_arguments_and_options(subcommand):
|
||||||
for decorator in reversed(
|
for decorator in reversed(
|
||||||
|
|
@ -76,9 +78,7 @@ def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
|
||||||
"""Exit (with error message) if ``binary` isn't installed"""
|
"""Exit (with error message) if ``binary` isn't installed"""
|
||||||
if not shutil.which(binary):
|
if not shutil.which(binary):
|
||||||
click.secho(
|
click.secho(
|
||||||
"Publishing to {publish_target} requires {binary} to be installed and configured".format(
|
f"Publishing to {publish_target} requires {binary} to be installed and configured",
|
||||||
publish_target=publish_target, binary=binary
|
|
||||||
),
|
|
||||||
bg="red",
|
bg="red",
|
||||||
fg="white",
|
fg="white",
|
||||||
bold=True,
|
bold=True,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
from contextlib import contextmanager
|
|
||||||
from datasette import hookimpl
|
|
||||||
import click
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
from subprocess import call, check_output
|
|
||||||
import tempfile
|
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
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
add_common_publish_arguments_and_options,
|
add_common_publish_arguments_and_options,
|
||||||
fail_if_publish_binary_not_installed,
|
fail_if_publish_binary_not_installed,
|
||||||
)
|
)
|
||||||
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|
@ -234,7 +236,7 @@ def temporary_heroku_directory(
|
||||||
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
extras.extend(["--static", f"{mount_point}:{mount_point}"])
|
||||||
|
|
||||||
quoted_files = " ".join(
|
quoted_files = " ".join(
|
||||||
["-i {}".format(shlex.quote(file_name)) for file_name in file_names]
|
[f"-i {shlex.quote(file_name)}" for file_name in file_names]
|
||||||
)
|
)
|
||||||
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
|
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
|
||||||
quoted_files=quoted_files, extras=" ".join(extras)
|
quoted_files=quoted_files, extras=" ".join(extras)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from datasette.extras import extra_names_from_request
|
from datasette.extras import extra_names_from_request
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
value_as_boolean,
|
|
||||||
remove_infinites,
|
|
||||||
CustomJSONEncoder,
|
CustomJSONEncoder,
|
||||||
|
error_body,
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
|
remove_infinites,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
|
value_as_boolean,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
|
|
||||||
|
|
@ -52,8 +54,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
||||||
if error:
|
if error:
|
||||||
shape = "objects"
|
shape = "objects"
|
||||||
status_code = 400
|
status_code = 400
|
||||||
data["error"] = error
|
data.update(error_body(error, status_code))
|
||||||
data["ok"] = False
|
|
||||||
|
|
||||||
if truncated is not None:
|
if truncated is not None:
|
||||||
data["truncated"] = truncated
|
data["truncated"] = truncated
|
||||||
|
|
@ -87,7 +88,8 @@ def json_renderer(request, args, data, error, truncated=None):
|
||||||
object_rows[pk_string] = row
|
object_rows[pk_string] = row
|
||||||
data = object_rows
|
data = object_rows
|
||||||
if shape_error:
|
if shape_error:
|
||||||
data = {"ok": False, "error": shape_error}
|
status_code = 400
|
||||||
|
data = error_body(shape_error, status_code)
|
||||||
elif shape == "array":
|
elif shape == "array":
|
||||||
data = data["rows"]
|
data = data["rows"]
|
||||||
|
|
||||||
|
|
@ -100,12 +102,7 @@ def json_renderer(request, args, data, error, truncated=None):
|
||||||
data["rows"] = [list(row.values()) for row in data["rows"]]
|
data["rows"] = [list(row.values()) for row in data["rows"]]
|
||||||
else:
|
else:
|
||||||
status_code = 400
|
status_code = 400
|
||||||
data = {
|
data = error_body(f"Invalid _shape: {shape}", status_code)
|
||||||
"ok": False,
|
|
||||||
"error": f"Invalid _shape: {shape}",
|
|
||||||
"status": 400,
|
|
||||||
"title": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Don't include "columns" in output
|
# Don't include "columns" in output
|
||||||
# https://github.com/simonw/datasette/issues/2136
|
# https://github.com/simonw/datasette/issues/2136
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -25,29 +25,6 @@ const DOM_SELECTORS = {
|
||||||
facetResults: ".facet-results [data-column]",
|
facetResults: ".facet-results [data-column]",
|
||||||
};
|
};
|
||||||
|
|
||||||
let editToolsPromise = null;
|
|
||||||
let autocompletePromise = null;
|
|
||||||
|
|
||||||
function datasettePath(path) {
|
|
||||||
const baseUrl = window.datasetteBaseUrl || "/";
|
|
||||||
return baseUrl.replace(/\/?$/, "/") + String(path || "").replace(/^\/+/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInsertDialogRow(value) {
|
|
||||||
if (!value) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const row = JSON.parse(value);
|
|
||||||
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
||||||
throw new Error("row must be an object");
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error("Invalid insert dialog row: " + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monolith class for interacting with Datasette JS API
|
* Monolith class for interacting with Datasette JS API
|
||||||
* Imported with DEFER, runs after main document parsed
|
* Imported with DEFER, runs after main document parsed
|
||||||
|
|
@ -62,62 +39,6 @@ const datasetteManager = {
|
||||||
// Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?
|
// Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?
|
||||||
plugins: new Map(),
|
plugins: new Map(),
|
||||||
|
|
||||||
path: datasettePath,
|
|
||||||
|
|
||||||
loadAutocomplete: () => {
|
|
||||||
if (!window.customElements) {
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
if (customElements.get("datasette-autocomplete")) {
|
|
||||||
return Promise.resolve(customElements.get("datasette-autocomplete"));
|
|
||||||
}
|
|
||||||
if (!autocompletePromise) {
|
|
||||||
const url =
|
|
||||||
window.datasetteAutocompleteUrl ||
|
|
||||||
datasettePath("/-/static/autocomplete.js");
|
|
||||||
autocompletePromise = import(url).then(() =>
|
|
||||||
customElements.get("datasette-autocomplete"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return autocompletePromise;
|
|
||||||
},
|
|
||||||
|
|
||||||
loadEditTools: () => {
|
|
||||||
if (window.__DATASETTE_EDIT_TOOLS__) {
|
|
||||||
if (window.__DATASETTE_EDIT_TOOLS__.install) {
|
|
||||||
window.__DATASETTE_EDIT_TOOLS__.install(datasetteManager);
|
|
||||||
}
|
|
||||||
return Promise.resolve(window.__DATASETTE_EDIT_TOOLS__);
|
|
||||||
}
|
|
||||||
if (!editToolsPromise) {
|
|
||||||
const url =
|
|
||||||
window.datasetteEditToolsUrl ||
|
|
||||||
datasettePath("/-/static/edit-tools.js");
|
|
||||||
editToolsPromise = import(url).then(() => {
|
|
||||||
if (!window.__DATASETTE_EDIT_TOOLS__) {
|
|
||||||
throw new Error("edit-tools.js did not register its API");
|
|
||||||
}
|
|
||||||
if (window.__DATASETTE_EDIT_TOOLS__.install) {
|
|
||||||
window.__DATASETTE_EDIT_TOOLS__.install(datasetteManager);
|
|
||||||
}
|
|
||||||
return window.__DATASETTE_EDIT_TOOLS__;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return editToolsPromise;
|
|
||||||
},
|
|
||||||
|
|
||||||
insertDialog: async (database, table, row, message, options) => {
|
|
||||||
const editTools = await datasetteManager.loadEditTools();
|
|
||||||
return editTools.insertDialog(
|
|
||||||
datasetteManager,
|
|
||||||
database,
|
|
||||||
table,
|
|
||||||
row,
|
|
||||||
message,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
registerPlugin: (name, pluginMetadata) => {
|
registerPlugin: (name, pluginMetadata) => {
|
||||||
if (datasetteManager.plugins.has(name)) {
|
if (datasetteManager.plugins.has(name)) {
|
||||||
console.warn(`Warning -> plugin ${name} was redefined`);
|
console.warn(`Warning -> plugin ${name} was redefined`);
|
||||||
|
|
@ -309,7 +230,9 @@ const datasetteManager = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const initializeDatasette = () => {
|
const initializeDatasette = () => {
|
||||||
window.datasette = datasetteManager;
|
// Hide the global behind __ prefix. Ideally they should be listening for the
|
||||||
|
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
||||||
|
|
||||||
window.__DATASETTE__ = datasetteManager;
|
window.__DATASETTE__ = datasetteManager;
|
||||||
|
|
||||||
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
||||||
|
|
@ -317,43 +240,6 @@ const initializeDatasette = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
document.dispatchEvent(initDatasetteEvent);
|
document.dispatchEvent(initDatasetteEvent);
|
||||||
|
|
||||||
document.addEventListener("click", function (event) {
|
|
||||||
const button = event.target.closest("[data-insert-dialog]");
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
let row;
|
|
||||||
try {
|
|
||||||
row = parseInsertDialogRow(button.dataset.row || "{}");
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reloadOnInsert = button.hasAttribute("data-insert-dialog-reload");
|
|
||||||
datasetteManager
|
|
||||||
.insertDialog(
|
|
||||||
button.dataset.database,
|
|
||||||
button.dataset.table,
|
|
||||||
row,
|
|
||||||
button.dataset.message || "",
|
|
||||||
{ flashMessage: reloadOnInsert },
|
|
||||||
)
|
|
||||||
.then((result) => {
|
|
||||||
if (
|
|
||||||
reloadOnInsert &&
|
|
||||||
result &&
|
|
||||||
result.ok &&
|
|
||||||
result.status === "inserted"
|
|
||||||
) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -633,32 +633,151 @@ const initDatasetteTable = function (manager) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Add x buttons to the filter rows */
|
function filterRowSelector(manager) {
|
||||||
function addButtonsToFilterRows(manager) {
|
return manager.selectors.filterRows || manager.selectors.filterRow;
|
||||||
var x = "✖";
|
}
|
||||||
var rows = Array.from(
|
|
||||||
document.querySelectorAll(manager.selectors.filterRow),
|
function filterRowsWithControls(manager) {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll(filterRowSelector(manager)),
|
||||||
).filter((el) => el.querySelector(".filter-op"));
|
).filter((el) => el.querySelector(".filter-op"));
|
||||||
rows.forEach((row) => {
|
}
|
||||||
var a = document.createElement("a");
|
|
||||||
a.setAttribute("href", "#");
|
function filterRowNumberFromName(name) {
|
||||||
a.setAttribute("aria-label", "Remove this filter");
|
var match = name && name.match(/^_filter_column_(\d+)$/);
|
||||||
a.style.textDecoration = "none";
|
return match ? parseInt(match[1], 10) : 0;
|
||||||
a.innerText = x;
|
}
|
||||||
a.addEventListener("click", (ev) => {
|
|
||||||
ev.preventDefault();
|
function nextFilterRowNumber(manager) {
|
||||||
let row = ev.target.closest("div");
|
return filterRowsWithControls(manager).reduce((max, row) => {
|
||||||
row.querySelector("select").value = "";
|
|
||||||
row.querySelector(".filter-op select").value = "exact";
|
|
||||||
row.querySelector("input.filter-value").value = "";
|
|
||||||
ev.target.closest("a").style.display = "none";
|
|
||||||
});
|
|
||||||
row.appendChild(a);
|
|
||||||
var column = row.querySelector("select");
|
var column = row.querySelector("select");
|
||||||
if (!column.value) {
|
return Math.max(max, filterRowNumberFromName(column && column.name));
|
||||||
a.style.display = "none";
|
}, 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilterRowNumber(row, number) {
|
||||||
|
row.querySelector("select").name = `_filter_column_${number}`;
|
||||||
|
row.querySelector(".filter-op select").name = `_filter_op_${number}`;
|
||||||
|
row.querySelector("input.filter-value").name = `_filter_value_${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilterRow(row) {
|
||||||
|
row.querySelector("select").value = "";
|
||||||
|
row.querySelector(".filter-op select").value = "exact";
|
||||||
|
row.querySelector("input.filter-value").value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterRowButtons(manager) {
|
||||||
|
var rows = filterRowsWithControls(manager);
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
var removeButton = row.querySelector(".filter-row-remove");
|
||||||
|
var addButton = row.querySelector(".filter-row-add");
|
||||||
|
var column = row.querySelector("select");
|
||||||
|
if (removeButton) {
|
||||||
|
removeButton.hidden = index === 0;
|
||||||
|
}
|
||||||
|
if (addButton) {
|
||||||
|
addButton.hidden = index !== rows.length - 1 || !column.value;
|
||||||
|
}
|
||||||
|
var visibleButtonCount = [removeButton, addButton].filter(function (button) {
|
||||||
|
return button && !button.hidden;
|
||||||
|
}).length;
|
||||||
|
row.classList.toggle(
|
||||||
|
"filter-controls-row-has-buttons",
|
||||||
|
visibleButtonCount > 0,
|
||||||
|
);
|
||||||
|
row.classList.toggle(
|
||||||
|
"filter-controls-row-one-button",
|
||||||
|
visibleButtonCount === 1,
|
||||||
|
);
|
||||||
|
row.classList.toggle(
|
||||||
|
"filter-controls-row-two-buttons",
|
||||||
|
visibleButtonCount === 2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneFilterRow(row) {
|
||||||
|
var clone = row.cloneNode(true);
|
||||||
|
clone.querySelector("select").name = "_filter_column";
|
||||||
|
clone.querySelector(".filter-op select").name = "_filter_op";
|
||||||
|
clone.querySelector("input.filter-value").name = "_filter_value";
|
||||||
|
resetFilterRow(clone);
|
||||||
|
clone.querySelectorAll(".filter-row-icon").forEach((button) => button.remove());
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
var FILTER_REMOVE_ICON_SVG = `<svg class="filter-row-remove-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"></path>
|
||||||
|
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
|
||||||
|
<path d="M10 11v6"></path>
|
||||||
|
<path d="M14 11v6"></path>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
var FILTER_ADD_ICON_SVG = `<svg class="filter-row-add-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 12h14"></path>
|
||||||
|
<path d="M12 5v14"></path>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
function addFilterRowButtons(row, manager) {
|
||||||
|
var removeButton = document.createElement("button");
|
||||||
|
removeButton.type = "button";
|
||||||
|
removeButton.className = "filter-row-icon filter-row-remove";
|
||||||
|
removeButton.setAttribute("aria-label", "Remove this filter");
|
||||||
|
removeButton.title = "Remove this filter";
|
||||||
|
removeButton.tabIndex = 0;
|
||||||
|
removeButton.innerHTML = FILTER_REMOVE_ICON_SVG;
|
||||||
|
removeButton.addEventListener("click", (ev) => {
|
||||||
|
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
||||||
|
var rows = filterRowsWithControls(manager);
|
||||||
|
var rowIndex = rows.indexOf(row);
|
||||||
|
var focusRow = rows[rowIndex + 1] || rows[rowIndex - 1] || null;
|
||||||
|
row.remove();
|
||||||
|
updateFilterRowButtons(manager);
|
||||||
|
if (focusRow) {
|
||||||
|
var focusTarget =
|
||||||
|
focusRow.querySelector(".filter-row-add:not([hidden])") ||
|
||||||
|
focusRow.querySelector("select");
|
||||||
|
if (focusTarget) {
|
||||||
|
focusTarget.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
row.appendChild(removeButton);
|
||||||
|
|
||||||
|
var addButton = document.createElement("button");
|
||||||
|
addButton.type = "button";
|
||||||
|
addButton.className = "filter-row-icon filter-row-add";
|
||||||
|
addButton.setAttribute("aria-label", "Add another filter");
|
||||||
|
addButton.title = "Add another filter";
|
||||||
|
addButton.tabIndex = 0;
|
||||||
|
addButton.innerHTML = FILTER_ADD_ICON_SVG;
|
||||||
|
addButton.addEventListener("click", (ev) => {
|
||||||
|
var row = ev.currentTarget.closest(filterRowSelector(manager));
|
||||||
|
if (row.querySelector("select").name === "_filter_column") {
|
||||||
|
setFilterRowNumber(row, nextFilterRowNumber(manager));
|
||||||
|
}
|
||||||
|
var clone = cloneFilterRow(row);
|
||||||
|
addFilterRowButtons(clone, manager);
|
||||||
|
row.parentNode.insertBefore(clone, row.nextSibling);
|
||||||
|
updateFilterRowButtons(manager);
|
||||||
|
clone.querySelector("select").focus();
|
||||||
|
});
|
||||||
|
row.appendChild(addButton);
|
||||||
|
|
||||||
|
row.querySelector("select").addEventListener("change", () => {
|
||||||
|
updateFilterRowButtons(manager);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add buttons to the filter rows */
|
||||||
|
function addButtonsToFilterRows(manager) {
|
||||||
|
var rows = filterRowsWithControls(manager);
|
||||||
|
rows.forEach((row) => {
|
||||||
|
addFilterRowButtons(row, manager);
|
||||||
|
});
|
||||||
|
updateFilterRowButtons(manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Set up datalist autocomplete for filter values */
|
/* Set up datalist autocomplete for filter values */
|
||||||
|
|
@ -687,11 +806,11 @@ function initAutocompleteForFilterValues(manager) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
createDataLists();
|
createDataLists();
|
||||||
// When any select with name=_filter_column changes, update the datalist
|
// When any filter column select changes, update the datalist
|
||||||
document.body.addEventListener("change", function (event) {
|
document.body.addEventListener("change", function (event) {
|
||||||
if (event.target.name === "_filter_column") {
|
if (event.target.name && event.target.name.startsWith("_filter_column")) {
|
||||||
event.target
|
event.target
|
||||||
.closest(manager.selectors.filterRow)
|
.closest(filterRowSelector(manager))
|
||||||
.querySelector(".filter-value")
|
.querySelector(".filter-value")
|
||||||
.setAttribute("list", "datalist-" + event.target.value);
|
.setAttribute("list", "datalist-" + event.target.value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Iterable
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .utils import tilde_encode, urlsafe_components
|
from .utils import tilde_encode, urlsafe_components
|
||||||
|
|
||||||
|
|
@ -62,7 +63,6 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
|
||||||
"description_html": query.description_html,
|
"description_html": query.description_html,
|
||||||
"hide_sql": query.hide_sql,
|
"hide_sql": query.hide_sql,
|
||||||
"fragment": query.fragment,
|
"fragment": query.fragment,
|
||||||
"params": list(query.parameters),
|
|
||||||
"parameters": list(query.parameters),
|
"parameters": list(query.parameters),
|
||||||
"is_write": query.is_write,
|
"is_write": query.is_write,
|
||||||
"is_private": query.is_private,
|
"is_private": query.is_private,
|
||||||
|
|
@ -84,7 +84,6 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
"queries": [stored_query_to_dict(query) for query in page.queries],
|
||||||
"next": page.next,
|
"next": page.next,
|
||||||
"has_more": page.has_more,
|
|
||||||
"limit": page.limit,
|
"limit": page.limit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,7 +387,7 @@ async def count_queries(
|
||||||
OR q.sql LIKE :query_search
|
OR q.sql LIKE :query_search
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
params["query_search"] = "%{}%".format(q)
|
params["query_search"] = f"%{q}%"
|
||||||
if is_write is not None:
|
if is_write is not None:
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
where_clauses.append("q.is_write = :query_is_write")
|
||||||
params["query_is_write"] = int(bool(is_write))
|
params["query_is_write"] = int(bool(is_write))
|
||||||
|
|
@ -464,7 +463,7 @@ async def list_queries(
|
||||||
except ValueError:
|
except ValueError:
|
||||||
components = []
|
components = []
|
||||||
if database is None and len(components) == 3:
|
if database is None and len(components) == 3:
|
||||||
where_clauses.append("""
|
where_clauses.append(f"""
|
||||||
(
|
(
|
||||||
q.database_name > :cursor_database
|
q.database_name > :cursor_database
|
||||||
OR (
|
OR (
|
||||||
|
|
@ -478,12 +477,12 @@ async def list_queries(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""".format(sort_key_sql=sort_key_sql))
|
""")
|
||||||
params["cursor_database"] = components[0]
|
params["cursor_database"] = components[0]
|
||||||
params["cursor_sort_key"] = components[1]
|
params["cursor_sort_key"] = components[1]
|
||||||
params["cursor_name"] = components[2]
|
params["cursor_name"] = components[2]
|
||||||
elif database is not None and len(components) == 2:
|
elif database is not None and len(components) == 2:
|
||||||
where_clauses.append("""
|
where_clauses.append(f"""
|
||||||
(
|
(
|
||||||
{sort_key_sql} > :cursor_sort_key
|
{sort_key_sql} > :cursor_sort_key
|
||||||
OR (
|
OR (
|
||||||
|
|
@ -491,7 +490,7 @@ async def list_queries(
|
||||||
AND q.name > :cursor_name
|
AND q.name > :cursor_name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
""".format(sort_key_sql=sort_key_sql))
|
""")
|
||||||
params["cursor_sort_key"] = components[0]
|
params["cursor_sort_key"] = components[0]
|
||||||
params["cursor_name"] = components[1]
|
params["cursor_name"] = components[1]
|
||||||
|
|
||||||
|
|
@ -504,7 +503,7 @@ async def list_queries(
|
||||||
OR q.sql LIKE :query_search
|
OR q.sql LIKE :query_search
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
params["query_search"] = "%{}%".format(q)
|
params["query_search"] = f"%{q}%"
|
||||||
if is_write is not None:
|
if is_write is not None:
|
||||||
where_clauses.append("q.is_write = :query_is_write")
|
where_clauses.append("q.is_write = :query_is_write")
|
||||||
params["query_is_write"] = int(bool(is_write))
|
params["query_is_write"] = int(bool(is_write))
|
||||||
|
|
|
||||||
40
datasette/template_contexts.py
Normal file
40
datasette/template_contexts.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""
|
||||||
|
Index of the documented template contexts for Datasette's core HTML pages.
|
||||||
|
|
||||||
|
This module deliberately contains no documentation strings of its own -
|
||||||
|
the documentation lives next to the code it describes:
|
||||||
|
|
||||||
|
- Every page renders a Context dataclass defined in its view module
|
||||||
|
(DatabaseContext, QueryContext in views/database.py, TableContext in
|
||||||
|
views/table.py, RowContext in views/row.py). Fields added by view code
|
||||||
|
carry ``help`` metadata; fields declared with from_extra() take their
|
||||||
|
documentation from the description on the matching Extra class in
|
||||||
|
views/table_extras.py.
|
||||||
|
- The keys render_template() adds to every page are documented in
|
||||||
|
TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds
|
||||||
|
them.
|
||||||
|
|
||||||
|
The contract tests in tests/test_template_context.py assert that the real
|
||||||
|
rendered context for each page exactly matches what is documented, and
|
||||||
|
docs/template_context_doc.py generates docs/template_context.rst from the
|
||||||
|
same classes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datasette.app import TEMPLATE_BASE_CONTEXT
|
||||||
|
from datasette.views.database import DatabaseContext, QueryContext
|
||||||
|
from datasette.views.row import RowContext
|
||||||
|
from datasette.views.table import TableContext
|
||||||
|
|
||||||
|
PAGES = {
|
||||||
|
"database": DatabaseContext,
|
||||||
|
"query": QueryContext,
|
||||||
|
"table": TableContext,
|
||||||
|
"row": RowContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def documented_context_keys(page_name):
|
||||||
|
"Set of every documented key for the named page, including base context keys"
|
||||||
|
return set(TEMPLATE_BASE_CONTEXT) | {
|
||||||
|
f.name for f in PAGES[page_name].documented_fields()
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
|
<script src="{{ static('sql-formatter-2.3.3.min.js') }}" defer></script>
|
||||||
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
|
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
|
||||||
<style>
|
<style>
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
resize: both;
|
resize: both;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,20 @@
|
||||||
padding: 1.5em;
|
padding: 1.5em;
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
}
|
}
|
||||||
|
.permission-form form {
|
||||||
|
max-width: 60rem;
|
||||||
|
}
|
||||||
|
.permission-form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.permission-form-result {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-width: 60rem;
|
||||||
|
}
|
||||||
.form-section {
|
.form-section {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1.25em;
|
||||||
}
|
}
|
||||||
.form-section label {
|
.form-section label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -15,22 +27,51 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.form-section input[type="text"],
|
.form-section input[type="text"],
|
||||||
.form-section select {
|
.form-section input[type="number"],
|
||||||
width: 100%;
|
.form-section select,
|
||||||
max-width: 500px;
|
.permission-textarea {
|
||||||
padding: 0.5em;
|
background-color: #fff;
|
||||||
|
border: 1px solid #aaa;
|
||||||
|
border-radius: 4px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid #ccc;
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||||
border-radius: 3px;
|
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;
|
||||||
}
|
}
|
||||||
.form-section input[type="text"]:focus,
|
.form-section input[type="text"]:focus,
|
||||||
.form-section select:focus {
|
.form-section input[type="number"]:focus,
|
||||||
outline: 2px solid #0066cc;
|
.form-section select:focus,
|
||||||
|
.permission-textarea:focus {
|
||||||
border-color: #0066cc;
|
border-color: #0066cc;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.18);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
.form-section small {
|
.form-section small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.3em;
|
margin-top: 0.45em;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
|
|
@ -142,4 +183,9 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
.permission-form-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,10 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<nav class="permissions-debug-tabs">
|
<nav class="permissions-debug-tabs">
|
||||||
<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 %}>Explain</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 %}>Access map</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 %}>Rule explorer</a>
|
||||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
|
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Activity</a>
|
||||||
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
||||||
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,11 @@
|
||||||
{% block title %}Debug allow rules{% endblock %}
|
{% block title %}Debug allow rules{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
|
{% include "_permission_ui_styles.html" %}
|
||||||
<style>
|
<style>
|
||||||
textarea {
|
|
||||||
height: 10em;
|
|
||||||
width: 95%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0.5em;
|
|
||||||
border: 2px dotted black;
|
|
||||||
}
|
|
||||||
.two-col {
|
|
||||||
display: inline-block;
|
|
||||||
width: 48%;
|
|
||||||
}
|
|
||||||
.two-col label {
|
|
||||||
width: 48%;
|
|
||||||
}
|
|
||||||
p.message-warning {
|
p.message-warning {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@media only screen and (max-width: 576px) {
|
|
||||||
.two-col {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -38,24 +20,28 @@ p.message-warning {
|
||||||
|
|
||||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||||
|
|
||||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
<div class="permission-form">
|
||||||
<div class="two-col">
|
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get">
|
||||||
<p><label>Allow block</label></p>
|
<div class="permission-form-grid">
|
||||||
<textarea name="allow">{{ allow_input }}</textarea>
|
<div class="form-section">
|
||||||
</div>
|
<label for="allow-block">Allow block</label>
|
||||||
<div class="two-col">
|
<textarea class="permission-textarea" id="allow-block" name="allow">{{ allow_input }}</textarea>
|
||||||
<p><label>Actor</label></p>
|
</div>
|
||||||
<textarea name="actor">{{ actor_input }}</textarea>
|
<div class="form-section">
|
||||||
</div>
|
<label for="allow-actor">Actor</label>
|
||||||
<div style="margin-top: 1em;">
|
<textarea class="permission-textarea" id="allow-actor" name="actor">{{ actor_input }}</textarea>
|
||||||
<input type="submit" value="Apply allow block to actor">
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="submit-btn">Apply allow block to actor</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
|
{% if error %}<p class="message-warning permission-form-result">{{ error }}</p>{% endif %}
|
||||||
|
|
||||||
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
|
{% if result == "True" %}<p class="message-info permission-form-result">Result: allow</p>{% endif %}
|
||||||
|
|
||||||
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
|
{% if result == "False" %}<p class="message-error permission-form-result">Result: deny</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}API Explorer{% endblock %}
|
{% block title %}API Explorer{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,13 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
<link rel="stylesheet" href="{{ static('app.css') }}">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
{% for url in extra_css_urls %}
|
{% for url in extra_css_urls %}
|
||||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<script>
|
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||||
window.datasetteVersion = {{ datasette_version|tojson }};
|
<script src="{{ static('datasette-manager.js') }}" defer></script>
|
||||||
window.datasetteBaseUrl = {{ base_url|tojson }};
|
|
||||||
window.datasetteEditToolsUrl = {{ (urls.static('edit-tools.js') ~ '?hash=' ~ edit_tools_js_hash)|tojson }};
|
|
||||||
window.datasetteAutocompleteUrl = {{ urls.static('autocomplete.js')|tojson }};
|
|
||||||
</script>
|
|
||||||
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
|
||||||
{% for url in extra_js_urls %}
|
{% for url in extra_js_urls %}
|
||||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -75,7 +70,7 @@ window.datasetteAutocompleteUrl = {{ urls.static('autocomplete.js')|tojson }};
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
<script src="{{ static('navigation-search.js') }}" defer></script>
|
||||||
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
|
<navigation-search url="{{ urls.path("/-/jump") }}"></navigation-search>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
{% include "_codemirror.html" %}
|
{% include "_codemirror.html" %}
|
||||||
{% include "_sql_parameter_styles.html" %}
|
{% include "_sql_parameter_styles.html" %}
|
||||||
|
{% if database_page_data.createTable %}
|
||||||
|
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
|
||||||
|
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||||
|
|
@ -72,7 +76,7 @@
|
||||||
<div class="db-table">
|
<div class="db-table">
|
||||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||||
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}>{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% include "_permissions_debug_tabs.html" %}
|
||||||
|
|
||||||
<p style="margin-bottom: 2em;">
|
<p style="margin-bottom: 2em;">
|
||||||
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
|
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
|
||||||
Actions are used by the permission system to control access to different features.
|
Actions are used by the permission system to control access to different features.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for action in data %}
|
{% for action in data.actions %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ action.name }}</strong></td>
|
<td><strong>{{ action.name }}</strong></td>
|
||||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}Allowed Resources{% endblock %}
|
{% block title %}Allowed Resources{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
||||||
(function() {
|
(function() {
|
||||||
const params = populateFormFromURL();
|
const params = populateFormFromURL();
|
||||||
const action = params.get('action');
|
const action = params.get('action');
|
||||||
const page = params.get('page');
|
const page = params.get('_page');
|
||||||
if (action) {
|
if (action) {
|
||||||
fetchResults(page ? parseInt(page) : 1);
|
fetchResults(page ? parseInt(page) : 1);
|
||||||
}
|
}
|
||||||
|
|
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
if (value && key !== 'page_size') {
|
if (value && key !== '_size' && key !== '_page') {
|
||||||
params.append(key, value);
|
params.append(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = document.getElementById('page_size').value || '50';
|
const pageSize = document.getElementById('page_size').value || '50';
|
||||||
params.append('page', page.toString());
|
params.append('_page', page.toString());
|
||||||
params.append('page_size', pageSize);
|
params.append('_size', pageSize);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Permission Check{% endblock %}
|
{% block title %}Explain a permission decision{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -13,29 +13,35 @@
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
#output.allowed {
|
#output.allowed {
|
||||||
background-color: #e8f5e9;
|
background-color: #f3fbf4;
|
||||||
border: 2px solid #4caf50;
|
border: 2px solid #4caf50;
|
||||||
}
|
}
|
||||||
#output.denied {
|
#output.denied {
|
||||||
background-color: #ffebee;
|
background-color: #fff7f7;
|
||||||
border: 2px solid #f44336;
|
border: 2px solid #f44336;
|
||||||
}
|
}
|
||||||
#output h2 {
|
#output h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
#output .result-badge {
|
#output h3 {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
#output .result-badge,
|
||||||
|
.effect-badge,
|
||||||
|
.rule-status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.3em 0.8em;
|
padding: 0.2em 0.5em;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
}
|
||||||
#output .allowed-badge {
|
#output .allowed-badge,
|
||||||
background-color: #4caf50;
|
.effect-allow {
|
||||||
|
background-color: #2e7d32;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
#output .denied-badge {
|
#output .denied-badge,
|
||||||
background-color: #f44336;
|
.effect-deny {
|
||||||
|
background-color: #c62828;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.details-section {
|
.details-section {
|
||||||
|
|
@ -48,70 +54,130 @@
|
||||||
.details-section dd {
|
.details-section dd {
|
||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
}
|
}
|
||||||
|
.explanation-section {
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 1em;
|
||||||
|
padding: 0 1em 1em;
|
||||||
|
}
|
||||||
|
.rules-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rules-table th,
|
||||||
|
.rules-table td {
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding: 0.5em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.rule-status {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #1b5e20;
|
||||||
|
}
|
||||||
|
.rule-ignored {
|
||||||
|
background: #eee;
|
||||||
|
color: #555;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.requirement-allowed {
|
||||||
|
color: #1b5e20;
|
||||||
|
}
|
||||||
|
.requirement-denied {
|
||||||
|
color: #b71c1c;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 576px) {
|
||||||
|
.rules-table,
|
||||||
|
.rules-table tbody,
|
||||||
|
.rules-table tr,
|
||||||
|
.rules-table td {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.rules-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.rules-table td::before {
|
||||||
|
content: attr(data-label) ": ";
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Permission check</h1>
|
<h1>Explain a permission decision</h1>
|
||||||
|
|
||||||
{% set current_tab = "check" %}
|
{% set current_tab = "check" %}
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% include "_permissions_debug_tabs.html" %}
|
||||||
|
|
||||||
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
<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>
|
||||||
|
|
||||||
{% if request.actor %}
|
|
||||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
|
||||||
{% else %}
|
|
||||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="permission-form">
|
<div class="permission-form">
|
||||||
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
<form id="check-form" method="get" action="{{ urls.path('-/check') }}">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="action">Action (permission name):</label>
|
<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>
|
||||||
<select id="action" name="action" required>
|
<select id="action" name="action" required>
|
||||||
<option value="">Select an action...</option>
|
<option value="">Select an action...</option>
|
||||||
{% for action_name in sorted_actions %}
|
{% for action in actions %}
|
||||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
<option value="{{ action.name }}">{{ action.name }}{% if action.description %} — {{ action.description }}{% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<small>The permission action to check</small>
|
<small id="action-help">The operation to evaluate</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section" id="parent-section">
|
||||||
<label for="parent">Parent resource (optional):</label>
|
<label for="parent">Parent resource:</label>
|
||||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||||
<small>For database-level permissions, specify the database name</small>
|
<small>The database or other parent resource</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section" id="child-section">
|
||||||
<label for="child">Child resource (optional):</label>
|
<label for="child">Child resource:</label>
|
||||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
<input type="text" id="child" name="child" placeholder="e.g., table or query name">
|
||||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
<small>The table, query or other child resource</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
|
<button type="submit" class="submit-btn" id="submit-btn">Explain decision</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="output" style="display: none;">
|
<div id="output" style="display: none;">
|
||||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||||
|
<p id="result-summary"></p>
|
||||||
|
|
||||||
<dl class="details-section">
|
<dl class="details-section">
|
||||||
|
<dt>Actor:</dt>
|
||||||
|
<dd><code id="result-actor"></code></dd>
|
||||||
<dt>Action:</dt>
|
<dt>Action:</dt>
|
||||||
<dd id="result-action"></dd>
|
<dd><code id="result-action"></code></dd>
|
||||||
|
<dt>Resource:</dt>
|
||||||
<dt>Resource Path:</dt>
|
<dd><code id="result-resource"></code></dd>
|
||||||
<dd id="result-resource"></dd>
|
|
||||||
|
|
||||||
<dt>Actor ID:</dt>
|
|
||||||
<dd id="result-actor"></dd>
|
|
||||||
|
|
||||||
<div id="additional-details"></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<section class="explanation-section">
|
||||||
|
<h3>Matching rules</h3>
|
||||||
|
<div id="matching-rules"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="explanation-section" id="restrictions-section">
|
||||||
|
<h3>Actor restrictions</h3>
|
||||||
|
<div id="restriction-results"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="explanation-section" id="requirements-section">
|
||||||
|
<h3>Required actions</h3>
|
||||||
|
<div id="requirement-results"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<details style="margin-top: 1em;">
|
<details style="margin-top: 1em;">
|
||||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||||
|
|
@ -119,152 +185,134 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const actions = Object.fromEntries({{ actions|tojson }}.map(action => [action.name, action]));
|
||||||
const form = document.getElementById('check-form');
|
const form = document.getElementById('check-form');
|
||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
const submitBtn = document.getElementById('submit-btn');
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const actionSelect = document.getElementById('action');
|
||||||
|
|
||||||
|
function updateResourceFields() {
|
||||||
|
const action = actions[actionSelect.value];
|
||||||
|
document.getElementById('parent-section').style.display = action && action.takes_parent ? 'block' : 'none';
|
||||||
|
document.getElementById('child-section').style.display = action && action.takes_child ? 'block' : 'none';
|
||||||
|
let help = action && action.description ? action.description : 'The operation to evaluate';
|
||||||
|
if (action && action.also_requires) {
|
||||||
|
help += `; also requires ${action.also_requires}`;
|
||||||
|
}
|
||||||
|
document.getElementById('action-help').textContent = help;
|
||||||
|
}
|
||||||
|
|
||||||
async function performCheck() {
|
async function performCheck() {
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Checking...';
|
submitBtn.textContent = 'Explaining...';
|
||||||
|
const params = new URLSearchParams(new FormData(form));
|
||||||
const formData = new FormData(form);
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
for (const [key, value] of formData.entries()) {
|
|
||||||
if (value) {
|
|
||||||
params.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||||
method: 'GET',
|
headers: {'Accept': 'application/json'}
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
displayResult(data);
|
displayResult(data);
|
||||||
} else {
|
} else {
|
||||||
displayError(data);
|
displayError(data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Error: ' + error.message);
|
displayError({error: error.message});
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = 'Check Permission';
|
submitBtn.textContent = 'Explain decision';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate form on initial load
|
|
||||||
(function() {
|
|
||||||
const params = populateFormFromURL();
|
|
||||||
const action = params.get('action');
|
|
||||||
if (action) {
|
|
||||||
performCheck();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
function displayResult(data) {
|
function displayResult(data) {
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
|
|
||||||
// Set badge and styling
|
|
||||||
const resultBadge = document.getElementById('result-badge');
|
const resultBadge = document.getElementById('result-badge');
|
||||||
if (data.allowed) {
|
output.className = data.allowed ? 'allowed' : 'denied';
|
||||||
output.className = 'allowed';
|
resultBadge.className = `result-badge ${data.allowed ? 'allowed-badge' : 'denied-badge'}`;
|
||||||
resultBadge.className = 'result-badge allowed-badge';
|
resultBadge.textContent = data.allowed ? 'ALLOWED ✓' : 'DENIED ✗';
|
||||||
resultBadge.textContent = 'ALLOWED ✓';
|
document.getElementById('result-summary').textContent = data.explanation.summary;
|
||||||
} else {
|
document.getElementById('result-actor').textContent = data.actor === null ? 'anonymous' : JSON.stringify(data.actor);
|
||||||
output.className = 'denied';
|
document.getElementById('result-action').textContent = data.action;
|
||||||
resultBadge.className = 'result-badge denied-badge';
|
document.getElementById('result-resource').textContent = data.resource.path;
|
||||||
resultBadge.textContent = 'DENIED ✗';
|
displayRules(data.explanation);
|
||||||
}
|
displayRestrictions(data.explanation.restrictions);
|
||||||
|
displayRequirements(data.explanation.required_actions);
|
||||||
// Basic details
|
|
||||||
document.getElementById('result-action').textContent = data.action || 'N/A';
|
|
||||||
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
|
||||||
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
|
||||||
|
|
||||||
// Additional details
|
|
||||||
const additionalDetails = document.getElementById('additional-details');
|
|
||||||
additionalDetails.innerHTML = '';
|
|
||||||
|
|
||||||
if (data.reason !== undefined) {
|
|
||||||
const dt = document.createElement('dt');
|
|
||||||
dt.textContent = 'Reason:';
|
|
||||||
const dd = document.createElement('dd');
|
|
||||||
dd.textContent = data.reason || 'N/A';
|
|
||||||
additionalDetails.appendChild(dt);
|
|
||||||
additionalDetails.appendChild(dd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.source_plugin !== undefined) {
|
|
||||||
const dt = document.createElement('dt');
|
|
||||||
dt.textContent = 'Source Plugin:';
|
|
||||||
const dd = document.createElement('dd');
|
|
||||||
dd.textContent = data.source_plugin || 'N/A';
|
|
||||||
additionalDetails.appendChild(dt);
|
|
||||||
additionalDetails.appendChild(dd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.used_default !== undefined) {
|
|
||||||
const dt = document.createElement('dt');
|
|
||||||
dt.textContent = 'Used Default:';
|
|
||||||
const dd = document.createElement('dd');
|
|
||||||
dd.textContent = data.used_default ? 'Yes' : 'No';
|
|
||||||
additionalDetails.appendChild(dt);
|
|
||||||
additionalDetails.appendChild(dd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.depth !== undefined) {
|
|
||||||
const dt = document.createElement('dt');
|
|
||||||
dt.textContent = 'Depth:';
|
|
||||||
const dd = document.createElement('dd');
|
|
||||||
dd.textContent = data.depth;
|
|
||||||
additionalDetails.appendChild(dt);
|
|
||||||
additionalDetails.appendChild(dd);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raw JSON
|
|
||||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to output
|
function displayRules(explanation) {
|
||||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
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('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayError(data) {
|
function displayError(data) {
|
||||||
output.style.display = 'block';
|
output.style.display = 'block';
|
||||||
output.className = 'denied';
|
output.className = 'denied';
|
||||||
|
|
||||||
const resultBadge = document.getElementById('result-badge');
|
const resultBadge = document.getElementById('result-badge');
|
||||||
resultBadge.className = 'result-badge denied-badge';
|
resultBadge.className = 'result-badge denied-badge';
|
||||||
resultBadge.textContent = 'ERROR';
|
resultBadge.textContent = 'ERROR';
|
||||||
|
document.getElementById('result-summary').textContent = data.error || 'Unknown error';
|
||||||
document.getElementById('result-action').textContent = 'N/A';
|
document.getElementById('result-actor').textContent = '—';
|
||||||
document.getElementById('result-resource').textContent = 'N/A';
|
document.getElementById('result-action').textContent = '—';
|
||||||
document.getElementById('result-actor').textContent = 'N/A';
|
document.getElementById('result-resource').textContent = '—';
|
||||||
|
document.getElementById('matching-rules').innerHTML = '';
|
||||||
const additionalDetails = document.getElementById('additional-details');
|
document.getElementById('restrictions-section').style.display = 'none';
|
||||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
document.getElementById('requirements-section').style.display = 'none';
|
||||||
|
|
||||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||||
|
|
||||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable child input if parent is empty
|
form.addEventListener('submit', event => {
|
||||||
const parentInput = document.getElementById('parent');
|
event.preventDefault();
|
||||||
const childInput = document.getElementById('child');
|
performCheck();
|
||||||
|
|
||||||
childInput.addEventListener('focus', () => {
|
|
||||||
if (!parentInput.value) {
|
|
||||||
alert('Please specify a parent resource first before adding a child resource.');
|
|
||||||
parentInput.focus();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
actionSelect.addEventListener('change', updateResourceFields);
|
||||||
|
|
||||||
|
(function initializeFromUrl() {
|
||||||
|
const params = populateFormFromURL();
|
||||||
|
updateResourceFields();
|
||||||
|
if (params.get('action')) {
|
||||||
|
performCheck();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Debug permissions{% endblock %}
|
{% block title %}Permission activity{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
|
|
@ -20,60 +20,45 @@
|
||||||
.check-action, .check-when, .check-result {
|
.check-action, .check-when, .check-result {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
textarea {
|
|
||||||
height: 10em;
|
|
||||||
width: 95%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0.5em;
|
|
||||||
border: 2px dotted black;
|
|
||||||
}
|
|
||||||
.two-col {
|
|
||||||
display: inline-block;
|
|
||||||
width: 48%;
|
|
||||||
}
|
|
||||||
.two-col label {
|
|
||||||
width: 48%;
|
|
||||||
}
|
|
||||||
@media only screen and (max-width: 576px) {
|
|
||||||
.two-col {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Permission playground</h1>
|
<h1>Permission activity</h1>
|
||||||
|
|
||||||
{% set current_tab = "permissions" %}
|
{% set current_tab = "permissions" %}
|
||||||
{% include "_permissions_debug_tabs.html" %}
|
{% include "_permissions_debug_tabs.html" %}
|
||||||
|
|
||||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
<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>
|
||||||
|
|
||||||
<div class="permission-form">
|
<div class="permission-form">
|
||||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||||
<div class="two-col">
|
<div class="permission-form-grid">
|
||||||
<div class="form-section">
|
<div>
|
||||||
<label>Actor</label>
|
<div class="form-section">
|
||||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
<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>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div class="two-col" style="vertical-align: top">
|
<div class="form-section">
|
||||||
<div class="form-section">
|
<label for="permission">Action</label>
|
||||||
<label for="permission">Action</label>
|
<select name="permission" id="permission">
|
||||||
<select name="permission" id="permission">
|
{% for permission in permissions %}
|
||||||
{% for permission in permissions %}
|
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
{% endfor %}
|
||||||
{% endfor %}
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div class="form-section">
|
||||||
<div class="form-section">
|
<label for="resource_1">Parent</label>
|
||||||
<label for="resource_1">Parent</label>
|
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
</div>
|
||||||
</div>
|
<div class="form-section">
|
||||||
<div class="form-section">
|
<label for="resource_2">Child</label>
|
||||||
<label for="resource_2">Child</label>
|
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|
@ -125,7 +110,7 @@ debugPost.addEventListener('submit', function(ev) {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Recent permissions checks</h1>
|
<h2>Recent permission checks</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
{% block title %}Permission Rules{% endblock %}
|
{% block title %}Permission Rules{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
<script src="{{ static('json-format-highlight-1.0.1.js') }}"></script>
|
||||||
{% include "_permission_ui_styles.html" %}
|
{% include "_permission_ui_styles.html" %}
|
||||||
{% include "_debug_common_functions.html" %}
|
{% include "_debug_common_functions.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<label for="page_size">Page size:</label>
|
<label for="page_size">Page size:</label>
|
||||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
<input type="number" id="page_size" name="_size" value="50" min="1" max="200">
|
||||||
<small>Number of results per page (max 200)</small>
|
<small>Number of results per page (max 200)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
|
||||||
(function() {
|
(function() {
|
||||||
const params = populateFormFromURL();
|
const params = populateFormFromURL();
|
||||||
const action = params.get('action');
|
const action = params.get('action');
|
||||||
const page = params.get('page');
|
const page = params.get('_page');
|
||||||
if (action) {
|
if (action) {
|
||||||
fetchResults(page ? parseInt(page) : 1);
|
fetchResults(page ? parseInt(page) : 1);
|
||||||
}
|
}
|
||||||
|
|
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
if (value && key !== 'page_size') {
|
if (value && key !== '_size' && key !== '_page') {
|
||||||
params.append(key, value);
|
params.append(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = document.getElementById('page_size').value || '50';
|
const pageSize = document.getElementById('page_size').value || '50';
|
||||||
params.append('page', page.toString());
|
params.append('_page', page.toString());
|
||||||
params.append('page_size', pageSize);
|
params.append('_size', pageSize);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Datasette: Pattern Portfolio</title>
|
<title>Datasette: Pattern Portfolio</title>
|
||||||
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
|
<link rel="stylesheet" href="{{ static('app.css') }}">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|
@ -202,9 +202,9 @@
|
||||||
<h3>3 rows
|
<h3>3 rows
|
||||||
where characteristic_id = 2
|
where characteristic_id = 2
|
||||||
</h3>
|
</h3>
|
||||||
<form class="filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
<form class="core filters" action="{{ base_url }}fixtures/roadside_attraction_characteristics" method="get">
|
||||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
||||||
<div class="filter-row">
|
<div class="filter-row filter-controls-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column_1">
|
<select name="_filter_column_1">
|
||||||
<option value="">- remove filter -</option>
|
<option value="">- remove filter -</option>
|
||||||
|
|
@ -238,7 +238,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
|
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row filter-controls-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column">
|
<select name="_filter_column">
|
||||||
<option value="">- column -</option>
|
<option value="">- column -</option>
|
||||||
|
|
@ -272,8 +272,8 @@
|
||||||
</select>
|
</select>
|
||||||
</div><input type="text" name="_filter_value" class="filter-value">
|
</div><input type="text" name="_filter_value" class="filter-value">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row filter-actions-row">
|
||||||
<div class="select-wrapper small-screen-only">
|
<div class="select-wrapper">
|
||||||
<select name="_sort" id="sort_by">
|
<select name="_sort" id="sort_by">
|
||||||
<option value="">Sort...</option>
|
<option value="">Sort...</option>
|
||||||
<option value="rowid" selected>Sort by rowid</option>
|
<option value="rowid" selected>Sort by rowid</option>
|
||||||
|
|
@ -281,8 +281,8 @@
|
||||||
<option value="characteristic_id">Sort by characteristic_id</option>
|
<option value="characteristic_id">Sort by characteristic_id</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
||||||
<input type="submit" value="Apply">
|
<input type="submit" value="Apply filters">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
{% if row_mutation_ui %}
|
{% if row_mutation_ui %}
|
||||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||||
{% if table_page_data.foreignKeys %}
|
{% if table_page_data.foreignKeys %}
|
||||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<style>
|
<style>
|
||||||
@media only screen and (max-width: 576px) {
|
@media only screen and (max-width: 576px) {
|
||||||
|
|
@ -42,24 +42,12 @@
|
||||||
|
|
||||||
{% if foreign_key_tables %}
|
{% if foreign_key_tables %}
|
||||||
<h2>Links from other tables</h2>
|
<h2>Links from other tables</h2>
|
||||||
<ul class="row-foreign-key-tables">
|
<ul>
|
||||||
{% for other in foreign_key_tables %}
|
{% for other in foreign_key_tables %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ other.link }}">
|
<a href="{{ other.link }}">
|
||||||
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
|
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
|
||||||
from {{ other.other_column }} in {{ other.other_table }}
|
from {{ other.other_column }} in {{ other.other_table }}
|
||||||
{% if other.can_insert %}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="core row-foreign-key-insert"
|
|
||||||
data-insert-dialog
|
|
||||||
data-insert-dialog-reload
|
|
||||||
data-database="{{ database }}"
|
|
||||||
data-table="{{ other.other_table }}"
|
|
||||||
data-row="{{ other.insert_row|tojson|forceescape }}"
|
|
||||||
data-message="{{ other.insert_message }}"
|
|
||||||
>Insert</button>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{- super() -}}
|
{{- super() -}}
|
||||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||||
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
<script src="{{ static('column-chooser.js') }}" defer></script>
|
||||||
{% if table_page_data.foreignKeys %}
|
{% if table_page_data.foreignKeys %}
|
||||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
<script src="{{ static('autocomplete.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
<script src="{{ static('edit-tools.js') }}" defer></script>
|
||||||
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
|
<script src="{{ static('table.js') }}" defer></script>
|
||||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
<script src="{{ static('mobile-column-actions.js') }}" defer></script>
|
||||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||||
<style>
|
<style>
|
||||||
@media only screen and (max-width: 576px) {
|
@media only screen and (max-width: 576px) {
|
||||||
|
|
@ -48,19 +48,19 @@
|
||||||
|
|
||||||
{% if count or human_description_en %}
|
{% if count or human_description_en %}
|
||||||
<h3>
|
<h3>
|
||||||
{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows
|
{% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows
|
||||||
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||||
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form class="core" class="filters" action="{{ urls.table(database, table) }}" method="get">
|
<form class="core filters" action="{{ urls.table(database, table) }}" method="get">
|
||||||
{% if supports_search %}
|
{% if supports_search %}
|
||||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for column, lookup, value in filters.selections() %}
|
{% for column, lookup, value in filters.selections() %}
|
||||||
<div class="filter-row">
|
<div class="filter-row filter-controls-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column_{{ loop.index }}">
|
<select name="_filter_column_{{ loop.index }}">
|
||||||
<option value="">- remove filter -</option>
|
<option value="">- remove filter -</option>
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
|
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="filter-row">
|
<div class="filter-row filter-controls-row">
|
||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select name="_filter_column">
|
<select name="_filter_column">
|
||||||
<option value="">- column -</option>
|
<option value="">- column -</option>
|
||||||
|
|
@ -93,9 +93,9 @@
|
||||||
</select>
|
</select>
|
||||||
</div><input type="text" name="_filter_value" class="filter-value">
|
</div><input type="text" name="_filter_value" class="filter-value">
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row filter-actions-row">
|
||||||
{% if is_sortable %}
|
{% if is_sortable %}
|
||||||
<div class="select-wrapper small-screen-only">
|
<div class="select-wrapper">
|
||||||
<select name="_sort" id="sort_by">
|
<select name="_sort" id="sort_by">
|
||||||
<option value="">Sort...</option>
|
<option value="">Sort...</option>
|
||||||
{% for column in display_columns %}
|
{% for column in display_columns %}
|
||||||
|
|
@ -105,12 +105,12 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> descending</label>
|
<label class="sort_by_desc"><input type="checkbox" name="_sort_by_desc" tabindex="0"{% if sort_desc %} checked{% endif %}> descending</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for key, value in form_hidden_args %}
|
{% for key, value in form_hidden_args %}
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<input type="submit" value="Apply">
|
<input type="submit" value="Apply filters" tabindex="0">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import itsdangerous
|
import itsdangerous
|
||||||
|
|
||||||
|
|
@ -18,6 +18,21 @@ if TYPE_CHECKING:
|
||||||
from datasette.app import Datasette
|
from datasette.app import Datasette
|
||||||
|
|
||||||
|
|
||||||
|
class TokenInvalid(Exception):
|
||||||
|
"""
|
||||||
|
Raised by a TokenHandler when a token it recognizes is invalid -
|
||||||
|
for example a bad signature, malformed payload or expired token.
|
||||||
|
|
||||||
|
Datasette responds to this with an HTTP 401 error. Handlers should
|
||||||
|
return None instead for tokens they do not recognize at all, so that
|
||||||
|
other registered handlers get a chance to verify them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message="Invalid token"):
|
||||||
|
self.message = message
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class TokenRestrictions:
|
class TokenRestrictions:
|
||||||
"""
|
"""
|
||||||
|
|
@ -35,24 +50,24 @@ class TokenRestrictions:
|
||||||
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
||||||
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
|
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
|
||||||
|
|
||||||
def allow_all(self, action: str) -> "TokenRestrictions":
|
def allow_all(self, action: str) -> TokenRestrictions:
|
||||||
"""Allow an action across all databases and resources."""
|
"""Allow an action across all databases and resources."""
|
||||||
self.all.append(action)
|
self.all.append(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def allow_database(self, database: str, action: str) -> "TokenRestrictions":
|
def allow_database(self, database: str, action: str) -> TokenRestrictions:
|
||||||
"""Allow an action on a specific database."""
|
"""Allow an action on a specific database."""
|
||||||
self.database.setdefault(database, []).append(action)
|
self.database.setdefault(database, []).append(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def allow_resource(
|
def allow_resource(
|
||||||
self, database: str, resource: str, action: str
|
self, database: str, resource: str, action: str
|
||||||
) -> "TokenRestrictions":
|
) -> TokenRestrictions:
|
||||||
"""Allow an action on a specific resource within a database."""
|
"""Allow an action on a specific resource within a database."""
|
||||||
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
|
def abbreviated(self, datasette: Datasette) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Return the abbreviated ``_r`` dictionary shape for this set of
|
Return the abbreviated ``_r`` dictionary shape for this set of
|
||||||
restrictions, using action abbreviations registered with ``datasette``.
|
restrictions, using action abbreviations registered with ``datasette``.
|
||||||
|
|
@ -97,19 +112,23 @@ class TokenHandler:
|
||||||
|
|
||||||
async def create_token(
|
async def create_token(
|
||||||
self,
|
self,
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
*,
|
*,
|
||||||
expires_after: Optional[int] = None,
|
expires_after: int | None = None,
|
||||||
restrictions: Optional[TokenRestrictions] = None,
|
restrictions: TokenRestrictions | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create and return a token string for the given actor."""
|
"""Create and return a token string for the given actor."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Verify a token and return an actor dict, or None if this handler
|
Verify a token and return an actor dict.
|
||||||
does not recognize the token.
|
|
||||||
|
Return None if this handler does not recognize the token at all,
|
||||||
|
so other handlers can try it. Raise TokenInvalid if the token is
|
||||||
|
recognized but invalid (bad signature, malformed, expired) - the
|
||||||
|
request will fail with a 401 error.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
@ -123,11 +142,11 @@ class SignedTokenHandler(TokenHandler):
|
||||||
|
|
||||||
async def create_token(
|
async def create_token(
|
||||||
self,
|
self,
|
||||||
datasette: "Datasette",
|
datasette: Datasette,
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
*,
|
*,
|
||||||
expires_after: Optional[int] = None,
|
expires_after: int | None = None,
|
||||||
restrictions: Optional[TokenRestrictions] = None,
|
restrictions: TokenRestrictions | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not datasette.setting("allow_signed_tokens"):
|
if not datasette.setting("allow_signed_tokens"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -144,32 +163,35 @@ class SignedTokenHandler(TokenHandler):
|
||||||
token["_r"] = abbreviated
|
token["_r"] = abbreviated
|
||||||
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
return "dstok_{}".format(datasette.sign(token, namespace="token"))
|
||||||
|
|
||||||
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
|
async def verify_token(self, datasette: Datasette, token: str) -> dict | None:
|
||||||
prefix = "dstok_"
|
prefix = "dstok_"
|
||||||
|
|
||||||
if not datasette.setting("allow_signed_tokens"):
|
if not token.startswith(prefix):
|
||||||
|
# Not one of our tokens - leave it for other handlers
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if not datasette.setting("allow_signed_tokens"):
|
||||||
|
raise TokenInvalid(
|
||||||
|
"Signed tokens are not enabled for this Datasette instance"
|
||||||
|
)
|
||||||
|
|
||||||
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
|
||||||
|
|
||||||
if not token.startswith(prefix):
|
|
||||||
return None
|
|
||||||
|
|
||||||
raw = token[len(prefix) :]
|
raw = token[len(prefix) :]
|
||||||
try:
|
try:
|
||||||
decoded = datasette.unsign(raw, namespace="token")
|
decoded = datasette.unsign(raw, namespace="token")
|
||||||
except itsdangerous.BadSignature:
|
except itsdangerous.BadSignature:
|
||||||
return None
|
raise TokenInvalid("Invalid token signature")
|
||||||
|
|
||||||
if "t" not in decoded:
|
if "t" not in decoded:
|
||||||
return None
|
raise TokenInvalid("Invalid token: no timestamp")
|
||||||
created = decoded["t"]
|
created = decoded["t"]
|
||||||
if not isinstance(created, int):
|
if not isinstance(created, int):
|
||||||
return None
|
raise TokenInvalid("Invalid token: invalid timestamp")
|
||||||
|
|
||||||
duration = decoded.get("d")
|
duration = decoded.get("d")
|
||||||
if duration is not None and not isinstance(duration, int):
|
if duration is not None and not isinstance(duration, int):
|
||||||
return None
|
raise TokenInvalid("Invalid token: invalid duration")
|
||||||
|
|
||||||
if (duration is None and max_signed_tokens_ttl) or (
|
if (duration is None and max_signed_tokens_ttl) or (
|
||||||
duration is not None
|
duration is not None
|
||||||
|
|
@ -178,9 +200,8 @@ class SignedTokenHandler(TokenHandler):
|
||||||
):
|
):
|
||||||
duration = max_signed_tokens_ttl
|
duration = max_signed_tokens_ttl
|
||||||
|
|
||||||
if duration:
|
if duration and time.time() - created > duration:
|
||||||
if time.time() - created > duration:
|
raise TokenInvalid("Token has expired")
|
||||||
return None
|
|
||||||
|
|
||||||
actor = {"id": decoded["a"], "token": "dstok"}
|
actor = {"id": decoded["a"], "token": "dstok"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
tracers = {}
|
tracers = {}
|
||||||
|
|
||||||
|
|
@ -132,17 +133,17 @@ class AsgiTracer:
|
||||||
"num_traces": len(traces),
|
"num_traces": len(traces),
|
||||||
"traces": traces,
|
"traces": traces,
|
||||||
}
|
}
|
||||||
try:
|
content_type = next(
|
||||||
content_type = [
|
(
|
||||||
v.decode("utf8")
|
v.decode("utf8")
|
||||||
for k, v in response_headers
|
for k, v in response_headers
|
||||||
if k.lower() == b"content-type"
|
if k.lower() == b"content-type"
|
||||||
][0]
|
),
|
||||||
except IndexError:
|
"",
|
||||||
content_type = ""
|
)
|
||||||
if "text/html" in content_type and b"</body>" in accumulated_body:
|
if "text/html" in content_type and b"</body>" in accumulated_body:
|
||||||
extra = escape(json.dumps(trace_info, indent=2))
|
extra = escape(json.dumps(trace_info, indent=2))
|
||||||
extra_html = f"<pre>{extra}</pre></body>".encode("utf8")
|
extra_html = f"<pre>{extra}</pre></body>".encode()
|
||||||
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
|
accumulated_body = accumulated_body.replace(b"</body>", extra_html)
|
||||||
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
elif "json" in content_type and accumulated_body.startswith(b"{"):
|
||||||
data = json.loads(accumulated_body.decode("utf8"))
|
data = json.loads(accumulated_body.decode("utf8"))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from .utils import tilde_encode, path_with_format, PrefixedUrlString
|
|
||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
|
from .utils import PrefixedUrlString, path_with_format, tilde_encode
|
||||||
|
|
||||||
|
|
||||||
class Urls:
|
class Urls:
|
||||||
def __init__(self, ds):
|
def __init__(self, ds):
|
||||||
|
|
@ -8,8 +9,7 @@ class Urls:
|
||||||
|
|
||||||
def path(self, path, format=None):
|
def path(self, path, format=None):
|
||||||
if not isinstance(path, PrefixedUrlString):
|
if not isinstance(path, PrefixedUrlString):
|
||||||
if path.startswith("/"):
|
path = path.removeprefix("/")
|
||||||
path = path[1:]
|
|
||||||
path = self.ds.setting("base_url") + path
|
path = self.ds.setting("base_url") + path
|
||||||
if format is not None:
|
if format is not None:
|
||||||
path = path_with_format(path=path, format=format)
|
path = path_with_format(path=path, format=format)
|
||||||
|
|
@ -56,6 +56,7 @@ class Urls:
|
||||||
return PrefixedUrlString(path)
|
return PrefixedUrlString(path)
|
||||||
|
|
||||||
def row_blob(self, database, table, row_path, column):
|
def row_blob(self, database, table, row_path, column):
|
||||||
return self.table(database, table) + "/{}.blob?_blob_column={}".format(
|
return (
|
||||||
row_path, urllib.parse.quote_plus(column)
|
self.table(database, table)
|
||||||
|
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,31 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import contextmanager
|
import base64
|
||||||
import aiofiles
|
import binascii
|
||||||
import click
|
|
||||||
from collections import OrderedDict, namedtuple, Counter
|
|
||||||
import copy
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import base64
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import markupsafe
|
|
||||||
import mergedeep
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import typing
|
|
||||||
import time
|
import time
|
||||||
import types
|
import types
|
||||||
import secrets
|
import typing
|
||||||
import shutil
|
|
||||||
from typing import Iterable, List, Tuple
|
|
||||||
import urllib
|
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 yaml
|
import yaml
|
||||||
|
|
||||||
from .shutil_backport import copytree
|
from .shutil_backport import copytree
|
||||||
from .sqlite import sqlite3, supports_table_xinfo
|
from .sqlite import sqlite3, supports_table_xinfo
|
||||||
|
|
||||||
|
|
@ -35,7 +38,7 @@ if typing.TYPE_CHECKING:
|
||||||
class PaginatedResources:
|
class PaginatedResources:
|
||||||
"""Paginated results from allowed_resources query."""
|
"""Paginated results from allowed_resources query."""
|
||||||
|
|
||||||
resources: List["Resource"]
|
resources: list["Resource"]
|
||||||
next: str | None # Keyset token for next page (None if no more results)
|
next: str | None # Keyset token for next page (None if no more results)
|
||||||
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
_datasette: typing.Any = dataclasses.field(default=None, repr=False)
|
||||||
_action: str = dataclasses.field(default=None, repr=False)
|
_action: str = dataclasses.field(default=None, repr=False)
|
||||||
|
|
@ -82,22 +85,132 @@ class PaginatedResources:
|
||||||
|
|
||||||
|
|
||||||
# From https://www.sqlite.org/lang_keywords.html
|
# From https://www.sqlite.org/lang_keywords.html
|
||||||
reserved_words = set(
|
reserved_words = {
|
||||||
(
|
"abort",
|
||||||
"abort action add after all alter analyze and as asc attach autoincrement "
|
"action",
|
||||||
"before begin between by cascade case cast check collate column commit "
|
"add",
|
||||||
"conflict constraint create cross current_date current_time "
|
"after",
|
||||||
"current_timestamp database default deferrable deferred delete desc detach "
|
"all",
|
||||||
"distinct drop each else end escape except exclusive exists explain fail "
|
"alter",
|
||||||
"for foreign from full glob group having if ignore immediate in index "
|
"analyze",
|
||||||
"indexed initially inner insert instead intersect into is isnull join key "
|
"and",
|
||||||
"left like limit match natural no not notnull null of offset on or order "
|
"as",
|
||||||
"outer plan pragma primary query raise recursive references regexp reindex "
|
"asc",
|
||||||
"release rename replace restrict right rollback row savepoint select set "
|
"attach",
|
||||||
"table temp temporary then to transaction trigger union unique update using "
|
"autoincrement",
|
||||||
"vacuum values view virtual when where with without"
|
"before",
|
||||||
).split()
|
"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",
|
||||||
|
}
|
||||||
|
|
||||||
APT_GET_DOCKERFILE_EXTRAS = r"""
|
APT_GET_DOCKERFILE_EXTRAS = r"""
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
|
@ -157,7 +270,7 @@ functions_marked_as_documented = []
|
||||||
|
|
||||||
def documented(fn=None, *, label=None):
|
def documented(fn=None, *, label=None):
|
||||||
def decorate(fn):
|
def decorate(fn):
|
||||||
fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
|
fn._datasette_docs_label = label or f"internals_utils_{fn.__name__}"
|
||||||
functions_marked_as_documented.append(fn)
|
functions_marked_as_documented.append(fn)
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
|
|
@ -224,24 +337,71 @@ def compound_keys_after_sql(pks, start_index=0):
|
||||||
return "({})".format("\n or\n".join(or_clauses))
|
return "({})".format("\n or\n".join(or_clauses))
|
||||||
|
|
||||||
|
|
||||||
|
@documented
|
||||||
class CustomJSONEncoder(json.JSONEncoder):
|
class CustomJSONEncoder(json.JSONEncoder):
|
||||||
|
"""
|
||||||
|
The CustomJSONEncoder class handles serialization for objects commonly used by Datasette,
|
||||||
|
including SQLite cursors and binary blobs. Datasette uses it internally to serve .json endpoints,
|
||||||
|
and plugins that return JSON can use it to match Datasette's own handling.
|
||||||
|
|
||||||
|
Built-in types (text, numbers, lists, etc) are encoded the same as Python's built-in ``json`` module.
|
||||||
|
|
||||||
|
- ``sqlite3.Row`` becomes a tuple
|
||||||
|
- ``sqlite3.Cursor`` becomes a list
|
||||||
|
|
||||||
|
Binary blobs are encoded as an object, with the actual data base64-encoded,
|
||||||
|
like so: ::
|
||||||
|
|
||||||
|
{
|
||||||
|
"$base64": True,
|
||||||
|
"encoded": ...,
|
||||||
|
}
|
||||||
|
|
||||||
|
Example: https://latest.datasette.io/fixtures/binary_data.json
|
||||||
|
"""
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, sqlite3.Row):
|
if isinstance(obj, sqlite3.Row):
|
||||||
return tuple(obj)
|
return tuple(obj)
|
||||||
if isinstance(obj, sqlite3.Cursor):
|
if isinstance(obj, sqlite3.Cursor):
|
||||||
return list(obj)
|
return list(obj)
|
||||||
if isinstance(obj, bytes):
|
if isinstance(obj, bytes):
|
||||||
# Does it encode to utf8?
|
return {
|
||||||
try:
|
"$base64": True,
|
||||||
return obj.decode("utf8")
|
"encoded": base64.b64encode(obj).decode("latin1"),
|
||||||
except UnicodeDecodeError:
|
}
|
||||||
return {
|
|
||||||
"$base64": True,
|
|
||||||
"encoded": base64.b64encode(obj).decode("latin1"),
|
|
||||||
}
|
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
|
class WriteJsonValueError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def decode_write_json_cell(value):
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
keys = set(value.keys())
|
||||||
|
if keys == {"$raw"}:
|
||||||
|
return value["$raw"]
|
||||||
|
if keys == {"$base64", "encoded"} and value.get("$base64") is True:
|
||||||
|
encoded = value["encoded"]
|
||||||
|
if not isinstance(encoded, str):
|
||||||
|
raise WriteJsonValueError("$base64 encoded value must be a string")
|
||||||
|
try:
|
||||||
|
return base64.b64decode(encoded, validate=True)
|
||||||
|
except binascii.Error as ex:
|
||||||
|
raise WriteJsonValueError("Invalid $base64 encoded value") from ex
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def decode_write_json_row(row):
|
||||||
|
return {key: decode_write_json_cell(value) for key, value in row.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_write_json_rows(rows):
|
||||||
|
return [decode_write_json_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def sqlite_timelimit(conn, ms):
|
def sqlite_timelimit(conn, ms):
|
||||||
deadline = time.perf_counter() + (ms / 1000)
|
deadline = time.perf_counter() + (ms / 1000)
|
||||||
|
|
@ -312,7 +472,7 @@ disallawed_sql_res = [
|
||||||
(
|
(
|
||||||
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
|
||||||
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
|
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
|
||||||
", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
|
", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
@ -410,12 +570,7 @@ def escape_css_string(s):
|
||||||
def escape_sqlite(s):
|
def escape_sqlite(s):
|
||||||
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
if _boring_keyword_re.match(s) and (s.lower() not in reserved_words):
|
||||||
return s
|
return s
|
||||||
elif "]" in s:
|
return '"{}"'.format(s.replace('"', '""'))
|
||||||
# SQLite does not support escaping ] inside [bracket] quoting, so fall
|
|
||||||
# back to double-quote quoting (doubling any embedded ") - #2677
|
|
||||||
return '"{}"'.format(s.replace('"', '""'))
|
|
||||||
else:
|
|
||||||
return f"[{s}]"
|
|
||||||
|
|
||||||
|
|
||||||
def make_dockerfile(
|
def make_dockerfile(
|
||||||
|
|
@ -491,10 +646,7 @@ CMD {cmd}""".format(
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
environment_variables="\n".join(
|
environment_variables="\n".join(
|
||||||
[
|
[f"ENV {key} '{value}'" for key, value in environment_variables.items()]
|
||||||
"ENV {} '{}'".format(key, value)
|
|
||||||
for key, value in environment_variables.items()
|
|
||||||
]
|
|
||||||
),
|
),
|
||||||
install_from=" ".join(install),
|
install_from=" ".join(install),
|
||||||
files=" ".join(files),
|
files=" ".join(files),
|
||||||
|
|
@ -593,11 +745,11 @@ def detect_primary_keys(conn, table):
|
||||||
|
|
||||||
|
|
||||||
def get_outbound_foreign_keys(conn, table):
|
def get_outbound_foreign_keys(conn, table):
|
||||||
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall()
|
infos = conn.execute(f"PRAGMA foreign_key_list({escape_sqlite(table)})").fetchall()
|
||||||
fks = []
|
fks = []
|
||||||
for info in infos:
|
for info in infos:
|
||||||
if info is not None:
|
if info is not None:
|
||||||
id, seq, table_name, from_, to_, on_update, on_delete, match = info
|
id, seq, table_name, from_, to_, _on_update, _on_delete, _match = info
|
||||||
fks.append(
|
fks.append(
|
||||||
{
|
{
|
||||||
"column": from_,
|
"column": from_,
|
||||||
|
|
@ -698,7 +850,7 @@ def detect_json1(conn=None):
|
||||||
try:
|
try:
|
||||||
conn.execute("SELECT json('{}')")
|
conn.execute("SELECT json('{}')")
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except sqlite3.Error:
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
if close_conn:
|
if close_conn:
|
||||||
|
|
@ -778,9 +930,7 @@ def is_url(value):
|
||||||
if not value.startswith("http://") and not value.startswith("https://"):
|
if not value.startswith("http://") and not value.startswith("https://"):
|
||||||
return False
|
return False
|
||||||
# Any whitespace at all is invalid
|
# Any whitespace at all is invalid
|
||||||
if whitespace_re.search(value):
|
return not whitespace_re.search(value)
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
|
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
|
||||||
|
|
@ -833,7 +983,9 @@ def module_from_path(path, name):
|
||||||
mod.__file__ = path
|
mod.__file__ = path
|
||||||
with open(path, "r") as file:
|
with open(path, "r") as file:
|
||||||
code = compile(file.read(), path, "exec", dont_inherit=True)
|
code = compile(file.read(), path, "exec", dont_inherit=True)
|
||||||
exec(code, mod.__dict__)
|
# 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
|
||||||
return mod
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -990,9 +1142,7 @@ def escape_fts(query):
|
||||||
query += '"'
|
query += '"'
|
||||||
bits = _escape_fts_re.split(query)
|
bits = _escape_fts_re.split(query)
|
||||||
bits = [b for b in bits if b and b != '""']
|
bits = [b for b in bits if b and b != '""']
|
||||||
return " ".join(
|
return " ".join(f'"{bit}"' if not bit.startswith('"') else bit for bit in bits)
|
||||||
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MultiParams:
|
class MultiParams:
|
||||||
|
|
@ -1004,7 +1154,7 @@ class MultiParams:
|
||||||
data[key], (list, tuple)
|
data[key], (list, tuple)
|
||||||
), "dictionary data should be a dictionary of key => [list]"
|
), "dictionary data should be a dictionary of key => [list]"
|
||||||
self._data = data
|
self._data = data
|
||||||
elif isinstance(data, list) or isinstance(data, tuple):
|
elif isinstance(data, (list, tuple)):
|
||||||
new_data = {}
|
new_data = {}
|
||||||
for item in data:
|
for item in data:
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -1094,9 +1244,7 @@ def _gather_arguments(fn, kwargs):
|
||||||
for parameter in parameters:
|
for parameter in parameters:
|
||||||
if parameter not in kwargs:
|
if parameter not in kwargs:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"{} requires parameters {}, missing: {}".format(
|
f"{fn} requires parameters {tuple(parameters)}, missing: {set(parameters) - set(kwargs.keys())}"
|
||||||
fn, tuple(parameters), set(parameters) - set(kwargs.keys())
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
call_with.append(kwargs[parameter])
|
call_with.append(kwargs[parameter])
|
||||||
return call_with
|
return call_with
|
||||||
|
|
@ -1165,9 +1313,9 @@ def resolve_env_secrets(config, environ):
|
||||||
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
if list(config.keys()) == ["$env"]:
|
if list(config.keys()) == ["$env"]:
|
||||||
return environ.get(list(config.values())[0])
|
return environ.get(next(iter(config.values())))
|
||||||
elif list(config.keys()) == ["$file"]:
|
elif list(config.keys()) == ["$file"]:
|
||||||
with open(list(config.values())[0]) as fp:
|
with open(next(iter(config.values()))) as fp:
|
||||||
return fp.read()
|
return fp.read()
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
|
|
@ -1245,29 +1393,38 @@ class StartupError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
_single_line_comment_re = re.compile(r"--.*")
|
# Comments and string literals, matched in a single pass so that whichever
|
||||||
_multi_line_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
|
# construct starts first "wins" - this ensures a comment marker inside a string
|
||||||
_single_quote_re = re.compile(r"'(?:''|[^'])*'")
|
# literal (or a quote inside a comment) does not confuse the parameter scan.
|
||||||
_double_quote_re = re.compile(r'"(?:\"\"|[^"])*"')
|
_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,
|
||||||
|
)
|
||||||
_named_param_re = re.compile(r":(\w+)")
|
_named_param_re = re.compile(r":(\w+)")
|
||||||
|
|
||||||
|
|
||||||
@documented
|
@documented
|
||||||
def named_parameters(sql: str) -> List[str]:
|
def named_parameters(sql: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Given a SQL statement, return a list of named parameters that are used in the statement
|
Given a SQL statement, return a list of named parameters that are used in the statement
|
||||||
|
|
||||||
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
e.g. for ``select * from foo where id=:id`` this would return ``["id"]``
|
||||||
"""
|
"""
|
||||||
sql = _single_line_comment_re.sub("", sql)
|
# Strip comments and string literals first so that any ":name" sequences
|
||||||
sql = _multi_line_comment_re.sub("", sql)
|
# inside them are not mistaken for named parameters
|
||||||
sql = _single_quote_re.sub("", sql)
|
sql = _comments_and_strings_re.sub("", sql)
|
||||||
sql = _double_quote_re.sub("", sql)
|
|
||||||
# Extract parameters from what is left
|
# Extract parameters from what is left
|
||||||
return _named_param_re.findall(sql)
|
return _named_param_re.findall(sql)
|
||||||
|
|
||||||
|
|
||||||
async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
|
async def derive_named_parameters(db: "Database", sql: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
This undocumented but stable method exists for backwards compatibility
|
This undocumented but stable method exists for backwards compatibility
|
||||||
with plugins that were using it before it switched to named_parameters()
|
with plugins that were using it before it switched to named_parameters()
|
||||||
|
|
@ -1275,6 +1432,54 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
|
||||||
return named_parameters(sql)
|
return named_parameters(sql)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_limit(value, default, maximum, name="_size"):
|
||||||
|
"""
|
||||||
|
Parse a page-size parameter using the same semantics as the table
|
||||||
|
view's ?_size=: blank means default, "max" means maximum, integers
|
||||||
|
must be 0 or greater and no larger than maximum. Raises ValueError
|
||||||
|
with a message suitable for a 400 response.
|
||||||
|
"""
|
||||||
|
if value in (None, ""):
|
||||||
|
return default
|
||||||
|
if value == "max":
|
||||||
|
return maximum
|
||||||
|
try:
|
||||||
|
size = int(value)
|
||||||
|
if size < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"{name} must be a positive integer")
|
||||||
|
if size > maximum:
|
||||||
|
raise ValueError(f"{name} must be <= {maximum}")
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
UNSTABLE_API_MESSAGE = (
|
||||||
|
"This API is not part of Datasette's stable interface and may change at any time"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def error_body(messages, status):
|
||||||
|
"""
|
||||||
|
The canonical JSON error body used by every Datasette JSON error response:
|
||||||
|
|
||||||
|
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
|
||||||
|
|
||||||
|
"error" is all of the messages joined with "; ", "errors" is the full
|
||||||
|
list, "status" matches the HTTP status code. Callers may add extra
|
||||||
|
context keys to the returned dictionary but must not remove these four.
|
||||||
|
"""
|
||||||
|
if isinstance(messages, str):
|
||||||
|
messages = [messages]
|
||||||
|
messages = [str(message) for message in messages]
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "; ".join(messages),
|
||||||
|
"errors": messages,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def add_cors_headers(headers):
|
def add_cors_headers(headers):
|
||||||
headers["Access-Control-Allow-Origin"] = "*"
|
headers["Access-Control-Allow-Origin"] = "*"
|
||||||
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||||
|
|
@ -1303,7 +1508,7 @@ class TildeEncoder(dict):
|
||||||
elif b == _space:
|
elif b == _space:
|
||||||
res = "+"
|
res = "+"
|
||||||
else:
|
else:
|
||||||
res = "~{:02X}".format(b)
|
res = f"~{b:02X}"
|
||||||
self[b] = res
|
self[b] = res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
@ -1398,7 +1603,7 @@ def _combine(base: dict, update: dict) -> dict:
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
|
def pairs_to_nested_config(pairs: list[tuple[str, typing.Any]]) -> dict:
|
||||||
"""
|
"""
|
||||||
Parse a list of key-value pairs into a nested dictionary.
|
Parse a list of key-value pairs into a nested dictionary.
|
||||||
"""
|
"""
|
||||||
|
|
@ -1413,7 +1618,7 @@ def make_slot_function(name, datasette, request, **kwargs):
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
||||||
method = getattr(pm.hook, name, None)
|
method = getattr(pm.hook, name, None)
|
||||||
assert method is not None, "No hook found for {}".format(name)
|
assert method is not None, f"No hook found for {name}"
|
||||||
|
|
||||||
async def inner():
|
async def inner():
|
||||||
html_bits = []
|
html_bits = []
|
||||||
|
|
@ -1437,7 +1642,7 @@ def prune_empty_dicts(d: dict):
|
||||||
d.pop(key, None)
|
d.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
|
def move_plugins_and_allow(source: dict, destination: dict) -> tuple[dict, dict]:
|
||||||
"""
|
"""
|
||||||
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
|
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
|
||||||
hierarchy in destination if needed. After moving, recursively remove any keys
|
hierarchy in destination if needed. After moving, recursively remove any keys
|
||||||
|
|
@ -1548,6 +1753,17 @@ def md5_not_usedforsecurity(s):
|
||||||
_etag_cache = {}
|
_etag_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(filepath, chunk_size=4096):
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
with open(filepath, "rb") as fp:
|
||||||
|
while True:
|
||||||
|
chunk = fp.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
hasher.update(chunk)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def calculate_etag(filepath, chunk_size=4096):
|
async def calculate_etag(filepath, chunk_size=4096):
|
||||||
if filepath in _etag_cache:
|
if filepath in _etag_cache:
|
||||||
return _etag_cache[filepath]
|
return _etag_cache[filepath]
|
||||||
|
|
|
||||||
|
|
@ -252,88 +252,62 @@ async def _build_single_action_sql(
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Continue with the cascading logic
|
# Continue with the cascading logic.
|
||||||
query_parts.extend(
|
# Aggregate the RULES by cascade level (small), rather than grouping
|
||||||
[
|
# base x rules (which scales with the number of resources).
|
||||||
"child_lvl AS (",
|
def _agg(select_key, where, group_by):
|
||||||
" SELECT b.parent, b.child,",
|
parts = [
|
||||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
f" SELECT {select_key}",
|
||||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
" json_group_array(CASE WHEN ar.allow = 0 THEN ar.source_plugin || ': ' || ar.reason END) AS deny_reasons,",
|
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow,",
|
||||||
" json_group_array(CASE WHEN ar.allow = 1 THEN ar.source_plugin || ': ' || ar.reason END) AS allow_reasons",
|
" json_group_array(CASE WHEN allow = 0 THEN source_plugin || ': ' || reason END) AS deny_reasons,",
|
||||||
" FROM base b",
|
" json_group_array(CASE WHEN allow = 1 THEN source_plugin || ': ' || reason END) AS allow_reasons",
|
||||||
" LEFT JOIN all_rules ar ON ar.parent = b.parent AND ar.child = b.child",
|
f" FROM all_rules WHERE {where}",
|
||||||
" 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",
|
|
||||||
"),",
|
|
||||||
]
|
]
|
||||||
|
if group_by:
|
||||||
|
parts.append(f" GROUP BY {group_by}")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
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)
|
||||||
|
+ ["),"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add anonymous decision logic if needed
|
# Add anonymous decision logic if needed
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
query_parts.extend(
|
|
||||||
[
|
def _anon_agg(select_key, where, group_by):
|
||||||
"anon_child_lvl AS (",
|
parts = [
|
||||||
" SELECT b.parent, b.child,",
|
f" SELECT {select_key}",
|
||||||
" MAX(CASE WHEN ar.allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
" MAX(CASE WHEN allow = 0 THEN 1 ELSE 0 END) AS any_deny,",
|
||||||
" MAX(CASE WHEN ar.allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
" MAX(CASE WHEN allow = 1 THEN 1 ELSE 0 END) AS any_allow",
|
||||||
" FROM base b",
|
f" FROM anon_rules WHERE {where}",
|
||||||
" 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))",
|
|
||||||
"),",
|
|
||||||
]
|
]
|
||||||
|
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)
|
||||||
|
+ ["),"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Final decisions
|
# Final decisions
|
||||||
|
|
@ -342,31 +316,28 @@ async def _build_single_action_sql(
|
||||||
"decisions AS (",
|
"decisions AS (",
|
||||||
" SELECT",
|
" SELECT",
|
||||||
" b.parent, b.child,",
|
" b.parent, b.child,",
|
||||||
" -- Cascading permission logic: child → parent → global, DENY beats ALLOW at each level",
|
" -- Cascading permission logic: child -> parent -> global, DENY beats ALLOW at each level",
|
||||||
" -- Priority order:",
|
" -- Priority order:",
|
||||||
" -- 1. Child-level deny (most specific, blocks access)",
|
" -- 1. Child-level deny 2. Child-level allow",
|
||||||
" -- 2. Child-level allow (most specific, grants access)",
|
" -- 3. Parent-level deny 4. Parent-level allow",
|
||||||
" -- 3. Parent-level deny (intermediate, blocks access)",
|
" -- 5. Global-level deny 6. Global-level allow",
|
||||||
" -- 4. Parent-level allow (intermediate, grants access)",
|
|
||||||
" -- 5. Global-level deny (least specific, blocks access)",
|
|
||||||
" -- 6. Global-level allow (least specific, grants access)",
|
|
||||||
" -- 7. Default deny (no rules match)",
|
" -- 7. Default deny (no rules match)",
|
||||||
" CASE",
|
" CASE",
|
||||||
" WHEN cl.any_deny = 1 THEN 0",
|
" WHEN ca.any_deny = 1 THEN 0",
|
||||||
" WHEN cl.any_allow = 1 THEN 1",
|
" WHEN ca.any_allow = 1 THEN 1",
|
||||||
" WHEN pl.any_deny = 1 THEN 0",
|
" WHEN pa.any_deny = 1 THEN 0",
|
||||||
" WHEN pl.any_allow = 1 THEN 1",
|
" WHEN pa.any_allow = 1 THEN 1",
|
||||||
" WHEN gl.any_deny = 1 THEN 0",
|
" WHEN ga.any_deny = 1 THEN 0",
|
||||||
" WHEN gl.any_allow = 1 THEN 1",
|
" WHEN ga.any_allow = 1 THEN 1",
|
||||||
" ELSE 0",
|
" ELSE 0",
|
||||||
" END AS is_allowed,",
|
" END AS is_allowed,",
|
||||||
" CASE",
|
" CASE",
|
||||||
" WHEN cl.any_deny = 1 THEN cl.deny_reasons",
|
" WHEN ca.any_deny = 1 THEN ca.deny_reasons",
|
||||||
" WHEN cl.any_allow = 1 THEN cl.allow_reasons",
|
" WHEN ca.any_allow = 1 THEN ca.allow_reasons",
|
||||||
" WHEN pl.any_deny = 1 THEN pl.deny_reasons",
|
" WHEN pa.any_deny = 1 THEN pa.deny_reasons",
|
||||||
" WHEN pl.any_allow = 1 THEN pl.allow_reasons",
|
" WHEN pa.any_allow = 1 THEN pa.allow_reasons",
|
||||||
" WHEN gl.any_deny = 1 THEN gl.deny_reasons",
|
" WHEN ga.any_deny = 1 THEN ga.deny_reasons",
|
||||||
" WHEN gl.any_allow = 1 THEN gl.allow_reasons",
|
" WHEN ga.any_allow = 1 THEN ga.allow_reasons",
|
||||||
" ELSE '[]'",
|
" ELSE '[]'",
|
||||||
" END AS reason",
|
" END AS reason",
|
||||||
]
|
]
|
||||||
|
|
@ -374,21 +345,34 @@ async def _build_single_action_sql(
|
||||||
|
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
query_parts.append(
|
query_parts.append(
|
||||||
" , CASE WHEN ad.anon_is_allowed = 0 THEN 1 ELSE 0 END AS is_private"
|
" , 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
[
|
[
|
||||||
" FROM base b",
|
" FROM base b",
|
||||||
" JOIN child_lvl cl ON b.parent = cl.parent AND (b.child = cl.child OR (b.child IS NULL AND cl.child IS NULL))",
|
" LEFT JOIN child_agg ca ON ca.parent = b.parent AND ca.child = b.child",
|
||||||
" JOIN parent_lvl pl ON b.parent = pl.parent AND (b.child = pl.child OR (b.child IS NULL AND pl.child IS NULL))",
|
" LEFT JOIN parent_agg pa ON pa.parent = b.parent",
|
||||||
" JOIN global_lvl gl ON b.parent = gl.parent AND (b.child = gl.child OR (b.child IS NULL AND gl.child IS NULL))",
|
" CROSS JOIN global_agg ga",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_is_private:
|
if include_is_private:
|
||||||
query_parts.append(
|
query_parts.extend(
|
||||||
" JOIN anon_decisions ad ON b.parent = ad.parent AND (b.child = ad.child OR (b.child IS NULL AND ad.child IS NULL))"
|
[
|
||||||
|
" LEFT JOIN anon_child_agg aca ON aca.parent = b.parent AND aca.child = b.child",
|
||||||
|
" LEFT JOIN anon_parent_agg apa ON apa.parent = b.parent",
|
||||||
|
" CROSS JOIN anon_global_agg aga",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
query_parts.append(")")
|
query_parts.append(")")
|
||||||
|
|
@ -400,8 +384,28 @@ async def _build_single_action_sql(
|
||||||
restriction_intersect = "\nINTERSECT\n".join(
|
restriction_intersect = "\nINTERSECT\n".join(
|
||||||
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
f"SELECT * FROM ({sql})" for sql in restriction_sqls
|
||||||
)
|
)
|
||||||
|
# Decompose by NULL-pattern so the final filter can use pure-equality
|
||||||
|
# EXISTS lookups (satisfiable via automatic indexes) instead of a
|
||||||
|
# correlated OR-scan over the whole list.
|
||||||
query_parts.extend(
|
query_parts.extend(
|
||||||
[",", "restriction_list AS (", f" {restriction_intersect}", ")"]
|
[
|
||||||
|
",",
|
||||||
|
"restriction_list AS (",
|
||||||
|
f" {restriction_intersect}",
|
||||||
|
"),",
|
||||||
|
"restriction_exact AS (",
|
||||||
|
" SELECT parent, child FROM restriction_list WHERE parent IS NOT NULL AND child IS NOT NULL",
|
||||||
|
"),",
|
||||||
|
"restriction_parent_any AS (",
|
||||||
|
" SELECT DISTINCT parent FROM restriction_list WHERE parent IS NOT NULL AND child IS NULL",
|
||||||
|
"),",
|
||||||
|
"restriction_child_any AS (",
|
||||||
|
" SELECT DISTINCT child FROM restriction_list WHERE parent IS NULL AND child IS NOT NULL",
|
||||||
|
"),",
|
||||||
|
"restriction_all AS (",
|
||||||
|
" SELECT 1 AS matched FROM restriction_list WHERE parent IS NULL AND child IS NULL LIMIT 1",
|
||||||
|
")",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Final SELECT
|
# Final SELECT
|
||||||
|
|
@ -416,10 +420,11 @@ async def _build_single_action_sql(
|
||||||
# Add restriction filter if there are restrictions
|
# Add restriction filter if there are restrictions
|
||||||
if restriction_sqls:
|
if restriction_sqls:
|
||||||
query_parts.append("""
|
query_parts.append("""
|
||||||
AND EXISTS (
|
AND (
|
||||||
SELECT 1 FROM restriction_list r
|
EXISTS (SELECT 1 FROM restriction_all)
|
||||||
WHERE (r.parent = decisions.parent OR r.parent IS NULL)
|
OR EXISTS (SELECT 1 FROM restriction_parent_any r WHERE r.parent = decisions.parent)
|
||||||
AND (r.child = decisions.child OR r.child IS NULL)
|
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)
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
# Add parent filter if specified
|
# Add parent filter if specified
|
||||||
|
|
@ -673,3 +678,239 @@ async def check_permission_for_resource(
|
||||||
child=child,
|
child=child,
|
||||||
)
|
)
|
||||||
return results[action]
|
return results[action]
|
||||||
|
|
||||||
|
|
||||||
|
async def explain_permission_for_resource(
|
||||||
|
*,
|
||||||
|
datasette: "Datasette",
|
||||||
|
actor: dict | None,
|
||||||
|
action: str,
|
||||||
|
parent: str | None,
|
||||||
|
child: str | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Explain a permission decision for one action and resource.
|
||||||
|
|
||||||
|
This is intended for Datasette's permission debugging tools. It uses the
|
||||||
|
same ``permission_resources_sql`` hook results and the same resolution
|
||||||
|
rules as :func:`check_permissions_for_actions`, but also returns the
|
||||||
|
matching rules, actor restriction results and ``also_requires`` chain.
|
||||||
|
|
||||||
|
The returned dictionary is part of Datasette's unstable debugging API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
action_obj = datasette.actions.get(action)
|
||||||
|
if action_obj is None:
|
||||||
|
raise ValueError(f"Unknown action: {action}")
|
||||||
|
|
||||||
|
explanation = await _explain_single_action(
|
||||||
|
datasette=datasette,
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
parent=parent,
|
||||||
|
child=child,
|
||||||
|
)
|
||||||
|
|
||||||
|
required_actions = []
|
||||||
|
if action_obj.also_requires:
|
||||||
|
required = await explain_permission_for_resource(
|
||||||
|
datasette=datasette,
|
||||||
|
actor=actor,
|
||||||
|
action=action_obj.also_requires,
|
||||||
|
parent=parent,
|
||||||
|
child=child,
|
||||||
|
)
|
||||||
|
required_actions.append(required)
|
||||||
|
|
||||||
|
explanation["required_actions"] = required_actions
|
||||||
|
explanation["allowed"] = bool(
|
||||||
|
explanation["rule_allowed"]
|
||||||
|
and explanation["restriction_allowed"]
|
||||||
|
and all(required["allowed"] for required in required_actions)
|
||||||
|
)
|
||||||
|
explanation["summary"] = _permission_explanation_summary(explanation)
|
||||||
|
return explanation
|
||||||
|
|
||||||
|
|
||||||
|
async def _explain_single_action(
|
||||||
|
*,
|
||||||
|
datasette: "Datasette",
|
||||||
|
actor: dict | None,
|
||||||
|
action: str,
|
||||||
|
parent: str | None,
|
||||||
|
child: str | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return matching rules and restrictions for a single action."""
|
||||||
|
from datasette.utils.permissions import SKIP_PERMISSION_CHECKS
|
||||||
|
|
||||||
|
permission_sqls = await gather_permission_sql_from_hooks(
|
||||||
|
datasette=datasette,
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
if permission_sqls is SKIP_PERMISSION_CHECKS:
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"rule_allowed": True,
|
||||||
|
"restriction_allowed": True,
|
||||||
|
"winning_scope": "global",
|
||||||
|
"matched_rules": [
|
||||||
|
{
|
||||||
|
"scope": "global",
|
||||||
|
"effect": "allow",
|
||||||
|
"source": "skip_permission_checks",
|
||||||
|
"reason": "Permission checks were explicitly skipped",
|
||||||
|
"decisive": True,
|
||||||
|
"ignored_because": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"restrictions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
db = datasette.get_internal_database()
|
||||||
|
matched_rules = []
|
||||||
|
restrictions = []
|
||||||
|
|
||||||
|
for permission_sql in permission_sqls:
|
||||||
|
params = dict(permission_sql.params or {})
|
||||||
|
parent_param = _unused_parameter_name(params, "_explain_parent")
|
||||||
|
params[parent_param] = parent
|
||||||
|
child_param = _unused_parameter_name(params, "_explain_child")
|
||||||
|
params[child_param] = child
|
||||||
|
|
||||||
|
if permission_sql.sql:
|
||||||
|
rows = await db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT parent, child, allow, reason
|
||||||
|
FROM ({permission_sql.sql}) AS permission_rules
|
||||||
|
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||||
|
AND (child IS NULL OR child = :{child_param})
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
specificity = (
|
||||||
|
2
|
||||||
|
if row["child"] is not None
|
||||||
|
else 1 if row["parent"] is not None else 0
|
||||||
|
)
|
||||||
|
matched_rules.append(
|
||||||
|
{
|
||||||
|
"scope": ("resource", "parent", "global")[2 - specificity],
|
||||||
|
"effect": "allow" if row["allow"] else "deny",
|
||||||
|
"source": permission_sql.source,
|
||||||
|
"reason": row["reason"],
|
||||||
|
"_specificity": specificity,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if permission_sql.restriction_sql:
|
||||||
|
restriction_row = (
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM ({permission_sql.restriction_sql}) AS restriction_rules
|
||||||
|
WHERE (parent IS NULL OR parent = :{parent_param})
|
||||||
|
AND (child IS NULL OR child = :{child_param})
|
||||||
|
) AS resource_is_in_allowlist
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
restriction_allowed = bool(restriction_row[0])
|
||||||
|
restrictions.append(
|
||||||
|
{
|
||||||
|
"source": permission_sql.source,
|
||||||
|
"allowed": restriction_allowed,
|
||||||
|
"reason": params.get("deny")
|
||||||
|
or (
|
||||||
|
"Resource is included in this restriction allowlist"
|
||||||
|
if restriction_allowed
|
||||||
|
else "Resource is not included in this restriction allowlist"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
matched_rules.sort(
|
||||||
|
key=lambda rule: (
|
||||||
|
-rule["_specificity"],
|
||||||
|
0 if rule["effect"] == "deny" else 1,
|
||||||
|
rule["source"] or "",
|
||||||
|
rule["reason"] or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if matched_rules:
|
||||||
|
winning_specificity = matched_rules[0]["_specificity"]
|
||||||
|
winning_rules = [
|
||||||
|
rule
|
||||||
|
for rule in matched_rules
|
||||||
|
if rule["_specificity"] == winning_specificity
|
||||||
|
]
|
||||||
|
rule_allowed = not any(rule["effect"] == "deny" for rule in winning_rules)
|
||||||
|
winning_scope = winning_rules[0]["scope"]
|
||||||
|
else:
|
||||||
|
winning_specificity = None
|
||||||
|
rule_allowed = False
|
||||||
|
winning_scope = None
|
||||||
|
|
||||||
|
for rule in matched_rules:
|
||||||
|
specificity = rule.pop("_specificity")
|
||||||
|
if specificity != winning_specificity:
|
||||||
|
rule["decisive"] = False
|
||||||
|
rule["ignored_because"] = "A more specific rule matched"
|
||||||
|
elif not rule_allowed and rule["effect"] == "allow":
|
||||||
|
rule["decisive"] = False
|
||||||
|
rule["ignored_because"] = "A deny rule matched at the same scope"
|
||||||
|
else:
|
||||||
|
rule["decisive"] = True
|
||||||
|
rule["ignored_because"] = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"rule_allowed": rule_allowed,
|
||||||
|
"restriction_allowed": all(
|
||||||
|
restriction["allowed"] for restriction in restrictions
|
||||||
|
),
|
||||||
|
"winning_scope": winning_scope,
|
||||||
|
"matched_rules": matched_rules,
|
||||||
|
"restrictions": restrictions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unused_parameter_name(params: dict, preferred: str) -> str:
|
||||||
|
"""Return a SQL parameter name that is not already in ``params``."""
|
||||||
|
candidate = preferred
|
||||||
|
suffix = 2
|
||||||
|
while candidate in params:
|
||||||
|
candidate = f"{preferred}_{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _permission_explanation_summary(explanation: dict) -> str:
|
||||||
|
denied_requirement = next(
|
||||||
|
(
|
||||||
|
required
|
||||||
|
for required in explanation["required_actions"]
|
||||||
|
if not required["allowed"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if denied_requirement:
|
||||||
|
return (
|
||||||
|
f"Denied because {explanation['action']} also requires "
|
||||||
|
f"{denied_requirement['action']}, which was denied."
|
||||||
|
)
|
||||||
|
if not explanation["matched_rules"]:
|
||||||
|
return "Denied because no permission rule matched this actor and resource."
|
||||||
|
if not explanation["rule_allowed"]:
|
||||||
|
return (
|
||||||
|
f"Denied by a {explanation['winning_scope']}-level rule. "
|
||||||
|
"Deny rules take precedence over allow rules at the same scope."
|
||||||
|
)
|
||||||
|
if not explanation["restriction_allowed"]:
|
||||||
|
return (
|
||||||
|
"Denied because the resource is not included in the actor's restrictions."
|
||||||
|
)
|
||||||
|
return f"Allowed by the matching {explanation['winning_scope']}-level rule."
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,29 @@
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
import re
|
||||||
from datasette.utils import MultiParams, calculate_etag
|
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 datasette.utils.multipart import (
|
from datasette.utils.multipart import (
|
||||||
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_FIELD_SIZE,
|
||||||
|
DEFAULT_MAX_FIELDS,
|
||||||
|
DEFAULT_MAX_FILE_SIZE,
|
||||||
|
DEFAULT_MAX_FILES,
|
||||||
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
DEFAULT_MAX_PART_HEADER_BYTES,
|
DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
DEFAULT_MAX_PART_HEADER_LINES,
|
DEFAULT_MAX_PART_HEADER_LINES,
|
||||||
|
DEFAULT_MAX_PARTS,
|
||||||
|
DEFAULT_MAX_REQUEST_SIZE,
|
||||||
DEFAULT_MIN_FREE_DISK_BYTES,
|
DEFAULT_MIN_FREE_DISK_BYTES,
|
||||||
|
FormData,
|
||||||
|
MultipartParseError,
|
||||||
|
parse_form_data,
|
||||||
)
|
)
|
||||||
from mimetypes import guess_type
|
|
||||||
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
|
||||||
from pathlib import Path
|
|
||||||
from http.cookies import SimpleCookie, Morsel
|
|
||||||
import aiofiles
|
|
||||||
import aiofiles.os
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Workaround for adding samesite support to pre 3.8 python
|
# Workaround for adding samesite support to pre 3.8 python
|
||||||
Morsel._reserved["samesite"] = "SameSite"
|
Morsel._reserved["samesite"] = "SameSite"
|
||||||
|
|
@ -67,16 +68,28 @@ class BadRequest(Base400):
|
||||||
status = 400
|
status = 400
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadTooLarge(Base400):
|
||||||
|
status = 413
|
||||||
|
|
||||||
|
|
||||||
SAMESITE_VALUES = ("strict", "lax", "none")
|
SAMESITE_VALUES = ("strict", "lax", "none")
|
||||||
|
|
||||||
|
# Bodies read fully into memory (post_body/post_vars/json) are capped at this
|
||||||
|
# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
|
||||||
|
# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
|
||||||
|
# while these bodies are held in RAM and json.loads() can multiply their
|
||||||
|
# footprint several times over.
|
||||||
|
DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
class Request:
|
class Request:
|
||||||
def __init__(self, scope, receive):
|
def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
|
||||||
self.scope = scope
|
self.scope = scope
|
||||||
self.receive = receive
|
self.receive = receive
|
||||||
|
self.max_post_body_bytes = max_post_body_bytes
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
|
return f'<asgi.Request method="{self.method}" url="{self.url}">'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def method(self):
|
def method(self):
|
||||||
|
|
@ -141,15 +154,43 @@ class Request:
|
||||||
def actor(self):
|
def actor(self):
|
||||||
return self.scope.get("actor", None)
|
return self.scope.get("actor", None)
|
||||||
|
|
||||||
async def post_body(self):
|
async def post_body(self, max_bytes=None):
|
||||||
body = b""
|
"""
|
||||||
|
Read the request body fully into memory.
|
||||||
|
|
||||||
|
The body is capped at max_bytes - or self.max_post_body_bytes
|
||||||
|
(default 2MB, set from the max_post_body_bytes setting for requests
|
||||||
|
created by Datasette) if max_bytes is not provided. Pass max_bytes=0
|
||||||
|
to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
|
||||||
|
oversized bodies are rejected as soon as the limit is passed, without
|
||||||
|
buffering the rest.
|
||||||
|
"""
|
||||||
|
if max_bytes is None:
|
||||||
|
max_bytes = self.max_post_body_bytes
|
||||||
|
too_large = PayloadTooLarge(
|
||||||
|
f"Request body exceeded maximum size of {max_bytes} bytes"
|
||||||
|
)
|
||||||
|
if max_bytes:
|
||||||
|
# Reject early if the client declares an oversized body
|
||||||
|
try:
|
||||||
|
if int(self.headers.get("content-length", "")) > max_bytes:
|
||||||
|
raise too_large
|
||||||
|
except ValueError:
|
||||||
|
# Missing or malformed - the streaming check below still applies
|
||||||
|
pass
|
||||||
|
chunks = []
|
||||||
|
received = 0
|
||||||
more_body = True
|
more_body = True
|
||||||
while more_body:
|
while more_body:
|
||||||
message = await self.receive()
|
message = await self.receive()
|
||||||
assert message["type"] == "http.request", message
|
assert message["type"] == "http.request", message
|
||||||
body += message.get("body", b"")
|
chunk = message.get("body", b"")
|
||||||
|
received += len(chunk)
|
||||||
|
if max_bytes and received > max_bytes:
|
||||||
|
raise too_large
|
||||||
|
chunks.append(chunk)
|
||||||
more_body = message.get("more_body", False)
|
more_body = message.get("more_body", False)
|
||||||
return body
|
return b"".join(chunks)
|
||||||
|
|
||||||
async def post_vars(self):
|
async def post_vars(self):
|
||||||
body = await self.post_body()
|
body = await self.post_body()
|
||||||
|
|
@ -166,7 +207,7 @@ class Request:
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
max_files: int = DEFAULT_MAX_FILES,
|
||||||
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
|
max_parts: int | None = DEFAULT_MAX_PARTS,
|
||||||
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
@ -397,6 +438,9 @@ async def asgi_send_file(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
HASHED_STATIC_CACHE_CONTROL = "max-age=31536000, immutable, public"
|
||||||
|
|
||||||
|
|
||||||
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
root_path = Path(root_path)
|
root_path = Path(root_path)
|
||||||
static_headers = {}
|
static_headers = {}
|
||||||
|
|
@ -423,11 +467,17 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
# Calculate ETag for filepath
|
# Calculate ETag for filepath
|
||||||
|
hash_value = request.args.get("_hash")
|
||||||
|
if (
|
||||||
|
hash_value
|
||||||
|
and hash_value == sha256_file(full_path, chunk_size=chunk_size)[:12]
|
||||||
|
):
|
||||||
|
headers["Cache-Control"] = HASHED_STATIC_CACHE_CONTROL
|
||||||
etag = await calculate_etag(full_path, chunk_size=chunk_size)
|
etag = await calculate_etag(full_path, chunk_size=chunk_size)
|
||||||
headers["ETag"] = etag
|
headers["ETag"] = etag
|
||||||
if_none_match = request.headers.get("if-none-match")
|
if_none_match = request.headers.get("if-none-match")
|
||||||
if if_none_match and if_none_match == etag:
|
if if_none_match and if_none_match == etag:
|
||||||
return await asgi_send(send, "", 304)
|
return await asgi_send(send, "", 304, headers=headers)
|
||||||
await asgi_send_file(
|
await asgi_send_file(
|
||||||
send, full_path, chunk_size=chunk_size, headers=headers
|
send, full_path, chunk_size=chunk_size, headers=headers
|
||||||
)
|
)
|
||||||
|
|
@ -480,9 +530,9 @@ class Response:
|
||||||
httponly=False,
|
httponly=False,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
):
|
):
|
||||||
assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
|
assert (
|
||||||
SAMESITE_VALUES
|
samesite in SAMESITE_VALUES
|
||||||
)
|
), f"samesite should be one of {SAMESITE_VALUES}"
|
||||||
cookie = SimpleCookie()
|
cookie = SimpleCookie()
|
||||||
cookie[key] = value
|
cookie[key] = value
|
||||||
for prop_name, prop_value in (
|
for prop_name, prop_value in (
|
||||||
|
|
@ -526,6 +576,18 @@ class Response:
|
||||||
content_type="application/json; charset=utf-8",
|
content_type="application/json; charset=utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def error(cls, messages, status=400, headers=None):
|
||||||
|
"""
|
||||||
|
A JSON error response using Datasette's standard error format.
|
||||||
|
|
||||||
|
messages can be a single string or a list of strings. For errors
|
||||||
|
that should content-negotiate between JSON and HTML, raise
|
||||||
|
Forbidden, NotFound, BadRequest or DatasetteError instead and let
|
||||||
|
Datasette's error handling hooks build the response.
|
||||||
|
"""
|
||||||
|
return cls.json(error_body(messages, status), status=status, headers=headers)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def redirect(cls, path, status=302, headers=None):
|
def redirect(cls, path, status=302, headers=None):
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BaseConverter(object):
|
class BaseConverter:
|
||||||
decimal_digits = "0123456789"
|
decimal_digits = "0123456789"
|
||||||
|
|
||||||
def __init__(self, digits):
|
def __init__(self, digits):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import inspect
|
import inspect
|
||||||
import types
|
import types
|
||||||
from typing import NamedTuple, Any
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
|
||||||
class CallableStatus(NamedTuple):
|
class CallableStatus(NamedTuple):
|
||||||
|
|
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
|
||||||
if isinstance(obj, types.FunctionType):
|
if isinstance(obj, types.FunctionType):
|
||||||
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
return CallableStatus(True, inspect.iscoroutinefunction(obj))
|
||||||
|
|
||||||
if hasattr(obj, "__call__"):
|
if callable(obj):
|
||||||
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
|
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
|
||||||
|
|
||||||
assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))
|
assert False, f"obj {obj!r} is somehow callable with no __call__ method"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,30 @@
|
||||||
import textwrap
|
import textwrap
|
||||||
from datasette.utils import table_column_details
|
|
||||||
|
|
||||||
|
from sqlite_utils import Database as SQLiteUtilsDatabase
|
||||||
|
from sqlite_utils import Migrations
|
||||||
|
|
||||||
async def init_internal_db(db):
|
from datasette.utils import escape_sqlite, table_column_details
|
||||||
create_tables_sql = textwrap.dedent("""
|
|
||||||
|
INTERNAL_DB_SCHEMA_TABLES = {
|
||||||
|
"catalog_databases",
|
||||||
|
"catalog_tables",
|
||||||
|
"catalog_views",
|
||||||
|
"catalog_columns",
|
||||||
|
"catalog_indexes",
|
||||||
|
"catalog_foreign_keys",
|
||||||
|
"metadata_instance",
|
||||||
|
"metadata_databases",
|
||||||
|
"metadata_resources",
|
||||||
|
"metadata_columns",
|
||||||
|
"column_types",
|
||||||
|
"queries",
|
||||||
|
}
|
||||||
|
|
||||||
|
INTERNAL_DB_SCHEMA_INDEXES = {
|
||||||
|
"queries_owner_idx",
|
||||||
|
}
|
||||||
|
|
||||||
|
INTERNAL_DB_SCHEMA_SQL = textwrap.dedent("""
|
||||||
CREATE TABLE IF NOT EXISTS catalog_databases (
|
CREATE TABLE IF NOT EXISTS catalog_databases (
|
||||||
database_name TEXT PRIMARY KEY,
|
database_name TEXT PRIMARY KEY,
|
||||||
path TEXT,
|
path TEXT,
|
||||||
|
|
@ -67,99 +88,101 @@ async def init_internal_db(db):
|
||||||
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
FOREIGN KEY (database_name) REFERENCES catalog_databases(database_name),
|
||||||
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
FOREIGN KEY (database_name, table_name) REFERENCES catalog_tables(database_name, table_name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata_instance (
|
||||||
|
key text,
|
||||||
|
value text,
|
||||||
|
unique(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata_databases (
|
||||||
|
database_name text,
|
||||||
|
key text,
|
||||||
|
value text,
|
||||||
|
unique(database_name, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata_resources (
|
||||||
|
database_name text,
|
||||||
|
resource_name text,
|
||||||
|
key text,
|
||||||
|
value text,
|
||||||
|
unique(database_name, resource_name, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata_columns (
|
||||||
|
database_name text,
|
||||||
|
resource_name text,
|
||||||
|
column_name text,
|
||||||
|
key text,
|
||||||
|
value text,
|
||||||
|
unique(database_name, resource_name, column_name, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS column_types (
|
||||||
|
database_name TEXT NOT NULL,
|
||||||
|
resource_name TEXT NOT NULL,
|
||||||
|
column_name TEXT NOT NULL,
|
||||||
|
column_type TEXT NOT NULL,
|
||||||
|
config TEXT,
|
||||||
|
PRIMARY KEY (database_name, resource_name, column_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS queries (
|
||||||
|
database_name TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sql TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
description_html TEXT,
|
||||||
|
options TEXT NOT NULL DEFAULT '{}',
|
||||||
|
parameters TEXT NOT NULL DEFAULT '[]',
|
||||||
|
is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)),
|
||||||
|
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
|
||||||
|
is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)),
|
||||||
|
source TEXT NOT NULL DEFAULT 'user',
|
||||||
|
owner_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (database_name, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS queries_owner_idx
|
||||||
|
ON queries(owner_id);
|
||||||
""").strip()
|
""").strip()
|
||||||
await db.execute_write_script(create_tables_sql)
|
|
||||||
await initialize_metadata_tables(db)
|
|
||||||
|
|
||||||
|
|
||||||
async def initialize_metadata_tables(db):
|
internal_migrations = Migrations("datasette_internal")
|
||||||
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);
|
|
||||||
"""))
|
|
||||||
|
|
||||||
|
|
||||||
async def populate_schema_tables(internal_db, db):
|
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):
|
||||||
database_name = db.name
|
database_name = db.name
|
||||||
|
|
||||||
def delete_everything(conn):
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM catalog_tables WHERE database_name = ?", [database_name]
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM catalog_views WHERE database_name = ?", [database_name]
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM catalog_columns WHERE database_name = ?", [database_name]
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM catalog_foreign_keys WHERE database_name = ?",
|
|
||||||
[database_name],
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM catalog_indexes WHERE database_name = ?", [database_name]
|
|
||||||
)
|
|
||||||
|
|
||||||
await internal_db.execute_write_fn(delete_everything)
|
|
||||||
|
|
||||||
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
tables = (await db.execute("select * from sqlite_master WHERE type = 'table'")).rows
|
||||||
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
views = (await db.execute("select * from sqlite_master WHERE type = 'view'")).rows
|
||||||
|
|
||||||
|
|
@ -184,25 +207,30 @@ async def populate_schema_tables(internal_db, db):
|
||||||
columns = table_column_details(conn, table_name)
|
columns = table_column_details(conn, table_name)
|
||||||
columns_to_insert.extend(
|
columns_to_insert.extend(
|
||||||
{
|
{
|
||||||
**{"database_name": database_name, "table_name": table_name},
|
"database_name": database_name,
|
||||||
|
"table_name": table_name,
|
||||||
**column._asdict(),
|
**column._asdict(),
|
||||||
}
|
}
|
||||||
for column in columns
|
for column in columns
|
||||||
)
|
)
|
||||||
foreign_keys = conn.execute(
|
foreign_keys = conn.execute(
|
||||||
f"PRAGMA foreign_key_list([{table_name}])"
|
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
foreign_keys_to_insert.extend(
|
foreign_keys_to_insert.extend(
|
||||||
{
|
{
|
||||||
**{"database_name": database_name, "table_name": table_name},
|
"database_name": database_name,
|
||||||
|
"table_name": table_name,
|
||||||
**dict(foreign_key),
|
**dict(foreign_key),
|
||||||
}
|
}
|
||||||
for foreign_key in foreign_keys
|
for foreign_key in foreign_keys
|
||||||
)
|
)
|
||||||
indexes = conn.execute(f"PRAGMA index_list([{table_name}])").fetchall()
|
indexes = conn.execute(
|
||||||
|
f"PRAGMA index_list({escape_sqlite(table_name)})"
|
||||||
|
).fetchall()
|
||||||
indexes_to_insert.extend(
|
indexes_to_insert.extend(
|
||||||
{
|
{
|
||||||
**{"database_name": database_name, "table_name": table_name},
|
"database_name": database_name,
|
||||||
|
"table_name": table_name,
|
||||||
**dict(index),
|
**dict(index),
|
||||||
}
|
}
|
||||||
for index in indexes
|
for index in indexes
|
||||||
|
|
@ -223,47 +251,76 @@ async def populate_schema_tables(internal_db, db):
|
||||||
indexes_to_insert,
|
indexes_to_insert,
|
||||||
) = await db.execute_fn(collect_info)
|
) = await db.execute_fn(collect_info)
|
||||||
|
|
||||||
await internal_db.execute_write_many(
|
def replace_catalog(conn):
|
||||||
"""
|
# Delete child rows before their catalog_tables parents so this also
|
||||||
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
# works if a prepare_connection plugin enables foreign key enforcement.
|
||||||
values (?, ?, ?, ?)
|
for table in (
|
||||||
""",
|
"catalog_columns",
|
||||||
tables_to_insert,
|
"catalog_foreign_keys",
|
||||||
)
|
"catalog_indexes",
|
||||||
await internal_db.execute_write_many(
|
"catalog_views",
|
||||||
"""
|
"catalog_tables",
|
||||||
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
):
|
||||||
values (?, ?, ?, ?)
|
conn.execute(
|
||||||
""",
|
f"DELETE FROM {table} WHERE database_name = ?",
|
||||||
views_to_insert,
|
[database_name],
|
||||||
)
|
)
|
||||||
await internal_db.execute_write_many(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO catalog_columns (
|
INSERT OR REPLACE INTO catalog_databases (
|
||||||
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
database_name, path, is_memory, schema_version
|
||||||
) VALUES (
|
) VALUES (?, ?, ?, ?)
|
||||||
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
""",
|
||||||
|
[
|
||||||
|
database_name,
|
||||||
|
str(db.path) if db.path is not None else None,
|
||||||
|
db.is_memory,
|
||||||
|
schema_version,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
""",
|
conn.executemany(
|
||||||
columns_to_insert,
|
"""
|
||||||
)
|
INSERT INTO catalog_tables (database_name, table_name, rootpage, sql)
|
||||||
await internal_db.execute_write_many(
|
values (?, ?, ?, ?)
|
||||||
"""
|
""",
|
||||||
INSERT INTO catalog_foreign_keys (
|
tables_to_insert,
|
||||||
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
|
||||||
) VALUES (
|
|
||||||
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
|
||||||
)
|
)
|
||||||
""",
|
conn.executemany(
|
||||||
foreign_keys_to_insert,
|
"""
|
||||||
)
|
INSERT INTO catalog_views (database_name, view_name, rootpage, sql)
|
||||||
await internal_db.execute_write_many(
|
values (?, ?, ?, ?)
|
||||||
"""
|
""",
|
||||||
INSERT INTO catalog_indexes (
|
views_to_insert,
|
||||||
database_name, table_name, seq, name, "unique", origin, partial
|
|
||||||
) VALUES (
|
|
||||||
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
|
||||||
)
|
)
|
||||||
""",
|
conn.executemany(
|
||||||
indexes_to_insert,
|
"""
|
||||||
)
|
INSERT INTO catalog_columns (
|
||||||
|
database_name, table_name, cid, name, type, "notnull", default_value, is_pk, hidden
|
||||||
|
) VALUES (
|
||||||
|
:database_name, :table_name, :cid, :name, :type, :notnull, :default_value, :is_pk, :hidden
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
columns_to_insert,
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO catalog_foreign_keys (
|
||||||
|
database_name, table_name, "id", seq, "table", "from", "to", on_update, on_delete, match
|
||||||
|
) VALUES (
|
||||||
|
:database_name, :table_name, :id, :seq, :table, :from, :to, :on_update, :on_delete, :match
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
foreign_keys_to_insert,
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO catalog_indexes (
|
||||||
|
database_name, table_name, seq, name, "unique", origin, partial
|
||||||
|
) VALUES (
|
||||||
|
:database_name, :table_name, :seq, :name, :unique, :origin, :partial
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
indexes_to_insert,
|
||||||
|
)
|
||||||
|
|
||||||
|
await internal_db.execute_write_fn(replace_catalog)
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,10 @@ Supports:
|
||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
|
||||||
Dict,
|
|
||||||
List,
|
|
||||||
Optional,
|
|
||||||
Tuple,
|
|
||||||
Union,
|
|
||||||
)
|
)
|
||||||
from urllib.parse import parse_qsl
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
|
|
@ -29,7 +24,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
|
||||||
DEFAULT_MAX_FIELDS = 1000
|
DEFAULT_MAX_FIELDS = 1000
|
||||||
DEFAULT_MAX_FILES = 100
|
DEFAULT_MAX_FILES = 100
|
||||||
# If max_parts is not specified, it defaults to max_fields + max_files
|
# If max_parts is not specified, it defaults to max_fields + max_files
|
||||||
DEFAULT_MAX_PARTS: Optional[int] = None
|
DEFAULT_MAX_PARTS: int | None = None
|
||||||
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
|
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
|
||||||
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
|
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
|
||||||
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
|
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
|
||||||
|
|
@ -40,8 +35,6 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
|
||||||
class MultipartParseError(Exception):
|
class MultipartParseError(Exception):
|
||||||
"""Raised when multipart parsing fails."""
|
"""Raised when multipart parsing fails."""
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UploadedFile:
|
class UploadedFile:
|
||||||
|
|
@ -57,7 +50,7 @@ class UploadedFile:
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
filename: str
|
filename: str
|
||||||
content_type: Optional[str]
|
content_type: str | None
|
||||||
size: int
|
size: int
|
||||||
_file: tempfile.SpooledTemporaryFile = field(repr=False)
|
_file: tempfile.SpooledTemporaryFile = field(repr=False)
|
||||||
|
|
||||||
|
|
@ -86,7 +79,8 @@ class UploadedFile:
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
try:
|
try:
|
||||||
self._file.close()
|
self._file.close()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
|
# __del__ must never raise
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,27 +92,27 @@ class FormData:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
|
self._data: list[tuple[str, str | UploadedFile]] = []
|
||||||
|
|
||||||
def append(self, key: str, value: Union[str, UploadedFile]) -> None:
|
def append(self, key: str, value: str | UploadedFile) -> None:
|
||||||
"""Add a key-value pair."""
|
"""Add a key-value pair."""
|
||||||
self._data.append((key, value))
|
self._data.append((key, value))
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> Union[str, UploadedFile]:
|
def __getitem__(self, key: str) -> str | UploadedFile:
|
||||||
"""Get the first value for a key."""
|
"""Get the first value for a key."""
|
||||||
for k, v in self._data:
|
for k, v in self._data:
|
||||||
if k == key:
|
if k == key:
|
||||||
return v
|
return v
|
||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
|
def get(self, key: str, default: Any = None) -> str | UploadedFile | None:
|
||||||
"""Get the first value for a key, or default if not found."""
|
"""Get the first value for a key, or default if not found."""
|
||||||
try:
|
try:
|
||||||
return self[key]
|
return self[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
|
def getlist(self, key: str) -> list[str | UploadedFile]:
|
||||||
"""Get all values for a key."""
|
"""Get all values for a key."""
|
||||||
return [v for k, v in self._data if k == key]
|
return [v for k, v in self._data if k == key]
|
||||||
|
|
||||||
|
|
@ -142,15 +136,15 @@ class FormData:
|
||||||
"""Return unique keys."""
|
"""Return unique keys."""
|
||||||
return list(self)
|
return list(self)
|
||||||
|
|
||||||
def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
|
def items(self) -> list[tuple[str, str | UploadedFile]]:
|
||||||
"""Return all key-value pairs."""
|
"""Return all key-value pairs."""
|
||||||
return list(self._data)
|
return list(self._data)
|
||||||
|
|
||||||
def values(self) -> List[Union[str, UploadedFile]]:
|
def values(self) -> list[str | UploadedFile]:
|
||||||
"""Return all values."""
|
"""Return all values."""
|
||||||
return [v for _, v in self._data]
|
return [v for _, v in self._data]
|
||||||
|
|
||||||
def _uploaded_files(self) -> List[UploadedFile]:
|
def _uploaded_files(self) -> list[UploadedFile]:
|
||||||
"""Return UploadedFile instances contained in this form."""
|
"""Return UploadedFile instances contained in this form."""
|
||||||
return [v for _, v in self._data if isinstance(v, UploadedFile)]
|
return [v for _, v in self._data if isinstance(v, UploadedFile)]
|
||||||
|
|
||||||
|
|
@ -163,7 +157,7 @@ class FormData:
|
||||||
for uploaded in self._uploaded_files():
|
for uploaded in self._uploaded_files():
|
||||||
try:
|
try:
|
||||||
uploaded.close_sync()
|
uploaded.close_sync()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
# Best-effort cleanup; ignore close errors
|
# Best-effort cleanup; ignore close errors
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -172,7 +166,7 @@ class FormData:
|
||||||
for uploaded in self._uploaded_files():
|
for uploaded in self._uploaded_files():
|
||||||
try:
|
try:
|
||||||
await uploaded.close()
|
await uploaded.close()
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
# Best-effort cleanup; ignore close errors
|
# Best-effort cleanup; ignore close errors
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -189,13 +183,13 @@ class FormData:
|
||||||
await self.aclose()
|
await self.aclose()
|
||||||
|
|
||||||
|
|
||||||
def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
|
def parse_content_disposition(header: str) -> dict[str, str | None]:
|
||||||
"""
|
"""
|
||||||
Parse Content-Disposition header value.
|
Parse Content-Disposition header value.
|
||||||
|
|
||||||
Returns dict with 'name', 'filename' keys (filename may be None).
|
Returns dict with 'name', 'filename' keys (filename may be None).
|
||||||
"""
|
"""
|
||||||
result: Dict[str, Optional[str]] = {"name": None, "filename": None}
|
result: dict[str, str | None] = {"name": None, "filename": None}
|
||||||
|
|
||||||
# Split on semicolons, handling quoted strings
|
# Split on semicolons, handling quoted strings
|
||||||
parts = []
|
parts = []
|
||||||
|
|
@ -238,7 +232,8 @@ def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
result["filename"] = unquote(encoded, encoding="utf-8")
|
result["filename"] = unquote(encoded, encoding="utf-8")
|
||||||
except Exception:
|
except Exception: # noqa: BLE001, S110
|
||||||
|
# Malformed RFC 5987 filename* - fall back to the plain filename
|
||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -250,20 +245,19 @@ def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
|
||||||
|
|
||||||
if key == "name":
|
if key == "name":
|
||||||
result["name"] = value
|
result["name"] = value
|
||||||
elif key == "filename":
|
# Only set filename if filename* hasn't already set it
|
||||||
# Only set if filename* hasn't already set it
|
elif key == "filename" and result["filename"] is None:
|
||||||
if result["filename"] is None:
|
# Strip path components (security)
|
||||||
# Strip path components (security)
|
# Handle both Unix and Windows paths
|
||||||
# Handle both Unix and Windows paths
|
value = value.replace("\\", "/")
|
||||||
value = value.replace("\\", "/")
|
if "/" in value:
|
||||||
if "/" in value:
|
value = value.rsplit("/", 1)[-1]
|
||||||
value = value.rsplit("/", 1)[-1]
|
result["filename"] = value
|
||||||
result["filename"] = value
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
|
def parse_content_type(header: str) -> tuple[str, dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Parse Content-Type header value.
|
Parse Content-Type header value.
|
||||||
|
|
||||||
|
|
@ -307,7 +301,7 @@ class MultipartParser:
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
max_files: int = DEFAULT_MAX_FILES,
|
||||||
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
|
max_parts: int | None = DEFAULT_MAX_PARTS,
|
||||||
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
@ -348,12 +342,12 @@ class MultipartParser:
|
||||||
self._tempdir = tempfile.gettempdir()
|
self._tempdir = tempfile.gettempdir()
|
||||||
|
|
||||||
# Current part state
|
# Current part state
|
||||||
self.current_headers: Dict[str, str] = {}
|
self.current_headers: dict[str, str] = {}
|
||||||
self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
|
self.current_file: tempfile.SpooledTemporaryFile | None = None
|
||||||
self.current_body = bytearray()
|
self.current_body = bytearray()
|
||||||
self.current_name: Optional[str] = None
|
self.current_name: str | None = None
|
||||||
self.current_filename: Optional[str] = None
|
self.current_filename: str | None = None
|
||||||
self.current_content_type: Optional[str] = None
|
self.current_content_type: str | None = None
|
||||||
|
|
||||||
def feed(self, chunk: bytes) -> None:
|
def feed(self, chunk: bytes) -> None:
|
||||||
"""Feed a chunk of data to the parser."""
|
"""Feed a chunk of data to the parser."""
|
||||||
|
|
@ -454,7 +448,7 @@ class MultipartParser:
|
||||||
# Parse header
|
# Parse header
|
||||||
try:
|
try:
|
||||||
line_str = line.decode("utf-8", errors="replace")
|
line_str = line.decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except UnicodeDecodeError:
|
||||||
line_str = line.decode("latin-1")
|
line_str = line.decode("latin-1")
|
||||||
|
|
||||||
if ":" in line_str:
|
if ":" in line_str:
|
||||||
|
|
@ -481,7 +475,9 @@ class MultipartParser:
|
||||||
if self.file_count > self.max_files:
|
if self.file_count > self.max_files:
|
||||||
raise MultipartParseError("Too many files")
|
raise MultipartParseError("Too many files")
|
||||||
if self.handle_files:
|
if self.handle_files:
|
||||||
self.current_file = tempfile.SpooledTemporaryFile(
|
# 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
|
||||||
max_size=self.max_memory_file_size
|
max_size=self.max_memory_file_size
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -644,7 +640,7 @@ async def parse_form_data(
|
||||||
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
|
||||||
max_fields: int = DEFAULT_MAX_FIELDS,
|
max_fields: int = DEFAULT_MAX_FIELDS,
|
||||||
max_files: int = DEFAULT_MAX_FILES,
|
max_files: int = DEFAULT_MAX_FILES,
|
||||||
max_parts: Optional[int] = DEFAULT_MAX_PARTS,
|
max_parts: int | None = DEFAULT_MAX_PARTS,
|
||||||
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
|
||||||
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
|
||||||
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, Iterable, List, Sequence, Tuple
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from datasette.permissions import PermissionSQL
|
from datasette.permissions import PermissionSQL
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
@ -15,7 +16,7 @@ SKIP_PERMISSION_CHECKS = object()
|
||||||
|
|
||||||
async def gather_permission_sql_from_hooks(
|
async def gather_permission_sql_from_hooks(
|
||||||
*, datasette, actor: dict | None, action: str
|
*, datasette, actor: dict | None, action: str
|
||||||
) -> List[PermissionSQL] | object:
|
) -> list[PermissionSQL] | object:
|
||||||
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
"""Collect PermissionSQL objects from the permission_resources_sql hook.
|
||||||
|
|
||||||
Ensures that each returned PermissionSQL has a populated ``source``.
|
Ensures that each returned PermissionSQL has a populated ``source``.
|
||||||
|
|
@ -34,7 +35,7 @@ async def gather_permission_sql_from_hooks(
|
||||||
hookimpls = hook_caller.get_hookimpls()
|
hookimpls = hook_caller.get_hookimpls()
|
||||||
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
|
||||||
|
|
||||||
collected: List[PermissionSQL] = []
|
collected: list[PermissionSQL] = []
|
||||||
actor_json = json.dumps(actor) if actor is not None else None
|
actor_json = json.dumps(actor) if actor is not None else None
|
||||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ def _iter_permission_sql_from_result(
|
||||||
if isinstance(result, PermissionSQL):
|
if isinstance(result, PermissionSQL):
|
||||||
return [result]
|
return [result]
|
||||||
if isinstance(result, (list, tuple)):
|
if isinstance(result, (list, tuple)):
|
||||||
collected: List[PermissionSQL] = []
|
collected: list[PermissionSQL] = []
|
||||||
for item in result:
|
for item in result:
|
||||||
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
collected.extend(_iter_permission_sql_from_result(item, action=action))
|
||||||
return collected
|
return collected
|
||||||
|
|
@ -90,7 +91,7 @@ def _iter_permission_sql_from_result(
|
||||||
|
|
||||||
def build_rules_union(
|
def build_rules_union(
|
||||||
actor: dict | None, plugins: Sequence[PermissionSQL]
|
actor: dict | None, plugins: Sequence[PermissionSQL]
|
||||||
) -> Tuple[str, Dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Compose plugin SQL into a UNION ALL.
|
Compose plugin SQL into a UNION ALL.
|
||||||
|
|
||||||
|
|
@ -102,10 +103,10 @@ def build_rules_union(
|
||||||
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
|
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
|
||||||
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
|
Plugin parameters should be prefixed with a unique identifier (e.g., source name).
|
||||||
"""
|
"""
|
||||||
parts: List[str] = []
|
parts: list[str] = []
|
||||||
actor_json = json.dumps(actor) if actor else None
|
actor_json = json.dumps(actor) if actor else None
|
||||||
actor_id = actor.get("id") if actor else None
|
actor_id = actor.get("id") if actor else None
|
||||||
params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
params: dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
|
||||||
|
|
||||||
for p in plugins:
|
for p in plugins:
|
||||||
# No namespacing - just use plugin params as-is
|
# No namespacing - just use plugin params as-is
|
||||||
|
|
@ -141,10 +142,10 @@ async def resolve_permissions_from_catalog(
|
||||||
plugins: Sequence[Any],
|
plugins: Sequence[Any],
|
||||||
action: str,
|
action: str,
|
||||||
candidate_sql: str,
|
candidate_sql: str,
|
||||||
candidate_params: Dict[str, Any] | None = None,
|
candidate_params: dict[str, Any] | None = None,
|
||||||
*,
|
*,
|
||||||
implicit_deny: bool = True,
|
implicit_deny: bool = True,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
Resolve permissions by embedding the provided *candidate_sql* in a CTE.
|
||||||
|
|
||||||
|
|
@ -168,8 +169,8 @@ async def resolve_permissions_from_catalog(
|
||||||
- parent, child, allow, reason, source_plugin, depth
|
- parent, child, allow, reason, source_plugin, depth
|
||||||
- resource (rendered "/parent/child" or "/parent" or "/")
|
- resource (rendered "/parent/child" or "/parent" or "/")
|
||||||
"""
|
"""
|
||||||
resolved_plugins: List[PermissionSQL] = []
|
resolved_plugins: list[PermissionSQL] = []
|
||||||
restriction_sqls: List[str] = []
|
restriction_sqls: list[str] = []
|
||||||
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
if callable(plugin) and not isinstance(plugin, PermissionSQL):
|
||||||
|
|
@ -398,11 +399,11 @@ async def resolve_permissions_with_candidates(
|
||||||
db,
|
db,
|
||||||
actor: dict | None,
|
actor: dict | None,
|
||||||
plugins: Sequence[Any],
|
plugins: Sequence[Any],
|
||||||
candidates: List[Tuple[str, str | None]],
|
candidates: list[tuple[str, str | None]],
|
||||||
action: str,
|
action: str,
|
||||||
*,
|
*,
|
||||||
implicit_deny: bool = True,
|
implicit_deny: bool = True,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Resolve permissions without any external candidate table by embedding
|
Resolve permissions without any external candidate table by embedding
|
||||||
the candidates as a UNION of parameterized SELECTs in a CTE.
|
the candidates as a UNION of parameterized SELECTs in a CTE.
|
||||||
|
|
@ -411,8 +412,8 @@ async def resolve_permissions_with_candidates(
|
||||||
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
|
||||||
"""
|
"""
|
||||||
# Build a small CTE for candidates.
|
# Build a small CTE for candidates.
|
||||||
cand_rows_sql: List[str] = []
|
cand_rows_sql: list[str] = []
|
||||||
cand_params: Dict[str, Any] = {}
|
cand_params: dict[str, Any] = {}
|
||||||
for i, (parent, child) in enumerate(candidates):
|
for i, (parent, child) in enumerate(candidates):
|
||||||
pkey = f"cand_p_{i}"
|
pkey = f"cand_p_{i}"
|
||||||
ckey = f"cand_c_{i}"
|
ckey = f"cand_c_{i}"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ https://github.com/python/cpython/blob/v3.8.3/LICENSE
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from shutil import copy, copy2, copystat, Error
|
from shutil import Error, copy, copy2, copystat
|
||||||
|
|
||||||
|
|
||||||
def _copytree(
|
def _copytree(
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,6 @@ _SQLITE_INTERNAL_SCHEMA_FUNCTIONS = {
|
||||||
"sqlite_rename_test",
|
"sqlite_rename_test",
|
||||||
"substr",
|
"substr",
|
||||||
}
|
}
|
||||||
|
|
||||||
_AUTHORIZER_ACTION_NAMES = {
|
_AUTHORIZER_ACTION_NAMES = {
|
||||||
getattr(sqlite3, name): name
|
getattr(sqlite3, name): name
|
||||||
for name in (
|
for name in (
|
||||||
|
|
@ -391,6 +390,10 @@ def analyze_sql_tables(
|
||||||
)
|
)
|
||||||
return sqlite3.SQLITE_OK
|
return sqlite3.SQLITE_OK
|
||||||
|
|
||||||
|
if action == sqlite3.SQLITE_RECURSIVE:
|
||||||
|
# Recursive CTE bookkeeping; table reads are reported separately.
|
||||||
|
return sqlite3.SQLITE_OK
|
||||||
|
|
||||||
if action == sqlite3.SQLITE_FUNCTION and arg2 is not None:
|
if action == sqlite3.SQLITE_FUNCTION and arg2 is not None:
|
||||||
record(
|
record(
|
||||||
"function",
|
"function",
|
||||||
|
|
@ -410,12 +413,12 @@ def analyze_sql_tables(
|
||||||
database=None,
|
database=None,
|
||||||
table=None,
|
table=None,
|
||||||
sqlite_schema=sqlite_schema,
|
sqlite_schema=sqlite_schema,
|
||||||
target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
|
target=f"{arg1} {arg2}" if arg2 is not None else arg1,
|
||||||
source=source,
|
source=source,
|
||||||
)
|
)
|
||||||
return sqlite3.SQLITE_OK
|
return sqlite3.SQLITE_OK
|
||||||
|
|
||||||
action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
|
action_name = _AUTHORIZER_ACTION_NAMES.get(action, f"SQLITE_{action}")
|
||||||
record(
|
record(
|
||||||
"unknown",
|
"unknown",
|
||||||
"unknown",
|
"unknown",
|
||||||
|
|
@ -485,17 +488,17 @@ def analyze_sql_tables(
|
||||||
and key.operation in {"create", "alter", "drop"}
|
and key.operation in {"create", "alter", "drop"}
|
||||||
for key in operations
|
for key in operations
|
||||||
)
|
)
|
||||||
dropped_tables = {
|
dropped_tables_and_views = {
|
||||||
(key.database, key.table)
|
(key.database, key.table)
|
||||||
for key in operations
|
for key in operations
|
||||||
if key.operation == "drop" and key.target_type == "table"
|
if key.operation == "drop" and key.target_type in {"table", "view"}
|
||||||
}
|
}
|
||||||
|
|
||||||
def key_is_drop_table_delete(key: OperationKey) -> bool:
|
def key_is_drop_table_delete(key: OperationKey) -> bool:
|
||||||
return (
|
return (
|
||||||
key.operation == "delete"
|
key.operation == "delete"
|
||||||
and key.target_type == "table"
|
and key.target_type == "table"
|
||||||
and (key.database, key.table) in dropped_tables
|
and (key.database, key.table) in dropped_tables_and_views
|
||||||
)
|
)
|
||||||
|
|
||||||
has_user_table_access_in_schema_operation = any(
|
has_user_table_access_in_schema_operation = any(
|
||||||
|
|
@ -518,9 +521,7 @@ def analyze_sql_tables(
|
||||||
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
|
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
if key_is_drop_table_delete(key):
|
return bool(key_is_drop_table_delete(key))
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
|
def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]
|
||||||
schema_table = _sqlite_schema_table(schema)
|
schema_table = _sqlite_schema_table(schema)
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
f"select name, sql from {schema_table} where type = 'table'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
return []
|
return []
|
||||||
|
|
@ -127,7 +127,7 @@ def _sqlite_table_type_from_schema(
|
||||||
schema_table = _sqlite_schema_table(schema)
|
schema_table = _sqlite_schema_table(schema)
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"select type, sql from {} where name = ?".format(schema_table),
|
f"select type, sql from {schema_table} where name = ?",
|
||||||
(table,),
|
(table,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
|
|
@ -155,7 +155,7 @@ def _is_known_shadow_table(
|
||||||
schema_table = _sqlite_schema_table(schema)
|
schema_table = _sqlite_schema_table(schema)
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"select name, sql from {} where type = 'table'".format(schema_table)
|
f"select name, sql from {schema_table} where type = 'table'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
return False
|
return False
|
||||||
|
|
@ -174,7 +174,7 @@ def _sqlite_schema_table(schema: str | None) -> str:
|
||||||
return "sqlite_master"
|
return "sqlite_master"
|
||||||
if schema == "temp":
|
if schema == "temp":
|
||||||
return "sqlite_temp_master"
|
return "sqlite_temp_master"
|
||||||
return "{}.sqlite_master".format(_quote_identifier(schema))
|
return f"{_quote_identifier(schema)}.sqlite_master"
|
||||||
|
|
||||||
|
|
||||||
def _quote_identifier(value: str) -> str:
|
def _quote_identifier(value: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
# These wrapper classes pre-date the introduction of
|
# These wrapper classes pre-date the introduction of
|
||||||
# datasette.client and httpx to Datasette. They could
|
# datasette.client and httpx to Datasette. They could
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
__version__ = "1.0a34"
|
__version__ = "1.0a37"
|
||||||
__version_info__ = tuple(__version__.split("."))
|
__version_info__ = tuple(__version__.split("."))
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,87 @@
|
||||||
|
import dataclasses
|
||||||
|
import types
|
||||||
|
import typing
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContextField:
|
||||||
|
name: str
|
||||||
|
type_name: str
|
||||||
|
help: str
|
||||||
|
from_extra: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _type_name(type_):
|
||||||
|
if type_ is type(None):
|
||||||
|
return "None"
|
||||||
|
origin = typing.get_origin(type_)
|
||||||
|
args = typing.get_args(type_)
|
||||||
|
if origin in (typing.Union, types.UnionType):
|
||||||
|
return " | ".join(_type_name(arg) for arg in args)
|
||||||
|
if origin is not None:
|
||||||
|
name = getattr(origin, "__name__", str(origin).removeprefix("typing."))
|
||||||
|
return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args))
|
||||||
|
return getattr(type_, "__name__", str(type_).removeprefix("typing."))
|
||||||
|
|
||||||
|
|
||||||
|
def from_extra():
|
||||||
|
"""
|
||||||
|
Declare a Context dataclass field whose value comes from a registered
|
||||||
|
Extra of the same name - its documentation is the Extra description,
|
||||||
|
so the doc string lives next to the resolve() code rather than being
|
||||||
|
duplicated on the dataclass.
|
||||||
|
"""
|
||||||
|
return dataclasses.field(metadata={"from_extra": True})
|
||||||
|
|
||||||
|
|
||||||
class Context:
|
class Context:
|
||||||
"Base class for all documented contexts"
|
"Base class for all documented contexts"
|
||||||
|
|
||||||
|
# Set on subclasses whose from_extra() fields should be resolved
|
||||||
|
# against the extras registry for this scope
|
||||||
|
extras_scope = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def documented_fields(cls):
|
||||||
|
"List of ContextField describing the documented fields of this context"
|
||||||
|
documented = []
|
||||||
|
for f in dataclasses.fields(cls):
|
||||||
|
if f.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
is_from_extra = bool(f.metadata.get("from_extra"))
|
||||||
|
if is_from_extra:
|
||||||
|
help_text = cls._extra_description(f.name)
|
||||||
|
else:
|
||||||
|
help_text = f.metadata.get("help", "")
|
||||||
|
documented.append(
|
||||||
|
ContextField(
|
||||||
|
name=f.name,
|
||||||
|
type_name=_type_name(f.type),
|
||||||
|
help=help_text,
|
||||||
|
from_extra=is_from_extra,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return documented
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extra_description(cls, name):
|
||||||
|
# Imported lazily - table_extras is not needed just to define
|
||||||
|
# Context subclasses
|
||||||
|
from datasette.views.table_extras import table_extra_registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
extra_class = table_extra_registry.classes_by_name[name]
|
||||||
|
except KeyError:
|
||||||
|
raise KeyError(
|
||||||
|
f"{cls.__name__}.{name} is declared with from_extra() but there is no "
|
||||||
|
"registered extra of that name"
|
||||||
|
)
|
||||||
|
if cls.extras_scope is not None and not extra_class.available_for(
|
||||||
|
cls.extras_scope
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{cls.__name__}.{name} is declared with from_extra() but the {name} extra is "
|
||||||
|
f"not available for scope {cls.extras_scope}"
|
||||||
|
)
|
||||||
|
return extra_class.description or ""
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,21 @@
|
||||||
import asyncio
|
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
|
||||||
import time
|
|
||||||
import urllib
|
|
||||||
from markupsafe import escape
|
|
||||||
|
|
||||||
|
|
||||||
from datasette.database import QueryInterrupted
|
|
||||||
from datasette.utils.asgi import Request
|
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
add_cors_headers,
|
|
||||||
await_me_maybe,
|
|
||||||
EscapeHtmlWriter,
|
EscapeHtmlWriter,
|
||||||
InvalidSql,
|
InvalidSql,
|
||||||
LimitedWriter,
|
LimitedWriter,
|
||||||
call_with_supported_arguments,
|
add_cors_headers,
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
path_with_added_args,
|
|
||||||
path_with_removed_args,
|
|
||||||
path_with_format,
|
path_with_format,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import (
|
from datasette.utils.asgi import (
|
||||||
AsgiStream,
|
AsgiStream,
|
||||||
NotFound,
|
|
||||||
Response,
|
|
||||||
BadRequest,
|
BadRequest,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,12 +28,15 @@ class DatasetteError(Exception):
|
||||||
status=500,
|
status=500,
|
||||||
template=None,
|
template=None,
|
||||||
message_is_html=False,
|
message_is_html=False,
|
||||||
|
plain_message=None,
|
||||||
):
|
):
|
||||||
self.message = message
|
self.message = message
|
||||||
self.title = title
|
self.title = title
|
||||||
self.error_dict = error_dict or {}
|
self.error_dict = error_dict or {}
|
||||||
self.status = status
|
self.status = status
|
||||||
self.message_is_html = message_is_html
|
self.message_is_html = message_is_html
|
||||||
|
# Plain text used for JSON error responses when message is HTML
|
||||||
|
self.plain_message = plain_message
|
||||||
|
|
||||||
|
|
||||||
class View:
|
class View:
|
||||||
|
|
@ -61,9 +52,7 @@ class View:
|
||||||
request.path.endswith(".json")
|
request.path.endswith(".json")
|
||||||
or request.headers.get("content-type") == "application/json"
|
or request.headers.get("content-type") == "application/json"
|
||||||
):
|
):
|
||||||
response = Response.json(
|
response = Response.error("Method not allowed", 405)
|
||||||
{"ok": False, "error": "Method not allowed"}, status=405
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
response = Response.text("Method not allowed", status=405)
|
response = Response.text("Method not allowed", status=405)
|
||||||
return response
|
return response
|
||||||
|
|
@ -102,9 +91,7 @@ class BaseView:
|
||||||
request.path.endswith(".json")
|
request.path.endswith(".json")
|
||||||
or request.headers.get("content-type") == "application/json"
|
or request.headers.get("content-type") == "application/json"
|
||||||
):
|
):
|
||||||
response = Response.json(
|
response = Response.error("Method not allowed", 405)
|
||||||
{"ok": False, "error": "Method not allowed"}, status=405
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
response = Response.text("Method not allowed", status=405)
|
response = Response.text("Method not allowed", status=405)
|
||||||
return response
|
return response
|
||||||
|
|
@ -142,12 +129,10 @@ class BaseView:
|
||||||
template = environment.select_template(templates)
|
template = environment.select_template(templates)
|
||||||
template_context = {
|
template_context = {
|
||||||
**context,
|
**context,
|
||||||
**{
|
"select_templates": [
|
||||||
"select_templates": [
|
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
for template_name in templates
|
||||||
for template_name in templates
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.has_json_alternate:
|
if self.has_json_alternate:
|
||||||
|
|
@ -164,9 +149,7 @@ class BaseView:
|
||||||
template_context["alternate_url_json"] = alternate_url_json
|
template_context["alternate_url_json"] = alternate_url_json
|
||||||
headers.update(
|
headers.update(
|
||||||
{
|
{
|
||||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
||||||
alternate_url_json
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return Response.html(
|
return Response.html(
|
||||||
|
|
@ -192,239 +175,12 @@ class BaseView:
|
||||||
return view
|
return view
|
||||||
|
|
||||||
|
|
||||||
class DataView(BaseView):
|
|
||||||
name = ""
|
|
||||||
|
|
||||||
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
|
||||||
if request.query_string and "?" not in path and forward_querystring:
|
|
||||||
path = f"{path}?{request.query_string}"
|
|
||||||
if remove_args:
|
|
||||||
path = path_with_removed_args(request, remove_args, path=path)
|
|
||||||
r = Response.redirect(path)
|
|
||||||
r.headers["Link"] = f"<{path}>; rel=preload"
|
|
||||||
if self.ds.cors:
|
|
||||||
add_cors_headers(r.headers)
|
|
||||||
return r
|
|
||||||
|
|
||||||
async def data(self, request):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
async def as_csv(self, request, database):
|
|
||||||
return await stream_csv(self.ds, self.data, request, database)
|
|
||||||
|
|
||||||
async def get(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
database = db.name
|
|
||||||
database_route = db.route
|
|
||||||
|
|
||||||
_format = request.url_vars["format"]
|
|
||||||
data_kwargs = {}
|
|
||||||
|
|
||||||
if _format == "csv":
|
|
||||||
return await self.as_csv(request, database_route)
|
|
||||||
|
|
||||||
if _format is None:
|
|
||||||
# HTML views default to expanding all foreign key labels
|
|
||||||
data_kwargs["default_labels"] = True
|
|
||||||
|
|
||||||
extra_template_data = {}
|
|
||||||
start = time.perf_counter()
|
|
||||||
status_code = None
|
|
||||||
templates = []
|
|
||||||
try:
|
|
||||||
response_or_template_contexts = await self.data(request, **data_kwargs)
|
|
||||||
if isinstance(response_or_template_contexts, Response):
|
|
||||||
return response_or_template_contexts
|
|
||||||
# If it has four items, it includes an HTTP status code
|
|
||||||
if len(response_or_template_contexts) == 4:
|
|
||||||
(
|
|
||||||
data,
|
|
||||||
extra_template_data,
|
|
||||||
templates,
|
|
||||||
status_code,
|
|
||||||
) = response_or_template_contexts
|
|
||||||
else:
|
|
||||||
data, extra_template_data, templates = response_or_template_contexts
|
|
||||||
except QueryInterrupted as ex:
|
|
||||||
raise DatasetteError(
|
|
||||||
textwrap.dedent("""
|
|
||||||
<p>SQL query took too long. The time limit is controlled by the
|
|
||||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
|
||||||
configuration option.</p>
|
|
||||||
<textarea style="width: 90%">{}</textarea>
|
|
||||||
<script>
|
|
||||||
let ta = document.querySelector("textarea");
|
|
||||||
ta.style.height = ta.scrollHeight + "px";
|
|
||||||
</script>
|
|
||||||
""".format(escape(ex.sql))).strip(),
|
|
||||||
title="SQL Interrupted",
|
|
||||||
status=400,
|
|
||||||
message_is_html=True,
|
|
||||||
)
|
|
||||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
|
||||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
|
||||||
|
|
||||||
except sqlite3.OperationalError as e:
|
|
||||||
raise DatasetteError(str(e))
|
|
||||||
|
|
||||||
except DatasetteError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
end = time.perf_counter()
|
|
||||||
data["query_ms"] = (end - start) * 1000
|
|
||||||
|
|
||||||
# Special case for .jsono extension - redirect to _shape=objects
|
|
||||||
if _format == "jsono":
|
|
||||||
return self.redirect(
|
|
||||||
request,
|
|
||||||
path_with_added_args(
|
|
||||||
request,
|
|
||||||
{"_shape": "objects"},
|
|
||||||
path=request.path.rsplit(".jsono", 1)[0] + ".json",
|
|
||||||
),
|
|
||||||
forward_querystring=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if _format in self.ds.renderers.keys():
|
|
||||||
# Dispatch request to the correct output format renderer
|
|
||||||
# (CSV is not handled here due to streaming)
|
|
||||||
result = call_with_supported_arguments(
|
|
||||||
self.ds.renderers[_format][0],
|
|
||||||
datasette=self.ds,
|
|
||||||
columns=data.get("columns") or [],
|
|
||||||
rows=data.get("rows") or [],
|
|
||||||
sql=data.get("query", {}).get("sql", None),
|
|
||||||
query_name=data.get("query_name"),
|
|
||||||
database=database,
|
|
||||||
table=data.get("table"),
|
|
||||||
request=request,
|
|
||||||
view_name=self.name,
|
|
||||||
truncated=False, # TODO: support this
|
|
||||||
error=data.get("error"),
|
|
||||||
# These will be deprecated in Datasette 1.0:
|
|
||||||
args=request.args,
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
result = await result
|
|
||||||
if result is None:
|
|
||||||
raise NotFound("No data")
|
|
||||||
if isinstance(result, dict):
|
|
||||||
r = Response(
|
|
||||||
body=result.get("body"),
|
|
||||||
status=result.get("status_code", status_code or 200),
|
|
||||||
content_type=result.get("content_type", "text/plain"),
|
|
||||||
headers=result.get("headers"),
|
|
||||||
)
|
|
||||||
elif isinstance(result, Response):
|
|
||||||
r = result
|
|
||||||
if status_code is not None:
|
|
||||||
# Over-ride the status code
|
|
||||||
r.status = status_code
|
|
||||||
else:
|
|
||||||
assert False, f"{result} should be dict or Response"
|
|
||||||
else:
|
|
||||||
extras = {}
|
|
||||||
if callable(extra_template_data):
|
|
||||||
extras = extra_template_data()
|
|
||||||
if asyncio.iscoroutine(extras):
|
|
||||||
extras = await extras
|
|
||||||
else:
|
|
||||||
extras = extra_template_data
|
|
||||||
url_labels_extra = {}
|
|
||||||
if data.get("expandable_columns"):
|
|
||||||
url_labels_extra = {"_labels": "on"}
|
|
||||||
|
|
||||||
renderers = {}
|
|
||||||
for key, (_, can_render) in self.ds.renderers.items():
|
|
||||||
it_can_render = call_with_supported_arguments(
|
|
||||||
can_render,
|
|
||||||
datasette=self.ds,
|
|
||||||
columns=data.get("columns") or [],
|
|
||||||
rows=data.get("rows") or [],
|
|
||||||
sql=data.get("query", {}).get("sql", None),
|
|
||||||
query_name=data.get("query_name"),
|
|
||||||
database=database,
|
|
||||||
table=data.get("table"),
|
|
||||||
request=request,
|
|
||||||
view_name=self.name,
|
|
||||||
)
|
|
||||||
it_can_render = await await_me_maybe(it_can_render)
|
|
||||||
if it_can_render:
|
|
||||||
renderers[key] = self.ds.urls.path(
|
|
||||||
path_with_format(
|
|
||||||
request=request,
|
|
||||||
path=request.scope.get("route_path"),
|
|
||||||
format=key,
|
|
||||||
extra_qs={**url_labels_extra},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
url_csv_args = {"_size": "max", **url_labels_extra}
|
|
||||||
url_csv = self.ds.urls.path(
|
|
||||||
path_with_format(
|
|
||||||
request=request,
|
|
||||||
path=request.scope.get("route_path"),
|
|
||||||
format="csv",
|
|
||||||
extra_qs=url_csv_args,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
url_csv_path = url_csv.split("?")[0]
|
|
||||||
context = {
|
|
||||||
**data,
|
|
||||||
**extras,
|
|
||||||
**{
|
|
||||||
"renderers": renderers,
|
|
||||||
"url_csv": url_csv,
|
|
||||||
"url_csv_path": url_csv_path,
|
|
||||||
"url_csv_hidden_args": [
|
|
||||||
(key, value)
|
|
||||||
for key, value in urllib.parse.parse_qsl(request.query_string)
|
|
||||||
if key not in ("_labels", "_facet", "_size")
|
|
||||||
]
|
|
||||||
+ [("_size", "max")],
|
|
||||||
"settings": self.ds.settings_dict(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if "metadata" not in context:
|
|
||||||
context["metadata"] = await self.ds.get_instance_metadata()
|
|
||||||
r = await self.render(templates, request=request, context=context)
|
|
||||||
if status_code is not None:
|
|
||||||
r.status = status_code
|
|
||||||
|
|
||||||
ttl = request.args.get("_ttl", None)
|
|
||||||
if ttl is None or not ttl.isdigit():
|
|
||||||
ttl = self.ds.setting("default_cache_ttl")
|
|
||||||
|
|
||||||
return self.set_response_headers(r, ttl)
|
|
||||||
|
|
||||||
def set_response_headers(self, response, ttl):
|
|
||||||
# Set far-future cache expiry
|
|
||||||
if self.ds.cache_headers and response.status == 200:
|
|
||||||
ttl = int(ttl)
|
|
||||||
if ttl == 0:
|
|
||||||
ttl_header = "no-cache"
|
|
||||||
else:
|
|
||||||
ttl_header = f"max-age={ttl}"
|
|
||||||
response.headers["Cache-Control"] = ttl_header
|
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
|
||||||
if self.ds.cors:
|
|
||||||
add_cors_headers(response.headers)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _error(messages, status=400):
|
|
||||||
return Response.json({"ok": False, "errors": messages}, status=status)
|
|
||||||
|
|
||||||
|
|
||||||
async def stream_csv(datasette, fetch_data, request, database):
|
async def stream_csv(datasette, fetch_data, request, database):
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
stream = request.args.get("_stream")
|
stream = request.args.get("_stream")
|
||||||
# Do not calculate facets or counts:
|
# Do not calculate facets or counts:
|
||||||
extra_parameters = [
|
extra_parameters = [
|
||||||
"{}=1".format(key)
|
f"{key}=1" for key in ("_nofacet", "_nocount") if not request.args.get(key)
|
||||||
for key in ("_nofacet", "_nocount")
|
|
||||||
if not request.args.get(key)
|
|
||||||
]
|
]
|
||||||
if extra_parameters:
|
if extra_parameters:
|
||||||
# Replace request object with a new one with modified scope
|
# Replace request object with a new one with modified scope
|
||||||
|
|
@ -454,9 +210,6 @@ async def stream_csv(datasette, fetch_data, request, database):
|
||||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||||
|
|
||||||
except sqlite3.OperationalError as e:
|
|
||||||
raise DatasetteError(str(e))
|
|
||||||
|
|
||||||
except DatasetteError:
|
except DatasetteError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -563,8 +316,9 @@ async def stream_csv(datasette, fetch_data, request, database):
|
||||||
else:
|
else:
|
||||||
new_row.append(cell)
|
new_row.append(cell)
|
||||||
await writer.writerow(new_row)
|
await writer.writerow(new_row)
|
||||||
except Exception as ex:
|
except Exception as ex: # noqa: BLE001
|
||||||
sys.stderr.write("Caught this error: {}\n".format(ex))
|
# Streaming CSV: report the error into the response body and stop
|
||||||
|
sys.stderr.write(f"Caught this error: {ex}\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
await r.write(str(ex))
|
await r.write(str(ex))
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,75 @@
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from urllib.parse import parse_qsl, urlencode
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
import markupsafe
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sqlite_utils
|
|
||||||
import textwrap
|
import textwrap
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import markupsafe
|
||||||
|
|
||||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
|
||||||
from datasette.extras import extra_names_from_request
|
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
|
from datasette.extras import ExtraScope, extra_names_from_request
|
||||||
|
from datasette.plugins import pm
|
||||||
from datasette.resources import DatabaseResource, QueryResource
|
from datasette.resources import DatabaseResource, QueryResource
|
||||||
from datasette.stored_queries import stored_query_to_dict
|
from datasette.stored_queries import StoredQuery, stored_query_to_dict
|
||||||
from datasette.write_sql import QueryWriteRejected
|
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
InvalidSql,
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
call_with_supported_arguments,
|
call_with_supported_arguments,
|
||||||
named_parameters as derive_named_parameters,
|
error_body,
|
||||||
format_bytes,
|
format_bytes,
|
||||||
make_slot_function,
|
|
||||||
tilde_decode,
|
|
||||||
to_css_class,
|
|
||||||
validate_sql_select,
|
|
||||||
is_url,
|
is_url,
|
||||||
|
make_slot_function,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
path_with_format,
|
path_with_format,
|
||||||
path_with_removed_args,
|
path_with_removed_args,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
|
tilde_decode,
|
||||||
|
to_css_class,
|
||||||
truncate_url,
|
truncate_url,
|
||||||
InvalidSql,
|
validate_sql_select,
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
from datasette.utils import (
|
||||||
from datasette.plugins import pm
|
named_parameters as derive_named_parameters,
|
||||||
|
)
|
||||||
|
from datasette.utils.asgi import AsgiFileDownload, Forbidden, NotFound, Response
|
||||||
|
from datasette.write_sql import QueryWriteRejected
|
||||||
|
|
||||||
from .base import BaseView, DatasetteError, View, _error, stream_csv
|
from . import Context
|
||||||
|
from .base import DatasetteError, View, stream_csv
|
||||||
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
|
||||||
|
from .table_create_alter import _create_table_ui_context
|
||||||
from .table_extras import (
|
from .table_extras import (
|
||||||
QueryExtraContext,
|
QueryExtraContext,
|
||||||
resolve_query_extras,
|
resolve_query_extras,
|
||||||
table_extra_registry,
|
table_extra_registry,
|
||||||
)
|
)
|
||||||
from . import Context
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatabaseTable:
|
||||||
|
"Summary of a table or view shown on database and query pages."
|
||||||
|
|
||||||
|
name: str
|
||||||
|
columns: list[str]
|
||||||
|
primary_keys: list[str]
|
||||||
|
count: int | None
|
||||||
|
count_truncated: bool
|
||||||
|
hidden: bool
|
||||||
|
fts_table: str | None
|
||||||
|
foreign_keys: dict[str, list[dict[str, str]]]
|
||||||
|
private: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatabaseViewInfo:
|
||||||
|
"Summary of a SQLite view shown on the database page."
|
||||||
|
|
||||||
|
name: str
|
||||||
|
private: bool
|
||||||
|
|
||||||
|
|
||||||
class DatabaseView(View):
|
class DatabaseView(View):
|
||||||
|
|
@ -78,7 +103,7 @@ class DatabaseView(View):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if format_ not in ("html", "json"):
|
if format_ not in ("html", "json"):
|
||||||
raise NotFound("Invalid format: {}".format(format_))
|
raise NotFound(f"Invalid format: {format_}")
|
||||||
|
|
||||||
metadata = await datasette.get_database_metadata(database)
|
metadata = await datasette.get_database_metadata(database)
|
||||||
|
|
||||||
|
|
@ -96,7 +121,7 @@ class DatabaseView(View):
|
||||||
# Filter to just views
|
# Filter to just views
|
||||||
view_names_set = set(await db.view_names())
|
view_names_set = set(await db.view_names())
|
||||||
sql_views = [
|
sql_views = [
|
||||||
{"name": name, "private": allowed_dict[name].private}
|
DatabaseViewInfo(name=name, private=allowed_dict[name].private)
|
||||||
for name in allowed_dict
|
for name in allowed_dict
|
||||||
if name in view_names_set
|
if name in view_names_set
|
||||||
]
|
]
|
||||||
|
|
@ -117,21 +142,36 @@ class DatabaseView(View):
|
||||||
else len(stored_queries)
|
else len(stored_queries)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Resolve the registered database-level actions for this database in
|
||||||
|
# one batched query, seeding the request permission cache so allowed()
|
||||||
|
# calls made inside plugin hooks below are served from the cache.
|
||||||
|
database_action_permissions = await datasette.allowed_many(
|
||||||
|
actions=[
|
||||||
|
name
|
||||||
|
for name, action in datasette.actions.items()
|
||||||
|
if action.resource_class is DatabaseResource
|
||||||
|
],
|
||||||
|
resource=DatabaseResource(database),
|
||||||
|
actor=request.actor,
|
||||||
|
)
|
||||||
|
create_table_ui = await _create_table_ui_context(
|
||||||
|
datasette, request, db, database, database_action_permissions
|
||||||
|
)
|
||||||
|
|
||||||
async def database_actions():
|
async def database_actions():
|
||||||
# Resolve the registered database-level actions for this
|
|
||||||
# database in one batched query, seeding the request permission
|
|
||||||
# cache so that allowed() calls made inside the plugin hooks
|
|
||||||
# below are served from the cache
|
|
||||||
await datasette.allowed_many(
|
|
||||||
actions=[
|
|
||||||
name
|
|
||||||
for name, action in datasette.actions.items()
|
|
||||||
if action.resource_class is DatabaseResource
|
|
||||||
],
|
|
||||||
resource=DatabaseResource(database),
|
|
||||||
actor=request.actor,
|
|
||||||
)
|
|
||||||
links = []
|
links = []
|
||||||
|
if create_table_ui:
|
||||||
|
links.append(
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"label": "Create table",
|
||||||
|
"description": "Create a new table in this database.",
|
||||||
|
"attrs": {
|
||||||
|
"aria-label": f"Create table in {database}",
|
||||||
|
"data-database-action": "create-table",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
for hook in pm.hook.database_actions(
|
for hook in pm.hook.database_actions(
|
||||||
datasette=datasette,
|
datasette=datasette,
|
||||||
database=database,
|
database=database,
|
||||||
|
|
@ -156,9 +196,9 @@ class DatabaseView(View):
|
||||||
"private": private,
|
"private": private,
|
||||||
"path": datasette.urls.database(database),
|
"path": datasette.urls.database(database),
|
||||||
"size": db.size,
|
"size": db.size,
|
||||||
"tables": tables,
|
"tables": [asdict(table) for table in tables],
|
||||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
"hidden_count": len([table for table in tables if table.hidden]),
|
||||||
"views": sql_views,
|
"views": [asdict(view) for view in sql_views],
|
||||||
"queries": [stored_query_to_dict(query) for query in stored_queries],
|
"queries": [stored_query_to_dict(query) for query in stored_queries],
|
||||||
"queries_more": queries_more,
|
"queries_more": queries_more,
|
||||||
"queries_count": queries_count,
|
"queries_count": queries_count,
|
||||||
|
|
@ -198,7 +238,7 @@ class DatabaseView(View):
|
||||||
path=datasette.urls.database(database),
|
path=datasette.urls.database(database),
|
||||||
size=db.size,
|
size=db.size,
|
||||||
tables=tables,
|
tables=tables,
|
||||||
hidden_count=len([t for t in tables if t["hidden"]]),
|
hidden_count=len([table for table in tables if table.hidden]),
|
||||||
views=sql_views,
|
views=sql_views,
|
||||||
queries=stored_queries,
|
queries=stored_queries,
|
||||||
queries_more=queries_more,
|
queries_more=queries_more,
|
||||||
|
|
@ -211,10 +251,12 @@ class DatabaseView(View):
|
||||||
),
|
),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
database_color=db.color,
|
database_color=db.color,
|
||||||
|
database_page_data=(
|
||||||
|
{"createTable": create_table_ui} if create_table_ui else {}
|
||||||
|
),
|
||||||
database_actions=database_actions,
|
database_actions=database_actions,
|
||||||
show_hidden=request.args.get("_show_hidden"),
|
show_hidden=request.args.get("_show_hidden"),
|
||||||
editable=True,
|
editable=True,
|
||||||
count_limit=db.count_limit,
|
|
||||||
allow_download=datasette.setting("allow_download")
|
allow_download=datasette.setting("allow_download")
|
||||||
and not db.is_mutable
|
and not db.is_mutable
|
||||||
and not db.is_memory,
|
and not db.is_memory,
|
||||||
|
|
@ -232,25 +274,39 @@ class DatabaseView(View):
|
||||||
view_name="database",
|
view_name="database",
|
||||||
),
|
),
|
||||||
headers={
|
headers={
|
||||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
||||||
alternate_url_json
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DatabaseContext(Context):
|
class DatabaseContext(Context):
|
||||||
|
"The page listing the tables, views and queries in a database, e.g. /fixtures."
|
||||||
|
|
||||||
|
documented_template = "database.html"
|
||||||
|
|
||||||
database: str = field(metadata={"help": "The name of the database"})
|
database: str = field(metadata={"help": "The name of the database"})
|
||||||
private: bool = field(
|
private: bool = field(
|
||||||
metadata={"help": "Boolean indicating if this is a private database"}
|
metadata={"help": "Boolean indicating if this is a private database"}
|
||||||
)
|
)
|
||||||
path: str = field(metadata={"help": "The URL path to this database"})
|
path: str = field(metadata={"help": "The URL path to this database"})
|
||||||
size: int = field(metadata={"help": "The size of the database in bytes"})
|
size: int = field(metadata={"help": "The size of the database in bytes"})
|
||||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
tables: list[DatabaseTable] = field(
|
||||||
|
metadata={
|
||||||
|
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||||
|
}
|
||||||
|
)
|
||||||
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
||||||
views: list = field(metadata={"help": "List of view objects in the database"})
|
views: list[DatabaseViewInfo] = field(
|
||||||
queries: list = field(metadata={"help": "List of stored query objects"})
|
metadata={
|
||||||
|
"help": "List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
queries: list[StoredQuery] = field(
|
||||||
|
metadata={
|
||||||
|
"help": "List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``."
|
||||||
|
}
|
||||||
|
)
|
||||||
queries_more: bool = field(
|
queries_more: bool = field(
|
||||||
metadata={"help": "Boolean indicating if more stored queries are available"}
|
metadata={"help": "Boolean indicating if more stored queries are available"}
|
||||||
)
|
)
|
||||||
|
|
@ -259,45 +315,65 @@ class DatabaseContext(Context):
|
||||||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||||
)
|
)
|
||||||
table_columns: dict = field(
|
table_columns: dict = field(
|
||||||
metadata={"help": "Dictionary mapping table names to their column lists"}
|
metadata={
|
||||||
|
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata: dict = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
metadata: dict = field(metadata={"help": "Metadata for the database"})
|
|
||||||
database_color: str = field(metadata={"help": "The color assigned to the database"})
|
database_color: str = field(metadata={"help": "The color assigned to the database"})
|
||||||
|
database_page_data: dict = field(
|
||||||
|
metadata={
|
||||||
|
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
|
||||||
|
}
|
||||||
|
)
|
||||||
database_actions: callable = field(
|
database_actions: callable = field(
|
||||||
metadata={
|
metadata={
|
||||||
"help": "Callable returning list of action links for the database menu"
|
"help": 'Async callable returning action items for the database menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_database_actions`.'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
show_hidden: str = field(metadata={"help": "Value of _show_hidden query parameter"})
|
show_hidden: str = field(metadata={"help": "Value of _show_hidden query parameter"})
|
||||||
editable: bool = field(
|
editable: bool = field(
|
||||||
metadata={"help": "Boolean indicating if the database is editable"}
|
metadata={"help": "Boolean indicating if the database is editable"}
|
||||||
)
|
)
|
||||||
count_limit: int = field(metadata={"help": "The maximum number of rows to count"})
|
|
||||||
allow_download: bool = field(
|
allow_download: bool = field(
|
||||||
metadata={"help": "Boolean indicating if database download is allowed"}
|
metadata={"help": "Boolean indicating if database download is allowed"}
|
||||||
)
|
)
|
||||||
attached_databases: list = field(
|
attached_databases: list = field(
|
||||||
metadata={"help": "List of names of attached databases"}
|
metadata={
|
||||||
|
"help": "List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
alternate_url_json: str = field(
|
alternate_url_json: str = field(
|
||||||
metadata={"help": "URL for the alternate JSON version of this page"}
|
metadata={"help": "URL for the alternate JSON version of this page"}
|
||||||
)
|
)
|
||||||
select_templates: list = field(
|
select_templates: list = field(
|
||||||
metadata={
|
metadata={
|
||||||
"help": "List of templates that were considered for rendering this page"
|
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
top_database: callable = field(
|
top_database: callable = field(
|
||||||
metadata={"help": "Callable to render the top_database slot"}
|
metadata={
|
||||||
|
"help": "Async callable that renders the ``top_database`` plugin slot for this database and returns HTML."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class QueryContext(Context):
|
class QueryContext(Context):
|
||||||
|
"The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name)."
|
||||||
|
|
||||||
|
documented_template = "query.html"
|
||||||
|
|
||||||
database: str = field(metadata={"help": "The name of the database being queried"})
|
database: str = field(metadata={"help": "The name of the database being queried"})
|
||||||
database_color: str = field(metadata={"help": "The color of the database"})
|
database_color: str = field(metadata={"help": "The color of the database"})
|
||||||
query: dict = field(
|
query: dict = field(
|
||||||
metadata={"help": "The SQL query object containing the `sql` string"}
|
metadata={
|
||||||
|
"help": "Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
stored_query: str = field(
|
stored_query: str = field(
|
||||||
metadata={"help": "The name of the stored query if this is a stored query"}
|
metadata={"help": "The name of the stored query if this is a stored query"}
|
||||||
|
|
@ -314,7 +390,9 @@ class QueryContext(Context):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
metadata: dict = field(
|
metadata: dict = field(
|
||||||
metadata={"help": "Metadata about the database or the stored query"}
|
metadata={
|
||||||
|
"help": "Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
db_is_immutable: bool = field(
|
db_is_immutable: bool = field(
|
||||||
metadata={"help": "Boolean indicating if this database is immutable"}
|
metadata={"help": "Boolean indicating if this database is immutable"}
|
||||||
|
|
@ -338,22 +416,44 @@ class QueryContext(Context):
|
||||||
save_query_url: str = field(
|
save_query_url: str = field(
|
||||||
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
||||||
)
|
)
|
||||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
tables: list[DatabaseTable] = field(
|
||||||
|
metadata={
|
||||||
|
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||||
|
}
|
||||||
|
)
|
||||||
named_parameter_values: dict = field(
|
named_parameter_values: dict = field(
|
||||||
metadata={"help": "Dictionary of parameter names/values"}
|
metadata={
|
||||||
|
"help": "Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
edit_sql_url: str = field(
|
edit_sql_url: str = field(
|
||||||
metadata={"help": "URL to edit the SQL for a stored query"}
|
metadata={"help": "URL to edit the SQL for a stored query"}
|
||||||
)
|
)
|
||||||
display_rows: list = field(metadata={"help": "List of result rows to display"})
|
display_rows: list = field(
|
||||||
columns: list = field(metadata={"help": "List of column names"})
|
metadata={
|
||||||
renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"})
|
"help": "List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
columns: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "List of result column names in the order they appear in ``display_rows`` and ``rows``."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
renderers: dict = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Dictionary mapping output format names such as ``json`` to URLs for this query in that format."
|
||||||
|
}
|
||||||
|
)
|
||||||
url_csv: str = field(metadata={"help": "URL for CSV export"})
|
url_csv: str = field(metadata={"help": "URL for CSV export"})
|
||||||
show_hide_hidden: str = field(
|
show_hide_hidden: str = field(
|
||||||
metadata={"help": "Hidden input field for the _show_sql parameter"}
|
metadata={
|
||||||
|
"help": "Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
table_columns: dict = field(
|
table_columns: dict = field(
|
||||||
metadata={"help": "Dictionary of table name to list of column names"}
|
metadata={
|
||||||
|
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
alternate_url_json: str = field(
|
alternate_url_json: str = field(
|
||||||
metadata={"help": "URL for alternate JSON version of this page"}
|
metadata={"help": "URL for alternate JSON version of this page"}
|
||||||
|
|
@ -361,23 +461,27 @@ class QueryContext(Context):
|
||||||
# TODO: refactor this to somewhere else, probably ds.render_template()
|
# TODO: refactor this to somewhere else, probably ds.render_template()
|
||||||
select_templates: list = field(
|
select_templates: list = field(
|
||||||
metadata={
|
metadata={
|
||||||
"help": "List of templates that were considered for rendering this page"
|
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
top_query: callable = field(
|
top_query: callable = field(
|
||||||
metadata={"help": "Callable to render the top_query slot"}
|
metadata={
|
||||||
|
"help": "Async callable that renders the ``top_query`` plugin slot for this query and returns HTML."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
top_stored_query: callable = field(
|
top_stored_query: callable = field(
|
||||||
metadata={"help": "Callable to render the top_stored_query slot"}
|
metadata={
|
||||||
|
"help": "Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
query_actions: callable = field(
|
query_actions: callable = field(
|
||||||
metadata={
|
metadata={
|
||||||
"help": "Callable returning a list of links for the query action menu"
|
"help": 'Async callable returning action items for the query menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_query_actions`.'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_tables(datasette, request, db, allowed_dict):
|
async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable]:
|
||||||
"""
|
"""
|
||||||
Get list of tables with metadata for the database view.
|
Get list of tables with metadata for the database view.
|
||||||
|
|
||||||
|
|
@ -398,21 +502,36 @@ async def get_tables(datasette, request, db, allowed_dict):
|
||||||
|
|
||||||
table_columns = await db.table_columns(table)
|
table_columns = await db.table_columns(table)
|
||||||
tables.append(
|
tables.append(
|
||||||
{
|
DatabaseTable(
|
||||||
"name": table,
|
name=table,
|
||||||
"columns": table_columns,
|
columns=table_columns,
|
||||||
"primary_keys": await db.primary_keys(table),
|
primary_keys=await db.primary_keys(table),
|
||||||
"count": table_counts[table],
|
count=table_counts[table],
|
||||||
"hidden": table in hidden_table_names,
|
count_truncated=_table_count_truncated(
|
||||||
"fts_table": await db.fts_table(table),
|
datasette, db, table, table_counts[table]
|
||||||
"foreign_keys": all_foreign_keys[table],
|
),
|
||||||
"private": allowed_dict[table].private,
|
hidden=table in hidden_table_names,
|
||||||
}
|
fts_table=await db.fts_table(table),
|
||||||
|
foreign_keys=all_foreign_keys[table],
|
||||||
|
private=allowed_dict[table].private,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
tables.sort(key=lambda table: (table.hidden, table.name))
|
||||||
return tables
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def _table_count_truncated(datasette, db, table, count):
|
||||||
|
if count != db.count_limit + 1:
|
||||||
|
return False
|
||||||
|
if not db.is_mutable and datasette.inspect_data:
|
||||||
|
try:
|
||||||
|
datasette.inspect_data[db.name]["tables"][table]["count"]
|
||||||
|
return False
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def database_download(request, datasette):
|
async def database_download(request, datasette):
|
||||||
from datasette.resources import DatabaseResource
|
from datasette.resources import DatabaseResource
|
||||||
|
|
||||||
|
|
@ -438,7 +557,7 @@ async def database_download(request, datasette):
|
||||||
if datasette.cors:
|
if datasette.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
if db.hash:
|
if db.hash:
|
||||||
etag = '"{}"'.format(db.hash)
|
etag = f'"{db.hash}"'
|
||||||
headers["Etag"] = etag
|
headers["Etag"] = etag
|
||||||
# Has user seen this already?
|
# Has user seen this already?
|
||||||
if_none_match = request.headers.get("if-none-match")
|
if_none_match = request.headers.get("if-none-match")
|
||||||
|
|
@ -490,11 +609,7 @@ class QueryView(View):
|
||||||
"_json"
|
"_json"
|
||||||
):
|
):
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
dict(error_body([ex.message], 403), redirect=None),
|
||||||
"ok": False,
|
|
||||||
"message": ex.message,
|
|
||||||
"redirect": None,
|
|
||||||
},
|
|
||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
datasette.add_message(request, ex.message, datasette.ERROR)
|
datasette.add_message(request, ex.message, datasette.ERROR)
|
||||||
|
|
@ -529,8 +644,15 @@ class QueryView(View):
|
||||||
ok = None
|
ok = None
|
||||||
redirect_url = None
|
redirect_url = None
|
||||||
try:
|
try:
|
||||||
|
execute_write_kwargs = {"request": request}
|
||||||
|
if stored_query.is_trusted:
|
||||||
|
analysis = await db.analyze_sql(stored_query.sql, params_for_query)
|
||||||
|
if any(
|
||||||
|
operation.operation == "vacuum" for operation in analysis.operations
|
||||||
|
):
|
||||||
|
execute_write_kwargs["transaction"] = False
|
||||||
cursor = await db.execute_write(
|
cursor = await db.execute_write(
|
||||||
stored_query.sql, params_for_query, request=request
|
stored_query.sql, params_for_query, **execute_write_kwargs
|
||||||
)
|
)
|
||||||
# success message can come from on_success_message or on_success_message_sql
|
# success message can come from on_success_message or on_success_message_sql
|
||||||
message = None
|
message = None
|
||||||
|
|
@ -543,8 +665,9 @@ class QueryView(View):
|
||||||
).first()
|
).first()
|
||||||
if message_result:
|
if message_result:
|
||||||
message = message_result[0]
|
message = message_result[0]
|
||||||
except Exception as ex:
|
except Exception as ex: # noqa: BLE001
|
||||||
message = "Error running on_success_message_sql: {}".format(ex)
|
# Stored-query on_success_message_sql is user-authored
|
||||||
|
message = f"Error running on_success_message_sql: {ex}"
|
||||||
message_type = datasette.ERROR
|
message_type = datasette.ERROR
|
||||||
if not message:
|
if not message:
|
||||||
if stored_query.on_success_message:
|
if stored_query.on_success_message:
|
||||||
|
|
@ -558,18 +681,24 @@ class QueryView(View):
|
||||||
|
|
||||||
redirect_url = stored_query.on_success_redirect
|
redirect_url = stored_query.on_success_redirect
|
||||||
ok = True
|
ok = True
|
||||||
except Exception as ex:
|
except Exception as ex: # noqa: BLE001
|
||||||
|
# Stored-query execution is user-authored SQL
|
||||||
message = stored_query.on_error_message or str(ex)
|
message = stored_query.on_error_message or str(ex)
|
||||||
message_type = datasette.ERROR
|
message_type = datasette.ERROR
|
||||||
redirect_url = stored_query.on_error_redirect
|
redirect_url = stored_query.on_error_redirect
|
||||||
ok = False
|
ok = False
|
||||||
if should_return_json:
|
if should_return_json:
|
||||||
|
if ok:
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"message": message,
|
||||||
|
"redirect": redirect_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
dict(error_body([message], 400), redirect=redirect_url),
|
||||||
"ok": ok,
|
status=400,
|
||||||
"message": message,
|
|
||||||
"redirect": redirect_url,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
datasette.add_message(request, message, message_type)
|
datasette.add_message(request, message, message_type)
|
||||||
|
|
@ -687,19 +816,23 @@ class QueryView(View):
|
||||||
rows = results.rows
|
rows = results.rows
|
||||||
except QueryInterrupted as ex:
|
except QueryInterrupted as ex:
|
||||||
raise DatasetteError(
|
raise DatasetteError(
|
||||||
textwrap.dedent("""
|
textwrap.dedent(f"""
|
||||||
<p>SQL query took too long. The time limit is controlled by the
|
<p>SQL query took too long. The time limit is controlled by the
|
||||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||||
configuration option.</p>
|
configuration option.</p>
|
||||||
<textarea style="width: 90%">{}</textarea>
|
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea>
|
||||||
<script>
|
<script>
|
||||||
let ta = document.querySelector("textarea");
|
let ta = document.querySelector("textarea");
|
||||||
ta.style.height = ta.scrollHeight + "px";
|
ta.style.height = ta.scrollHeight + "px";
|
||||||
</script>
|
</script>
|
||||||
""".format(markupsafe.escape(ex.sql))).strip(),
|
""").strip(),
|
||||||
title="SQL Interrupted",
|
title="SQL Interrupted",
|
||||||
status=400,
|
status=400,
|
||||||
message_is_html=True,
|
message_is_html=True,
|
||||||
|
plain_message=(
|
||||||
|
"SQL query took too long. The time limit is"
|
||||||
|
" controlled by the sql_time_limit_ms setting."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except sqlite3.DatabaseError as ex:
|
except sqlite3.DatabaseError as ex:
|
||||||
query_error = str(ex)
|
query_error = str(ex)
|
||||||
|
|
@ -708,8 +841,6 @@ class QueryView(View):
|
||||||
columns = []
|
columns = []
|
||||||
except (sqlite3.OperationalError, InvalidSql) as ex:
|
except (sqlite3.OperationalError, InvalidSql) as ex:
|
||||||
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
|
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
|
||||||
except sqlite3.OperationalError as ex:
|
|
||||||
raise DatasetteError(str(ex))
|
|
||||||
except DatasetteError:
|
except DatasetteError:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -731,9 +862,12 @@ class QueryView(View):
|
||||||
return data, None, None
|
return data, None, None
|
||||||
|
|
||||||
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
||||||
elif format_ in datasette.renderers.keys():
|
elif format_ in datasette.renderers:
|
||||||
|
if not sql:
|
||||||
|
raise DatasetteError("?sql= is required", status=400)
|
||||||
data = {"ok": True, "rows": rows, "columns": columns}
|
data = {"ok": True, "rows": rows, "columns": columns}
|
||||||
extras = extra_names_from_request(request)
|
extras = extra_names_from_request(request)
|
||||||
|
table_extra_registry.validate_requested(extras, ExtraScope.QUERY)
|
||||||
if extras:
|
if extras:
|
||||||
query_extra_context = QueryExtraContext(
|
query_extra_context = QueryExtraContext(
|
||||||
datasette=datasette,
|
datasette=datasette,
|
||||||
|
|
@ -820,9 +954,7 @@ class QueryView(View):
|
||||||
}
|
}
|
||||||
headers.update(
|
headers.update(
|
||||||
{
|
{
|
||||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
||||||
alternate_url_json
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
metadata = await query_metadata()
|
metadata = await query_metadata()
|
||||||
|
|
@ -903,9 +1035,7 @@ class QueryView(View):
|
||||||
+ "?"
|
+ "?"
|
||||||
+ urlencode(
|
+ urlencode(
|
||||||
{
|
{
|
||||||
**{
|
"sql": sql,
|
||||||
"sql": sql,
|
|
||||||
},
|
|
||||||
**named_parameter_values,
|
**named_parameter_values,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1007,7 +1137,7 @@ class QueryView(View):
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
assert False, "Invalid format: {}".format(format_)
|
assert False, f"Invalid format: {format_}"
|
||||||
if datasette.cors:
|
if datasette.cors:
|
||||||
add_cors_headers(r.headers)
|
add_cors_headers(r.headers)
|
||||||
return r
|
return r
|
||||||
|
|
@ -1055,260 +1185,6 @@ class MagicParameters(dict):
|
||||||
return super().__getitem__(key)
|
return super().__getitem__(key)
|
||||||
|
|
||||||
|
|
||||||
class TableCreateView(BaseView):
|
|
||||||
name = "table-create"
|
|
||||||
|
|
||||||
_valid_keys = {
|
|
||||||
"table",
|
|
||||||
"rows",
|
|
||||||
"row",
|
|
||||||
"columns",
|
|
||||||
"pk",
|
|
||||||
"pks",
|
|
||||||
"ignore",
|
|
||||||
"replace",
|
|
||||||
"alter",
|
|
||||||
}
|
|
||||||
_supported_column_types = {
|
|
||||||
"text",
|
|
||||||
"integer",
|
|
||||||
"float",
|
|
||||||
"blob",
|
|
||||||
}
|
|
||||||
# Any string that does not contain a newline or start with sqlite_
|
|
||||||
_table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$")
|
|
||||||
|
|
||||||
def __init__(self, datasette):
|
|
||||||
self.ds = datasette
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
database_name = db.name
|
|
||||||
|
|
||||||
# Must have create-table permission
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="create-table",
|
|
||||||
resource=DatabaseResource(database=database_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied"], 403)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
return _error(["Invalid JSON: {}".format(e)])
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return _error(["JSON must be an object"])
|
|
||||||
|
|
||||||
invalid_keys = set(data.keys()) - self._valid_keys
|
|
||||||
if invalid_keys:
|
|
||||||
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
|
||||||
|
|
||||||
# ignore and replace are mutually exclusive
|
|
||||||
if data.get("ignore") and data.get("replace"):
|
|
||||||
return _error(["ignore and replace are mutually exclusive"])
|
|
||||||
|
|
||||||
# ignore and replace only allowed with row or rows
|
|
||||||
if "ignore" in data or "replace" in data:
|
|
||||||
if not data.get("row") and not data.get("rows"):
|
|
||||||
return _error(["ignore and replace require row or rows"])
|
|
||||||
|
|
||||||
# ignore and replace require pk or pks
|
|
||||||
if "ignore" in data or "replace" in data:
|
|
||||||
if not data.get("pk") and not data.get("pks"):
|
|
||||||
return _error(["ignore and replace require pk or pks"])
|
|
||||||
|
|
||||||
ignore = data.get("ignore")
|
|
||||||
replace = data.get("replace")
|
|
||||||
|
|
||||||
if replace:
|
|
||||||
# Must have update-row permission
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="update-row",
|
|
||||||
resource=DatabaseResource(database=database_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied: need update-row"], 403)
|
|
||||||
|
|
||||||
table_name = data.get("table")
|
|
||||||
if not table_name:
|
|
||||||
return _error(["Table is required"])
|
|
||||||
|
|
||||||
if not self._table_name_re.match(table_name):
|
|
||||||
return _error(["Invalid table name"])
|
|
||||||
|
|
||||||
table_exists = await db.table_exists(data["table"])
|
|
||||||
columns = data.get("columns")
|
|
||||||
rows = data.get("rows")
|
|
||||||
row = data.get("row")
|
|
||||||
if not columns and not rows and not row:
|
|
||||||
return _error(["columns, rows or row is required"])
|
|
||||||
|
|
||||||
if rows and row:
|
|
||||||
return _error(["Cannot specify both rows and row"])
|
|
||||||
|
|
||||||
if rows or row:
|
|
||||||
# Must have insert-row permission
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="insert-row",
|
|
||||||
resource=DatabaseResource(database=database_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied: need insert-row"], 403)
|
|
||||||
|
|
||||||
alter = False
|
|
||||||
if rows or row:
|
|
||||||
if not table_exists:
|
|
||||||
# if table is being created for the first time, alter=True
|
|
||||||
alter = True
|
|
||||||
else:
|
|
||||||
# alter=True only if they request it AND they have permission
|
|
||||||
if data.get("alter"):
|
|
||||||
if not await self.ds.allowed(
|
|
||||||
action="alter-table",
|
|
||||||
resource=DatabaseResource(database=database_name),
|
|
||||||
actor=request.actor,
|
|
||||||
):
|
|
||||||
return _error(["Permission denied: need alter-table"], 403)
|
|
||||||
alter = True
|
|
||||||
|
|
||||||
if columns:
|
|
||||||
if rows or row:
|
|
||||||
return _error(["Cannot specify columns with rows or row"])
|
|
||||||
if not isinstance(columns, list):
|
|
||||||
return _error(["columns must be a list"])
|
|
||||||
for column in columns:
|
|
||||||
if not isinstance(column, dict):
|
|
||||||
return _error(["columns must be a list of objects"])
|
|
||||||
if not column.get("name") or not isinstance(column.get("name"), str):
|
|
||||||
return _error(["Column name is required"])
|
|
||||||
if not column.get("type"):
|
|
||||||
column["type"] = "text"
|
|
||||||
if column["type"] not in self._supported_column_types:
|
|
||||||
return _error(
|
|
||||||
["Unsupported column type: {}".format(column["type"])]
|
|
||||||
)
|
|
||||||
# No duplicate column names
|
|
||||||
dupes = {c["name"] for c in columns if columns.count(c) > 1}
|
|
||||||
if dupes:
|
|
||||||
return _error(["Duplicate column name: {}".format(", ".join(dupes))])
|
|
||||||
|
|
||||||
if row:
|
|
||||||
rows = [row]
|
|
||||||
|
|
||||||
if rows:
|
|
||||||
if not isinstance(rows, list):
|
|
||||||
return _error(["rows must be a list"])
|
|
||||||
for row in rows:
|
|
||||||
if not isinstance(row, dict):
|
|
||||||
return _error(["rows must be a list of objects"])
|
|
||||||
|
|
||||||
pk = data.get("pk")
|
|
||||||
pks = data.get("pks")
|
|
||||||
|
|
||||||
if pk and pks:
|
|
||||||
return _error(["Cannot specify both pk and pks"])
|
|
||||||
if pk:
|
|
||||||
if not isinstance(pk, str):
|
|
||||||
return _error(["pk must be a string"])
|
|
||||||
if pks:
|
|
||||||
if not isinstance(pks, list):
|
|
||||||
return _error(["pks must be a list"])
|
|
||||||
for pk in pks:
|
|
||||||
if not isinstance(pk, str):
|
|
||||||
return _error(["pks must be a list of strings"])
|
|
||||||
|
|
||||||
# If table exists already, read pks from that instead
|
|
||||||
if table_exists:
|
|
||||||
actual_pks = await db.primary_keys(table_name)
|
|
||||||
# if pk passed and table already exists check it does not change
|
|
||||||
bad_pks = False
|
|
||||||
if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]:
|
|
||||||
bad_pks = True
|
|
||||||
elif (
|
|
||||||
len(actual_pks) > 1
|
|
||||||
and data.get("pks")
|
|
||||||
and set(data["pks"]) != set(actual_pks)
|
|
||||||
):
|
|
||||||
bad_pks = True
|
|
||||||
if bad_pks:
|
|
||||||
return _error(["pk cannot be changed for existing table"])
|
|
||||||
pks = actual_pks
|
|
||||||
|
|
||||||
initial_schema = None
|
|
||||||
if table_exists:
|
|
||||||
initial_schema = await db.execute_fn(
|
|
||||||
lambda conn: sqlite_utils.Database(conn)[table_name].schema
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_table(conn):
|
|
||||||
table = sqlite_utils.Database(conn)[table_name]
|
|
||||||
if rows:
|
|
||||||
table.insert_all(
|
|
||||||
rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
table.create(
|
|
||||||
{c["name"]: c["type"] for c in columns},
|
|
||||||
pk=pks or pk,
|
|
||||||
)
|
|
||||||
return table.schema
|
|
||||||
|
|
||||||
try:
|
|
||||||
schema = await db.execute_write_fn(create_table, request=request)
|
|
||||||
except Exception as e:
|
|
||||||
return _error([str(e)])
|
|
||||||
|
|
||||||
if initial_schema is not None and initial_schema != schema:
|
|
||||||
await self.ds.track_event(
|
|
||||||
AlterTableEvent(
|
|
||||||
request.actor,
|
|
||||||
database=database_name,
|
|
||||||
table=table_name,
|
|
||||||
before_schema=initial_schema,
|
|
||||||
after_schema=schema,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
table_url = self.ds.absolute_url(
|
|
||||||
request, self.ds.urls.table(db.name, table_name)
|
|
||||||
)
|
|
||||||
table_api_url = self.ds.absolute_url(
|
|
||||||
request, self.ds.urls.table(db.name, table_name, format="json")
|
|
||||||
)
|
|
||||||
details = {
|
|
||||||
"ok": True,
|
|
||||||
"database": db.name,
|
|
||||||
"table": table_name,
|
|
||||||
"table_url": table_url,
|
|
||||||
"table_api_url": table_api_url,
|
|
||||||
"schema": schema,
|
|
||||||
}
|
|
||||||
if rows:
|
|
||||||
details["row_count"] = len(rows)
|
|
||||||
|
|
||||||
if not table_exists:
|
|
||||||
# Only log creation if we created a table
|
|
||||||
await self.ds.track_event(
|
|
||||||
CreateTableEvent(
|
|
||||||
request.actor, database=db.name, table=table_name, schema=schema
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if rows:
|
|
||||||
await self.ds.track_event(
|
|
||||||
InsertRowsEvent(
|
|
||||||
request.actor,
|
|
||||||
database=db.name,
|
|
||||||
table=table_name,
|
|
||||||
num_rows=len(rows),
|
|
||||||
ignore=ignore,
|
|
||||||
replace=replace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return Response.json(details, status=201)
|
|
||||||
|
|
||||||
|
|
||||||
async def display_rows(datasette, database, request, rows, columns):
|
async def display_rows(datasette, database, request, rows, columns):
|
||||||
display_rows = []
|
display_rows = []
|
||||||
truncate_cells = datasette.setting("truncate_cells_html")
|
truncate_cells = datasette.setting("truncate_cells_html")
|
||||||
|
|
@ -1362,7 +1238,7 @@ async def display_rows(datasette, database, request, rows, columns):
|
||||||
'<a class="blob-download" href="{}"{}><Binary: {:,} byte{}></a>'.format(
|
'<a class="blob-download" href="{}"{}><Binary: {:,} byte{}></a>'.format(
|
||||||
blob_url,
|
blob_url,
|
||||||
(
|
(
|
||||||
' title="{}"'.format(formatted)
|
f' title="{formatted}"'
|
||||||
if "bytes" not in formatted
|
if "bytes" not in formatted
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ import re
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from datasette.resources import DatabaseResource
|
from datasette.resources import DatabaseResource
|
||||||
from datasette.utils import sqlite3
|
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
|
|
||||||
from .base import BaseView, _error
|
from .base import BaseView
|
||||||
from .database import display_rows as display_query_rows
|
from .database import display_rows as display_query_rows
|
||||||
from .query_helpers import (
|
from .query_helpers import (
|
||||||
QueryValidationError,
|
|
||||||
SQL_PARAMETER_FORM_PREFIX,
|
SQL_PARAMETER_FORM_PREFIX,
|
||||||
|
QueryValidationError,
|
||||||
_analysis_is_write,
|
_analysis_is_write,
|
||||||
_analysis_rows,
|
_analysis_rows,
|
||||||
_analysis_rows_with_permissions,
|
_analysis_rows_with_permissions,
|
||||||
|
|
@ -31,15 +31,7 @@ WRITE_TEMPLATE_LABELS = {
|
||||||
"delete": "Delete rows",
|
"delete": "Delete rows",
|
||||||
}
|
}
|
||||||
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
|
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
|
||||||
CREATE_TABLE_TEMPLATE_SQL = "\n".join(
|
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 new_table (",
|
|
||||||
" id integer primary key,",
|
|
||||||
" name text",
|
|
||||||
" -- created text default (datetime('now'))",
|
|
||||||
")",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parameter_names(columns):
|
def _parameter_names(columns):
|
||||||
|
|
@ -49,11 +41,11 @@ def _parameter_names(columns):
|
||||||
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
|
||||||
base = base.strip("_") or "value"
|
base = base.strip("_") or "value"
|
||||||
if base[0].isdigit():
|
if base[0].isdigit():
|
||||||
base = "p_{}".format(base)
|
base = f"p_{base}"
|
||||||
name = base
|
name = base
|
||||||
index = 2
|
index = 2
|
||||||
while name in seen:
|
while name in seen:
|
||||||
name = "{}_{}".format(base, index)
|
name = f"{base}_{index}"
|
||||||
index += 1
|
index += 1
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
names[column] = name
|
names[column] = name
|
||||||
|
|
@ -65,7 +57,7 @@ def _quote_identifier(identifier):
|
||||||
|
|
||||||
|
|
||||||
def _preferred_where_column(table, columns):
|
def _preferred_where_column(table, columns):
|
||||||
lower_table_id = "{}_id".format(table.lower())
|
lower_table_id = f"{table.lower()}_id"
|
||||||
return (
|
return (
|
||||||
next((column for column in columns if column.lower() == "id"), None)
|
next((column for column in columns if column.lower() == "id"), None)
|
||||||
or next(
|
or next(
|
||||||
|
|
@ -90,17 +82,15 @@ def _insert_template_sql(table, columns):
|
||||||
auto_pk = _auto_incrementing_primary_key(columns)
|
auto_pk = _auto_incrementing_primary_key(columns)
|
||||||
insert_columns = [column for column in column_names if column != auto_pk]
|
insert_columns = [column for column in column_names if column != auto_pk]
|
||||||
if not insert_columns:
|
if not insert_columns:
|
||||||
return "insert into {}\ndefault values".format(_quote_identifier(table))
|
return f"insert into {_quote_identifier(table)}\ndefault values"
|
||||||
names = _parameter_names(insert_columns)
|
names = _parameter_names(insert_columns)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
"insert into {} (".format(_quote_identifier(table)),
|
f"insert into {_quote_identifier(table)} (",
|
||||||
",\n".join(
|
",\n".join(f" {_quote_identifier(column)}" for column in insert_columns),
|
||||||
" {}".format(_quote_identifier(column)) for column in insert_columns
|
|
||||||
),
|
|
||||||
")",
|
")",
|
||||||
"values (",
|
"values (",
|
||||||
",\n".join(" :{}".format(names[column]) for column in insert_columns),
|
",\n".join(f" :{names[column]}" for column in insert_columns),
|
||||||
")",
|
")",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -114,18 +104,14 @@ def _update_template_sql(table, columns):
|
||||||
if not set_columns:
|
if not set_columns:
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
"update {}".format(_quote_identifier(table)),
|
f"update {_quote_identifier(table)}",
|
||||||
"set {} = :new_{}".format(
|
f"set {_quote_identifier(where_column)} = :new_{names[where_column]}",
|
||||||
_quote_identifier(where_column), names[where_column]
|
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
||||||
),
|
|
||||||
"where {} = :{}".format(
|
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
"update {}".format(_quote_identifier(table)),
|
f"update {_quote_identifier(table)}",
|
||||||
"set "
|
"set "
|
||||||
+ ",\n".join(
|
+ ",\n".join(
|
||||||
"{}{} = :{}".format(
|
"{}{} = :{}".format(
|
||||||
|
|
@ -135,9 +121,7 @@ def _update_template_sql(table, columns):
|
||||||
)
|
)
|
||||||
for index, column in enumerate(set_columns)
|
for index, column in enumerate(set_columns)
|
||||||
),
|
),
|
||||||
"where {} = :{}".format(
|
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -148,10 +132,8 @@ def _delete_template_sql(table, columns):
|
||||||
where_column = _preferred_where_column(table, column_names)
|
where_column = _preferred_where_column(table, column_names)
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
(
|
(
|
||||||
"delete from {}".format(_quote_identifier(table)),
|
f"delete from {_quote_identifier(table)}",
|
||||||
"where {} = :{}".format(
|
f"where {_quote_identifier(where_column)} = :{names[where_column]}",
|
||||||
_quote_identifier(where_column), names[where_column]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -348,7 +330,7 @@ class ExecuteWriteView(BaseView):
|
||||||
)
|
)
|
||||||
if not db.is_mutable:
|
if not db.is_mutable:
|
||||||
return _block_framing(
|
return _block_framing(
|
||||||
_error(
|
Response.error(
|
||||||
["Cannot execute write SQL because this database is immutable."],
|
["Cannot execute write SQL because this database is immutable."],
|
||||||
403,
|
403,
|
||||||
)
|
)
|
||||||
|
|
@ -367,10 +349,10 @@ class ExecuteWriteView(BaseView):
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _block_framing(
|
return _block_framing(
|
||||||
_error(["Permission denied: need execute-write-sql"], 403)
|
Response.error(["Permission denied: need execute-write-sql"], 403)
|
||||||
)
|
)
|
||||||
if not db.is_mutable:
|
if not db.is_mutable:
|
||||||
return _block_framing(_error(["Database is immutable"], 403))
|
return _block_framing(Response.error(["Database is immutable"], 403))
|
||||||
|
|
||||||
data = {}
|
data = {}
|
||||||
is_json = request.headers.get("content-type", "").startswith("application/json")
|
is_json = request.headers.get("content-type", "").startswith("application/json")
|
||||||
|
|
@ -384,7 +366,7 @@ class ExecuteWriteView(BaseView):
|
||||||
)
|
)
|
||||||
except QueryValidationError as ex:
|
except QueryValidationError as ex:
|
||||||
if _wants_json(request, is_json, data):
|
if _wants_json(request, is_json, data):
|
||||||
return _block_framing(_error([ex.message], ex.status))
|
return _block_framing(Response.error([ex.message], ex.status))
|
||||||
if ex.flash:
|
if ex.flash:
|
||||||
self.ds.add_message(request, ex.message, self.ds.ERROR)
|
self.ds.add_message(request, ex.message, self.ds.ERROR)
|
||||||
return await self._render_form(
|
return await self._render_form(
|
||||||
|
|
@ -405,7 +387,7 @@ class ExecuteWriteView(BaseView):
|
||||||
except sqlite3.DatabaseError as ex:
|
except sqlite3.DatabaseError as ex:
|
||||||
message = str(ex)
|
message = str(ex)
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return _block_framing(_error([message], 400))
|
return _block_framing(Response.error([message], 400))
|
||||||
return await self._render_form(
|
return await self._render_form(
|
||||||
request,
|
request,
|
||||||
db,
|
db,
|
||||||
|
|
@ -488,20 +470,18 @@ class ExecuteWriteAnalyzeView(BaseView):
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _block_framing(
|
return _block_framing(
|
||||||
_error(["Permission denied: need execute-write-sql"], 403)
|
Response.error(["Permission denied: need execute-write-sql"], 403)
|
||||||
)
|
)
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
invalid_keys = set(request.args) - {"sql"}
|
||||||
if invalid_keys:
|
if invalid_keys:
|
||||||
return _block_framing(
|
return _block_framing(
|
||||||
_error(
|
Response.error(
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||||
400,
|
400,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sql = request.args.get("sql") or ""
|
sql = request.args.get("sql") or ""
|
||||||
return _block_framing(
|
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
||||||
Response.json(
|
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
||||||
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
|
return _block_framing(Response.json(analysis))
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import json
|
||||||
|
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
UNSTABLE_API_MESSAGE,
|
||||||
|
CustomJSONEncoder,
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
make_slot_function,
|
make_slot_function,
|
||||||
CustomJSONEncoder,
|
|
||||||
)
|
)
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
from datasette.version import __version__
|
from datasette.version import __version__
|
||||||
|
|
@ -45,15 +46,15 @@ class IndexView(BaseView):
|
||||||
|
|
||||||
databases = []
|
databases = []
|
||||||
# Iterate over allowed databases instead of all databases
|
# Iterate over allowed databases instead of all databases
|
||||||
for name in allowed_db_dict.keys():
|
for name, allowed_db in allowed_db_dict.items():
|
||||||
db = self.ds.databases[name]
|
db = self.ds.databases[name]
|
||||||
database_private = allowed_db_dict[name].private
|
database_private = allowed_db.private
|
||||||
|
|
||||||
# Get allowed tables/views for this database
|
# Get allowed tables/views for this database
|
||||||
allowed_for_db = tables_by_db.get(name, {})
|
allowed_for_db = tables_by_db.get(name, {})
|
||||||
|
|
||||||
# Get table names from allowed set instead of db.table_names()
|
# Get table names from allowed set instead of db.table_names()
|
||||||
table_names = [child_name for child_name in allowed_for_db.keys()]
|
table_names = [child_name for child_name in allowed_for_db]
|
||||||
|
|
||||||
hidden_table_names = set(await db.hidden_table_names())
|
hidden_table_names = set(await db.hidden_table_names())
|
||||||
|
|
||||||
|
|
@ -98,7 +99,7 @@ class IndexView(BaseView):
|
||||||
# We will be sorting by number of relationships, so populate that field
|
# We will be sorting by number of relationships, so populate that field
|
||||||
all_foreign_keys = await db.get_all_foreign_keys()
|
all_foreign_keys = await db.get_all_foreign_keys()
|
||||||
for table, foreign_keys in all_foreign_keys.items():
|
for table, foreign_keys in all_foreign_keys.items():
|
||||||
if table in tables.keys():
|
if table in tables:
|
||||||
count = len(foreign_keys["incoming"] + foreign_keys["outgoing"])
|
count = len(foreign_keys["incoming"] + foreign_keys["outgoing"])
|
||||||
tables[table]["num_relationships_for_sorting"] = count
|
tables[table]["num_relationships_for_sorting"] = count
|
||||||
|
|
||||||
|
|
@ -120,8 +121,7 @@ class IndexView(BaseView):
|
||||||
# Only add views if this is less than TRUNCATE_AT
|
# Only add views if this is less than TRUNCATE_AT
|
||||||
if len(tables_and_views_truncated) < TRUNCATE_AT:
|
if len(tables_and_views_truncated) < TRUNCATE_AT:
|
||||||
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
|
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
|
||||||
for view in views[:num_views_to_add]:
|
tables_and_views_truncated.extend(views[:num_views_to_add])
|
||||||
tables_and_views_truncated.append(view)
|
|
||||||
|
|
||||||
databases.append(
|
databases.append(
|
||||||
{
|
{
|
||||||
|
|
@ -151,7 +151,9 @@ class IndexView(BaseView):
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"databases": {db["name"]: db for db in databases},
|
"ok": True,
|
||||||
|
"unstable": UNSTABLE_API_MESSAGE,
|
||||||
|
"databases": databases,
|
||||||
"metadata": await self.ds.get_instance_metadata(),
|
"metadata": await self.ds.get_instance_metadata(),
|
||||||
},
|
},
|
||||||
cls=CustomJSONEncoder,
|
cls=CustomJSONEncoder,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,19 @@ from datasette.resources import DatabaseResource
|
||||||
from datasette.stored_queries import (
|
from datasette.stored_queries import (
|
||||||
StoredQuery,
|
StoredQuery,
|
||||||
)
|
)
|
||||||
|
from datasette.utils import (
|
||||||
|
InvalidSql,
|
||||||
|
escape_sqlite,
|
||||||
|
parse_size_limit,
|
||||||
|
path_from_row_pks,
|
||||||
|
sqlite3,
|
||||||
|
validate_sql_select,
|
||||||
|
)
|
||||||
|
from datasette.utils import (
|
||||||
|
named_parameters as derive_named_parameters,
|
||||||
|
)
|
||||||
|
from datasette.utils.asgi import Forbidden
|
||||||
|
from datasette.utils.sql_analysis import Operation, SQLAnalysis
|
||||||
from datasette.write_sql import (
|
from datasette.write_sql import (
|
||||||
IgnoreWriteSqlOperation,
|
IgnoreWriteSqlOperation,
|
||||||
QueryWriteRejected,
|
QueryWriteRejected,
|
||||||
|
|
@ -12,16 +25,6 @@ from datasette.write_sql import (
|
||||||
decision_for_write_sql_operation,
|
decision_for_write_sql_operation,
|
||||||
operation_is_write,
|
operation_is_write,
|
||||||
)
|
)
|
||||||
from datasette.utils import (
|
|
||||||
named_parameters as derive_named_parameters,
|
|
||||||
escape_sqlite,
|
|
||||||
path_from_row_pks,
|
|
||||||
sqlite3,
|
|
||||||
validate_sql_select,
|
|
||||||
InvalidSql,
|
|
||||||
)
|
|
||||||
from datasette.utils.asgi import Forbidden
|
|
||||||
from datasette.utils.sql_analysis import Operation, SQLAnalysis
|
|
||||||
|
|
||||||
_query_name_re = re.compile(r"^[^/\.\n]+$")
|
_query_name_re = re.compile(r"^[^/\.\n]+$")
|
||||||
|
|
||||||
|
|
@ -32,7 +35,6 @@ _query_fields = {
|
||||||
"hide_sql",
|
"hide_sql",
|
||||||
"fragment",
|
"fragment",
|
||||||
"parameters",
|
"parameters",
|
||||||
"params",
|
|
||||||
"is_private",
|
"is_private",
|
||||||
"on_success_message",
|
"on_success_message",
|
||||||
"on_success_redirect",
|
"on_success_redirect",
|
||||||
|
|
@ -91,16 +93,14 @@ def _as_optional_bool(value, name):
|
||||||
return True
|
return True
|
||||||
if lowered in {"0", "false", "f", "no", "off"}:
|
if lowered in {"0", "false", "f", "no", "off"}:
|
||||||
return False
|
return False
|
||||||
raise QueryValidationError("{} must be 0 or 1".format(name))
|
raise QueryValidationError(f"{name} must be 0 or 1")
|
||||||
|
|
||||||
|
|
||||||
def _query_list_limit(value, default=50):
|
def _query_list_limit(value, default, maximum):
|
||||||
if value in (None, ""):
|
|
||||||
return default
|
|
||||||
try:
|
try:
|
||||||
return min(max(1, int(value)), 1000)
|
return parse_size_limit(value, default, maximum)
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
raise QueryValidationError("_size must be an integer") from ex
|
raise QueryValidationError(str(ex)) from ex
|
||||||
|
|
||||||
|
|
||||||
def _derived_query_parameters(sql):
|
def _derived_query_parameters(sql):
|
||||||
|
|
@ -173,7 +173,7 @@ async def _json_or_form_payload(request):
|
||||||
try:
|
try:
|
||||||
return json.loads(body or b"{}"), True
|
return json.loads(body or b"{}"), True
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise QueryValidationError("Invalid JSON: {}".format(e))
|
raise QueryValidationError(f"Invalid JSON: {e}")
|
||||||
return await request.post_vars(), False
|
return await request.post_vars(), False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -194,7 +194,7 @@ async def _analyze_user_query(datasette, db, sql, *, actor):
|
||||||
try:
|
try:
|
||||||
analysis = await db.analyze_sql(sql, params)
|
analysis = await db.analyze_sql(sql, params)
|
||||||
except sqlite3.DatabaseError as ex:
|
except sqlite3.DatabaseError as ex:
|
||||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
raise QueryValidationError(f"Could not analyze query: {ex}") from ex
|
||||||
|
|
||||||
is_write = _analysis_is_write(analysis)
|
is_write = _analysis_is_write(analysis)
|
||||||
if is_write:
|
if is_write:
|
||||||
|
|
@ -295,8 +295,7 @@ def _coerce_execute_write_payload(data, is_json):
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key in {"sql", "csrftoken", "_json"}:
|
if key in {"sql", "csrftoken", "_json"}:
|
||||||
continue
|
continue
|
||||||
if key.startswith(SQL_PARAMETER_FORM_PREFIX):
|
key = key.removeprefix(SQL_PARAMETER_FORM_PREFIX)
|
||||||
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
|
|
||||||
params[key] = value
|
params[key] = value
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
raise QueryValidationError("params must be a dictionary")
|
raise QueryValidationError("params must be a dictionary")
|
||||||
|
|
@ -316,7 +315,7 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
|
||||||
try:
|
try:
|
||||||
analysis = await db.analyze_sql(sql, params)
|
analysis = await db.analyze_sql(sql, params)
|
||||||
except sqlite3.DatabaseError as ex:
|
except sqlite3.DatabaseError as ex:
|
||||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
raise QueryValidationError(f"Could not analyze query: {ex}") from ex
|
||||||
if not _analysis_is_write(analysis):
|
if not _analysis_is_write(analysis):
|
||||||
raise QueryValidationError(
|
raise QueryValidationError(
|
||||||
"Use /-/query for read-only SQL; this endpoint only executes writes"
|
"Use /-/query for read-only SQL; this endpoint only executes writes"
|
||||||
|
|
@ -498,7 +497,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor):
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
"select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
|
f"select {select} from {escape_sqlite(table)} where rowid = ?",
|
||||||
[lastrowid],
|
[lastrowid],
|
||||||
)
|
)
|
||||||
except sqlite3.DatabaseError:
|
except sqlite3.DatabaseError:
|
||||||
|
|
@ -541,7 +540,7 @@ async def _prepare_query_create(datasette, request, db, data):
|
||||||
raise QueryValidationError("Writable query fields require writable SQL")
|
raise QueryValidationError("Writable query fields require writable SQL")
|
||||||
|
|
||||||
parameters = _coerce_query_parameters(
|
parameters = _coerce_query_parameters(
|
||||||
data.get("parameters", data.get("params")),
|
data.get("parameters"),
|
||||||
derived,
|
derived,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|
@ -586,9 +585,9 @@ async def _prepare_query_update(datasette, request, db, existing: StoredQuery, u
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
if "parameters" in update or "params" in update:
|
if "parameters" in update:
|
||||||
parameters = _coerce_query_parameters(
|
parameters = _coerce_query_parameters(
|
||||||
update.get("parameters", update.get("params")),
|
update.get("parameters"),
|
||||||
derived,
|
derived,
|
||||||
)
|
)
|
||||||
elif "sql" in update:
|
elif "sql" in update:
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,395 @@
|
||||||
from datasette.utils.asgi import NotFound, Forbidden, Response
|
import asyncio
|
||||||
from datasette.database import QueryInterrupted
|
|
||||||
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
|
||||||
from datasette.resources import TableResource
|
|
||||||
from .base import DataView, BaseView, _error
|
|
||||||
from datasette.utils import (
|
|
||||||
await_me_maybe,
|
|
||||||
CustomRow,
|
|
||||||
make_slot_function,
|
|
||||||
path_from_row_pks,
|
|
||||||
to_css_class,
|
|
||||||
escape_sqlite,
|
|
||||||
)
|
|
||||||
from datasette.plugins import pm
|
|
||||||
import json
|
import json
|
||||||
|
import textwrap
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import markupsafe
|
import markupsafe
|
||||||
import sqlite_utils
|
import sqlite_utils
|
||||||
from datasette.extras import extra_names_from_request
|
|
||||||
|
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.resources import TableResource
|
||||||
|
from datasette.utils import (
|
||||||
|
CustomJSONEncoder,
|
||||||
|
CustomRow,
|
||||||
|
InvalidSql,
|
||||||
|
WriteJsonValueError,
|
||||||
|
add_cors_headers,
|
||||||
|
await_me_maybe,
|
||||||
|
call_with_supported_arguments,
|
||||||
|
decode_write_json_row,
|
||||||
|
escape_sqlite,
|
||||||
|
make_slot_function,
|
||||||
|
path_from_row_pks,
|
||||||
|
path_with_format,
|
||||||
|
path_with_removed_args,
|
||||||
|
sqlite3,
|
||||||
|
to_css_class,
|
||||||
|
)
|
||||||
|
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response
|
||||||
|
|
||||||
|
from . import Context, from_extra
|
||||||
|
from .base import BaseView, DatasetteError, stream_csv
|
||||||
from .table import (
|
from .table import (
|
||||||
display_columns_and_rows,
|
|
||||||
_table_page_data,
|
_table_page_data,
|
||||||
|
display_columns_and_rows,
|
||||||
row_label_from_label_column,
|
row_label_from_label_column,
|
||||||
)
|
)
|
||||||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
||||||
|
|
||||||
|
|
||||||
class RowView(DataView):
|
@dataclass
|
||||||
|
class RowContext(Context):
|
||||||
|
"The page showing an individual row, e.g. /fixtures/facetable/1."
|
||||||
|
|
||||||
|
documented_template = "row.html"
|
||||||
|
extras_scope = ExtraScope.ROW
|
||||||
|
|
||||||
|
# Fields resolved by registered extras - their documentation comes
|
||||||
|
# from the description on each Extra class in table_extras.py
|
||||||
|
columns: list = from_extra()
|
||||||
|
database: str = from_extra()
|
||||||
|
database_color: str = from_extra()
|
||||||
|
foreign_key_tables: list = from_extra()
|
||||||
|
metadata: dict = from_extra()
|
||||||
|
primary_keys: list = from_extra()
|
||||||
|
private: bool = from_extra()
|
||||||
|
table: str = from_extra()
|
||||||
|
|
||||||
|
# Fields added by the view code
|
||||||
|
ok: bool = field(
|
||||||
|
metadata={"help": "True if the data for this page was retrieved without errors"}
|
||||||
|
)
|
||||||
|
rows: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "A single-item list containing this row as a dictionary mapping column name to raw value."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
primary_key_values: list = field(
|
||||||
|
metadata={"help": "Values of the primary keys for this row, from the URL"}
|
||||||
|
)
|
||||||
|
query_ms: float = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Time taken by the SQL queries for this page, in milliseconds"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
display_columns: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
display_rows: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
custom_table_templates: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Custom template names that were considered for displaying this row's table, in lookup order."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row_actions: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": 'Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row_mutation_ui: bool = field(
|
||||||
|
metadata={"help": "True if the row edit/delete JavaScript UI should be enabled"}
|
||||||
|
)
|
||||||
|
table_page_data: dict = field(
|
||||||
|
metadata={
|
||||||
|
"help": "JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
top_row: callable = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Async callable that renders the ``top_row`` plugin slot for this row and returns HTML."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
renderers: dict = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Dictionary mapping output format names such as ``json`` to URLs for this row in that format."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
|
||||||
|
url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
|
||||||
|
url_csv_hidden_args: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
settings: dict = field(
|
||||||
|
metadata={
|
||||||
|
"help": "Dictionary of Datasette's current settings, keyed by setting name."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
select_templates: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
alternate_url_json: str = field(
|
||||||
|
metadata={"help": "URL for the JSON version of this page"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RowView(BaseView):
|
||||||
name = "row"
|
name = "row"
|
||||||
|
|
||||||
|
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
||||||
|
if request.query_string and "?" not in path and forward_querystring:
|
||||||
|
path = f"{path}?{request.query_string}"
|
||||||
|
if remove_args:
|
||||||
|
path = path_with_removed_args(request, remove_args, path=path)
|
||||||
|
response = Response.redirect(path)
|
||||||
|
response.headers["Link"] = f"<{path}>; rel=preload"
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(response.headers)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def as_csv(self, request, database):
|
||||||
|
return await stream_csv(self.ds, self.data, request, database)
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
db = await self.ds.resolve_database(request)
|
||||||
|
database = db.name
|
||||||
|
database_route = db.route
|
||||||
|
format_ = request.url_vars.get("format") or "html"
|
||||||
|
data_kwargs = {}
|
||||||
|
|
||||||
|
if format_ == "csv":
|
||||||
|
return await self.as_csv(request, database_route)
|
||||||
|
|
||||||
|
if format_ == "html":
|
||||||
|
# HTML views default to expanding all foreign key labels
|
||||||
|
data_kwargs["default_labels"] = True
|
||||||
|
|
||||||
|
extra_template_data = {}
|
||||||
|
start = time.perf_counter()
|
||||||
|
status_code = None
|
||||||
|
templates = ()
|
||||||
|
try:
|
||||||
|
response_or_template_contexts = await self.data(request, **data_kwargs)
|
||||||
|
if isinstance(response_or_template_contexts, Response):
|
||||||
|
return response_or_template_contexts
|
||||||
|
# If it has four items, it includes an HTTP status code
|
||||||
|
if len(response_or_template_contexts) == 4:
|
||||||
|
(
|
||||||
|
data,
|
||||||
|
extra_template_data,
|
||||||
|
templates,
|
||||||
|
status_code,
|
||||||
|
) = response_or_template_contexts
|
||||||
|
else:
|
||||||
|
data, extra_template_data, templates = response_or_template_contexts
|
||||||
|
except QueryInterrupted as ex:
|
||||||
|
raise DatasetteError(
|
||||||
|
textwrap.dedent(f"""
|
||||||
|
<p>SQL query took too long. The time limit is controlled by the
|
||||||
|
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||||
|
configuration option.</p>
|
||||||
|
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea>
|
||||||
|
<script>
|
||||||
|
let ta = document.querySelector("textarea");
|
||||||
|
ta.style.height = ta.scrollHeight + "px";
|
||||||
|
</script>
|
||||||
|
""").strip(),
|
||||||
|
title="SQL Interrupted",
|
||||||
|
status=400,
|
||||||
|
message_is_html=True,
|
||||||
|
plain_message=(
|
||||||
|
"SQL query took too long. The time limit is"
|
||||||
|
" controlled by the sql_time_limit_ms setting."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||||
|
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||||
|
except DatasetteError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
end = time.perf_counter()
|
||||||
|
data["query_ms"] = (end - start) * 1000
|
||||||
|
|
||||||
|
if format_ in self.ds.renderers:
|
||||||
|
# Dispatch request to the correct output format renderer
|
||||||
|
# (CSV is not handled here due to streaming)
|
||||||
|
result = call_with_supported_arguments(
|
||||||
|
self.ds.renderers[format_][0],
|
||||||
|
datasette=self.ds,
|
||||||
|
columns=data.get("columns") or [],
|
||||||
|
rows=data.get("rows") or [],
|
||||||
|
sql=data.get("query", {}).get("sql", None),
|
||||||
|
query_name=data.get("query_name"),
|
||||||
|
database=database,
|
||||||
|
table=data.get("table"),
|
||||||
|
request=request,
|
||||||
|
view_name=self.name,
|
||||||
|
truncated=False, # TODO: support this
|
||||||
|
error=data.get("error"),
|
||||||
|
# These will be deprecated in Datasette 1.0:
|
||||||
|
args=request.args,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
result = await result
|
||||||
|
if result is None:
|
||||||
|
raise NotFound("No data")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
response = Response(
|
||||||
|
body=result.get("body"),
|
||||||
|
status=result.get("status_code", status_code or 200),
|
||||||
|
content_type=result.get("content_type", "text/plain"),
|
||||||
|
headers=result.get("headers"),
|
||||||
|
)
|
||||||
|
elif isinstance(result, Response):
|
||||||
|
response = result
|
||||||
|
if status_code is not None:
|
||||||
|
# Over-ride the status code
|
||||||
|
response.status = status_code
|
||||||
|
else:
|
||||||
|
assert False, f"{result} should be dict or Response"
|
||||||
|
elif format_ == "html":
|
||||||
|
response = await self.html(request, data, extra_template_data, templates)
|
||||||
|
if status_code is not None:
|
||||||
|
response.status = status_code
|
||||||
|
else:
|
||||||
|
raise NotFound(f"Invalid format: {format_}")
|
||||||
|
|
||||||
|
ttl = request.args.get("_ttl", None)
|
||||||
|
if ttl is None or not ttl.isdigit():
|
||||||
|
ttl = self.ds.setting("default_cache_ttl")
|
||||||
|
|
||||||
|
return self.set_response_headers(response, ttl)
|
||||||
|
|
||||||
|
async def html(self, request, data, extra_template_data, templates):
|
||||||
|
extras = {}
|
||||||
|
if callable(extra_template_data):
|
||||||
|
extras = extra_template_data()
|
||||||
|
if asyncio.iscoroutine(extras):
|
||||||
|
extras = await extras
|
||||||
|
else:
|
||||||
|
extras = extra_template_data
|
||||||
|
|
||||||
|
url_labels_extra = {}
|
||||||
|
if data.get("expandable_columns"):
|
||||||
|
url_labels_extra = {"_labels": "on"}
|
||||||
|
|
||||||
|
renderers = {}
|
||||||
|
for key, (_, can_render) in self.ds.renderers.items():
|
||||||
|
it_can_render = call_with_supported_arguments(
|
||||||
|
can_render,
|
||||||
|
datasette=self.ds,
|
||||||
|
columns=data.get("columns") or [],
|
||||||
|
rows=data.get("rows") or [],
|
||||||
|
sql=data.get("query", {}).get("sql", None),
|
||||||
|
query_name=data.get("query_name"),
|
||||||
|
database=data.get("database"),
|
||||||
|
table=data.get("table"),
|
||||||
|
request=request,
|
||||||
|
view_name=self.name,
|
||||||
|
)
|
||||||
|
it_can_render = await await_me_maybe(it_can_render)
|
||||||
|
if it_can_render:
|
||||||
|
renderers[key] = self.ds.urls.path(
|
||||||
|
path_with_format(
|
||||||
|
request=request,
|
||||||
|
path=request.scope.get("route_path"),
|
||||||
|
format=key,
|
||||||
|
extra_qs={**url_labels_extra},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
url_csv_args = {"_size": "max", **url_labels_extra}
|
||||||
|
url_csv = self.ds.urls.path(
|
||||||
|
path_with_format(
|
||||||
|
request=request,
|
||||||
|
path=request.scope.get("route_path"),
|
||||||
|
format="csv",
|
||||||
|
extra_qs=url_csv_args,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
url_csv_path = url_csv.split("?")[0]
|
||||||
|
context = {**data, **extras}
|
||||||
|
if "metadata" not in context:
|
||||||
|
context["metadata"] = await self.ds.get_instance_metadata()
|
||||||
|
|
||||||
|
environment = self.ds.get_jinja_environment(request)
|
||||||
|
template = environment.select_template(templates)
|
||||||
|
alternate_url_json = self.ds.absolute_url(
|
||||||
|
request,
|
||||||
|
self.ds.urls.path(
|
||||||
|
path_with_format(
|
||||||
|
request=request,
|
||||||
|
path=request.scope.get("route_path"),
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Response.html(
|
||||||
|
await self.ds.render_template(
|
||||||
|
template,
|
||||||
|
RowContext(
|
||||||
|
columns=context["columns"],
|
||||||
|
database=context["database"],
|
||||||
|
database_color=context["database_color"],
|
||||||
|
foreign_key_tables=context["foreign_key_tables"],
|
||||||
|
metadata=context["metadata"],
|
||||||
|
primary_keys=context["primary_keys"],
|
||||||
|
private=context["private"],
|
||||||
|
table=context["table"],
|
||||||
|
ok=context["ok"],
|
||||||
|
rows=context["rows"],
|
||||||
|
primary_key_values=context["primary_key_values"],
|
||||||
|
query_ms=context["query_ms"],
|
||||||
|
display_columns=context["display_columns"],
|
||||||
|
display_rows=context["display_rows"],
|
||||||
|
custom_table_templates=context["custom_table_templates"],
|
||||||
|
row_actions=context["row_actions"],
|
||||||
|
row_mutation_ui=context["row_mutation_ui"],
|
||||||
|
table_page_data=context["table_page_data"],
|
||||||
|
top_row=context["top_row"],
|
||||||
|
renderers=renderers,
|
||||||
|
url_csv=url_csv,
|
||||||
|
url_csv_path=url_csv_path,
|
||||||
|
url_csv_hidden_args=[
|
||||||
|
(key, value)
|
||||||
|
for key, value in urllib.parse.parse_qsl(request.query_string)
|
||||||
|
if key not in ("_labels", "_facet", "_size")
|
||||||
|
]
|
||||||
|
+ [("_size", "max")],
|
||||||
|
settings=self.ds.settings_dict(),
|
||||||
|
select_templates=[
|
||||||
|
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||||
|
for template_name in templates
|
||||||
|
],
|
||||||
|
alternate_url_json=alternate_url_json,
|
||||||
|
),
|
||||||
|
request=request,
|
||||||
|
view_name=self.name,
|
||||||
|
),
|
||||||
|
headers={
|
||||||
|
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_response_headers(self, response, ttl):
|
||||||
|
# Set far-future cache expiry
|
||||||
|
if self.ds.cache_headers and response.status == 200:
|
||||||
|
ttl = int(ttl)
|
||||||
|
if ttl == 0:
|
||||||
|
ttl_header = "no-cache"
|
||||||
|
else:
|
||||||
|
ttl_header = f"max-age={ttl}"
|
||||||
|
response.headers["Cache-Control"] = ttl_header
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
if self.ds.cors:
|
||||||
|
add_cors_headers(response.headers)
|
||||||
|
return response
|
||||||
|
|
||||||
async def data(self, request, default_labels=False):
|
async def data(self, request, default_labels=False):
|
||||||
resolved = await self.ds.resolve_row(request)
|
resolved = await self.ds.resolve_row(request)
|
||||||
db = resolved.db
|
db = resolved.db
|
||||||
|
|
@ -134,7 +497,7 @@ class RowView(DataView):
|
||||||
|
|
||||||
row_action_label = pk_path
|
row_action_label = pk_path
|
||||||
if row_label and row_label != pk_path:
|
if row_label and row_label != pk_path:
|
||||||
row_action_label = "{} {}".format(pk_path, row_label)
|
row_action_label = f"{pk_path} {row_label}"
|
||||||
|
|
||||||
row_action_permissions = {}
|
row_action_permissions = {}
|
||||||
if is_table and db.is_mutable:
|
if is_table and db.is_mutable:
|
||||||
|
|
@ -147,7 +510,7 @@ class RowView(DataView):
|
||||||
row_actions = []
|
row_actions = []
|
||||||
if row_action_permissions.get("update-row"):
|
if row_action_permissions.get("update-row"):
|
||||||
attrs = {
|
attrs = {
|
||||||
"aria-label": "Edit row {}".format(row_action_label),
|
"aria-label": f"Edit row {row_action_label}",
|
||||||
"data-row": row_path,
|
"data-row": row_path,
|
||||||
"data-row-action": "edit",
|
"data-row-action": "edit",
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +526,7 @@ class RowView(DataView):
|
||||||
)
|
)
|
||||||
if row_action_permissions.get("delete-row"):
|
if row_action_permissions.get("delete-row"):
|
||||||
attrs = {
|
attrs = {
|
||||||
"aria-label": "Delete row {}".format(row_action_label),
|
"aria-label": f"Delete row {row_action_label}",
|
||||||
"data-row": row_path,
|
"data-row": row_path,
|
||||||
"data-row-action": "delete",
|
"data-row-action": "delete",
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +556,7 @@ class RowView(DataView):
|
||||||
"private": private,
|
"private": private,
|
||||||
"columns": reordered_columns,
|
"columns": reordered_columns,
|
||||||
"foreign_key_tables": await self.foreign_key_tables(
|
"foreign_key_tables": await self.foreign_key_tables(
|
||||||
database, table, pk_values, actor=request.actor, include_insert=True
|
database, table, pk_values
|
||||||
),
|
),
|
||||||
"database_color": db.color,
|
"database_color": db.color,
|
||||||
"display_columns": display_columns,
|
"display_columns": display_columns,
|
||||||
|
|
@ -205,13 +568,14 @@ class RowView(DataView):
|
||||||
],
|
],
|
||||||
"row_mutation_ui": any(row_action_permissions.values()),
|
"row_mutation_ui": any(row_action_permissions.values()),
|
||||||
"table_page_data": await _table_page_data(
|
"table_page_data": await _table_page_data(
|
||||||
self.ds,
|
datasette=self.ds,
|
||||||
request,
|
request=request,
|
||||||
db,
|
db=db,
|
||||||
database,
|
database_name=database,
|
||||||
table,
|
table_name=table,
|
||||||
not is_table,
|
is_view=not is_table,
|
||||||
None,
|
table_insert_ui=None,
|
||||||
|
table_alter_ui=None,
|
||||||
),
|
),
|
||||||
"row_actions": row_actions,
|
"row_actions": row_actions,
|
||||||
"top_row": make_slot_function(
|
"top_row": make_slot_function(
|
||||||
|
|
@ -236,6 +600,9 @@ class RowView(DataView):
|
||||||
}
|
}
|
||||||
|
|
||||||
extras = extra_names_from_request(request)
|
extras = extra_names_from_request(request)
|
||||||
|
if request.url_vars.get("format"):
|
||||||
|
# Data formats reject unknown extras; HTML ignores them
|
||||||
|
table_extra_registry.validate_requested(extras, ExtraScope.ROW)
|
||||||
|
|
||||||
# Process extras
|
# Process extras
|
||||||
row_extra_context = RowExtraContext(
|
row_extra_context = RowExtraContext(
|
||||||
|
|
@ -266,9 +633,7 @@ class RowView(DataView):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def foreign_key_tables(
|
async def foreign_key_tables(self, database, table, pk_values):
|
||||||
self, database, table, pk_values, actor=None, include_insert=False
|
|
||||||
):
|
|
||||||
if len(pk_values) != 1:
|
if len(pk_values) != 1:
|
||||||
return []
|
return []
|
||||||
db = self.ds.databases[database]
|
db = self.ds.databases[database]
|
||||||
|
|
@ -311,24 +676,7 @@ class RowView(DataView):
|
||||||
key,
|
key,
|
||||||
",".join(pk_values),
|
",".join(pk_values),
|
||||||
)
|
)
|
||||||
item = {**fk, **{"count": count, "link": link}}
|
foreign_key_tables.append({**fk, "count": count, "link": link})
|
||||||
if include_insert:
|
|
||||||
can_insert = db.is_mutable and await self.ds.allowed(
|
|
||||||
action="insert-row",
|
|
||||||
resource=TableResource(database=database, table=fk["other_table"]),
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
item["can_insert"] = can_insert
|
|
||||||
if can_insert:
|
|
||||||
item["insert_row"] = {fk["other_column"]: pk_values[0]}
|
|
||||||
item["insert_message"] = (
|
|
||||||
"Insert a row in {} with {} set to {}.".format(
|
|
||||||
fk["other_table"],
|
|
||||||
fk["other_column"],
|
|
||||||
pk_values[0],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
foreign_key_tables.append(item)
|
|
||||||
return foreign_key_tables
|
return foreign_key_tables
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -354,21 +702,21 @@ async def _row_flash_message(db, action, resolved, row=None):
|
||||||
if label:
|
if label:
|
||||||
label = _truncated_row_flash_label(label)
|
label = _truncated_row_flash_label(label)
|
||||||
if label and label != pk_label:
|
if label and label != pk_label:
|
||||||
return "{} row {} ({})".format(action, pk_label, label)
|
return f"{action} row {pk_label} ({label})"
|
||||||
return "{} row {}".format(action, pk_label)
|
return f"{action} row {pk_label}"
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_row_and_check_permission(datasette, request, permission):
|
async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||||
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
|
from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await datasette.resolve_row(request)
|
resolved = await datasette.resolve_row(request)
|
||||||
except DatabaseNotFound as e:
|
except DatabaseNotFound as e:
|
||||||
return False, _error(["Database not found: {}".format(e.database_name)], 404)
|
return False, Response.error([f"Database not found: {e.database_name}"], 404)
|
||||||
except TableNotFound as e:
|
except TableNotFound as e:
|
||||||
return False, _error(["Table not found: {}".format(e.table)], 404)
|
return False, Response.error([f"Table not found: {e.table}"], 404)
|
||||||
except RowNotFound as e:
|
except RowNotFound as e:
|
||||||
return False, _error(["Record not found: {}".format(e.pk_values)], 404)
|
return False, Response.error([f"Record not found: {e.pk_values}"], 404)
|
||||||
|
|
||||||
# Ensure user has permission to delete this row
|
# Ensure user has permission to delete this row
|
||||||
if not await datasette.allowed(
|
if not await datasette.allowed(
|
||||||
|
|
@ -376,7 +724,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return False, _error(["Permission denied"], 403)
|
return False, Response.error(["Permission denied"], 403)
|
||||||
|
|
||||||
return True, resolved
|
return True, resolved
|
||||||
|
|
||||||
|
|
@ -400,8 +748,9 @@ class RowDeleteView(BaseView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await resolved.db.execute_write_fn(delete_row, request=request)
|
await resolved.db.execute_write_fn(delete_row, request=request)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
return _error([str(e)], 500)
|
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||||
|
return Response.error([str(e)], 400)
|
||||||
|
|
||||||
await self.ds.track_event(
|
await self.ds.track_event(
|
||||||
DeleteRowEvent(
|
DeleteRowEvent(
|
||||||
|
|
@ -440,18 +789,24 @@ class RowUpdateView(BaseView):
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return _error(["Invalid JSON: {}".format(e)])
|
return Response.error([f"Invalid JSON: {e}"])
|
||||||
|
except PayloadTooLarge as e:
|
||||||
|
return Response.error([str(e)], 413)
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return _error(["JSON must be a dictionary"])
|
return Response.error(["JSON must be a dictionary"])
|
||||||
if "update" not in data or not isinstance(data["update"], dict):
|
if "update" not in data or not isinstance(data["update"], dict):
|
||||||
return _error(["JSON must contain an update dictionary"])
|
return Response.error(["JSON must contain an update dictionary"])
|
||||||
|
|
||||||
invalid_keys = set(data.keys()) - {"update", "return", "alter"}
|
invalid_keys = set(data.keys()) - {"update", "return", "alter"}
|
||||||
if invalid_keys:
|
if invalid_keys:
|
||||||
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))])
|
||||||
|
|
||||||
update = data["update"]
|
update = data["update"]
|
||||||
|
try:
|
||||||
|
update = decode_write_json_row(update)
|
||||||
|
except WriteJsonValueError as e:
|
||||||
|
return Response.error([str(e)], 400)
|
||||||
|
|
||||||
# Validate column types
|
# Validate column types
|
||||||
from datasette.views.table import _validate_column_types
|
from datasette.views.table import _validate_column_types
|
||||||
|
|
@ -460,7 +815,7 @@ class RowUpdateView(BaseView):
|
||||||
self.ds, resolved.db.name, resolved.table, [update]
|
self.ds, resolved.db.name, resolved.table, [update]
|
||||||
)
|
)
|
||||||
if ct_errors:
|
if ct_errors:
|
||||||
return _error(ct_errors, 400)
|
return Response.error(ct_errors, 400)
|
||||||
|
|
||||||
alter = data.get("alter")
|
alter = data.get("alter")
|
||||||
if alter and not await self.ds.allowed(
|
if alter and not await self.ds.allowed(
|
||||||
|
|
@ -468,7 +823,7 @@ class RowUpdateView(BaseView):
|
||||||
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
resource=TableResource(database=resolved.db.name, table=resolved.table),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _error(["Permission denied for alter-table"], 403)
|
return Response.error(["Permission denied for alter-table"], 403)
|
||||||
|
|
||||||
def update_row(conn):
|
def update_row(conn):
|
||||||
sqlite_utils.Database(conn)[resolved.table].update(
|
sqlite_utils.Database(conn)[resolved.table].update(
|
||||||
|
|
@ -477,8 +832,9 @@ class RowUpdateView(BaseView):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await resolved.db.execute_write_fn(update_row, request=request)
|
await resolved.db.execute_write_fn(update_row, request=request)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
return _error([str(e)], 400)
|
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
|
||||||
|
return Response.error([str(e)], 400)
|
||||||
|
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
returned_row = None
|
returned_row = None
|
||||||
|
|
@ -487,7 +843,7 @@ class RowUpdateView(BaseView):
|
||||||
resolved.sql, resolved.params, truncate=True
|
resolved.sql, resolved.params, truncate=True
|
||||||
)
|
)
|
||||||
returned_row = results.dicts()[0]
|
returned_row = results.dicts()[0]
|
||||||
result["row"] = returned_row
|
result["rows"] = [returned_row]
|
||||||
|
|
||||||
await self.ds.track_event(
|
await self.ds.track_event(
|
||||||
UpdateRowEvent(
|
UpdateRowEvent(
|
||||||
|
|
@ -513,4 +869,4 @@ class RowUpdateView(BaseView):
|
||||||
self.ds.INFO,
|
self.ds.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response.json(result, status=200)
|
return Response.json(result, status=200, default=CustomJSONEncoder().default)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
from datasette.events import CreateTokenEvent, LoginEvent, LogoutEvent
|
||||||
from datasette.jump import JumpSQL, namespace_sql_params
|
from datasette.jump import JumpSQL, namespace_sql_params
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
|
|
||||||
from datasette.resources import DatabaseResource, TableResource
|
from datasette.resources import DatabaseResource, TableResource
|
||||||
from datasette.utils.asgi import Response, Forbidden
|
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
UNSTABLE_API_MESSAGE,
|
||||||
actor_matches_allow,
|
actor_matches_allow,
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
tilde_encode,
|
error_body,
|
||||||
|
parse_size_limit,
|
||||||
tilde_decode,
|
tilde_decode,
|
||||||
|
tilde_encode,
|
||||||
)
|
)
|
||||||
|
from datasette.utils.asgi import Forbidden, Response
|
||||||
|
|
||||||
from .base import BaseView, View
|
from .base import BaseView, View
|
||||||
import secrets
|
|
||||||
import urllib
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -52,9 +57,9 @@ class JsonDataView(BaseView):
|
||||||
if self.permission:
|
if self.permission:
|
||||||
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
|
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
|
||||||
if self.needs_request:
|
if self.needs_request:
|
||||||
data = self.data_callback(request)
|
data = await await_me_maybe(self.data_callback(request))
|
||||||
else:
|
else:
|
||||||
data = self.data_callback()
|
data = await await_me_maybe(self.data_callback())
|
||||||
|
|
||||||
# Return JSON or HTML depending on format parameter
|
# Return JSON or HTML depending on format parameter
|
||||||
as_format = request.url_vars.get("format")
|
as_format = request.url_vars.get("format")
|
||||||
|
|
@ -62,6 +67,8 @@ class JsonDataView(BaseView):
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = {"ok": True, **data}
|
||||||
return Response.json(data, headers=headers)
|
return Response.json(data, headers=headers)
|
||||||
else:
|
else:
|
||||||
context = {
|
context = {
|
||||||
|
|
@ -174,9 +181,7 @@ class AutocompleteDebugView(BaseView):
|
||||||
)
|
)
|
||||||
context.update(
|
context.update(
|
||||||
{
|
{
|
||||||
"autocomplete_url": "{}/-/autocomplete".format(
|
"autocomplete_url": f"{self.ds.urls.table(database_name, table_name)}/-/autocomplete",
|
||||||
self.ds.urls.table(database_name, table_name)
|
|
||||||
),
|
|
||||||
"label_column": await db.label_column_for_table(table_name),
|
"label_column": await db.label_column_for_table(table_name),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -292,6 +297,12 @@ class PermissionsDebugView(BaseView):
|
||||||
response, status = await _check_permission_for_actor(
|
response, status = await _check_permission_for_actor(
|
||||||
self.ds, permission, parent, child, actor
|
self.ds, permission, parent, child, actor
|
||||||
)
|
)
|
||||||
|
if response.get("ok"):
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"unstable": UNSTABLE_API_MESSAGE,
|
||||||
|
**response,
|
||||||
|
}
|
||||||
return Response.json(response, status=status)
|
return Response.json(response, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -348,29 +359,32 @@ class AllowedResourcesView(BaseView):
|
||||||
async def _allowed_payload(self, request, has_debug_permission):
|
async def _allowed_payload(self, request, has_debug_permission):
|
||||||
action = request.args.get("action")
|
action = request.args.get("action")
|
||||||
if not action:
|
if not action:
|
||||||
return {"error": "action parameter is required"}, 400
|
return error_body("action parameter is required", 400), 400
|
||||||
if action not in self.ds.actions:
|
if action not in self.ds.actions:
|
||||||
return {"error": f"Unknown action: {action}"}, 404
|
return error_body(f"Unknown action: {action}", 404), 404
|
||||||
|
|
||||||
actor = request.actor if isinstance(request.actor, dict) else None
|
actor = request.actor if isinstance(request.actor, dict) else None
|
||||||
actor_id = actor.get("id") if actor else None
|
actor_id = actor.get("id") if actor else None
|
||||||
parent_filter = request.args.get("parent")
|
parent_filter = request.args.get("parent")
|
||||||
child_filter = request.args.get("child")
|
child_filter = request.args.get("child")
|
||||||
if child_filter and not parent_filter:
|
if child_filter and not parent_filter:
|
||||||
return {"error": "parent must be provided when child is specified"}, 400
|
return (
|
||||||
|
error_body("parent must be provided when child is specified", 400),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page = int(request.args.get("page", "1"))
|
page = int(request.args.get("_page", "1"))
|
||||||
page_size = int(request.args.get("page_size", "50"))
|
if page < 1:
|
||||||
|
raise ValueError
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": "page and page_size must be integers"}, 400
|
return error_body("_page must be a positive integer", 400), 400
|
||||||
if page < 1:
|
try:
|
||||||
return {"error": "page must be >= 1"}, 400
|
page_size = parse_size_limit(
|
||||||
if page_size < 1:
|
request.args.get("_size"), default=50, maximum=200
|
||||||
return {"error": "page_size must be >= 1"}, 400
|
)
|
||||||
max_page_size = 200
|
except ValueError as ex:
|
||||||
if page_size > max_page_size:
|
return error_body(str(ex), 400), 400
|
||||||
page_size = max_page_size
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# Use the simplified allowed_resources method
|
# Use the simplified allowed_resources method
|
||||||
|
|
@ -406,10 +420,14 @@ class AllowedResourcesView(BaseView):
|
||||||
row["reason"] = resource.reasons
|
row["reason"] = resource.reasons
|
||||||
|
|
||||||
allowed_rows.append(row)
|
allowed_rows.append(row)
|
||||||
except Exception:
|
except Exception: # noqa: BLE001
|
||||||
# If catalog tables don't exist yet, return empty results
|
# 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
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
|
"ok": True,
|
||||||
"action": action,
|
"action": action,
|
||||||
"actor_id": actor_id,
|
"actor_id": actor_id,
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|
@ -434,16 +452,17 @@ class AllowedResourcesView(BaseView):
|
||||||
def build_page_url(page_number):
|
def build_page_url(page_number):
|
||||||
pairs = []
|
pairs = []
|
||||||
for key in request.args:
|
for key in request.args:
|
||||||
if key in {"page", "page_size"}:
|
if key in {"_page", "_size"}:
|
||||||
continue
|
continue
|
||||||
for value in request.args.getlist(key):
|
for value in request.args.getlist(key):
|
||||||
pairs.append((key, value))
|
pairs.append((key, value))
|
||||||
pairs.append(("page", str(page_number)))
|
pairs.append(("_page", str(page_number)))
|
||||||
pairs.append(("page_size", str(page_size)))
|
pairs.append(("_size", str(page_size)))
|
||||||
query = urllib.parse.urlencode(pairs)
|
query = urllib.parse.urlencode(pairs)
|
||||||
return f"{request.path}?{query}"
|
return f"{request.path}?{query}"
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
|
"ok": True,
|
||||||
"action": action,
|
"action": action,
|
||||||
"actor_id": actor_id,
|
"actor_id": actor_id,
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|
@ -485,31 +504,29 @@ class PermissionRulesView(BaseView):
|
||||||
# JSON API - action parameter is required
|
# JSON API - action parameter is required
|
||||||
action = request.args.get("action")
|
action = request.args.get("action")
|
||||||
if not action:
|
if not action:
|
||||||
return Response.json({"error": "action parameter is required"}, status=400)
|
return Response.error("action parameter is required", 400)
|
||||||
if action not in self.ds.actions:
|
if action not in self.ds.actions:
|
||||||
return Response.json({"error": f"Unknown action: {action}"}, status=404)
|
return Response.error(f"Unknown action: {action}", 404)
|
||||||
|
|
||||||
actor = request.actor if isinstance(request.actor, dict) else None
|
actor = request.actor if isinstance(request.actor, dict) else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page = int(request.args.get("page", "1"))
|
page = int(request.args.get("_page", "1"))
|
||||||
page_size = int(request.args.get("page_size", "50"))
|
if page < 1:
|
||||||
|
raise ValueError
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return Response.json(
|
return Response.error("_page must be a positive integer", 400)
|
||||||
{"error": "page and page_size must be integers"}, status=400
|
try:
|
||||||
|
page_size = parse_size_limit(
|
||||||
|
request.args.get("_size"), default=50, maximum=200
|
||||||
)
|
)
|
||||||
if page < 1:
|
except ValueError as ex:
|
||||||
return Response.json({"error": "page must be >= 1"}, status=400)
|
return Response.error(str(ex), 400)
|
||||||
if page_size < 1:
|
|
||||||
return Response.json({"error": "page_size must be >= 1"}, status=400)
|
|
||||||
max_page_size = 200
|
|
||||||
if page_size > max_page_size:
|
|
||||||
page_size = max_page_size
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
from datasette.utils.actions_sql import build_permission_rules_sql
|
from datasette.utils.actions_sql import build_permission_rules_sql
|
||||||
|
|
||||||
union_sql, union_params, restriction_sqls = await build_permission_rules_sql(
|
union_sql, union_params, _restriction_sqls = await build_permission_rules_sql(
|
||||||
self.ds, actor, action
|
self.ds, actor, action
|
||||||
)
|
)
|
||||||
await self.ds.refresh_schemas()
|
await self.ds.refresh_schemas()
|
||||||
|
|
@ -555,16 +572,17 @@ class PermissionRulesView(BaseView):
|
||||||
def build_page_url(page_number):
|
def build_page_url(page_number):
|
||||||
pairs = []
|
pairs = []
|
||||||
for key in request.args:
|
for key in request.args:
|
||||||
if key in {"page", "page_size"}:
|
if key in {"_page", "_size"}:
|
||||||
continue
|
continue
|
||||||
for value in request.args.getlist(key):
|
for value in request.args.getlist(key):
|
||||||
pairs.append((key, value))
|
pairs.append((key, value))
|
||||||
pairs.append(("page", str(page_number)))
|
pairs.append(("_page", str(page_number)))
|
||||||
pairs.append(("page_size", str(page_size)))
|
pairs.append(("_size", str(page_size)))
|
||||||
query = urllib.parse.urlencode(pairs)
|
query = urllib.parse.urlencode(pairs)
|
||||||
return f"{request.path}?{query}"
|
return f"{request.path}?{query}"
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
|
"ok": True,
|
||||||
"action": action,
|
"action": action,
|
||||||
"actor_id": (actor or {}).get("id") if actor else None,
|
"actor_id": (actor or {}).get("id") if actor else None,
|
||||||
"page": page,
|
"page": page,
|
||||||
|
|
@ -585,17 +603,17 @@ class PermissionRulesView(BaseView):
|
||||||
|
|
||||||
|
|
||||||
async def _check_permission_for_actor(ds, action, parent, child, actor):
|
async def _check_permission_for_actor(ds, action, parent, child, actor):
|
||||||
"""Shared logic for checking permissions. Returns a dict with check results."""
|
"""Shared logic for checking and explaining a permission decision."""
|
||||||
if action not in ds.actions:
|
if action not in ds.actions:
|
||||||
return {"error": f"Unknown action: {action}"}, 404
|
return error_body(f"Unknown action: {action}", 404), 404
|
||||||
|
|
||||||
if child and not parent:
|
if child and not parent:
|
||||||
return {"error": "parent is required when child is provided"}, 400
|
return error_body("parent is required when child is provided", 400), 400
|
||||||
|
|
||||||
# Use the action's properties to create the appropriate resource object
|
# Use the action's properties to create the appropriate resource object
|
||||||
action_obj = ds.actions.get(action)
|
action_obj = ds.actions.get(action)
|
||||||
if not action_obj:
|
if not action_obj:
|
||||||
return {"error": f"Unknown action: {action}"}, 400
|
return error_body(f"Unknown action: {action}", 400), 400
|
||||||
|
|
||||||
# Global actions (no resource_class) don't have a resource
|
# Global actions (no resource_class) don't have a resource
|
||||||
if action_obj.resource_class is None:
|
if action_obj.resource_class is None:
|
||||||
|
|
@ -610,18 +628,32 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
|
||||||
resource_obj = action_obj.resource_class(parent)
|
resource_obj = action_obj.resource_class(parent)
|
||||||
else:
|
else:
|
||||||
# This shouldn't happen given validation in Action.__post_init__
|
# This shouldn't happen given validation in Action.__post_init__
|
||||||
return {"error": f"Invalid action configuration: {action}"}, 500
|
return error_body(f"Invalid action configuration: {action}", 500), 500
|
||||||
|
|
||||||
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
|
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
|
||||||
|
|
||||||
|
from datasette.utils.actions_sql import explain_permission_for_resource
|
||||||
|
|
||||||
|
explanation = await explain_permission_for_resource(
|
||||||
|
datasette=ds,
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
parent=parent,
|
||||||
|
child=child,
|
||||||
|
)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"unstable": UNSTABLE_API_MESSAGE,
|
||||||
"action": action,
|
"action": action,
|
||||||
"allowed": bool(allowed),
|
"allowed": bool(allowed),
|
||||||
|
"actor": actor,
|
||||||
"resource": {
|
"resource": {
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
"child": child,
|
"child": child,
|
||||||
"path": _resource_path(parent, child),
|
"path": _resource_path(parent, child),
|
||||||
},
|
},
|
||||||
|
"explanation": explanation,
|
||||||
}
|
}
|
||||||
|
|
||||||
if actor and "id" in actor:
|
if actor and "id" in actor:
|
||||||
|
|
@ -639,11 +671,25 @@ class PermissionCheckView(BaseView):
|
||||||
as_format = request.url_vars.get("format")
|
as_format = request.url_vars.get("format")
|
||||||
|
|
||||||
if not as_format:
|
if not as_format:
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"name": action.name,
|
||||||
|
"description": action.description,
|
||||||
|
"takes_parent": action.takes_parent,
|
||||||
|
"takes_child": action.takes_child,
|
||||||
|
"also_requires": action.also_requires,
|
||||||
|
}
|
||||||
|
for action in sorted(
|
||||||
|
self.ds.actions.values(), key=lambda action: action.name
|
||||||
|
)
|
||||||
|
]
|
||||||
return await self.render(
|
return await self.render(
|
||||||
["debug_check.html"],
|
["debug_check.html"],
|
||||||
request,
|
request,
|
||||||
{
|
{
|
||||||
"sorted_actions": sorted(self.ds.actions.keys()),
|
"actions": actions,
|
||||||
|
"actor_json": request.args.get("actor")
|
||||||
|
or json.dumps(request.actor, indent=2),
|
||||||
"has_debug_permission": True,
|
"has_debug_permission": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -651,13 +697,22 @@ class PermissionCheckView(BaseView):
|
||||||
# JSON API - action parameter is required
|
# JSON API - action parameter is required
|
||||||
action = request.args.get("action")
|
action = request.args.get("action")
|
||||||
if not action:
|
if not action:
|
||||||
return Response.json({"error": "action parameter is required"}, status=400)
|
return Response.error("action parameter is required", 400)
|
||||||
|
|
||||||
parent = request.args.get("parent")
|
parent = request.args.get("parent")
|
||||||
child = request.args.get("child")
|
child = request.args.get("child")
|
||||||
|
actor = request.actor
|
||||||
|
actor_json = request.args.get("actor")
|
||||||
|
if actor_json is not None:
|
||||||
|
try:
|
||||||
|
actor = json.loads(actor_json)
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
return Response.error(f"Invalid actor JSON: {ex}", 400)
|
||||||
|
if actor is not None and not isinstance(actor, dict):
|
||||||
|
return Response.error("actor must be a JSON object or null", 400)
|
||||||
|
|
||||||
response, status = await _check_permission_for_actor(
|
response, status = await _check_permission_for_actor(
|
||||||
self.ds, action, parent, child, request.actor
|
self.ds, action, parent, child, actor
|
||||||
)
|
)
|
||||||
return Response.json(response, status=status)
|
return Response.json(response, status=status)
|
||||||
|
|
||||||
|
|
@ -884,7 +939,7 @@ class ApiExplorerView(BaseView):
|
||||||
tables.append({"name": table, "links": table_links})
|
tables.append({"name": table, "links": table_links})
|
||||||
table_links.append(
|
table_links.append(
|
||||||
{
|
{
|
||||||
"label": "Get rows for {}".format(table),
|
"label": f"Get rows for {table}",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": self.ds.urls.table(name, table, format="json"),
|
"path": self.ds.urls.table(name, table, format="json"),
|
||||||
}
|
}
|
||||||
|
|
@ -904,7 +959,7 @@ class ApiExplorerView(BaseView):
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.table(name, table) + "/-/insert",
|
"path": self.ds.urls.table(name, table) + "/-/insert",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"label": "Insert rows into {}".format(table),
|
"label": f"Insert rows into {table}",
|
||||||
"json": {
|
"json": {
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
|
|
@ -918,7 +973,7 @@ class ApiExplorerView(BaseView):
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.table(name, table) + "/-/upsert",
|
"path": self.ds.urls.table(name, table) + "/-/upsert",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"label": "Upsert rows into {}".format(table),
|
"label": f"Upsert rows into {table}",
|
||||||
"json": {
|
"json": {
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
|
|
@ -948,7 +1003,7 @@ class ApiExplorerView(BaseView):
|
||||||
table_links.append(
|
table_links.append(
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.table(name, table) + "/-/drop",
|
"path": self.ds.urls.table(name, table) + "/-/drop",
|
||||||
"label": "Drop table {}".format(table),
|
"label": f"Drop table {table}",
|
||||||
"json": {"confirm": False},
|
"json": {"confirm": False},
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
}
|
}
|
||||||
|
|
@ -965,7 +1020,7 @@ class ApiExplorerView(BaseView):
|
||||||
database_links.append(
|
database_links.append(
|
||||||
{
|
{
|
||||||
"path": self.ds.urls.database(name) + "/-/create",
|
"path": self.ds.urls.database(name) + "/-/create",
|
||||||
"label": "Create table in {}".format(name),
|
"label": f"Create table in {name}",
|
||||||
"json": {
|
"json": {
|
||||||
"table": "new_table",
|
"table": "new_table",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
|
@ -1198,7 +1253,7 @@ class JumpView(BaseView):
|
||||||
match["display_name"] = row["display_name"]
|
match["display_name"] = row["display_name"]
|
||||||
matches.append(match)
|
matches.append(match)
|
||||||
|
|
||||||
return Response.json({"matches": matches, "truncated": truncated})
|
return Response.json({"ok": True, "matches": matches, "truncated": truncated})
|
||||||
|
|
||||||
|
|
||||||
class SchemaBaseView(BaseView):
|
class SchemaBaseView(BaseView):
|
||||||
|
|
@ -1220,7 +1275,7 @@ class SchemaBaseView(BaseView):
|
||||||
headers = {}
|
headers = {}
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
return Response.json(data, headers=headers)
|
return Response.json({"ok": True, **data}, headers=headers)
|
||||||
|
|
||||||
def format_error_response(self, error_message, format_, status=404):
|
def format_error_response(self, error_message, format_, status=404):
|
||||||
"""Format error response based on requested format."""
|
"""Format error response based on requested format."""
|
||||||
|
|
@ -1229,7 +1284,7 @@ class SchemaBaseView(BaseView):
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
add_cors_headers(headers)
|
add_cors_headers(headers)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{"ok": False, "error": error_message}, status=status, headers=headers
|
error_body(error_message, status), status=status, headers=headers
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return Response.text(error_message, status=status)
|
return Response.text(error_message, status=status)
|
||||||
|
|
@ -1305,17 +1360,17 @@ class DatabaseSchemaView(SchemaBaseView):
|
||||||
database_name = request.url_vars["database"]
|
database_name = request.url_vars["database"]
|
||||||
format_ = request.url_vars.get("format") or "html"
|
format_ = request.url_vars.get("format") or "html"
|
||||||
|
|
||||||
# Check if database exists
|
# Permission check comes first, so actors without view-database
|
||||||
if database_name not in self.ds.databases:
|
# cannot distinguish existing databases from missing ones
|
||||||
return self.format_error_response("Database not found", format_)
|
|
||||||
|
|
||||||
# Check view-database permission
|
|
||||||
await self.ds.ensure_permission(
|
await self.ds.ensure_permission(
|
||||||
action="view-database",
|
action="view-database",
|
||||||
resource=DatabaseResource(database=database_name),
|
resource=DatabaseResource(database=database_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if database_name not in self.ds.databases:
|
||||||
|
return self.format_error_response("Database not found", format_)
|
||||||
|
|
||||||
schema = await self.get_database_schema(database_name)
|
schema = await self.get_database_schema(database_name)
|
||||||
|
|
||||||
if format_ == "json":
|
if format_ == "json":
|
||||||
|
|
@ -1349,6 +1404,9 @@ class TableSchemaView(SchemaBaseView):
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if database_name not in self.ds.databases:
|
||||||
|
return self.format_error_response("Database not found", format_)
|
||||||
|
|
||||||
# Get schema for the table
|
# Get schema for the table
|
||||||
db = self.ds.databases[database_name]
|
db = self.ds.databases[database_name]
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
from datasette.resources import DatabaseResource, QueryResource
|
from datasette.resources import DatabaseResource, QueryResource
|
||||||
from datasette.stored_queries import stored_query_to_dict
|
from datasette.stored_queries import stored_query_to_dict
|
||||||
from datasette.utils import sqlite3, tilde_decode
|
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3, tilde_decode
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
|
|
||||||
from .base import BaseView, _error
|
from .base import BaseView
|
||||||
from .query_helpers import (
|
from .query_helpers import (
|
||||||
QueryValidationError,
|
QueryValidationError,
|
||||||
_as_bool,
|
_as_bool,
|
||||||
|
|
@ -34,12 +34,14 @@ class QueryParametersView(BaseView):
|
||||||
resource=DatabaseResource(db.name),
|
resource=DatabaseResource(db.name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
return _block_framing(
|
||||||
|
Response.error(["Permission denied: need execute-sql"], 403)
|
||||||
|
)
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
invalid_keys = set(request.args) - {"sql"}
|
||||||
if invalid_keys:
|
if invalid_keys:
|
||||||
return _block_framing(
|
return _block_framing(
|
||||||
_error(
|
Response.error(
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||||
400,
|
400,
|
||||||
)
|
)
|
||||||
|
|
@ -47,8 +49,16 @@ class QueryParametersView(BaseView):
|
||||||
try:
|
try:
|
||||||
parameters = _derived_query_parameters(request.args.get("sql") or "")
|
parameters = _derived_query_parameters(request.args.get("sql") or "")
|
||||||
except QueryValidationError as ex:
|
except QueryValidationError as ex:
|
||||||
return _block_framing(_error([ex.message], ex.status))
|
return _block_framing(Response.error([ex.message], ex.status))
|
||||||
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
|
return _block_framing(
|
||||||
|
Response.json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"unstable": UNSTABLE_API_MESSAGE,
|
||||||
|
"parameters": parameters,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
|
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
|
||||||
|
|
@ -82,11 +92,12 @@ class QueryListView(BaseView):
|
||||||
limit = _query_list_limit(
|
limit = _query_list_limit(
|
||||||
request.args.get("_size"),
|
request.args.get("_size"),
|
||||||
default=20 if format_ == "html" else 50,
|
default=20 if format_ == "html" else 50,
|
||||||
|
maximum=self.ds.max_returned_rows,
|
||||||
)
|
)
|
||||||
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
|
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
|
||||||
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
|
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
|
||||||
except QueryValidationError as ex:
|
except QueryValidationError as ex:
|
||||||
return _error([ex.message], ex.status)
|
return Response.error([ex.message], ex.status)
|
||||||
|
|
||||||
page = await self.ds.list_queries(
|
page = await self.ds.list_queries(
|
||||||
database,
|
database,
|
||||||
|
|
@ -111,9 +122,9 @@ class QueryListView(BaseView):
|
||||||
if key != "_next"
|
if key != "_next"
|
||||||
]
|
]
|
||||||
pairs.append(("_next", page.next))
|
pairs.append(("_next", page.next))
|
||||||
next_url = "{}?{}".format(
|
next_url = self.ds.absolute_url(
|
||||||
query_list_path,
|
request,
|
||||||
urlencode(pairs),
|
f"{request.path}?{urlencode(pairs)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
current_filters = {
|
current_filters = {
|
||||||
|
|
@ -199,7 +210,6 @@ class QueryListView(BaseView):
|
||||||
"queries": page.queries,
|
"queries": page.queries,
|
||||||
"next": page.next,
|
"next": page.next,
|
||||||
"next_url": next_url,
|
"next_url": next_url,
|
||||||
"has_more": page.has_more,
|
|
||||||
"limit": page.limit,
|
"limit": page.limit,
|
||||||
"show_private_note": any(query.is_private for query in page.queries),
|
"show_private_note": any(query.is_private for query in page.queries),
|
||||||
"show_trusted_note": any(query.is_trusted for query in page.queries),
|
"show_trusted_note": any(query.is_trusted for query in page.queries),
|
||||||
|
|
@ -298,28 +308,30 @@ class QueryCreateAnalyzeView(BaseView):
|
||||||
resource=DatabaseResource(db.name),
|
resource=DatabaseResource(db.name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
return _block_framing(
|
||||||
|
Response.error(["Permission denied: need execute-sql"], 403)
|
||||||
|
)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="store-query",
|
action="store-query",
|
||||||
resource=DatabaseResource(db.name),
|
resource=DatabaseResource(db.name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _block_framing(_error(["Permission denied: need store-query"], 403))
|
return _block_framing(
|
||||||
|
Response.error(["Permission denied: need store-query"], 403)
|
||||||
|
)
|
||||||
|
|
||||||
invalid_keys = set(request.args) - {"sql"}
|
invalid_keys = set(request.args) - {"sql"}
|
||||||
if invalid_keys:
|
if invalid_keys:
|
||||||
return _block_framing(
|
return _block_framing(
|
||||||
_error(
|
Response.error(
|
||||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||||
400,
|
400,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sql = request.args.get("sql") or ""
|
sql = request.args.get("sql") or ""
|
||||||
return _block_framing(
|
analysis = await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
||||||
Response.json(
|
analysis["unstable"] = UNSTABLE_API_MESSAGE
|
||||||
await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
return _block_framing(Response.json(analysis))
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryStoreView(QueryCreateView):
|
class QueryStoreView(QueryCreateView):
|
||||||
|
|
@ -346,13 +358,13 @@ class QueryStoreView(QueryCreateView):
|
||||||
resource=DatabaseResource(db.name),
|
resource=DatabaseResource(db.name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _error(["Permission denied: need execute-sql"], 403)
|
return Response.error(["Permission denied: need execute-sql"], 403)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="store-query",
|
action="store-query",
|
||||||
resource=DatabaseResource(db.name),
|
resource=DatabaseResource(db.name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _error(["Permission denied: need store-query"], 403)
|
return Response.error(["Permission denied: need store-query"], 403)
|
||||||
|
|
||||||
is_json = False
|
is_json = False
|
||||||
query_data = {}
|
query_data = {}
|
||||||
|
|
@ -369,7 +381,7 @@ class QueryStoreView(QueryCreateView):
|
||||||
return await self._error_response(
|
return await self._error_response(
|
||||||
request, db, query_data, ex.message, ex.status
|
request, db, query_data, ex.message, ex.status
|
||||||
)
|
)
|
||||||
return _error([ex.message], ex.status)
|
return Response.error([ex.message], ex.status)
|
||||||
|
|
||||||
prepared.pop("analysis")
|
prepared.pop("analysis")
|
||||||
name = prepared.pop("name")
|
name = prepared.pop("name")
|
||||||
|
|
@ -378,13 +390,18 @@ class QueryStoreView(QueryCreateView):
|
||||||
except sqlite3.IntegrityError as ex:
|
except sqlite3.IntegrityError as ex:
|
||||||
if not is_json and isinstance(query_data, dict):
|
if not is_json and isinstance(query_data, dict):
|
||||||
return await self._error_response(request, db, query_data, str(ex), 400)
|
return await self._error_response(request, db, query_data, str(ex), 400)
|
||||||
return _error([str(ex)], 400)
|
return Response.error([str(ex)], 400)
|
||||||
|
|
||||||
query = await self.ds.get_query(db.name, name)
|
query = await self.ds.get_query(db.name, name)
|
||||||
assert query is not None
|
assert query is not None
|
||||||
if is_json:
|
if is_json:
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{"ok": True, "query": stored_query_to_dict(query)}, status=201
|
{
|
||||||
|
"ok": True,
|
||||||
|
"unstable": UNSTABLE_API_MESSAGE,
|
||||||
|
"query": stored_query_to_dict(query),
|
||||||
|
},
|
||||||
|
status=201,
|
||||||
)
|
)
|
||||||
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
||||||
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
|
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
|
||||||
|
|
@ -398,14 +415,20 @@ class QueryDefinitionView(BaseView):
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
query_name = tilde_decode(request.url_vars["query"])
|
||||||
query = await self.ds.get_query(db.name, query_name)
|
query = await self.ds.get_query(db.name, query_name)
|
||||||
if query is None:
|
if query is None:
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
return Response.error([f"Query not found: {query_name}"], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="view-query",
|
action="view-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _error(["Permission denied"], 403)
|
return Response.error(["Permission denied"], 403)
|
||||||
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
|
return Response.json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"unstable": UNSTABLE_API_MESSAGE,
|
||||||
|
"query": stored_query_to_dict(query),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class QueryUpdateView(BaseView):
|
class QueryUpdateView(BaseView):
|
||||||
|
|
@ -416,15 +439,17 @@ class QueryUpdateView(BaseView):
|
||||||
query_name = tilde_decode(request.url_vars["query"])
|
query_name = tilde_decode(request.url_vars["query"])
|
||||||
existing = await self.ds.get_query(db.name, query_name)
|
existing = await self.ds.get_query(db.name, query_name)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
return Response.error([f"Query not found: {query_name}"], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="update-query",
|
action="update-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _error(["Permission denied: need update-query"], 403)
|
return Response.error(["Permission denied: need update-query"], 403)
|
||||||
if existing.is_trusted:
|
if existing.is_trusted:
|
||||||
return _error(["Trusted queries cannot be updated using the API"], 403)
|
return Response.error(
|
||||||
|
["Trusted queries cannot be updated using the API"], 403
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data, _ = await _json_or_form_payload(request)
|
data, _ = await _json_or_form_payload(request)
|
||||||
|
|
@ -450,7 +475,7 @@ class QueryUpdateView(BaseView):
|
||||||
self.ds, request, db, existing, update
|
self.ds, request, db, existing, update
|
||||||
)
|
)
|
||||||
except QueryValidationError as ex:
|
except QueryValidationError as ex:
|
||||||
return _error([ex.message], ex.status)
|
return Response.error([ex.message], ex.status)
|
||||||
|
|
||||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
||||||
if data.get("return"):
|
if data.get("return"):
|
||||||
|
|
@ -507,32 +532,32 @@ class QueryEditView(BaseView):
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
return Response.error([f"Query not found: {query_name}"], 404)
|
||||||
await self.ds.ensure_permission(
|
await self.ds.ensure_permission(
|
||||||
action="update-query",
|
action="update-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
if existing.is_trusted:
|
if existing.is_trusted:
|
||||||
return _error(["Trusted queries cannot be edited"], 403)
|
return Response.error(["Trusted queries cannot be edited"], 403)
|
||||||
return await self._render_form(request, db, existing)
|
return await self._render_form(request, db, existing)
|
||||||
|
|
||||||
async def post(self, request):
|
async def post(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
return Response.error([f"Query not found: {query_name}"], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="update-query",
|
action="update-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _error(["Permission denied: need update-query"], 403)
|
return Response.error(["Permission denied: need update-query"], 403)
|
||||||
if existing.is_trusted:
|
if existing.is_trusted:
|
||||||
return _error(["Trusted queries cannot be edited"], 403)
|
return Response.error(["Trusted queries cannot be edited"], 403)
|
||||||
|
|
||||||
data, _ = await _json_or_form_payload(request)
|
data, _ = await _json_or_form_payload(request)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return _error(["Invalid form submission"], 400)
|
return Response.error(["Invalid form submission"], 400)
|
||||||
sql = data.get("sql")
|
sql = data.get("sql")
|
||||||
sql = existing.sql if sql is None else sql.strip()
|
sql = existing.sql if sql is None else sql.strip()
|
||||||
title = data.get("title") or ""
|
title = data.get("title") or ""
|
||||||
|
|
@ -604,12 +629,16 @@ class QueryDeleteView(BaseView):
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
return Response.error([f"Query not found: {query_name}"], 404)
|
||||||
await self.ds.ensure_permission(
|
await self.ds.ensure_permission(
|
||||||
action="delete-query",
|
action="delete-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
)
|
)
|
||||||
|
if existing.is_trusted:
|
||||||
|
return Response.error(
|
||||||
|
["Trusted queries cannot be deleted using the API"], 403
|
||||||
|
)
|
||||||
return await self.render(
|
return await self.render(
|
||||||
["query_delete.html"],
|
["query_delete.html"],
|
||||||
request,
|
request,
|
||||||
|
|
@ -624,21 +653,25 @@ class QueryDeleteView(BaseView):
|
||||||
async def post(self, request):
|
async def post(self, request):
|
||||||
db, query_name, existing = await self._load(request)
|
db, query_name, existing = await self._load(request)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return _error(["Query not found: {}".format(query_name)], 404)
|
return Response.error([f"Query not found: {query_name}"], 404)
|
||||||
if not await self.ds.allowed(
|
if not await self.ds.allowed(
|
||||||
action="delete-query",
|
action="delete-query",
|
||||||
resource=QueryResource(db.name, query_name),
|
resource=QueryResource(db.name, query_name),
|
||||||
actor=request.actor,
|
actor=request.actor,
|
||||||
):
|
):
|
||||||
return _error(["Permission denied: need delete-query"], 403)
|
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
|
||||||
|
)
|
||||||
|
|
||||||
data, is_json = await _json_or_form_payload(request)
|
_data, is_json = await _json_or_form_payload(request)
|
||||||
await self.ds.remove_query(db.name, query_name)
|
await self.ds.remove_query(db.name, query_name)
|
||||||
if is_json:
|
if is_json:
|
||||||
return Response.json({"ok": True})
|
return Response.json({"ok": True})
|
||||||
self.ds.add_message(
|
self.ds.add_message(
|
||||||
request,
|
request,
|
||||||
"Query “{}” deleted".format(existing.title or query_name),
|
f"Query “{existing.title or query_name}” deleted",
|
||||||
self.ds.INFO,
|
self.ds.INFO,
|
||||||
)
|
)
|
||||||
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))
|
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue