/db/table/-/blob/pk/column.blob download URL, refs #1036

pull/1049/head
Simon Willison 2020-10-24 16:09:18 -07:00 zatwierdzone przez GitHub
rodzic 10c35bd371
commit 5a15197960
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 184 dodań i 67 usunięć

Wyświetl plik

@ -38,7 +38,7 @@ from .views.special import (
PermissionsDebugView,
MessagesDebugView,
)
from .views.table import RowView, TableView
from .views.table import RowView, TableView, BlobView
from .renderer import json_renderer
from .database import Database, QueryInterrupted
@ -923,6 +923,10 @@ class Datasette:
+ renderer_regex
+ r")?$",
)
add_route(
BlobView.as_view(self),
r"/(?P<db_name>[^/]+)/(?P<table>[^/]+?)/\-/blob/(?P<pk_path>[^/]+?)/(?P<column>[^/]+)\.blob$",
)
self._register_custom_units()
async def setup_db():

Wyświetl plik

@ -51,6 +51,9 @@ class DatasetteError(Exception):
class BaseView:
ds = None
def __init__(self, datasette):
self.ds = datasette
async def head(self, *args, **kwargs):
response = await self.get(*args, **kwargs)
response.body = b""
@ -151,9 +154,6 @@ class DataView(BaseView):
name = ""
re_named_parameter = re.compile(":([a-zA-Z0-9_]+)")
def __init__(self, datasette):
self.ds = datasette
def options(self, request, *args, **kwargs):
r = Response.text("ok")
if self.ds.cors:

Wyświetl plik

@ -18,9 +18,6 @@ COUNT_DB_SIZE_LIMIT = 100 * 1024 * 1024
class IndexView(BaseView):
name = "index"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request, as_format):
await self.check_permission(request, "view-instance")
databases = []

Wyświetl plik

@ -44,9 +44,6 @@ class JsonDataView(BaseView):
class PatternPortfolioView(BaseView):
name = "patterns"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
await self.check_permission(request, "view-instance")
return await self.render(["patterns.html"], request=request)
@ -55,9 +52,6 @@ class PatternPortfolioView(BaseView):
class AuthTokenView(BaseView):
name = "auth_token"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
token = request.args.get("token") or ""
if not self.ds._root_token:
@ -76,9 +70,6 @@ class AuthTokenView(BaseView):
class LogoutView(BaseView):
name = "logout"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
if not request.actor:
return Response.redirect(self.ds.urls.instance())
@ -98,9 +89,6 @@ class LogoutView(BaseView):
class PermissionsDebugView(BaseView):
name = "permissions_debug"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
await self.check_permission(request, "view-instance")
if not await self.ds.permission_allowed(request.actor, "permissions-debug"):
@ -115,9 +103,6 @@ class PermissionsDebugView(BaseView):
class AllowDebugView(BaseView):
name = "allow_debug"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
errors = []
actor_input = request.args.get("actor") or '{"id": "root"}'
@ -152,9 +137,6 @@ class AllowDebugView(BaseView):
class MessagesDebugView(BaseView):
name = "messages_debug"
def __init__(self, datasette):
self.ds = datasette
async def get(self, request):
await self.check_permission(request, "view-instance")
return await self.render(["messages_debug.html"], request)

Wyświetl plik

@ -23,9 +23,9 @@ from datasette.utils import (
urlsafe_components,
value_as_boolean,
)
from datasette.utils.asgi import NotFound
from datasette.utils.asgi import NotFound, Response
from datasette.filters import Filters
from .base import DataView, DatasetteError, ureg
from .base import BaseView, DataView, DatasetteError, ureg
from .database import QueryView
LINK_WITH_LABEL = (
@ -903,28 +903,38 @@ class TableView(RowTableShared):
)
async def _sql_params_pks(db, table, pk_values):
pks = await db.primary_keys(table)
use_rowid = not pks
select = "*"
if use_rowid:
select = "rowid, *"
pks = ["rowid"]
wheres = ['"{}"=:p{}'.format(pk, i) for i, pk in enumerate(pks)]
sql = "select {} from {} where {}".format(
select, escape_sqlite(table), " AND ".join(wheres)
)
params = {}
for i, pk_value in enumerate(pk_values):
params["p{}".format(i)] = pk_value
return sql, params, pks
class RowView(RowTableShared):
name = "row"
async def data(self, request, database, hash, table, pk_path, default_labels=False):
pk_values = urlsafe_components(pk_path)
await self.check_permission(request, "view-instance")
await self.check_permission(request, "view-database", database)
await self.check_permission(request, "view-table", (database, table))
db = self.ds.databases[database]
pks = await db.primary_keys(table)
use_rowid = not pks
select = "*"
if use_rowid:
select = "rowid, *"
pks = ["rowid"]
wheres = ['"{}"=:p{}'.format(pk, i) for i, pk in enumerate(pks)]
sql = "select {} from {} where {}".format(
select, escape_sqlite(table), " AND ".join(wheres)
await self.check_permissions(
request,
[
("view-table", (database, table)),
("view-database", database),
"view-instance",
],
)
params = {}
for i, pk_value in enumerate(pk_values):
params["p{}".format(i)] = pk_value
pk_values = urlsafe_components(pk_path)
db = self.ds.databases[database]
sql, params, pks = await _sql_params_pks(db, table, pk_values)
results = await db.execute(sql, params, truncate=True)
columns = [r[0] for r in results.description]
rows = list(results.rows)
@ -1024,3 +1034,50 @@ class RowView(RowTableShared):
)
foreign_key_tables.append({**fk, **{"count": count}})
return foreign_key_tables
class BlobView(BaseView):
async def get(self, request, db_name, table, pk_path, column):
await self.check_permissions(
request,
[
("view-table", (db_name, table)),
("view-database", db_name),
"view-instance",
],
)
try:
db = self.ds.get_database(db_name)
except KeyError:
raise NotFound("Database {} does not exist".format(db_name))
if not await db.table_exists(table):
raise NotFound("Table {} does not exist".format(table))
# Ensure the column exists and is of type BLOB
column_types = {c.name: c.type for c in await db.table_column_details(table)}
if column not in column_types:
raise NotFound("Table {} does not have column {}".format(table, column))
if column_types[column].upper() not in ("BLOB", ""):
raise NotFound(
"Table {} does not have column {} of type BLOB".format(table, column)
)
# Ensure the row exists for the pk_path
pk_values = urlsafe_components(pk_path)
sql, params, _ = await _sql_params_pks(db, table, pk_values)
results = await db.execute(sql, params, truncate=True)
rows = list(results.rows)
if not rows:
raise NotFound("Record not found: {}".format(pk_values))
# Serve back the binary data
filename_bits = [to_css_class(table), pk_path, to_css_class(column)]
filename = "-".join(filename_bits) + ".blob"
headers = {
"X-Content-Type-Options": "nosniff",
"Content-Disposition": 'attachment; filename="{}"'.format(filename),
}
return Response(
body=rows[0][column],
status=200,
headers=headers,
content_type="application/binary",
)

Wyświetl plik

@ -385,7 +385,15 @@ The ``datasette.urls`` object contains methods for building URLs to pages within
For example, ``datasette.urls.path("-/logout")`` will return the path to the logout page, which will be ``"/-/logout"`` by default or ``/prefix-path/-/logout`` if ``base_url`` is set to ``/prefix-path/``
``datasette.urls.logout()``
Returns the URL to the logout page, usually ``"/-/logout"``.
Returns the URL to the logout page, usually ``"/-/logout"``
``datasette.urls.static(path)``
Returns the URL of one of Datasette's default static assets, for example ``"/-/static/app.css"``
``datasette.urls.static_plugins(plugin_name, path)``
Returns the URL of one of the static assets belonging to a plugin.
``datasette.url.static_plugins("datasette_cluster_map", "datasette-cluster-map.js")`` would return ``"/-/static-plugins/datasette_cluster_map/datasette-cluster-map.js"``
``datasette.urls.static(path)``
Returns the URL of one of Datasette's default static assets, for example ``"/-/static/app.css"``.

Wyświetl plik

@ -77,3 +77,14 @@ Note that this URL includes the encoded primary key of the record.
Here's that same page as JSON:
`../people/uk.org.publicwhip%2Fperson%2F10001.json <https://register-of-members-interests.datasettes.com/regmem/people/uk.org.publicwhip%2Fperson%2F10001.json>`_
.. _BlobView:
Blob
====
SQLite databases can contain binary data, stored in a ``BLOB`` column. Datasette makes the content of these columns available to download directly, at URLs that look like the following::
/database-name/table-name/-/blob/row-identifier/column-name.blob
Binary content is also made available as a base64 encoded string in the ``.json`` representation of the row.

Wyświetl plik

@ -1244,6 +1244,46 @@ def test_binary_data_display(app_client):
]
def test_blob_download(app_client):
response = app_client.get("/fixtures/binary_data/-/blob/1/data.blob")
assert response.status == 200
assert response.body == b"\x15\x1c\x02\xc7\xad\x05\xfe"
assert response.headers["x-content-type-options"] == "nosniff"
assert (
response.headers["content-disposition"]
== 'attachment; filename="binary_data-1-data.blob"'
)
assert response.headers["content-type"] == "application/binary"
@pytest.mark.parametrize(
"path,expected_message",
[
("/baddb/binary_data/-/blob/1/data.blob", "Database baddb does not exist"),
(
"/fixtures/binary_data_bad/-/blob/1/data.blob",
"Table binary_data_bad does not exist",
),
(
"/fixtures/binary_data/-/blob/1/bad.blob",
"Table binary_data does not have column bad",
),
(
"/fixtures/facetable/-/blob/1/state.blob",
"Table facetable does not have column state of type BLOB",
),
(
"/fixtures/binary_data/-/blob/101/data.blob",
"Record not found: [&#39;101&#39;]",
),
],
)
def test_blob_download_not_found_messages(app_client, path, expected_message):
response = app_client.get(path)
assert response.status == 404
assert expected_message in response.text
def test_metadata_json_html(app_client):
response = app_client.get("/-/metadata")
assert response.status == 200

Wyświetl plik

@ -399,31 +399,49 @@ def cascade_app_client():
@pytest.mark.parametrize(
"path,expected_status,permissions",
"path,permissions,expected_status",
[
("/", 403, []),
("/", 200, ["instance"]),
("/", [], 403),
("/", ["instance"], 200),
# Can view table even if not allowed database or instance
("/fixtures/facet_cities", 403, []),
("/fixtures/facet_cities", 403, ["database"]),
("/fixtures/facet_cities", 403, ["instance"]),
("/fixtures/facet_cities", 200, ["table"]),
("/fixtures/facet_cities", 200, ["table", "database"]),
("/fixtures/facet_cities", 200, ["table", "database", "instance"]),
("/fixtures/binary_data", [], 403),
("/fixtures/binary_data", ["database"], 403),
("/fixtures/binary_data", ["instance"], 403),
("/fixtures/binary_data", ["table"], 200),
("/fixtures/binary_data", ["table", "database"], 200),
("/fixtures/binary_data", ["table", "database", "instance"], 200),
# ... same for row
("/fixtures/binary_data/1", [], 403),
("/fixtures/binary_data/1", ["database"], 403),
("/fixtures/binary_data/1", ["instance"], 403),
("/fixtures/binary_data/1", ["table"], 200),
("/fixtures/binary_data/1", ["table", "database"], 200),
("/fixtures/binary_data/1", ["table", "database", "instance"], 200),
# ... and for binary blob
("/fixtures/binary_data/-/blob/1/data.blob", [], 403),
("/fixtures/binary_data/-/blob/1/data.blob", ["database"], 403),
("/fixtures/binary_data/-/blob/1/data.blob", ["instance"], 403),
("/fixtures/binary_data/-/blob/1/data.blob", ["table"], 200),
("/fixtures/binary_data/-/blob/1/data.blob", ["table", "database"], 200),
(
"/fixtures/binary_data/-/blob/1/data.blob",
["table", "database", "instance"],
200,
),
# Can view query even if not allowed database or instance
("/fixtures/magic_parameters", 403, []),
("/fixtures/magic_parameters", 403, ["database"]),
("/fixtures/magic_parameters", 403, ["instance"]),
("/fixtures/magic_parameters", 200, ["query"]),
("/fixtures/magic_parameters", 200, ["query", "database"]),
("/fixtures/magic_parameters", 200, ["query", "database", "instance"]),
("/fixtures/magic_parameters", [], 403),
("/fixtures/magic_parameters", ["database"], 403),
("/fixtures/magic_parameters", ["instance"], 403),
("/fixtures/magic_parameters", ["query"], 200),
("/fixtures/magic_parameters", ["query", "database"], 200),
("/fixtures/magic_parameters", ["query", "database", "instance"], 200),
# Can view database even if not allowed instance
("/fixtures", 403, []),
("/fixtures", 403, ["instance"]),
("/fixtures", 200, ["database"]),
("/fixtures", [], 403),
("/fixtures", ["instance"], 403),
("/fixtures", ["database"], 200),
],
)
def test_permissions_cascade(cascade_app_client, path, expected_status, permissions):
def test_permissions_cascade(cascade_app_client, path, permissions, expected_status):
"Test that e.g. having view-table but NOT view-database lets you view table page, etc"
allow = {"id": "*"}
deny = {}
@ -435,9 +453,9 @@ def test_permissions_cascade(cascade_app_client, path, expected_status, permissi
updated_metadata["databases"]["fixtures"]["allow"] = (
allow if "database" in permissions else deny
)
updated_metadata["databases"]["fixtures"]["tables"]["facet_cities"]["allow"] = (
allow if "table" in permissions else deny
)
updated_metadata["databases"]["fixtures"]["tables"]["binary_data"] = {
"allow": (allow if "table" in permissions else deny)
}
updated_metadata["databases"]["fixtures"]["queries"]["magic_parameters"][
"allow"
] = (allow if "query" in permissions else deny)