diff --git a/falcon/routing/static.py b/falcon/routing/static.py index 57e327ab6..892409cb4 100644 --- a/falcon/routing/static.py +++ b/falcon/routing/static.py @@ -70,6 +70,21 @@ def _open_range( return _BoundedFile(fh, length), length, (start, end, size) +def _generate_etag(file_path: str) -> falcon.ETag: + """Generate an Etag for a file under a given path. + + Args: + file_path (str): Path to the file the ETag should be generated for. + + Returns: + falcon.ETag: ETag generated for the file using its modification time and size. + """ + fh = io.open(file_path, 'rb') + content_length = os.fstat(fh.fileno()).st_size + last_modified_time = os.fstat(fh.fileno()).st_mtime + return falcon.ETag(f'"{int(last_modified_time):X}-{content_length:X}"') + + class _BoundedFile: """Wrap a file to only allow part of it to be read. @@ -180,6 +195,19 @@ def match(self, path: str) -> bool: return path.startswith(self._prefix) return path.startswith(self._prefix) or path == self._prefix[:-1] + def generate_etag(self, file_path: str) -> falcon.ETag: + """Generate an ETag for a file under the file_path or fallback_filename.""" + try: + etag = _generate_etag(file_path) + except IOError: + try: + if self._fallback_filename is None: + raise falcon.HTTPNotFound() + etag = _generate_etag(self._fallback_filename) + except IOError: + raise falcon.HTTPNotFound() + return etag + def __call__(self, req: Request, resp: Response, **kw: Any) -> None: """Resource responder for this route.""" assert not kw @@ -217,35 +245,47 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None: if '..' in file_path or not file_path.startswith(self._directory): raise falcon.HTTPNotFound() + etag = self.generate_etag(file_path) + + if req.get_header('If-None-Match') == etag: + resp.status = falcon.HTTP_304 + resp.content_type = None + resp.text = None + req_range = req.range if req.range_unit != 'bytes': req_range = None - try: - stream, length, content_range = _open_range(file_path, req_range) - resp.set_stream(stream, length) - except IOError: - if self._fallback_filename is None: - raise falcon.HTTPNotFound() + + if resp.status != falcon.HTTP_304: try: - stream, length, content_range = _open_range( - self._fallback_filename, req_range - ) + stream, length, content_range = _open_range(file_path, req_range) resp.set_stream(stream, length) - file_path = self._fallback_filename except IOError: - raise falcon.HTTPNotFound() + if self._fallback_filename is None: + raise falcon.HTTPNotFound() + try: + stream, length, content_range = _open_range( + self._fallback_filename, req_range + ) + resp.set_stream(stream, length) + file_path = self._fallback_filename + except IOError: + raise falcon.HTTPNotFound() + + suffix = os.path.splitext(file_path)[1] + resp.content_type = resp.options.static_media_types.get( + suffix, 'application/octet-stream' + ) + resp.accept_ranges = 'bytes' + + if content_range: + resp.status = falcon.HTTP_206 + resp.content_range = content_range - suffix = os.path.splitext(file_path)[1] - resp.content_type = resp.options.static_media_types.get( - suffix, 'application/octet-stream' - ) - resp.accept_ranges = 'bytes' + resp.set_header('ETag', etag) if self._downloadable: resp.downloadable_as = os.path.basename(file_path) - if content_range: - resp.status = falcon.HTTP_206 - resp.content_range = content_range class StaticRouteAsync(StaticRoute): diff --git a/tests/test_static.py b/tests/test_static.py index 5830b904c..1fd3e3243 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -10,6 +10,7 @@ from falcon.routing import StaticRoute from falcon.routing import StaticRouteAsync from falcon.routing.static import _BoundedFile +from falcon.routing.static import _generate_etag import falcon.testing as testing @@ -57,8 +58,9 @@ class FakeFD(int): pass class FakeStat: - def __init__(self, size): + def __init__(self, size, mtime): self.st_size = size + self.st_mtime = mtime if validate: validate(path) @@ -66,7 +68,7 @@ def __init__(self, size): data = path.encode() if content is None else content fake_file = io.BytesIO(data) fd = FakeFD(1337) - fd._stat = FakeStat(len(data)) + fd._stat = FakeStat(len(data), 1297230027) fake_file.fileno = lambda: fd patch.current_file = fake_file @@ -277,6 +279,10 @@ def test_range_requests( monkeypatch, use_fallback, ): + monkeypatch.setattr( + 'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"' + ) + def validate(path): if use_fallback and not path.endswith('index.html'): raise OSError(errno.ENOENT, 'File not found') @@ -441,7 +447,11 @@ def test_downloadable(client, patch_open): assert 'Content-Disposition' not in response.headers -def test_downloadable_not_found(client): +def test_downloadable_not_found(client, monkeypatch): + monkeypatch.setattr( + 'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"' + ) + client.app.add_static_route( '/downloads', '/opt/somesite/downloads', downloadable=True ) @@ -479,6 +489,10 @@ def test_fallback_filename( patch_open, monkeypatch, ): + monkeypatch.setattr( + 'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"' + ) + def validate(path): if normalize_path(default) not in path: raise IOError() @@ -537,6 +551,10 @@ async def run(): def test_e2e_fallback_filename( client, patch_open, monkeypatch, strip_slash, path, fallback, static_exp, assert_axp ): + monkeypatch.setattr( + 'falcon.routing.static._generate_etag', lambda path: '"fixed-etag"' + ) + def validate(path): if 'index' not in path or 'raise' in path: raise IOError() @@ -633,3 +651,141 @@ def test_options_request(client, patch_open): assert resp.text == '' assert int(resp.headers['Content-Length']) == 0 assert resp.headers['Access-Control-Allow-Methods'] == 'GET' + + +def test_render_etag_header(client, patch_open): + patch_open(b'0123456789abcdef') + + client.app.add_static_route('/downloads', '/opt/somesite/downloads') + + response = client.simulate_request(path='/downloads/thing.zip') + + assert response.status == falcon.HTTP_200 + assert response.headers.get('Etag') is not None + + +def test_renders_the_same_etag_header_when_file_does_not_change(client, patch_open): + patch_open(b'0123456789abcdef') + + client.app.add_static_route('/downloads', '/opt/somesite/downloads') + + response = client.simulate_request(path='/downloads/thing.zip') + + assert response.status == falcon.HTTP_200 + assert response.headers.get('Etag') is not None + + first_etag = response.headers.get('Etag') + + response = client.simulate_request(path='/downloads/thing.zip') + + assert response.status == falcon.HTTP_200 + assert response.headers.get('Etag') is not None + + second_etag = response.headers.get('Etag') + + assert first_etag == second_etag + + +def test_if_none_match_header_when_etag_has_not_changed(client, patch_open): + patch_open(b'0123456789abcdef') + + client.app.add_static_route('/downloads', '/opt/somesite/downloads') + + expected_etag = _generate_etag('/downloads/thing.zip') + + response = client.simulate_request( + path='/downloads/thing.zip', headers={'If-None-Match': expected_etag} + ) + assert response.status == falcon.HTTP_304 + assert response.text == '' + + +def test_if_none_match_header_when_etag_has_changed(client, patch_open): + patch_open(b'0123456789abcdef') + + client.app.add_static_route('/downloads', '/opt/somesite/downloads') + + response = client.simulate_request( + path='/downloads/thing.zip', headers={'If-None-Match': 'outdated etag'} + ) + assert response.status == falcon.HTTP_200 + assert response.headers.get('Etag') is not None + + +def test_etag_with_fallback_filename(client, patch_open, monkeypatch): + def fake_generate_etag(file_path): + if normalize_path('thing.zip') in file_path: + raise OSError(errno.ENOENT, 'File not found') + elif normalize_path('index.html') in file_path: + return '"etag-fallback-file"' + return '"some etag"' + + monkeypatch.setattr('falcon.routing.static._generate_etag', fake_generate_etag) + + patch_open(b'Fallback content') + + monkeypatch.setattr('os.path.isfile', lambda file: file.endswith('index.html')) + + client.app.add_static_route( + '/downloads', '/opt/somesite/downloads', fallback_filename='index.html' + ) + + response = client.simulate_request(path='/downloads/thing.zip') + + assert response.status == falcon.HTTP_200 + assert response.text == 'Fallback content' + assert response.headers.get('Etag') == '"etag-fallback-file"' + + +def test_etag_with_fallback_filename_also_missing(client, patch_open, monkeypatch): + def fake_generate_etag(file_path): + if normalize_path('thing.zip') in file_path: + raise OSError(errno.ENOENT, 'File not found') + elif normalize_path('index.html') in file_path: + raise OSError(errno.ENOENT, 'File not found') + return '"some etag"' + + monkeypatch.setattr('falcon.routing.static._generate_etag', fake_generate_etag) + + def validate(path): + if normalize_path('thing.zip') in path: + raise IOError() + + patch_open(b'Fallback content', validate) + + monkeypatch.setattr('os.path.isfile', lambda file: file.endswith('index.html')) + + client.app.add_static_route( + '/downloads', '/opt/somesite/downloads', fallback_filename='index.html' + ) + + response = client.simulate_request(path='/downloads/thing.zip') + + assert response.headers.get('Etag') is None + assert response.status == falcon.HTTP_404 + assert response.text != 'Fallback content' + + +def test_etag_with_no_fallback_filename(client, patch_open, monkeypatch): + def fake_generate_etag(file_path): + if normalize_path('thing.zip') in file_path: + raise OSError(errno.ENOENT, 'File not found') + return '"some etag"' + + monkeypatch.setattr('falcon.routing.static._generate_etag', fake_generate_etag) + + def validate(path): + if normalize_path('thing.zip') in path: + raise IOError() + + patch_open(b'Fallback content', validate) + + client.app.add_static_route( + '/downloads', '/opt/somesite/downloads', fallback_filename=None + ) + + response = client.simulate_request(path='/downloads/thing.zip') + + assert response.headers.get('Etag') is None + assert response.status == falcon.HTTP_404 + assert response.text != 'Fallback content'