mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
default_cache_ttl_hashed config and ?_hash= param
This commit is contained in:
parent
fe9765962b
commit
8f003d9545
7 changed files with 72 additions and 21 deletions
|
|
@ -84,9 +84,12 @@ CONFIG_OPTIONS = (
|
||||||
ConfigOption("allow_sql", True, """
|
ConfigOption("allow_sql", True, """
|
||||||
Allow arbitrary SQL queries via ?sql= parameter
|
Allow arbitrary SQL queries via ?sql= parameter
|
||||||
""".strip()),
|
""".strip()),
|
||||||
ConfigOption("default_cache_ttl", 365 * 24 * 60 * 60, """
|
ConfigOption("default_cache_ttl", 5, """
|
||||||
Default HTTP cache TTL (used in Cache-Control: max-age= header)
|
Default HTTP cache TTL (used in Cache-Control: max-age= header)
|
||||||
""".strip()),
|
""".strip()),
|
||||||
|
ConfigOption("default_cache_ttl_hashed", 365 * 24 * 60 * 60, """
|
||||||
|
Default HTTP cache TTL for hashed URL pages
|
||||||
|
""".strip()),
|
||||||
ConfigOption("cache_size_kb", 0, """
|
ConfigOption("cache_size_kb", 0, """
|
||||||
SQLite cache size in KB (0 == use SQLite default)
|
SQLite cache size in KB (0 == use SQLite default)
|
||||||
""".strip()),
|
""".strip()),
|
||||||
|
|
|
||||||
|
|
@ -208,8 +208,14 @@ def path_with_added_args(request, args, path=None):
|
||||||
|
|
||||||
|
|
||||||
def path_with_removed_args(request, args, path=None):
|
def path_with_removed_args(request, args, path=None):
|
||||||
|
query_string = request.query_string
|
||||||
|
if path is None:
|
||||||
|
path = request.path
|
||||||
|
else:
|
||||||
|
if "?" in path:
|
||||||
|
bits = path.split("?", 1)
|
||||||
|
path, query_string = bits
|
||||||
# args can be a dict or a set
|
# args can be a dict or a set
|
||||||
path = path or request.path
|
|
||||||
current = []
|
current = []
|
||||||
if isinstance(args, set):
|
if isinstance(args, set):
|
||||||
def should_remove(key, value):
|
def should_remove(key, value):
|
||||||
|
|
@ -218,7 +224,7 @@ def path_with_removed_args(request, args, path=None):
|
||||||
# Must match key AND value
|
# Must match key AND value
|
||||||
def should_remove(key, value):
|
def should_remove(key, value):
|
||||||
return args.get(key) == value
|
return args.get(key) == value
|
||||||
for key, value in urllib.parse.parse_qsl(request.query_string):
|
for key, value in urllib.parse.parse_qsl(query_string):
|
||||||
if not should_remove(key, value):
|
if not should_remove(key, value):
|
||||||
current.append((key, value))
|
current.append((key, value))
|
||||||
query_string = urllib.parse.urlencode(current)
|
query_string = urllib.parse.urlencode(current)
|
||||||
|
|
|
||||||
|
|
@ -144,16 +144,18 @@ class BaseView(RenderMixin):
|
||||||
r.headers["Access-Control-Allow-Origin"] = "*"
|
r.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
return r
|
return r
|
||||||
|
|
||||||
def redirect(self, request, path, forward_querystring=True):
|
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
||||||
if request.query_string and "?" not in path and forward_querystring:
|
if request.query_string and "?" not in path and forward_querystring:
|
||||||
path = "{}?{}".format(path, request.query_string)
|
path = "{}?{}".format(path, request.query_string)
|
||||||
|
if remove_args:
|
||||||
|
path = path_with_removed_args(request, remove_args, path=path)
|
||||||
r = response.redirect(path)
|
r = response.redirect(path)
|
||||||
r.headers["Link"] = "<{}>; rel=preload".format(path)
|
r.headers["Link"] = "<{}>; rel=preload".format(path)
|
||||||
if self.ds.cors:
|
if self.ds.cors:
|
||||||
r.headers["Access-Control-Allow-Origin"] = "*"
|
r.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
return r
|
return r
|
||||||
|
|
||||||
def resolve_db_name(self, db_name, **kwargs):
|
def resolve_db_name(self, request, db_name, **kwargs):
|
||||||
databases = self.ds.inspect()
|
databases = self.ds.inspect()
|
||||||
hash = None
|
hash = None
|
||||||
name = None
|
name = None
|
||||||
|
|
@ -174,7 +176,9 @@ class BaseView(RenderMixin):
|
||||||
raise NotFound("Database not found: {}".format(name))
|
raise NotFound("Database not found: {}".format(name))
|
||||||
|
|
||||||
expected = info["hash"][:HASH_LENGTH]
|
expected = info["hash"][:HASH_LENGTH]
|
||||||
if expected != hash:
|
correct_hash_provided = (expected == hash)
|
||||||
|
|
||||||
|
if not correct_hash_provided:
|
||||||
if "table_and_format" in kwargs:
|
if "table_and_format" in kwargs:
|
||||||
table, _format = resolve_table_and_format(
|
table, _format = resolve_table_and_format(
|
||||||
table_and_format=urllib.parse.unquote_plus(
|
table_and_format=urllib.parse.unquote_plus(
|
||||||
|
|
@ -202,10 +206,10 @@ class BaseView(RenderMixin):
|
||||||
if "as_db" in kwargs:
|
if "as_db" in kwargs:
|
||||||
should_redirect += kwargs["as_db"]
|
should_redirect += kwargs["as_db"]
|
||||||
|
|
||||||
if self.ds.config("hash_urls"):
|
if self.ds.config("hash_urls") or "_hash" in request.args:
|
||||||
return name, expected, should_redirect
|
return name, expected, correct_hash_provided, should_redirect
|
||||||
|
|
||||||
return name, expected, None
|
return name, expected, correct_hash_provided, None
|
||||||
|
|
||||||
def absolute_url(self, request, path):
|
def absolute_url(self, request, path):
|
||||||
url = urllib.parse.urljoin(request.url, path)
|
url = urllib.parse.urljoin(request.url, path)
|
||||||
|
|
@ -217,11 +221,13 @@ class BaseView(RenderMixin):
|
||||||
assert NotImplemented
|
assert NotImplemented
|
||||||
|
|
||||||
async def get(self, request, db_name, **kwargs):
|
async def get(self, request, db_name, **kwargs):
|
||||||
database, hash, should_redirect = self.resolve_db_name(db_name, **kwargs)
|
database, hash, correct_hash_provided, should_redirect = self.resolve_db_name(
|
||||||
|
request, db_name, **kwargs
|
||||||
|
)
|
||||||
if should_redirect:
|
if should_redirect:
|
||||||
return self.redirect(request, should_redirect)
|
return self.redirect(request, should_redirect, remove_args={"_hash"})
|
||||||
|
|
||||||
return await self.view_get(request, database, hash, **kwargs)
|
return await self.view_get(request, database, hash, correct_hash_provided, **kwargs)
|
||||||
|
|
||||||
async def as_csv(self, request, database, hash, **kwargs):
|
async def as_csv(self, request, database, hash, **kwargs):
|
||||||
stream = request.args.get("_stream")
|
stream = request.args.get("_stream")
|
||||||
|
|
@ -316,7 +322,7 @@ class BaseView(RenderMixin):
|
||||||
content_type=content_type
|
content_type=content_type
|
||||||
)
|
)
|
||||||
|
|
||||||
async def view_get(self, request, database, hash, **kwargs):
|
async def view_get(self, request, database, hash, correct_hash_provided, **kwargs):
|
||||||
# If ?_format= is provided, use that as the format
|
# If ?_format= is provided, use that as the format
|
||||||
_format = request.args.get("_format", None)
|
_format = request.args.get("_format", None)
|
||||||
if not _format:
|
if not _format:
|
||||||
|
|
@ -503,10 +509,13 @@ class BaseView(RenderMixin):
|
||||||
r = self.render(templates, **context)
|
r = self.render(templates, **context)
|
||||||
r.status = status_code
|
r.status = status_code
|
||||||
# Set far-future cache expiry
|
# Set far-future cache expiry
|
||||||
if self.ds.cache_headers:
|
if self.ds.cache_headers and r.status == 200:
|
||||||
ttl = request.args.get("_ttl", None)
|
ttl = request.args.get("_ttl", None)
|
||||||
if ttl is None or not ttl.isdigit():
|
if ttl is None or not ttl.isdigit():
|
||||||
ttl = self.ds.config("default_cache_ttl")
|
if correct_hash_provided:
|
||||||
|
ttl = self.ds.config("default_cache_ttl_hashed")
|
||||||
|
else:
|
||||||
|
ttl = self.ds.config("default_cache_ttl")
|
||||||
else:
|
else:
|
||||||
ttl = int(ttl)
|
ttl = int(ttl)
|
||||||
if ttl == 0:
|
if ttl == 0:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class DatabaseView(BaseView):
|
||||||
|
|
||||||
class DatabaseDownload(BaseView):
|
class DatabaseDownload(BaseView):
|
||||||
|
|
||||||
async def view_get(self, request, database, hash, **kwargs):
|
async def view_get(self, request, database, hash, correct_hash_present, **kwargs):
|
||||||
if not self.ds.config("allow_download"):
|
if not self.ds.config("allow_download"):
|
||||||
raise DatasetteError("Database download is forbidden", status=403)
|
raise DatasetteError("Database download is forbidden", status=403)
|
||||||
filepath = self.ds.inspect()[database]["file"]
|
filepath = self.ds.inspect()[database]["file"]
|
||||||
|
|
|
||||||
|
|
@ -115,11 +115,21 @@ Enable/disable the ability for users to run custom SQL directly against a databa
|
||||||
default_cache_ttl
|
default_cache_ttl
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
Default HTTP caching max-age header in seconds, used for ``Cache-Control: max-age=X``. Can be over-ridden on a per-request basis using the ``?_ttl=`` querystring parameter. Set this to ``0`` to disable HTTP caching entirely. Defaults to 365 days (31536000 seconds).
|
Default HTTP caching max-age header in seconds, used for ``Cache-Control: max-age=X``. Can be over-ridden on a per-request basis using the ``?_ttl=`` querystring parameter. Set this to ``0`` to disable HTTP caching entirely. Defaults to 5 seconds.
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
datasette mydatabase.db --config default_cache_ttl:10
|
datasette mydatabase.db --config default_cache_ttl:60
|
||||||
|
|
||||||
|
default_cache_ttl_hashed
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Default HTTP caching max-age for responses served using using the :ref:`hashed-urls mechanism <config_hash_urls>`. Defaults to 365 days (31536000 seconds).
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
datasette mydatabase.db --config default_cache_ttl_hashed:10000
|
||||||
|
|
||||||
|
|
||||||
cache_size_kb
|
cache_size_kb
|
||||||
-------------
|
-------------
|
||||||
|
|
@ -180,6 +190,8 @@ HTTP but is served to the outside world via a proxy that enables HTTPS.
|
||||||
|
|
||||||
datasette mydatabase.db --config force_https_urls:1
|
datasette mydatabase.db --config force_https_urls:1
|
||||||
|
|
||||||
|
.. _config_hash_urls:
|
||||||
|
|
||||||
hash_urls
|
hash_urls
|
||||||
---------
|
---------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1050,7 +1050,8 @@ def test_config_json(app_client):
|
||||||
"allow_facet": True,
|
"allow_facet": True,
|
||||||
"suggest_facets": True,
|
"suggest_facets": True,
|
||||||
"allow_sql": True,
|
"allow_sql": True,
|
||||||
"default_cache_ttl": 365 * 24 * 60 * 60,
|
"default_cache_ttl": 5,
|
||||||
|
"default_cache_ttl_hashed": 365 * 24 * 60 * 60,
|
||||||
"num_sql_threads": 3,
|
"num_sql_threads": 3,
|
||||||
"cache_size_kb": 0,
|
"cache_size_kb": 0,
|
||||||
"allow_csv_stream": True,
|
"allow_csv_stream": True,
|
||||||
|
|
@ -1302,8 +1303,8 @@ def test_expand_label(app_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('path,expected_cache_control', [
|
@pytest.mark.parametrize('path,expected_cache_control', [
|
||||||
("/fixtures/facetable.json", "max-age=31536000"),
|
("/fixtures/facetable.json", "max-age=5"),
|
||||||
("/fixtures/facetable.json?_ttl=invalid", "max-age=31536000"),
|
("/fixtures/facetable.json?_ttl=invalid", "max-age=5"),
|
||||||
("/fixtures/facetable.json?_ttl=10", "max-age=10"),
|
("/fixtures/facetable.json?_ttl=10", "max-age=10"),
|
||||||
("/fixtures/facetable.json?_ttl=0", "no-cache"),
|
("/fixtures/facetable.json?_ttl=0", "no-cache"),
|
||||||
])
|
])
|
||||||
|
|
@ -1312,6 +1313,19 @@ def test_ttl_parameter(app_client, path, expected_cache_control):
|
||||||
assert expected_cache_control == response.headers['Cache-Control']
|
assert expected_cache_control == response.headers['Cache-Control']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,expected_redirect", [
|
||||||
|
("/fixtures/facetable.json?_hash=1", "/fixtures-HASH/facetable.json"),
|
||||||
|
("/fixtures/facetable.json?city_id=1&_hash=1", "/fixtures-HASH/facetable.json?city_id=1"),
|
||||||
|
])
|
||||||
|
def test_hash_parameter(app_client, path, expected_redirect):
|
||||||
|
# First get the current hash for the fixtures database
|
||||||
|
current_hash = app_client.get("/-/inspect.json").json["fixtures"]["hash"][:7]
|
||||||
|
response = app_client.get(path, allow_redirects=False)
|
||||||
|
assert response.status == 302
|
||||||
|
location = response.headers["Location"]
|
||||||
|
assert expected_redirect.replace("HASH", current_hash) == location
|
||||||
|
|
||||||
|
|
||||||
test_json_columns_default_expected = [{
|
test_json_columns_default_expected = [{
|
||||||
"intval": 1,
|
"intval": 1,
|
||||||
"strval": "s",
|
"strval": "s",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ def test_path_with_removed_args(path, args, expected):
|
||||||
)
|
)
|
||||||
actual = utils.path_with_removed_args(request, args)
|
actual = utils.path_with_removed_args(request, args)
|
||||||
assert expected == actual
|
assert expected == actual
|
||||||
|
# Run the test again but this time use the path= argument
|
||||||
|
request = Request(
|
||||||
|
"/".encode('utf8'),
|
||||||
|
{}, '1.1', 'GET', None
|
||||||
|
)
|
||||||
|
actual = utils.path_with_removed_args(request, args, path=path)
|
||||||
|
assert expected == actual
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('path,args,expected', [
|
@pytest.mark.parametrize('path,args,expected', [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue