Repository: falconry/falcon

Issue:
Consider adding byte range support to static routes
Our first pass at this could be basic support allowing to, for instance, stream a media file along the lines of this proof-of-concept snippet: https://github.com/falconry/falcon/issues/1857#issuecomment-774330272.
If we encounter complex cases we don't understand (such as multiple ranges), we can always choose to neglect the range request, and serve the whole file (with `HTTP 200 OK`).

Or is this just feature creep given that serving files via Falcon's static routes probably isn't used often in the production setting (or at least it shouldn't be)?


Discussion:

Resolving patch:
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -350,7 +350,7 @@ class Request:
             header is missing.
 
         range (tuple of int): A 2-member ``tuple`` parsed from the value of the
-            Range header.
+            Range header, or ``None`` if the header is missing.
 
             The two members correspond to the first and last byte
             positions of the requested resource, inclusive. Negative
@@ -691,16 +691,24 @@ def range(self):
             if not sep:
                 raise ValueError()
 
-            if first:
-                return (int(first), int(last or -1))
+            if first and last:
+                first, last = (int(first), int(last))
+                if last < first:
+                    raise ValueError()
+            elif first:
+                first, last = (int(first), -1)
             elif last:
-                return (-int(last), -1)
+                first, last = (-int(last), -1)
+                if first >= 0:
+                    raise ValueError()
             else:
                 msg = 'The range offsets are missing.'
                 raise errors.HTTPInvalidHeader(msg, 'Range')
 
+            return first, last
+
         except ValueError:
-            href = 'http://goo.gl/zZ6Ey'
+            href = 'https://tools.ietf.org/html/rfc7233'
             href_text = 'HTTP/1.1 Range Requests'
             msg = 'It must be a range formatted according to RFC 7233.'
             raise errors.HTTPInvalidHeader(msg, 'Range', href=href, href_text=href_text)
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -8,6 +8,72 @@
 from falcon.util.sync import get_running_loop
 
 
+def _open_range(file_path, req_range):
+    """Open a file for a ranged request.
+
+    Args:
+        file_path (str): Path to the file to open.
+        req_range (Optional[Tuple[int, int]]): Request.range value.
+    Returns:
+        tuple: Two-member tuple of (stream, content-range). If req_range is
+            ``None`` or ignored, content-range will be ``None``; otherwise,
+            the stream will be appropriately seeked and possibly bounded,
+            and the content-range will be a tuple of (start, end, size).
+    """
+    fh = io.open(file_path, 'rb')
+    if req_range is None:
+        return fh, None
+
+    start, end = req_range
+    size = os.fstat(fh.fileno()).st_size
+    if size == 0:
+        # Ignore Range headers for zero-byte files; just serve the empty body
+        # since Content-Range can't be used to express a zero-byte body
+        return fh, None
+
+    if start < 0 and end == -1:
+        # Special case: only want the last N bytes
+        start = max(start, -size)
+        fh.seek(start, os.SEEK_END)
+        return fh, (size + start, size - 1, size)
+
+    if start >= size:
+        raise falcon.HTTPRangeNotSatisfiable(size)
+
+    fh.seek(start)
+    if end == -1:
+        return fh, (start, size - 1, size)
+
+    end = min(end, size - 1)
+    return _BoundedFile(fh, end - start + 1), (start, end, size)
+
+
+class _BoundedFile:
+    """Wrap a file to only allow part of it to be read.
+
+    Args:
+        fh: The file object to wrap. Should be opened in binary mode,
+            and already seeked to an appropriate position. The object must
+            expose a ``.close()`` method.
+        length (int): Number of bytes that may be read.
+    """
+
+    def __init__(self, fh, length):
+        self.fh = fh
+        self.close = fh.close
+        self.remaining = length
+
+    def read(self, size=-1):
+        """Read the underlying file object, within the specified bounds."""
+        if size < 0:
+            size = self.remaining
+        else:
+            size = min(size, self.remaining)
+        data = self.fh.read(size)
+        self.remaining -= len(data)
+        return data
+
+
 class StaticRoute:
     """Represents a static route.
 
@@ -114,13 +180,18 @@ def __call__(self, req, resp):
         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:
-            resp.stream = io.open(file_path, 'rb')
+            resp.stream, content_range = _open_range(file_path, req_range)
         except IOError:
             if self._fallback_filename is None:
                 raise falcon.HTTPNotFound()
             try:
-                resp.stream = io.open(self._fallback_filename, 'rb')
+                resp.stream, content_range = _open_range(
+                    self._fallback_filename, req_range
+                )
                 file_path = self._fallback_filename
             except IOError:
                 raise falcon.HTTPNotFound()
@@ -129,9 +200,13 @@ def __call__(self, req, resp):
         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
 
 
 class StaticRouteAsync(StaticRoute):


Test patch:
diff --git a/tests/test_request_attrs.py b/tests/test_request_attrs.py
--- a/tests/test_request_attrs.py
+++ b/tests/test_request_attrs.py
@@ -526,6 +526,16 @@ def test_range_invalid(self, asgi):
         with pytest.raises(falcon.HTTPBadRequest):
             req.range
 
+        headers = {'Range': 'bytes=--1'}
+        req = create_req(asgi, headers=headers)
+        with pytest.raises(falcon.HTTPBadRequest):
+            req.range
+
+        headers = {'Range': 'bytes=--0'}
+        req = create_req(asgi, headers=headers)
+        with pytest.raises(falcon.HTTPBadRequest):
+            req.range
+
         headers = {'Range': 'bytes=-3-'}
         req = create_req(asgi, headers=headers)
         with pytest.raises(falcon.HTTPBadRequest):
@@ -536,6 +546,11 @@ def test_range_invalid(self, asgi):
         with pytest.raises(falcon.HTTPBadRequest):
             req.range
 
+        headers = {'Range': 'bytes=4-3'}
+        req = create_req(asgi, headers=headers)
+        with pytest.raises(falcon.HTTPBadRequest):
+            req.range
+
         headers = {'Range': 'bytes=3-3-4'}
         req = create_req(asgi, headers=headers)
         with pytest.raises(falcon.HTTPBadRequest):
diff --git a/tests/test_static.py b/tests/test_static.py
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 
+import errno
 import io
 import os
 import pathlib
@@ -8,6 +9,7 @@
 
 import falcon
 from falcon.routing import StaticRoute, StaticRouteAsync
+from falcon.routing.static import _BoundedFile
 import falcon.testing as testing
 
 import _util  # NOQA
@@ -200,6 +202,127 @@ async def run():
 
     assert resp.content_type in _MIME_ALTERNATIVE.get(mtype, (mtype,))
     assert body.decode() == os.path.normpath('/var/www/statics' + expected_path)
+    assert resp.headers.get('accept-ranges') == 'bytes'
+
+
+@pytest.mark.parametrize(
+    'range_header, exp_content_range, exp_content',
+    [
+        ('bytes=1-3', 'bytes 1-3/16', '123'),
+        ('bytes=-3', 'bytes 13-15/16', 'def'),
+        ('bytes=8-', 'bytes 8-15/16', '89abcdef'),
+        ('words=1-3', None, '0123456789abcdef'),  # unknown unit; ignore
+        ('bytes=15-30', 'bytes 15-15/16', 'f'),
+        ('bytes=0-30', 'bytes 0-15/16', '0123456789abcdef'),
+        ('bytes=-30', 'bytes 0-15/16', '0123456789abcdef'),
+    ],
+)
+@pytest.mark.parametrize('use_fallback', [True, False])
+def test_range_requests(
+    client, range_header, exp_content_range, exp_content, monkeypatch, use_fallback
+):
+    fake_file = io.BytesIO(b'0123456789abcdef')
+    fake_file.fileno = lambda: 123
+
+    def fake_open(path, mode):
+        if use_fallback and not path.endswith('index.html'):
+            raise OSError(errno.ENOENT, 'File not found')
+        return fake_file
+
+    class FakeStat:
+        st_size = len(fake_file.getvalue())
+
+    monkeypatch.setattr(io, 'open', fake_open)
+    monkeypatch.setattr(os, 'fstat', lambda fileno: FakeStat())
+    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', headers={'Range': range_header}
+    )
+    if exp_content_range is None:
+        assert response.status == falcon.HTTP_200
+    else:
+        assert response.status == falcon.HTTP_206
+    assert response.text == exp_content
+    assert response.headers.get('Content-Range') == exp_content_range
+    assert response.headers.get('Accept-Ranges') == 'bytes'
+    if use_fallback:
+        assert response.headers.get('Content-Type') == 'text/html'
+    else:
+        assert (
+            response.headers.get('Content-Type') in _MIME_ALTERNATIVE['application/zip']
+        )
+
+
+@pytest.mark.parametrize(
+    'range_header',
+    [
+        'bytes=1-3',
+        'bytes=-3',
+        'bytes=8-',
+        'words=1-3',
+        'bytes=15-30',
+        'bytes=0-30',
+        'bytes=-30',
+    ],
+)
+def test_range_request_zero_length(client, range_header, monkeypatch):
+    fake_file = io.BytesIO(b'')
+    fake_file.fileno = lambda: 123
+
+    class FakeStat:
+        st_size = 0
+
+    monkeypatch.setattr(io, 'open', lambda path, mode: fake_file)
+    monkeypatch.setattr(os, 'fstat', lambda fileno: FakeStat())
+
+    client.app.add_static_route('/downloads', '/opt/somesite/downloads')
+
+    response = client.simulate_request(
+        path='/downloads/thing.zip', headers={'Range': range_header}
+    )
+    assert response.status == falcon.HTTP_200
+    assert response.text == ''
+    assert 'Content-Range' not in response.headers
+    assert response.headers.get('Accept-Ranges') == 'bytes'
+
+
+@pytest.mark.parametrize(
+    'range_header, exp_status',
+    [
+        ('1-3', falcon.HTTP_400),
+        ('bytes=0-0,-1', falcon.HTTP_400),
+        ('bytes=8-4', falcon.HTTP_400),
+        ('bytes=1--3', falcon.HTTP_400),
+        ('bytes=--0', falcon.HTTP_400),
+        ('bytes=100-200', falcon.HTTP_416),
+        ('bytes=100-', falcon.HTTP_416),
+        ('bytes=16-20', falcon.HTTP_416),
+        ('bytes=16-', falcon.HTTP_416),
+    ],
+)
+def test_bad_range_requests(client, range_header, exp_status, monkeypatch):
+    fake_file = io.BytesIO(b'0123456789abcdef')
+    fake_file.fileno = lambda: 123
+
+    class FakeStat:
+        st_size = len(fake_file.getvalue())
+
+    monkeypatch.setattr(io, 'open', lambda path, mode: fake_file)
+    monkeypatch.setattr(os, 'fstat', lambda fileno: FakeStat())
+
+    client.app.add_static_route('/downloads', '/opt/somesite/downloads')
+
+    response = client.simulate_request(
+        path='/downloads/thing.zip', headers={'Range': range_header}
+    )
+    assert response.status == exp_status
+    if response.status == falcon.HTTP_416:
+        assert response.headers.get('Content-Range') == 'bytes */16'
 
 
 def test_pathlib_path(asgi, monkeypatch):
@@ -348,8 +471,10 @@ async def run():
         body = resp.stream.read()
 
     assert sr.match(req.path)
-    assert body.decode() == os.path.normpath(os.path.join('/var/www/statics', expected))
+    expected_content = os.path.normpath(os.path.join('/var/www/statics', expected))
+    assert body.decode() == expected_content
     assert resp.content_type in _MIME_ALTERNATIVE.get(content_type, (content_type,))
+    assert resp.headers.get('accept-ranges') == 'bytes'
 
     if downloadable:
         assert os.path.basename(expected) in resp.downloadable_as
@@ -425,3 +550,15 @@ def suspicious_normpath(path):
     monkeypatch.setattr('os.path.normpath', suspicious_normpath)
     response = client.simulate_request(path='/static/shadow')
     assert response.status == falcon.HTTP_404
+
+
+def test_bounded_file_wrapper():
+    buffer = io.BytesIO(b'0123456789')
+    fh = _BoundedFile(buffer, 4)
+    assert fh.read() == b'0123'
+    assert fh.read() == b''
+    fh = _BoundedFile(buffer, 4)
+    assert list(iter(lambda: fh.read(3), b'')) == [b'456', b'7']
+    assert not buffer.closed
+    fh.close()
+    assert buffer.closed