mirror of
https://github.com/simonw/datasette.git
synced 2026-06-24 09:44:36 +02:00
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:
parent
a4f74d1d2b
commit
5eca46a4bc
25 changed files with 361 additions and 65 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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``:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue