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 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 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 @@ -16,14 +18,19 @@
from falcon import Request
from falcon import Response

_stat = os.stat


def _open_range(
file_path: Union[str, Path], req_range: Optional[Tuple[int, int]]
file_path: Union[Path, str],
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.

Args:
file_path (str): Path to the file to open.
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 @@ -33,7 +40,7 @@ def _open_range(
(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 +224,39 @@ 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)
st = _stat(file_path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice going overall so far 👍

I don't like this specific change though, please revert it if possible. We do want to stat() an open fd instead of path, as this prevents a number of race conditions, for instance, if the file in question is deleted between stat() and open(), etc.

except PermissionError:
raise falcon.HTTPForbidden()
except IOError:
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)
st = _stat(self._fallback_filename)
file_path = self._fallback_filename
except PermissionError:
raise falcon.HTTPForbidden()
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 = _open_range(
file_path, st, req_range
)
resp.set_stream(stream, length)
except PermissionError:
raise falcon.HTTPForbidden()
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'
Expand Down
107 changes: 96 additions & 11 deletions tests/test_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import os
import pathlib
import posixpath
from unittest import mock

import pytest

import falcon
from falcon.routing import StaticRoute
from falcon.routing import StaticRouteAsync
import falcon.routing.static
from falcon.routing.static import _BoundedFile
import falcon.testing as testing

Expand Down Expand Up @@ -51,29 +53,33 @@ def create_sr(asgi, prefix, directory, **kwargs):

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

class FakeStat:
def __init__(self, size):
self.st_size = size
def open(path, mode):

if validate:
validate(path)

data = path.encode() if content is None else content
fake_file = io.BytesIO(data)
fd = FakeFD(1337)
fd._stat = FakeStat(len(data))
fake_file.fileno = lambda: fd

patch.current_file = fake_file
return fake_file

def stat(path, **kwargs):

if validate:
validate(path)

data = path.encode() if content is None else content
return FakeStat(len(data), mtime)

monkeypatch.setattr(io, 'open', open)
monkeypatch.setattr(os, 'fstat', lambda fileno: fileno._stat)
monkeypatch.setattr(falcon.routing.static, '_stat', stat)

patch.current_file = None
return patch
Expand Down Expand Up @@ -633,3 +639,82 @@ 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


def test_read_permission_error(client, patch_open):
patch_open()
client.app.add_static_route('/assets/', '/opt/somesite/assets')

with mock.patch("io.open", mock.mock_open()) as m:
m.side_effect = PermissionError()
resp = client.simulate_request(path='/assets/css/main.css')

assert resp.status == falcon.HTTP_403


def test_read_ioerror(client, patch_open):
patch_open()
client.app.add_static_route('/assets/', '/opt/somesite/assets')

with mock.patch("io.open", mock.mock_open()) as m:
m.side_effect = IOError()
resp = client.simulate_request(path='/assets/css/main.css')

assert resp.status == falcon.HTTP_404
Loading