Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ETag header for static responses #2306

Merged
merged 3 commits into from
Mar 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 22 additions & 0 deletions datasette/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
from contextlib import contextmanager
import aiofiles
import click
from collections import OrderedDict, namedtuple, Counter
import copy
Expand Down Expand Up @@ -1418,3 +1419,24 @@ def md5_not_usedforsecurity(s):
except TypeError:
# For Python 3.8 which does not support usedforsecurity=False
return hashlib.md5(s.encode("utf8")).hexdigest()


_etag_cache = {}


async def calculate_etag(filepath, chunk_size=4096):
if filepath in _etag_cache:
return _etag_cache[filepath]

hasher = hashlib.md5()
async with aiofiles.open(filepath, "rb") as f:
while True:
chunk = await f.read(chunk_size)
if not chunk:
break
hasher.update(chunk)

etag = f'"{hasher.hexdigest()}"'
_etag_cache[filepath] = etag

return etag
19 changes: 17 additions & 2 deletions datasette/utils/asgi.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import hashlib
import json
from datasette.utils import MultiParams
from datasette.utils import MultiParams, calculate_etag
from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path
Expand Down Expand Up @@ -285,6 +286,7 @@
headers = headers or {}
if filename:
headers["content-disposition"] = f'attachment; filename="{filename}"'

first = True
headers["content-length"] = str((await aiofiles.os.stat(str(filepath))).st_size)
async with aiofiles.open(str(filepath), mode="rb") as fp:
Expand All @@ -307,9 +309,14 @@

def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
root_path = Path(root_path)
static_headers = {}

if headers:
static_headers = headers.copy()

Check warning on line 315 in datasette/utils/asgi.py

View check run for this annotation

Codecov / codecov/patch

datasette/utils/asgi.py#L315

Added line #L315 was not covered by tests

async def inner_static(request, send):
path = request.scope["url_route"]["kwargs"]["path"]
headers = static_headers.copy()
try:
full_path = (root_path / path).resolve().absolute()
except FileNotFoundError:
Expand All @@ -325,7 +332,15 @@
await asgi_send_html(send, "404: Path not inside root path", 404)
return
try:
await asgi_send_file(send, full_path, chunk_size=chunk_size)
# Calculate ETag for filepath
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)
await asgi_send_file(
send, full_path, chunk_size=chunk_size, headers=headers
)
except FileNotFoundError:
await asgi_send_html(send, "404: File not found", 404)
return
Expand Down
4 changes: 4 additions & 0 deletions tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ async def test_static(ds_client):
response = await ds_client.get("/-/static/app.css")
assert response.status_code == 200
assert "text/css" == response.headers["content-type"]
assert "etag" in response.headers
etag = response.headers.get("etag")
response = await ds_client.get("/-/static/app.css", headers={"if-none-match": etag})
assert response.status_code == 304


def test_static_mounts():
Expand Down
12 changes: 12 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,3 +706,15 @@ def test_truncate_url(url, length, expected):
def test_pairs_to_nested_config(pairs, expected):
actual = utils.pairs_to_nested_config(pairs)
assert actual == expected


@pytest.mark.asyncio
async def test_calculate_etag(tmp_path):
path = tmp_path / "test.txt"
path.write_text("hello")
etag = '"5d41402abc4b2a76b9719d911017c592"'
assert etag == await utils.calculate_etag(path)
assert utils._etag_cache[path] == etag
utils._etag_cache[path] = "hash"
assert "hash" == await utils.calculate_etag(path)
utils._etag_cache.clear()