diff --git a/docs/_newsfragments/2337.newandimproved.rst b/docs/_newsfragments/2337.newandimproved.rst new file mode 100644 index 000000000..a69a1609c --- /dev/null +++ b/docs/_newsfragments/2337.newandimproved.rst @@ -0,0 +1,9 @@ +Static routes now support ``HEAD`` requests properly. A ``HEAD`` request +returns all the same headers as a ``GET`` request (``Content-Type``, +``Content-Length``, ``ETag``, etc.) but does not open or stream file +contents. + +Additionally, static routes now block unsupported HTTP methods (such as +``POST``, ``PUT``, ``PATCH``, and ``DELETE``) with ``405 Method Not +Allowed``, and the ``Allow`` header in ``OPTIONS`` responses now correctly +lists all supported methods (``GET``, ``HEAD``, ``OPTIONS``). diff --git a/falcon/routing/static.py b/falcon/routing/static.py index a336026e9..0e4d633c9 100644 --- a/falcon/routing/static.py +++ b/falcon/routing/static.py @@ -223,12 +223,19 @@ def match(self, path: str) -> bool: return path.startswith(self._prefix) return path.startswith(self._prefix) or path == self._prefix[:-1] + _ALLOWED_METHODS: ClassVar[frozenset[str]] = frozenset( + {'GET', 'HEAD', 'OPTIONS'} + ) + def __call__(self, req: Request, resp: Response, **kw: Any) -> None: """Resource responder for this route.""" assert not kw + + if req.method not in self._ALLOWED_METHODS: + raise falcon.HTTPMethodNotAllowed(sorted(self._ALLOWED_METHODS)) + if req.method == 'OPTIONS': - # it's likely a CORS request. Set the allow header to the appropriate value. - resp.set_header('Allow', 'GET') + resp.set_header('Allow', ', '.join(sorted(self._ALLOWED_METHODS))) resp.set_header('Content-Length', '0') return @@ -279,11 +286,27 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None: last_modified = last_modified.replace(microsecond=0) resp.last_modified = last_modified + 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 self._downloadable: + resp.downloadable_as = os.path.basename(file_path) + if _is_not_modified(req, etag, last_modified): fh.close() resp.status = falcon.HTTP_304 return + if req.method == 'HEAD': + # NOTE(alexchenai): For HEAD requests, we set headers but do not + # open a file stream, as the response must not include a body. + resp.content_length = st.st_size + fh.close() + return + req_range = req.range if req.range_unit == 'bytes' else None try: stream, length, content_range = _set_range(fh, st, req_range) @@ -292,14 +315,7 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None: raise falcon.HTTPNotFound() resp.set_stream(stream, length) - 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 self._downloadable: - resp.downloadable_as = os.path.basename(file_path) if content_range: resp.status = falcon.HTTP_206 resp.content_range = content_range diff --git a/tests/test_static.py b/tests/test_static.py index 19dc4148d..3d21e15a1 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -757,3 +757,68 @@ def test_if_none_match_precedence(client, patch_open): ) assert resp2.status == falcon.HTTP_304 assert resp2.text == '' + + +def test_head_request(client, patch_open): + content = b'Hello, World!' + patch_open(content=content) + + client.app.add_static_route('/static', '/var/www/statics') + + resp = client.simulate_head(path='/static/foo/bar.txt') + assert resp.status == falcon.HTTP_200 + assert resp.headers.get('Content-Type') is not None + assert int(resp.headers['Content-Length']) == len(content) + assert resp.headers.get('ETag') is not None + assert resp.headers.get('Accept-Ranges') == 'bytes' + # HEAD response must not include a body + assert resp.content == b'' + + +def test_head_request_not_modified(client, patch_open): + mtime = (1736617934, 'Sat, 11 Jan 2025 17:52:14 GMT') + patch_open(mtime=mtime[0]) + + client.app.add_static_route('/assets/', '/opt/somesite/assets') + + # First, get the ETag + resp1 = client.simulate_request(path='/assets/css/main.css') + etag = resp1.headers['ETag'] + + # HEAD with matching ETag should return 304 + resp2 = client.simulate_head( + path='/assets/css/main.css', + headers={'If-None-Match': etag}, + ) + assert resp2.status == falcon.HTTP_304 + assert resp2.content == b'' + + +@pytest.mark.parametrize('method', ['POST', 'PUT', 'PATCH', 'DELETE']) +def test_method_not_allowed(client, patch_open, method): + patch_open() + + client.app.add_static_route('/static', '/var/www/statics') + + resp = client.simulate_request( + method=method, + path='/static/foo/bar.txt', + ) + assert resp.status == falcon.HTTP_405 + allow = resp.headers.get('Allow') + assert allow is not None + allowed_methods = {m.strip() for m in allow.split(',')} + assert allowed_methods == {'GET', 'HEAD', 'OPTIONS'} + + +def test_options_returns_all_allowed_methods(client, patch_open): + patch_open() + + client.app.add_static_route('/static', '/var/www/statics') + + resp = client.simulate_options(path='/static/foo/bar.txt') + assert resp.status_code == 200 + allow = resp.headers.get('Allow') + assert allow is not None + allowed_methods = {m.strip() for m in allow.split(',')} + assert allowed_methods == {'GET', 'HEAD', 'OPTIONS'}