mirror of
https://github.com/simonw/datasette.git
synced 2026-09-29 05:14:21 +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
|
|
@ -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