Add cache-busted static asset helper (#2804)

* Add cache-busted static asset helper

Add a static() helper for Datasette, plugin, and mounted static assets that appends content-based hashes, caches hashes in production, and serves matching hashed asset URLs with immutable far-future cache headers.

Closes #2800
This commit is contained in:
Simon Willison 2026-06-23 13:44:58 -07:00 committed by GitHub
commit 5eca46a4bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 361 additions and 65 deletions

View file

@ -12,7 +12,6 @@ import dataclasses
import datetime
import functools
import glob
import hashlib
import httpx
import importlib.metadata
import inspect
@ -123,6 +122,7 @@ from .utils import (
parse_metadata,
resolve_env_secrets,
resolve_routes,
sha256_file,
tilde_decode,
tilde_encode,
to_css_class,
@ -314,7 +314,7 @@ async def favicon(request, send):
send,
str(FAVICON_PATH),
content_type="image/png",
headers={"Cache-Control": "max-age=3600, immutable, public"},
headers={"Cache-Control": "max-age=3600, public"},
)
@ -348,6 +348,16 @@ def _legacy_template_csrftoken(context):
return ""
def _resolve_static_asset_path(root_path, path):
root = Path(root_path).resolve()
full_path = (root / path).resolve()
try:
full_path.relative_to(root)
except ValueError:
raise ValueError("Static asset path cannot escape static root") from None
return full_path
# Documentation for the variables Datasette.render_template() adds to the
# context for every page. This is part of the documented template contract:
# keys added in render_template() must be documented here - the contract
@ -361,9 +371,6 @@ TEMPLATE_BASE_CONTEXT = {
"menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.",
"display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.",
"show_logout": "True if the logout link should be shown in the navigation menu",
"app_css_hash": "Hash of Datasette's app.css contents, used for cache busting",
"edit_tools_js_hash": "Hash of Datasette's edit-tools.js contents, used for cache busting",
"table_js_hash": "Hash of Datasette's table.js contents, used for cache busting",
"zip": "Python's ``zip()`` builtin, made available to template logic",
"body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``<script type="module">``; otherwise Datasette wraps it in a regular ``<script>`` block.',
"format_bytes": "Function that accepts a byte count integer and returns a human-readable string such as ``1.2 MB``.",
@ -467,6 +474,7 @@ class Datasette:
self._internal_database.name = INTERNAL_DB_NAME
self.cache_headers = cache_headers
self._static_asset_hashes = {}
self.cors = cors
config_files = []
metadata_files = []
@ -608,6 +616,7 @@ class Datasette:
environment.filters["escape_css_string"] = escape_css_string
environment.filters["quote_plus"] = urllib.parse.quote_plus
environment.globals["csrftoken"] = _legacy_template_csrftoken
environment.globals["static"] = self.static
self._jinja_env = environment
environment.filters["escape_sqlite"] = escape_sqlite
environment.filters["to_css_class"] = to_css_class
@ -1481,24 +1490,55 @@ class Datasette:
return db_plugin_config
def static_hash(self, filename):
if not hasattr(self, "_static_hashes"):
self._static_hashes = {}
path = os.path.join(str(app_root), "datasette/static", filename)
signature = (os.path.getmtime(path), os.path.getsize(path))
cached = self._static_hashes.get(filename)
if cached and cached["signature"] == signature:
return cached["hash"]
with open(path) as fp:
static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6]
self._static_hashes[filename] = {
"signature": signature,
"hash": static_hash,
}
return static_hash
def _static_asset_path(self, path):
return _resolve_static_asset_path(app_root / "datasette" / "static", path)
def app_css_hash(self):
return self.static_hash("app.css")
def _static_plugin_asset_path(self, plugin_name, path):
for plugin in get_plugins():
if not plugin["static_path"]:
continue
possible_names = {plugin["name"], plugin["name"].replace("-", "_")}
if plugin_name in possible_names:
return _resolve_static_asset_path(plugin["static_path"], path)
raise FileNotFoundError(
"No static assets found for plugin {}".format(plugin_name)
)
def _static_mounted_asset(self, mount_name, path):
mount_name = mount_name.strip("/")
for mount, dirname in self.static_mounts:
if mount.strip("/") == mount_name:
return (
_resolve_static_asset_path(dirname, path),
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))),
)
raise FileNotFoundError("No static mount found for {}".format(mount_name))
def _static_asset_hash(self, filepath):
filepath = Path(filepath)
if self.cache_headers:
cached = self._static_asset_hashes.get(filepath)
if cached:
return cached
digest = sha256_file(filepath)[:12]
if self.cache_headers:
self._static_asset_hashes[filepath] = digest
return digest
def static(self, path, plugin=None, mount=None):
if plugin and mount:
raise ValueError("Use either plugin= or mount=, not both")
if plugin:
filepath = self._static_plugin_asset_path(plugin, path)
url = self.urls.static_plugins(plugin, path)
elif mount:
filepath, url = self._static_mounted_asset(mount, path)
else:
filepath = self._static_asset_path(path)
url = self.urls.static(path)
hash_value = self._static_asset_hash(filepath)
separator = "&" if "?" in url else "?"
return url + separator + urllib.parse.urlencode({"_hash": hash_value})
def _prepare_connection(self, conn, database):
conn.row_factory = sqlite3.Row
@ -2379,9 +2419,6 @@ class Datasette:
"show_logout": request is not None
and "ds_actor" in request.cookies
and request.actor,
"app_css_hash": self.app_css_hash(),
"edit_tools_js_hash": self.static_hash("edit-tools.js"),
"table_js_hash": self.static_hash("table.js"),
"zip": zip,
"body_scripts": body_scripts,
"format_bytes": format_bytes,
@ -2480,7 +2517,6 @@ class Datasette:
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
add_route(permanent_redirect("/-/"), r"/-$")
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
add_route(favicon, "/favicon.ico")
add_route(

View file

@ -66,7 +66,6 @@ def handle_exception(datasette, request, exception):
dict(
info,
urls=datasette.urls,
app_css_hash=datasette.app_css_hash(),
menu_links=lambda: [],
)
),

View file

@ -1,5 +1,5 @@
<script src="{{ base_url }}-/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('sql-formatter-2.3.3.min.js') }}" defer></script>
<script src="{{ static('cm-editor-6.0.1.bundle.js') }}"></script>
<style>
.cm-editor {
resize: both;

View file

@ -3,7 +3,7 @@
{% block title %}API Explorer{% endblock %}
{% 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 %}
{% block content %}

View file

@ -2,13 +2,13 @@
<html lang="en">
<head>
<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">
{% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
<script src="{{ static('datasette-manager.js') }}" defer></script>
{% 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>
{% endfor %}
@ -70,7 +70,7 @@
{% endfor %}
{% 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>
</body>
</html>

View file

@ -8,7 +8,7 @@
{% include "_sql_parameter_styles.html" %}
{% if database_page_data.createTable %}
<script>window._datasetteDatabaseData = {{ database_page_data|tojson }};</script>
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ static('edit-tools.js') }}" defer></script>
{% endif %}
{% endblock %}

View file

@ -3,7 +3,7 @@
{% block title %}Allowed Resources{% endblock %}
{% 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 "_debug_common_functions.html" %}
{% endblock %}

View file

@ -4,7 +4,7 @@
{% block extra_head %}
{{ super() }}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
<script src="{{ static('autocomplete.js') }}" defer></script>
{% endblock %}
{% block content %}

View file

@ -3,7 +3,7 @@
{% block title %}Permission Check{% endblock %}
{% 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 "_debug_common_functions.html" %}
<style>

View file

@ -3,7 +3,7 @@
{% block title %}Permission Rules{% endblock %}
{% 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 "_debug_common_functions.html" %}
{% endblock %}

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<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="robots" content="noindex">
<style></style>

View file

@ -7,9 +7,9 @@
{% if row_mutation_ui %}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
<script src="{{ static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ static('edit-tools.js') }}" defer></script>
{% endif %}
<style>
@media only screen and (max-width: 576px) {

View file

@ -5,13 +5,13 @@
{% block extra_head %}
{{- super() -}}
<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 %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
<script src="{{ static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script src="{{ static('edit-tools.js') }}" defer></script>
<script src="{{ static('table.js') }}" defer></script>
<script src="{{ static('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
<style>
@media only screen and (max-width: 576px) {

View file

@ -1548,6 +1548,17 @@ def md5_not_usedforsecurity(s):
_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):
if filepath in _etag_cache:
return _etag_cache[filepath]

View file

@ -1,6 +1,6 @@
import json
from typing import Optional
from datasette.utils import MultiParams, calculate_etag
from datasette.utils import MultiParams, calculate_etag, sha256_file
from datasette.utils.multipart import (
parse_form_data,
MultipartParseError,
@ -397,6 +397,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):
root_path = Path(root_path)
static_headers = {}
@ -423,11 +426,17 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
return
try:
# 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)
headers["ETag"] = etag
if_none_match = request.headers.get("if-none-match")
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(
send, full_path, chunk_size=chunk_size, headers=headers
)

View file

@ -15,7 +15,8 @@ Unreleased
- Create and alter table dialogs share their column-editing controls, including literal and expression defaults, custom column types, foreign keys and column ordering.
- The "Write to this database" page now includes a Create table starter template, alongside the existing Insert, Update and Delete templates. (:pr:`2794`)
- New :ref:`template_context` documentation listing the variables available to custom templates for Datasette's core pages. Variables documented there are treated as a stable API for custom templates until Datasette 2.0. The documentation is generated from dataclass definitions next to the view code, with tests that compare the documented fields against the actual contexts rendered by the database, table, query and row pages. (:issue:`1510`, :issue:`2127`, :issue:`1477`, :pr:`2803`)
- Database and table pages now use the ``count_truncated`` template context value to display capped row counts as ``>N rows`.
- New ``static()`` template function and ``datasette.static()`` method for generating cache-busting static asset URLs based on the file contents. Static assets served with a matching ``?_hash=`` parameter now receive far-future immutable cache headers. This works for Datasette's bundled static assets, plugin static assets and directories mounted using ``--static``. See :ref:`customization_static_files`.
- Database and table pages now use the ``count_truncated`` template context value to display capped row counts as ``>N rows``.
- Significant visual improvements to the table filter form UI, plus working add/remove filter buttons. (:issue:`2798`)
- Improved edit row icon on table pages. (:issue:`2796`)

View file

@ -157,6 +157,9 @@ If you want to change Datasette's Python code you can use the ``--reload`` optio
uv run datasette --reload fixtures.db
This also enables development mode for static asset cache busting, described in
:ref:`customization_static_files`.
You can also use the ``fixtures.py`` script to recreate the testing version of ``metadata.json`` used by the unit tests. To do that::
uv run python tests/fixtures.py fixtures.db fixtures-metadata.json

View file

@ -151,6 +151,45 @@ You can reference those files from ``datasette.yaml`` like this, see :ref:`custo
}
.. [[[end]]]
If you reference those files from a custom template, use the ``static()``
template function to create a cache-busting URL based on the file contents.
Pass the mount point as the ``mount=`` argument, and pass a path relative to
that mounted directory:
.. code-block:: html+jinja
<link rel="stylesheet" href="{{ static('styles.css', mount='assets') }}">
<script src="{{ static('app.js', mount='assets') }}" defer></script>
The returned URL will include a ``?_hash=`` parameter and will take the
``base_url`` setting into account. When that hash matches the current file
contents, Datasette will serve the static asset with a far-future immutable
``Cache-Control`` header. You can also use ``urls.path()`` if you want to link
to the mounted file without adding a content hash:
.. code-block:: html+jinja
<link rel="stylesheet" href="{{ urls.path('/assets/styles.css') }}">
When Datasette is run using ``--reload``, the file contents are hashed every time
the template is rendered, so edits to static files will update their URLs
without restarting Datasette. Without ``--reload``, the hash is cached for the
lifetime of the Datasette process.
You can use the same function for Datasette's bundled static assets, omitting
``mount=``:
.. code-block:: html+jinja
<link rel="stylesheet" href="{{ static('app.css') }}">
For plugin static assets, pass the plugin name using ``plugin=`` and a path
relative to the plugin's ``static/`` directory:
.. code-block:: html+jinja
<script src="{{ static('plugin.js', plugin='datasette_plugin_name') }}" defer></script>
Publishing static assets
~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -451,6 +451,52 @@ await .render_template(template, context=None, request=None)
Renders a `Jinja template <https://jinja.palletsprojects.com/en/2.11.x/>`__ using Datasette's preconfigured instance of Jinja and returns the resulting string. The template will have access to Datasette's default template functions and any functions that have been made available by other plugins.
.. _datasette_static:
.static(path, plugin=None, mount=None)
--------------------------------------
``path`` - string
The path to the static asset, relative to the selected static directory.
``plugin`` - string, optional
The plugin name, for linking to an asset in that plugin's ``static/``
directory.
``mount`` - string, optional
The ``--static`` mount name, for linking to an asset in a directory mounted
using ``datasette --static mount_name:directory``.
Returns a URL for a static asset with a ``?_hash=`` parameter based on the file
contents. That URL takes the ``base_url`` setting into account.
When the ``?_hash=`` parameter matches the current file contents, Datasette will
serve the asset with ``Cache-Control: max-age=31536000, immutable, public``.
Call this with just ``path`` for one of Datasette's bundled static assets:
.. code-block:: python
datasette.static("app.css")
Use ``plugin=`` for plugin static assets:
.. code-block:: python
datasette.static(
"plugin.js", plugin="datasette_plugin_name"
)
Use ``mount=`` for static directories mounted using the ``--static`` option:
.. code-block:: python
datasette.static("styles.css", mount="assets")
``plugin`` and ``mount`` are mutually exclusive. The same feature is available
to Jinja templates as the ``static()`` template function, described in
:ref:`customization_static_files`.
.. _datasette_actors_from_ids:
await .actors_from_ids(actor_ids)

View file

@ -1968,9 +1968,8 @@ Here is a minimal plugin example that adds a button to a table page and loads Ja
@hookimpl
def extra_js_urls(datasette):
return [
datasette.urls.static_plugins(
"datasette_show_table",
"show-table.js",
datasette.static(
"show-table.js", plugin="datasette_show_table"
)
]

View file

@ -47,15 +47,6 @@ These variables are available on every page rendered by Datasette, including pag
``show_logout``
True if the logout link should be shown in the navigation menu
``app_css_hash``
Hash of Datasette's app.css contents, used for cache busting
``edit_tools_js_hash``
Hash of Datasette's edit-tools.js contents, used for cache busting
``table_js_hash``
Hash of Datasette's table.js contents, used for cache busting
``zip``
Python's ``zip()`` builtin, made available to template logic

View file

@ -145,7 +145,16 @@ If your plugin has a ``static/`` directory, Datasette will automatically configu
/-/static-plugins/NAME_OF_PLUGIN_PACKAGE/yourfile.js
Use the ``datasette.urls.static_plugins(plugin_name, path)`` method to generate URLs to that asset that take the ``base_url`` setting into account, see :ref:`internals_datasette_urls`.
Use the ``datasette.static(path, plugin=plugin_name)`` method to generate
cache-busting URLs to those assets that take the ``base_url`` setting into
account, see :ref:`datasette_static`.
This can also be used from plugin templates as the ``static()`` template
function:
.. code-block:: html+jinja
<script src="{{ static('plugin.js', plugin='datasette_plugin_name') }}" defer></script>
To bundle the static assets for a plugin in the package that you publish to PyPI, add the following to the plugin's ``setup.py``:

View file

@ -4,6 +4,7 @@ from datasette.utils import allowed_pragmas
from .fixtures import make_app_client
from .utils import assert_footer_links, inner_html
import copy
import hashlib
import json
import pathlib
import pytest
@ -83,7 +84,7 @@ async def test_homepage_options(ds_client):
async def test_favicon(ds_client):
response = await ds_client.get("/favicon.ico")
assert response.status_code == 200
assert response.headers["cache-control"] == "max-age=3600, immutable, public"
assert response.headers["cache-control"] == "max-age=3600, public"
assert int(response.headers["content-length"]) > 100
assert response.headers["content-type"] == "image/png"
@ -101,6 +102,24 @@ async def test_static(ds_client):
assert response.status_code == 304
@pytest.mark.asyncio
async def test_static_hash_cache_control(ds_client):
hashed_url = ds_client.ds.static("app.css")
response = await ds_client.get(hashed_url)
assert response.status_code == 200
assert response.headers["cache-control"] == "max-age=31536000, immutable, public"
response = await ds_client.get(
hashed_url, headers={"if-none-match": response.headers["etag"]}
)
assert response.status_code == 304
assert response.headers["cache-control"] == "max-age=31536000, immutable, public"
response = await ds_client.get("/-/static/app.css?_hash=incorrect")
assert response.status_code == 200
assert "cache-control" not in response.headers
def test_static_mounts():
with make_app_client(
static_mounts=[("custom-static", str(pathlib.Path(__file__).parent))]
@ -113,6 +132,23 @@ def test_static_mounts():
assert response.status_code == 404
def test_static_mounts_hash_cache_control():
mount_path = pathlib.Path(__file__).parent
with make_app_client(static_mounts=[("custom-static", str(mount_path))]) as client:
response = client.get(client.ds.static("test_html.py", mount="custom-static"))
assert response.status_code == 200
assert (
response.headers["cache-control"] == "max-age=31536000, immutable, public"
)
incorrect_hash = hashlib.sha256(b"incorrect").hexdigest()[:12]
response = client.get(
"/custom-static/test_html.py?_hash={}".format(incorrect_hash)
)
assert response.status_code == 200
assert "cache-control" not in response.headers
def test_memory_database_page():
with make_app_client(memory=True) as client:
response = client.get("/_memory")
@ -609,7 +645,7 @@ async def test_404(ds_client, path):
response = await ds_client.get(path)
assert response.status_code == 404
assert (
f'<link rel="stylesheet" href="/-/static/app.css?{ds_client.ds.app_css_hash()}'
'<link rel="stylesheet" href="{}"'.format(ds_client.ds.static("app.css"))
in response.text
)
@ -1082,7 +1118,9 @@ async def test_navigation_menu_links(
navigation_search_script = soup.find(
"script", {"src": re.compile(r"navigation-search\.js")}
)
assert navigation_search_script["src"] == "/-/static/navigation-search.js"
assert navigation_search_script["src"] == ds_client.ds.static(
"navigation-search.js"
)
assert details.find("li").find("button") == search_button
if not actor_id:
# The app menu is always visible, but anonymous users do not see logout

View file

@ -4,6 +4,8 @@ Tests for the datasette.app.Datasette class
import asyncio
import dataclasses
import hashlib
import importlib
import os
import sqlite3
import time
@ -11,6 +13,7 @@ from datasette import Context
from datasette.app import Datasette, Database, ResourcesSQL
from datasette.database import DatasetteClosedError
from datasette.resources import DatabaseResource
from datasette.utils import PrefixedUrlString
from itsdangerous import BadSignature
import pytest
@ -56,6 +59,111 @@ def test_datasette_setting(datasette, setting, expected):
assert datasette.setting(setting) == expected
def _setup_static_app_root(tmp_path, monkeypatch, filename, content):
app_module = importlib.import_module("datasette.app")
app_root = tmp_path / "app-root"
static_path = app_root / "datasette" / "static"
static_path.mkdir(parents=True)
asset_path = static_path / filename
asset_path.write_bytes(content)
monkeypatch.setattr(app_module, "app_root", app_root)
return asset_path
@pytest.mark.asyncio
async def test_static_template_function_hashes_core_asset(tmp_path, monkeypatch):
_setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"const demo = true;")
ds = Datasette()
template = ds.get_jinja_environment().from_string("{{ static('demo.js') }}")
expected_hash = hashlib.sha256(b"const demo = true;").hexdigest()[:12]
assert await template.render_async() == "/-/static/demo.js?_hash={}".format(
expected_hash
)
assert isinstance(ds.static("demo.js"), PrefixedUrlString)
def test_static_hash_cached_when_cache_headers_enabled(tmp_path, monkeypatch):
asset_path = _setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"let a = 1;")
ds = Datasette(cache_headers=True)
first_url = ds.static("demo.js")
asset_path.write_bytes(b"let a = 2;")
assert ds.static("demo.js") == first_url
def test_static_hash_recalculated_when_cache_headers_disabled(tmp_path, monkeypatch):
asset_path = _setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"let a = 1;")
ds = Datasette(cache_headers=False)
first_url = ds.static("demo.js")
asset_path.write_bytes(b"let a = 2;")
expected_hash = hashlib.sha256(b"let a = 2;").hexdigest()[:12]
assert ds.static("demo.js") == "/-/static/demo.js?_hash={}".format(expected_hash)
assert ds.static("demo.js") != first_url
def test_static_hashes_mounted_static_file(tmp_path):
static_path = tmp_path / "static-files"
static_path.mkdir()
asset_path = static_path / "styles.css"
asset_path.write_bytes(b"body { color: black; }")
ds = Datasette(static_mounts=[("assets", str(static_path))])
expected_hash = hashlib.sha256(b"body { color: black; }").hexdigest()[:12]
assert ds.static("styles.css", mount="assets") == (
"/assets/styles.css?_hash={}".format(expected_hash)
)
ds._settings["base_url"] = "/prefix/"
assert ds.static("styles.css", mount="assets") == (
"/prefix/assets/styles.css?_hash={}".format(expected_hash)
)
def test_static_hashes_plugin_static_file(tmp_path, monkeypatch):
plugin_static_path = tmp_path / "plugin-static"
plugin_static_path.mkdir()
asset_path = plugin_static_path / "plugin.js"
asset_path.write_bytes(b"console.log('plugin');")
app_module = importlib.import_module("datasette.app")
monkeypatch.setattr(
app_module,
"get_plugins",
lambda: [
{
"name": "datasette-cluster-map",
"static_path": str(plugin_static_path),
"templates_path": None,
}
],
)
ds = Datasette()
expected_hash = hashlib.sha256(b"console.log('plugin');").hexdigest()[:12]
assert ds.static("plugin.js", plugin="datasette_cluster_map") == (
"/-/static-plugins/datasette_cluster_map/plugin.js?_hash={}".format(
expected_hash
)
)
def test_static_rejects_plugin_and_mount():
ds = Datasette()
with pytest.raises(ValueError):
ds.static("styles.css", plugin="datasette_cluster_map", mount="assets")
def test_static_rejects_path_traversal(tmp_path, monkeypatch):
_setup_static_app_root(tmp_path, monkeypatch, "demo.js", b"")
ds = Datasette()
with pytest.raises(ValueError, match="cannot escape static root"):
ds.static("../secret.js")
@pytest.mark.asyncio
async def test_datasette_constructor():
ds = Datasette()

View file

@ -11,6 +11,7 @@ from datasette.utils.sqlite import (
sqlite_table_type,
supports_returning,
)
import hashlib
import json
import os
import pathlib
@ -835,6 +836,12 @@ def test_pairs_to_nested_config(pairs, expected):
assert actual == expected
def test_sha256_file(tmp_path):
path = tmp_path / "test.txt"
path.write_text("hello")
assert utils.sha256_file(path, chunk_size=2) == hashlib.sha256(b"hello").hexdigest()
@pytest.mark.asyncio
async def test_calculate_etag(tmp_path):
path = tmp_path / "test.txt"