Skip to content

Commit 9fbd9f1

Browse files
authored
Merge pull request #1380 from opengisch/fix_range_typing
refactor: fix typing for parsing the `Range` header and serving the files
2 parents ee68410 + e2422cd commit 9fbd9f1

File tree

3 files changed

+140
-54
lines changed

3 files changed

+140
-54
lines changed

docker-app/qfieldcloud/filestorage/tests/test_range.py

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import logging
22
from io import StringIO
33

4-
from django.test import override_settings
4+
from django.test import RequestFactory, override_settings
55
from rest_framework import status
66
from rest_framework.test import APITransactionTestCase
77

88
from qfieldcloud.authentication.models import AuthToken
9+
from qfieldcloud.core.exceptions import InvalidRangeError
910
from qfieldcloud.core.models import (
1011
Person,
1112
Project,
@@ -14,7 +15,7 @@
1415
from qfieldcloud.core.tests.utils import (
1516
setup_subscription_plans,
1617
)
17-
from qfieldcloud.filestorage.utils import parse_range
18+
from qfieldcloud.filestorage.utils import get_range, parse_range_header
1819

1920
logging.disable(logging.CRITICAL)
2021

@@ -39,9 +40,9 @@ def setUp(self):
3940
)
4041

4142
def test_parsing_range_function_succeeds(self):
42-
self.assertEquals(parse_range("bytes=4-8", 10), (4, 8))
43+
self.assertEquals(parse_range_header("bytes=4-8", 10), (4, 8))
4344

44-
start_byte, end_byte = parse_range("bytes=2-", 10)
45+
start_byte, end_byte = parse_range_header("bytes=2-", 10)
4546

4647
self.assertEquals(start_byte, 2)
4748
self.assertIsNone(end_byte)
@@ -50,39 +51,85 @@ def test_parsing_wrong_invalid_range_function_succeeds(self):
5051
file_size = 1000000
5152

5253
# not starting with 'bytes'
53-
self.assertIsNone(parse_range("byte=4-8", file_size))
54+
self.assertIsNone(parse_range_header("byte=4-8", file_size))
5455

5556
# start byte can not be negative
56-
self.assertIsNone(parse_range("bytes=-1-15", file_size))
57+
self.assertIsNone(parse_range_header("bytes=-1-15", file_size))
5758

5859
# start and end bytes can not be negative
59-
self.assertIsNone(parse_range("bytes=-10--15", file_size))
60+
self.assertIsNone(parse_range_header("bytes=-10--15", file_size))
6061

6162
# start position cannot be greater than the end position
62-
self.assertIsNone(parse_range("bytes=9-1", file_size))
63+
self.assertIsNone(parse_range_header("bytes=9-1", file_size))
6364

6465
# suffix ranges are not supported (yet), see https://www.rfc-editor.org/rfc/rfc9110.html#rule.suffix-range
65-
self.assertIsNone(parse_range("bytes=-5", file_size))
66+
self.assertIsNone(parse_range_header("bytes=-5", file_size))
6667

6768
# bytes should be numbers
68-
self.assertIsNone(parse_range("bytes=one-two", file_size))
69+
self.assertIsNone(parse_range_header("bytes=one-two", file_size))
6970
# whitespaces are not accepted
70-
self.assertIsNone(parse_range("bytes= 1-9", file_size))
71-
self.assertIsNone(parse_range("bytes=1 -9", file_size))
72-
self.assertIsNone(parse_range("bytes=1- 9", file_size))
73-
self.assertIsNone(parse_range("bytes=1-9 ", file_size))
74-
self.assertIsNone(parse_range("bytes=1- ", file_size))
75-
self.assertIsNone(parse_range(" bytes=1-9", file_size))
71+
self.assertIsNone(parse_range_header("bytes= 1-9", file_size))
72+
self.assertIsNone(parse_range_header("bytes=1 -9", file_size))
73+
self.assertIsNone(parse_range_header("bytes=1- 9", file_size))
74+
self.assertIsNone(parse_range_header("bytes=1-9 ", file_size))
75+
self.assertIsNone(parse_range_header("bytes=1- ", file_size))
76+
self.assertIsNone(parse_range_header(" bytes=1-9", file_size))
7677
# typos in bytes
77-
self.assertIsNone(parse_range("bites=0-9", file_size))
78-
self.assertIsNone(parse_range("starting bytes=0-9", file_size))
79-
self.assertIsNone(parse_range("bytes=0-9 closing bytes", file_size))
78+
self.assertIsNone(parse_range_header("bites=0-9", file_size))
79+
self.assertIsNone(parse_range_header("starting bytes=0-9", file_size))
80+
self.assertIsNone(parse_range_header("bytes=0-9 closing bytes", file_size))
8081
# empty range
81-
self.assertIsNone(parse_range("bytes=0-0", file_size))
82-
self.assertIsNone(parse_range("bytes=1-1", file_size))
82+
self.assertIsNone(parse_range_header("bytes=0-0", file_size))
83+
self.assertIsNone(parse_range_header("bytes=1-1", file_size))
8384
# multiple ranges are not supported (yet), see https://www.rfc-editor.org/rfc/rfc9110.html#section-14.1.2-9.4.1
84-
self.assertIsNone(parse_range("bytes=1-5, 10-15", file_size))
85-
self.assertIsNone(parse_range("bytes=1-5,10-15", file_size))
85+
self.assertIsNone(parse_range_header("bytes=1-5, 10-15", file_size))
86+
self.assertIsNone(parse_range_header("bytes=1-5,10-15", file_size))
87+
88+
def test_get_range_function(self):
89+
factory = RequestFactory()
90+
91+
request = factory.get("")
92+
range = get_range(request, 10)
93+
94+
self.assertIsNone(range)
95+
96+
request = factory.get("", headers={"Range": "bytes=4-8"})
97+
range = get_range(request, 10)
98+
99+
self.assertIsNotNone(range)
100+
# typing is not aware that the above function checks for `None`
101+
assert range
102+
103+
self.assertEqual(range.start, 4)
104+
self.assertEqual(range.end, 8)
105+
self.assertEqual(range.length, 5)
106+
self.assertEqual(range.total_size, 10)
107+
self.assertEqual(range.header, "bytes=4-8")
108+
109+
request = factory.get("", headers={"Range": "bytes=4-8"})
110+
111+
request = factory.get("", headers={"Range": "bytes=2-"})
112+
range = get_range(request, 10)
113+
114+
self.assertIsNotNone(range)
115+
assert range
116+
117+
self.assertEqual(range.start, 2)
118+
self.assertEqual(range.end, 9)
119+
self.assertEqual(range.length, 8)
120+
self.assertEqual(range.total_size, 10)
121+
self.assertEqual(range.header, "bytes=2-")
122+
123+
request = factory.get("", headers={"Range": "bytes= 2 - "})
124+
125+
with self.assertRaises(InvalidRangeError):
126+
get_range(request, 10)
127+
128+
with override_settings(QFIELDCLOUD_MINIMUM_RANGE_HEADER_LENGTH=3):
129+
request = factory.get("", headers={"Range": "bytes=0-1"})
130+
131+
with self.assertRaises(InvalidRangeError):
132+
get_range(request, 10)
86133

87134
def test_upload_file_then_download_range_succeeds(self):
88135
for project in [self.project_default_storage, self.project_webdav_storage]:

docker-app/qfieldcloud/filestorage/utils.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
from pathlib import Path, PurePath
55
from typing import Any
66

7+
from attr import dataclass
78
from django.conf import settings
89
from django.core.exceptions import ValidationError
910
from django.core.files.base import ContentFile
1011
from django.core.validators import RegexValidator
12+
from django.http import HttpRequest
1113
from django.utils.translation import gettext_lazy as _
1214

15+
from qfieldcloud.core.exceptions import InvalidRangeError
16+
1317
filename_validator = RegexValidator(
1418
settings.STORAGES_FILENAME_VALIDATION_REGEX,
1519
_(
@@ -158,7 +162,10 @@ def to_uuid(value: Any) -> uuid.UUID | None:
158162
return None
159163

160164

161-
def parse_range(input_range: str, file_size: int) -> tuple[int, int | None] | None:
165+
def parse_range_header(
166+
input_range: str,
167+
file_size: int,
168+
) -> tuple[int, int | None] | None:
162169
"""Parses a range HTTP Header string.
163170
164171
Arguments:
@@ -189,3 +196,54 @@ def parse_range(input_range: str, file_size: int) -> tuple[int, int | None] | No
189196
range_end = None
190197

191198
return (range_start, range_end)
199+
200+
201+
@dataclass
202+
class RangeForFile:
203+
"""A range for a file, as parsed from a HTTP Range header."""
204+
205+
start: int
206+
end: int
207+
length: int
208+
total_size: int
209+
header: str
210+
211+
212+
def get_range(request: HttpRequest, total_size: int) -> RangeForFile | None:
213+
"""Get a parsed range for a file, if any.
214+
215+
Arguments:
216+
request: the HTTP request to get the range from.
217+
file: the file to get the range for.
218+
"""
219+
range_header = request.headers.get("Range", "")
220+
221+
if not range_header:
222+
return None
223+
224+
range_match = parse_range_header(range_header, total_size)
225+
226+
if not range_match:
227+
raise InvalidRangeError("The provided HTTP range header is invalid.")
228+
229+
range_start, range_end = range_match
230+
231+
if range_end is None:
232+
range_end = total_size - 1
233+
234+
range_length = range_end - range_start + 1
235+
236+
if range_length < settings.QFIELDCLOUD_MINIMUM_RANGE_HEADER_LENGTH:
237+
raise InvalidRangeError(
238+
"Requested range too small, expected at least {} but got {} bytes".format(
239+
settings.QFIELDCLOUD_MINIMUM_RANGE_HEADER_LENGTH, range_length
240+
)
241+
)
242+
243+
return RangeForFile(
244+
start=range_start,
245+
end=range_end,
246+
length=range_length,
247+
total_size=total_size,
248+
header=range_header,
249+
)

docker-app/qfieldcloud/filestorage/view_helpers.py

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919

2020
from qfieldcloud.core import exceptions, permissions_utils
2121
from qfieldcloud.core.exceptions import (
22-
InvalidRangeError,
2322
MultipleProjectsError,
2423
RestrictedProjectModificationError,
2524
)
@@ -36,9 +35,9 @@
3635
FileVersion,
3736
)
3837
from qfieldcloud.filestorage.utils import (
38+
get_range,
3939
is_admin_restricted_file,
4040
is_qgis_project_file,
41-
parse_range,
4241
validate_filename,
4342
)
4443

@@ -247,27 +246,7 @@ def download_field_file(
247246
http_host = request.headers.get("host", "")
248247
https_port = http_host.split(":")[-1] if ":" in http_host else "443"
249248

250-
download_range = request.headers.get("Range", "")
251-
if download_range:
252-
file_size = field_file.size
253-
range_match = parse_range(download_range, file_size)
254-
255-
if not range_match:
256-
raise InvalidRangeError("The provided HTTP range header is invalid.")
257-
258-
range_start, range_end = range_match
259-
260-
if range_end is None:
261-
range_end = file_size - 1
262-
263-
range_length = range_end - range_start + 1
264-
265-
if range_length < settings.QFIELDCLOUD_MINIMUM_RANGE_HEADER_LENGTH:
266-
raise InvalidRangeError(
267-
"Requested range too small, expected at least {} but got {} bytes".format(
268-
settings.QFIELDCLOUD_MINIMUM_RANGE_HEADER_LENGTH, range_length
269-
)
270-
)
249+
range = get_range(request, field_file.size)
271250

272251
if https_port == settings.WEB_HTTPS_PORT and not settings.IN_TEST_SUITE:
273252
# this is the relative path of the file, including the containing directories.
@@ -321,25 +300,27 @@ def download_field_file(
321300
response["X-Accel-Redirect"] = "/storage-download/"
322301
response["redirect_uri"] = url
323302

324-
if download_range:
325-
response["file_range"] = download_range
303+
if range:
304+
response["file_range"] = range.header
326305

327306
field_file.storage.patch_nginx_download_redirect(response) # type: ignore
328307

329308
return response
330309
elif settings.DEBUG or settings.IN_TEST_SUITE:
331-
if download_range:
310+
if range:
332311
file = field_file.open()
333312

334-
file.seek(range_start)
335-
content = file.read(range_length)
313+
file.seek(range.start)
314+
content = file.read(range.length)
336315

337316
response = HttpResponse(
338317
content, status=206, content_type="application/octet-stream"
339318
)
340319

341-
response["Content-Range"] = f"bytes {range_start}-{range_end}/{file_size}"
342-
response["Content-Length"] = str(range_length)
320+
response["Content-Range"] = (
321+
f"bytes {range.start}-{range.end}/{range.total_size}"
322+
)
323+
response["Content-Length"] = str(range.length)
343324
response["Accept-Ranges"] = "bytes"
344325

345326
return response

0 commit comments

Comments
 (0)