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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(static): implement Last-Modified header for static route #2426

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
70 changes: 49 additions & 21 deletions falcon/routing/static.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import asyncio
from datetime import datetime
from datetime import timezone
from functools import partial
import io
import os
Expand All @@ -17,13 +19,34 @@
from falcon import Response


def _open_range(
file_path: Union[str, Path], req_range: Optional[Tuple[int, int]]
def _open_file(
file_path: Union[str, Path]
) -> Tuple[io.BufferedReader, os.stat_result]:
fh: Optional[io.BufferedReader] = None
try:
fh = io.open(file_path, 'rb')
st = os.fstat(fh.fileno())
except PermissionError:
if fh is not None:
fh.close()
raise falcon.HTTPForbidden()
except IOError:
if fh is not None:
fh.close()
raise falcon.HTTPNotFound()
return fh, st


def _set_range(
fh: io.BufferedReader,
st: os.stat_result,
req_range: Optional[Tuple[int, int]]
) -> Tuple[ReadableIO, int, Optional[Tuple[int, int, int]]]:
"""Open a file for a ranged request.
"""Process file handle for a ranged request.

Args:
file_path (str): Path to the file to open.
fh (io.BufferedReader): file handle of the file.
st (os.stat_result): fs stat result of the file.
req_range (Optional[Tuple[int, int]]): Request.range value.
Returns:
tuple: Three-member tuple of (stream, content-length, content-range).
Expand All @@ -32,8 +55,7 @@ def _open_range(
possibly bounded, and the content-range will be a tuple of
(start, end, size).
"""
fh = io.open(file_path, 'rb')
size = os.fstat(fh.fileno()).st_size
size = st.st_size
if req_range is None:
return fh, size, None

Expand Down Expand Up @@ -217,24 +239,30 @@ def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
if '..' in file_path or not file_path.startswith(self._directory):
raise falcon.HTTPNotFound()

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 self._fallback_filename is None:
fh, st = _open_file(file_path)
else:
try:
stream, length, content_range = _open_range(
self._fallback_filename, req_range
)
resp.set_stream(stream, length)
fh, st = _open_file(file_path)
except falcon.HTTPNotFound:
fh, st = _open_file(self._fallback_filename)
file_path = self._fallback_filename
except IOError:
raise falcon.HTTPNotFound()

last_modified = datetime.fromtimestamp(st.st_mtime, timezone.utc)
resp.last_modified = last_modified
if (req.if_modified_since is not None and
last_modified <= req.if_modified_since):
resp.status = falcon.HTTP_304
return

req_range = req.range if req.range_unit == 'bytes' else None
try:
stream, length, content_range = _set_range(fh, st, req_range)
except IOError:
fh.close()
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'
Expand Down
94 changes: 93 additions & 1 deletion tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import pathlib
import posixpath
from unittest import mock

import pytest

Expand Down Expand Up @@ -51,14 +52,15 @@ def create_sr(asgi, prefix, directory, **kwargs):

@pytest.fixture
def patch_open(monkeypatch):
def patch(content=None, validate=None):
def patch(content=None, validate=None, mtime=1736617934):
def open(path, mode):
class FakeFD(int):
pass

class FakeStat:
def __init__(self, size):
self.st_size = size
self.st_mtime = mtime

if validate:
validate(path)
Expand Down Expand Up @@ -633,3 +635,93 @@ 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_last_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')

response = client.simulate_request(path='/assets/css/main.css')
assert response.status == falcon.HTTP_200
assert response.headers['Last-Modified'] == mtime[1]


def test_if_modified_since(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')

resp = client.simulate_request(
path='/assets/css/main.css',
headers={"If-Modified-Since": "Sat, 11 Jan 2025 17:52:15 GMT"},
)
assert resp.status == falcon.HTTP_304
assert resp.text == ''

resp = client.simulate_request(
path='/assets/css/main.css',
headers={"If-Modified-Since": "Sat, 11 Jan 2025 17:52:13 GMT"},
)
assert resp.status == falcon.HTTP_200
assert resp.text != ''


@pytest.mark.parametrize('use_fallback', [True, False])
def test_permission_error(
client,
patch_open,
use_fallback,
monkeypatch
):
def validate(path):
if use_fallback and not path.endswith('fallback.css'):
raise IOError()
raise PermissionError()

patch_open(validate=validate)
monkeypatch.setattr(
'os.path.isfile', lambda file: file.endswith('fallback.css')
)

client.app.add_static_route(
'/assets/', '/opt/somesite/assets', fallback_filename='fallback.css'
)
resp = client.simulate_request(path='/assets/css/main.css')

assert resp.status == falcon.HTTP_403


@pytest.mark.parametrize('error_type', [PermissionError, FileNotFoundError])
def test_fstat_error(client, patch_open, error_type):
patch_open()

client.app.add_static_route('/assets/', '/opt/somesite/assets')

with mock.patch("os.fstat") as m:
m.side_effect = error_type()
resp = client.simulate_request(path='/assets/css/main.css')

if error_type == PermissionError:
assert resp.status == falcon.HTTP_403
else:
assert resp.status == falcon.HTTP_404

assert patch_open.current_file is not None
assert patch_open.current_file.closed


def test_set_range_error(client, patch_open):
patch_open()

client.app.add_static_route('/assets/', '/opt/somesite/assets')

with mock.patch("falcon.routing.static._set_range") as m:
m.side_effect = IOError()
resp = client.simulate_request(path='/assets/css/main.css')

assert resp.status == falcon.HTTP_404
assert patch_open.current_file is not None
assert patch_open.current_file.closed