mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
URL hashing is now off by default - closes #418
Prior to this commit Datasette would calculate the content hash of every
database and redirect to a URL containing that hash, like so:
https://v0-27.datasette.io/fixtures => https://v0-27.datasette.io/fixtures-dd88475
This assumed that all databases were opened in immutable mode and were not
expected to change.
This will be changing as a result of #419 - so this commit takes the first step
in implementing that change by changing this default behaviour. Datasette will
now only redirect hash-free URLs under two circumstances:
* The new `hash_urls` config option is set to true (it defaults to false).
* The user passes `?_hash=1` in the URL
This commit is contained in:
parent
afe9aa3ae0
commit
6f6d0ff2b4
15 changed files with 149 additions and 70 deletions
|
|
@ -69,6 +69,9 @@ CONFIG_OPTIONS = (
|
||||||
ConfigOption("facet_suggest_time_limit_ms", 50, """
|
ConfigOption("facet_suggest_time_limit_ms", 50, """
|
||||||
Time limit for calculating a suggested facet
|
Time limit for calculating a suggested facet
|
||||||
""".strip()),
|
""".strip()),
|
||||||
|
ConfigOption("hash_urls", False, """
|
||||||
|
Include DB file contents hash in URLs, for far-future caching
|
||||||
|
""".strip()),
|
||||||
ConfigOption("allow_facet", True, """
|
ConfigOption("allow_facet", True, """
|
||||||
Allow users to specify columns to facet using ?_facet= parameter
|
Allow users to specify columns to facet using ?_facet= parameter
|
||||||
""".strip()),
|
""".strip()),
|
||||||
|
|
@ -81,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()),
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,12 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="hd"><a href="/">home</a></div>
|
<div class="hd"><a href="/">home</a></div>
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ metadata.title or database }}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}</h1>
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
{% if config.allow_sql %}
|
{% if config.allow_sql %}
|
||||||
<form class="sql" action="/{{ database }}-{{ database_hash }}" method="get">
|
<form class="sql" action="{{ database_url(database) }}" method="get">
|
||||||
<h3>Custom SQL query</h3>
|
<h3>Custom SQL query</h3>
|
||||||
<p><textarea name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
<p><textarea name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||||
<p><input type="submit" value="Run SQL"></p>
|
<p><input type="submit" value="Run SQL"></p>
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
{% for table in tables %}
|
{% for table in tables %}
|
||||||
{% if show_hidden or not table.hidden %}
|
{% if show_hidden or not table.hidden %}
|
||||||
<div class="db-table">
|
<div class="db-table">
|
||||||
<h2><a href="/{{ database }}-{{ database_hash }}/{{ table.name|quote_plus }}">{{ table.name }}</a>{% if table.hidden %}<em> (hidden)</em>{% endif %}</h2>
|
<h2><a href="{{ database_url(database) }}/{{ table.name|quote_plus }}">{{ table.name }}</a>{% if table.hidden %}<em> (hidden)</em>{% endif %}</h2>
|
||||||
<p><em>{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}</em></p>
|
<p><em>{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}</em></p>
|
||||||
<p>{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}</p>
|
<p>{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,14 +35,14 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if hidden_count and not show_hidden %}
|
{% if hidden_count and not show_hidden %}
|
||||||
<p>... and <a href="/{{ database }}-{{ database_hash }}?_show_hidden=1">{{ "{:,}".format(hidden_count) }} hidden table{% if hidden_count == 1 %}{% else %}s{% endif %}</a></p>
|
<p>... and <a href="{{ database_url(database) }}?_show_hidden=1">{{ "{:,}".format(hidden_count) }} hidden table{% if hidden_count == 1 %}{% else %}s{% endif %}</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if views %}
|
{% if views %}
|
||||||
<h2>Views</h2>
|
<h2>Views</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for view in views %}
|
{% for view in views %}
|
||||||
<li><a href="/{{ database }}-{{ database_hash }}/{{ view|urlencode }}">{{ view }}</a></li>
|
<li><a href="{{ database_url(database) }}/{{ view|urlencode }}">{{ view }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -51,13 +51,13 @@
|
||||||
<h2>Queries</h2>
|
<h2>Queries</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for query in queries %}
|
{% for query in queries %}
|
||||||
<li><a href="/{{ database }}-{{ database_hash }}/{{ query.name|urlencode }}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a></li>
|
<li><a href="{{ database_url(database) }}/{{ query.name|urlencode }}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if config.allow_download and database != ":memory:" %}
|
{% if config.allow_download and database != ":memory:" %}
|
||||||
<p class="download-sqlite">Download SQLite DB: <a href="/{{ database }}-{{ database_hash }}.db">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
<p class="download-sqlite">Download SQLite DB: <a href="{{ database_url(database) }}.db">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
{% include "_codemirror_foot.html" %}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@
|
||||||
{% block body_class %}query db-{{ database|to_css_class }}{% endblock %}
|
{% block body_class %}query db-{{ database|to_css_class }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a></div>
|
<div class="hd"><a href="/">home</a> / <a href="{{ database_url(database) }}">{{ database }}</a></div>
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ metadata.title or database }}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}</h1>
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
<form class="sql" action="/{{ database }}-{{ database_hash }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="get">
|
<form class="sql" action="{{ database_url(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="get">
|
||||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
|
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
|
||||||
{% if not hide_sql %}
|
{% if not hide_sql %}
|
||||||
{% if editable and config.allow_sql %}
|
{% if editable and config.allow_sql %}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a> / <a href="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}">{{ table }}</a></div>
|
<div class="hd"><a href="/">home</a> / <a href="{{ database_url(database) }}">{{ database }}</a> / <a href="{{ database_url(database) }}/{{ table|quote_plus }}">{{ table }}</a></div>
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ table }}: {{ ', '.join(primary_key_values) }}</a></h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ table }}: {{ ', '.join(primary_key_values) }}</a></h1>
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
{% for other in foreign_key_tables %}
|
{% for other in foreign_key_tables %}
|
||||||
<li>
|
<li>
|
||||||
<a href="/{{ database }}-{{ database_hash }}/{{ other.other_table|quote_plus }}?{{ other.other_column }}={{ ', '.join(primary_key_values) }}">
|
<a href="{{ database_url(database) }}/{{ other.other_table|quote_plus }}?{{ other.other_column }}={{ ', '.join(primary_key_values) }}">
|
||||||
{{ "{:,}".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 }}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a></div>
|
<div class="hd"><a href="/">home</a> / <a href="{{ database_url(database) }}">{{ database }}</a></div>
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ metadata.title or table }}{% if is_view %} (view){% endif %}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or table }}{% if is_view %} (view){% endif %}</h1>
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form class="filters" action="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}" method="get">
|
<form class="filters" action="{{ database_url(database) }}/{{ table|quote_plus }}" 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 %}
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if query.sql and config.allow_sql %}
|
{% if query.sql and config.allow_sql %}
|
||||||
<p><a class="not-underlined" title="{{ query.sql }}" href="/{{ database }}-{{ database_hash }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ database_url(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="export-links">This data as <a href="{{ url_json }}">JSON</a>{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
<p class="export-links">This data as <a href="{{ url_json }}">JSON</a>{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,17 @@ class RenderMixin(HTTPMethodView):
|
||||||
else:
|
else:
|
||||||
yield {"url": url}
|
yield {"url": url}
|
||||||
|
|
||||||
|
def database_url(self, database):
|
||||||
|
if not self.ds.config("hash_urls"):
|
||||||
|
return "/{}".format(database)
|
||||||
|
else:
|
||||||
|
return "/{}-{}".format(
|
||||||
|
database, self.ds.inspect()[database]["hash"][:HASH_LENGTH]
|
||||||
|
)
|
||||||
|
|
||||||
|
def database_color(self, database):
|
||||||
|
return 'ff0000'
|
||||||
|
|
||||||
def render(self, templates, **context):
|
def render(self, templates, **context):
|
||||||
template = self.ds.jinja_env.select_template(templates)
|
template = self.ds.jinja_env.select_template(templates)
|
||||||
select_templates = [
|
select_templates = [
|
||||||
|
|
@ -105,6 +116,8 @@ class RenderMixin(HTTPMethodView):
|
||||||
"extra_js_urls", template, context
|
"extra_js_urls", template, context
|
||||||
),
|
),
|
||||||
"format_bytes": format_bytes,
|
"format_bytes": format_bytes,
|
||||||
|
"database_url": self.database_url,
|
||||||
|
"database_color": self.database_color,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -131,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
|
||||||
|
|
@ -161,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(
|
||||||
|
|
@ -188,9 +205,11 @@ class BaseView(RenderMixin):
|
||||||
should_redirect += kwargs["as_format"]
|
should_redirect += kwargs["as_format"]
|
||||||
if "as_db" in kwargs:
|
if "as_db" in kwargs:
|
||||||
should_redirect += kwargs["as_db"]
|
should_redirect += kwargs["as_db"]
|
||||||
return name, expected, should_redirect
|
|
||||||
|
|
||||||
return name, expected, None
|
if self.ds.config("hash_urls") or "_hash" in request.args:
|
||||||
|
return name, expected, correct_hash_provided, should_redirect
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -202,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")
|
||||||
|
|
@ -301,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:
|
||||||
|
|
@ -418,7 +439,6 @@ class BaseView(RenderMixin):
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": error,
|
"error": error,
|
||||||
"database": database,
|
"database": database,
|
||||||
"database_hash": hash,
|
|
||||||
}
|
}
|
||||||
elif shape == "array":
|
elif shape == "array":
|
||||||
data = data["rows"]
|
data = data["rows"]
|
||||||
|
|
@ -489,9 +509,12 @@ 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():
|
||||||
|
if correct_hash_provided:
|
||||||
|
ttl = self.ds.config("default_cache_ttl_hashed")
|
||||||
|
else:
|
||||||
ttl = self.ds.config("default_cache_ttl")
|
ttl = self.ds.config("default_cache_ttl")
|
||||||
else:
|
else:
|
||||||
ttl = int(ttl)
|
ttl = int(ttl)
|
||||||
|
|
@ -572,7 +595,6 @@ class BaseView(RenderMixin):
|
||||||
display_rows.append(display_row)
|
display_rows.append(display_row)
|
||||||
return {
|
return {
|
||||||
"display_rows": display_rows,
|
"display_rows": display_rows,
|
||||||
"database_hash": hash,
|
|
||||||
"custom_sql": True,
|
"custom_sql": True,
|
||||||
"named_parameter_values": named_parameter_values,
|
"named_parameter_values": named_parameter_values,
|
||||||
"editable": editable,
|
"editable": editable,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ class DatabaseView(BaseView):
|
||||||
"views": info["views"],
|
"views": info["views"],
|
||||||
"queries": self.ds.get_canned_queries(database),
|
"queries": self.ds.get_canned_queries(database),
|
||||||
}, {
|
}, {
|
||||||
"database_hash": hash,
|
|
||||||
"show_hidden": request.args.get("_show_hidden"),
|
"show_hidden": request.args.get("_show_hidden"),
|
||||||
"editable": True,
|
"editable": True,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
|
@ -41,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"]
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class IndexView(RenderMixin):
|
||||||
database = {
|
database = {
|
||||||
"name": key,
|
"name": key,
|
||||||
"hash": info["hash"],
|
"hash": info["hash"],
|
||||||
"path": "{}-{}".format(key, info["hash"][:HASH_LENGTH]),
|
"path": self.database_url(key),
|
||||||
"tables_truncated": sorted(
|
"tables_truncated": sorted(
|
||||||
tables, key=lambda t: t["count"], reverse=True
|
tables, key=lambda t: t["count"], reverse=True
|
||||||
)[
|
)[
|
||||||
|
|
|
||||||
|
|
@ -750,7 +750,6 @@ class TableView(RowTableShared):
|
||||||
)
|
)
|
||||||
self.ds.update_with_inherited_metadata(metadata)
|
self.ds.update_with_inherited_metadata(metadata)
|
||||||
return {
|
return {
|
||||||
"database_hash": hash,
|
|
||||||
"supports_search": bool(fts_table),
|
"supports_search": bool(fts_table),
|
||||||
"search": search or "",
|
"search": search or "",
|
||||||
"use_rowid": use_rowid,
|
"use_rowid": use_rowid,
|
||||||
|
|
@ -851,7 +850,6 @@ class RowView(RowTableShared):
|
||||||
for column in display_columns:
|
for column in display_columns:
|
||||||
column["sortable"] = False
|
column["sortable"] = False
|
||||||
return {
|
return {
|
||||||
"database_hash": hash,
|
|
||||||
"foreign_key_tables": await self.foreign_key_tables(
|
"foreign_key_tables": await self.foreign_key_tables(
|
||||||
database, table, pk_values
|
database, table, pk_values
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
-------------
|
-------------
|
||||||
|
|
@ -179,3 +189,19 @@ 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
|
||||||
|
---------
|
||||||
|
|
||||||
|
When enabled, this setting causes Datasette to append a content hash of the
|
||||||
|
database file to the URL path for every table and query within that database.
|
||||||
|
|
||||||
|
When combined with far-future expire headers this ensures that queries can be
|
||||||
|
cached forever, safe in the knowledge that any modifications to the database
|
||||||
|
itself will result in new, uncachcacheed URL paths.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
datasette mydatabase.db --config hash_urls:1
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,13 @@ def app_client_no_files():
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_client_with_hash():
|
||||||
|
yield from make_app_client(config={
|
||||||
|
'hash_urls': True
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def app_client_shorter_time_limit():
|
def app_client_shorter_time_limit():
|
||||||
yield from make_app_client(20)
|
yield from make_app_client(20)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from .fixtures import ( # noqa
|
from .fixtures import ( # noqa
|
||||||
app_client,
|
app_client,
|
||||||
app_client_no_files,
|
app_client_no_files,
|
||||||
|
app_client_with_hash,
|
||||||
app_client_shorter_time_limit,
|
app_client_shorter_time_limit,
|
||||||
app_client_larger_cache_size,
|
app_client_larger_cache_size,
|
||||||
app_client_returned_rows_matches_page_size,
|
app_client_returned_rows_matches_page_size,
|
||||||
|
|
@ -378,7 +379,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
|
||||||
"hidden_table_rows_sum": 0,
|
"hidden_table_rows_sum": 0,
|
||||||
"hidden_tables_count": 0,
|
"hidden_tables_count": 0,
|
||||||
"name": ":memory:",
|
"name": ":memory:",
|
||||||
"path": ":memory:-000",
|
"path": "/:memory:",
|
||||||
"table_rows_sum": 0,
|
"table_rows_sum": 0,
|
||||||
"tables_count": 0,
|
"tables_count": 0,
|
||||||
"tables_more": False,
|
"tables_more": False,
|
||||||
|
|
@ -388,7 +389,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
|
||||||
} == response.json
|
} == response.json
|
||||||
# Try that SQL query
|
# Try that SQL query
|
||||||
response = app_client_no_files.get(
|
response = app_client_no_files.get(
|
||||||
"/:memory:-0.json?sql=select+sqlite_version()&_shape=array"
|
"/:memory:.json?sql=select+sqlite_version()&_shape=array"
|
||||||
)
|
)
|
||||||
assert 1 == len(response.json)
|
assert 1 == len(response.json)
|
||||||
assert ["sqlite_version()"] == list(response.json[0].keys())
|
assert ["sqlite_version()"] == list(response.json[0].keys())
|
||||||
|
|
@ -501,12 +502,12 @@ def test_table_not_exists_json(app_client):
|
||||||
} == app_client.get('/fixtures/blah.json').json
|
} == app_client.get('/fixtures/blah.json').json
|
||||||
|
|
||||||
|
|
||||||
def test_jsono_redirects_to_shape_objects(app_client):
|
def test_jsono_redirects_to_shape_objects(app_client_with_hash):
|
||||||
response_1 = app_client.get(
|
response_1 = app_client_with_hash.get(
|
||||||
'/fixtures/simple_primary_key.jsono',
|
'/fixtures/simple_primary_key.jsono',
|
||||||
allow_redirects=False
|
allow_redirects=False
|
||||||
)
|
)
|
||||||
response = app_client.get(
|
response = app_client_with_hash.get(
|
||||||
response_1.headers['Location'],
|
response_1.headers['Location'],
|
||||||
allow_redirects=False
|
allow_redirects=False
|
||||||
)
|
)
|
||||||
|
|
@ -1049,13 +1050,15 @@ 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,
|
||||||
"max_csv_mb": 100,
|
"max_csv_mb": 100,
|
||||||
"truncate_cells_html": 2048,
|
"truncate_cells_html": 2048,
|
||||||
"force_https_urls": False,
|
"force_https_urls": False,
|
||||||
|
"hash_urls": False,
|
||||||
} == response.json
|
} == response.json
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1300,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"),
|
||||||
])
|
])
|
||||||
|
|
@ -1310,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",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from bs4 import BeautifulSoup as Soup
|
||||||
from .fixtures import ( # noqa
|
from .fixtures import ( # noqa
|
||||||
app_client,
|
app_client,
|
||||||
app_client_shorter_time_limit,
|
app_client_shorter_time_limit,
|
||||||
|
app_client_with_hash,
|
||||||
make_app_client,
|
make_app_client,
|
||||||
)
|
)
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -15,10 +16,10 @@ def test_homepage(app_client):
|
||||||
assert 'fixtures' in response.text
|
assert 'fixtures' in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_database_page(app_client):
|
def test_database_page_redirects_with_url_hash(app_client_with_hash):
|
||||||
response = app_client.get('/fixtures', allow_redirects=False)
|
response = app_client_with_hash.get('/fixtures', allow_redirects=False)
|
||||||
assert response.status == 302
|
assert response.status == 302
|
||||||
response = app_client.get('/fixtures')
|
response = app_client_with_hash.get('/fixtures')
|
||||||
assert 'fixtures' in response.text
|
assert 'fixtures' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,19 +42,19 @@ def test_sql_time_limit(app_client_shorter_time_limit):
|
||||||
assert expected_html_fragment in response.text
|
assert expected_html_fragment in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_row(app_client):
|
def test_row_redirects_with_url_hash(app_client_with_hash):
|
||||||
response = app_client.get(
|
response = app_client_with_hash.get(
|
||||||
'/fixtures/simple_primary_key/1',
|
'/fixtures/simple_primary_key/1',
|
||||||
allow_redirects=False
|
allow_redirects=False
|
||||||
)
|
)
|
||||||
assert response.status == 302
|
assert response.status == 302
|
||||||
assert response.headers['Location'].endswith('/1')
|
assert response.headers['Location'].endswith('/1')
|
||||||
response = app_client.get('/fixtures/simple_primary_key/1')
|
response = app_client_with_hash.get('/fixtures/simple_primary_key/1')
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
|
|
||||||
|
|
||||||
def test_row_strange_table_name(app_client):
|
def test_row_strange_table_name_with_url_hash(app_client_with_hash):
|
||||||
response = app_client.get(
|
response = app_client_with_hash.get(
|
||||||
'/fixtures/table%2Fwith%2Fslashes.csv/3',
|
'/fixtures/table%2Fwith%2Fslashes.csv/3',
|
||||||
allow_redirects=False
|
allow_redirects=False
|
||||||
)
|
)
|
||||||
|
|
@ -61,7 +62,7 @@ def test_row_strange_table_name(app_client):
|
||||||
assert response.headers['Location'].endswith(
|
assert response.headers['Location'].endswith(
|
||||||
'/table%2Fwith%2Fslashes.csv/3'
|
'/table%2Fwith%2Fslashes.csv/3'
|
||||||
)
|
)
|
||||||
response = app_client.get('/fixtures/table%2Fwith%2Fslashes.csv/3')
|
response = app_client_with_hash.get('/fixtures/table%2Fwith%2Fslashes.csv/3')
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -105,10 +106,7 @@ def test_add_filter_redirects(app_client):
|
||||||
'_filter_op': 'startswith',
|
'_filter_op': 'startswith',
|
||||||
'_filter_value': 'x'
|
'_filter_value': 'x'
|
||||||
})
|
})
|
||||||
# First we need to resolve the correct path before testing more redirects
|
path_base = '/fixtures/simple_primary_key'
|
||||||
path_base = app_client.get(
|
|
||||||
'/fixtures/simple_primary_key', allow_redirects=False
|
|
||||||
).headers['Location']
|
|
||||||
path = path_base + '?' + filter_args
|
path = path_base + '?' + filter_args
|
||||||
response = app_client.get(path, allow_redirects=False)
|
response = app_client.get(path, allow_redirects=False)
|
||||||
assert response.status == 302
|
assert response.status == 302
|
||||||
|
|
@ -146,9 +144,7 @@ def test_existing_filter_redirects(app_client):
|
||||||
'_filter_op_4': 'contains',
|
'_filter_op_4': 'contains',
|
||||||
'_filter_value_4': 'world',
|
'_filter_value_4': 'world',
|
||||||
}
|
}
|
||||||
path_base = app_client.get(
|
path_base = '/fixtures/simple_primary_key'
|
||||||
'/fixtures/simple_primary_key', allow_redirects=False
|
|
||||||
).headers['Location']
|
|
||||||
path = path_base + '?' + urllib.parse.urlencode(filter_args)
|
path = path_base + '?' + urllib.parse.urlencode(filter_args)
|
||||||
response = app_client.get(path, allow_redirects=False)
|
response = app_client.get(path, allow_redirects=False)
|
||||||
assert response.status == 302
|
assert response.status == 302
|
||||||
|
|
@ -174,9 +170,7 @@ def test_existing_filter_redirects(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_empty_search_parameter_gets_removed(app_client):
|
def test_empty_search_parameter_gets_removed(app_client):
|
||||||
path_base = app_client.get(
|
path_base = '/fixtures/simple_primary_key'
|
||||||
'/fixtures/simple_primary_key', allow_redirects=False
|
|
||||||
).headers['Location']
|
|
||||||
path = path_base + '?' + urllib.parse.urlencode({
|
path = path_base + '?' + urllib.parse.urlencode({
|
||||||
'_search': '',
|
'_search': '',
|
||||||
'_filter_column': 'name',
|
'_filter_column': 'name',
|
||||||
|
|
@ -191,9 +185,7 @@ def test_empty_search_parameter_gets_removed(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_sort_by_desc_redirects(app_client):
|
def test_sort_by_desc_redirects(app_client):
|
||||||
path_base = app_client.get(
|
path_base = '/fixtures/sortable'
|
||||||
'/fixtures/sortable', allow_redirects=False
|
|
||||||
).headers['Location']
|
|
||||||
path = path_base + '?' + urllib.parse.urlencode({
|
path = path_base + '?' + urllib.parse.urlencode({
|
||||||
'_sort': 'sortable',
|
'_sort': 'sortable',
|
||||||
'_sort_by_desc': '1',
|
'_sort_by_desc': '1',
|
||||||
|
|
|
||||||
|
|
@ -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