kopia lustrzana https://github.com/simonw/datasette
Add ETag header for static responses (#2306)
* add etag to static responses * fix RuntimeError related to static headers * Remove unnecessary import --------- Co-authored-by: Simon Willison <swillison@gmail.com>pull/2266/merge
rodzic
261fc8d875
commit
67e66f36c1
|
@ -1,5 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
import aiofiles
|
||||||
import click
|
import click
|
||||||
from collections import OrderedDict, namedtuple, Counter
|
from collections import OrderedDict, namedtuple, Counter
|
||||||
import copy
|
import copy
|
||||||
|
@ -1418,3 +1419,24 @@ def md5_not_usedforsecurity(s):
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# For Python 3.8 which does not support usedforsecurity=False
|
# For Python 3.8 which does not support usedforsecurity=False
|
||||||
return hashlib.md5(s.encode("utf8")).hexdigest()
|
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
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from datasette.utils import MultiParams
|
from datasette.utils import MultiParams, calculate_etag
|
||||||
from mimetypes import guess_type
|
from mimetypes import guess_type
|
||||||
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -285,6 +286,7 @@ async def asgi_send_file(
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
if filename:
|
if filename:
|
||||||
headers["content-disposition"] = f'attachment; filename="{filename}"'
|
headers["content-disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
first = True
|
first = True
|
||||||
headers["content-length"] = str((await aiofiles.os.stat(str(filepath))).st_size)
|
headers["content-length"] = str((await aiofiles.os.stat(str(filepath))).st_size)
|
||||||
async with aiofiles.open(str(filepath), mode="rb") as fp:
|
async with aiofiles.open(str(filepath), mode="rb") as fp:
|
||||||
|
@ -307,9 +309,14 @@ async def asgi_send_file(
|
||||||
|
|
||||||
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
root_path = Path(root_path)
|
root_path = Path(root_path)
|
||||||
|
static_headers = {}
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
static_headers = headers.copy()
|
||||||
|
|
||||||
async def inner_static(request, send):
|
async def inner_static(request, send):
|
||||||
path = request.scope["url_route"]["kwargs"]["path"]
|
path = request.scope["url_route"]["kwargs"]["path"]
|
||||||
|
headers = static_headers.copy()
|
||||||
try:
|
try:
|
||||||
full_path = (root_path / path).resolve().absolute()
|
full_path = (root_path / path).resolve().absolute()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
@ -325,7 +332,15 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
await asgi_send_html(send, "404: Path not inside root path", 404)
|
await asgi_send_html(send, "404: Path not inside root path", 404)
|
||||||
return
|
return
|
||||||
try:
|
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:
|
except FileNotFoundError:
|
||||||
await asgi_send_html(send, "404: File not found", 404)
|
await asgi_send_html(send, "404: File not found", 404)
|
||||||
return
|
return
|
||||||
|
|
|
@ -78,6 +78,10 @@ async def test_static(ds_client):
|
||||||
response = await ds_client.get("/-/static/app.css")
|
response = await ds_client.get("/-/static/app.css")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "text/css" == response.headers["content-type"]
|
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():
|
def test_static_mounts():
|
||||||
|
|
|
@ -706,3 +706,15 @@ def test_truncate_url(url, length, expected):
|
||||||
def test_pairs_to_nested_config(pairs, expected):
|
def test_pairs_to_nested_config(pairs, expected):
|
||||||
actual = utils.pairs_to_nested_config(pairs)
|
actual = utils.pairs_to_nested_config(pairs)
|
||||||
assert actual == expected
|
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()
|
||||||
|
|
Ładowanie…
Reference in New Issue