Skip to content

Commit

Permalink
/db/table/-/blob/pk/column.blob download URL, refs #1036
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw authored Oct 24, 2020
1 parent 10c35bd commit 5a15197
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 66 deletions.
6 changes: 5 additions & 1 deletion datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
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

Expand Down Expand Up @@ -923,6 +923,10 @@ def add_route(view, regex):
+ 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():
Expand Down
6 changes: 3 additions & 3 deletions datasette/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def __init__(
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""
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 0 additions & 3 deletions datasette/views/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@
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 = []
Expand Down
18 changes: 0 additions & 18 deletions datasette/views/special.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,6 @@ async def get(self, request, as_format):
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)
Expand All @@ -55,9 +52,6 @@ async def get(self, request):
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:
Expand All @@ -76,9 +70,6 @@ async def get(self, request):
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())
Expand All @@ -98,9 +89,6 @@ async def post(self, request):
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"):
Expand All @@ -115,9 +103,6 @@ async def get(self, request):
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"}'
Expand Down Expand Up @@ -152,9 +137,6 @@ async def get(self, request):
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)
Expand Down
93 changes: 75 additions & 18 deletions datasette/views/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
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 = (
Expand Down Expand Up @@ -903,28 +903,38 @@ async def extra_template():
)


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):
await self.check_permissions(
request,
[
("view-table", (database, table)),
("view-database", database),
"view-instance",
],
)
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)
)
params = {}
for i, pk_value in enumerate(pk_values):
params["p{}".format(i)] = pk_value
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)
Expand Down Expand Up @@ -1024,3 +1034,50 @@ async def foreign_key_tables(self, database, table, pk_values):
)
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",
)
10 changes: 9 additions & 1 deletion docs/internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"``.
Expand Down
11 changes: 11 additions & 0 deletions docs/pages.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
40 changes: 40 additions & 0 deletions tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 40 additions & 22 deletions tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand All @@ -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)
Expand Down

0 comments on commit 5a15197

Please sign in to comment.