Skip to content

Commit 67181da

Browse files
authored
Merge pull request learningequality#5995 from rtibbles/resumable_uploads
Add opt-in resumable uploads to the file upload URL endpoint
2 parents 702f802 + a972a5c commit 67181da

6 files changed

Lines changed: 205 additions & 59 deletions

File tree

contentcuration/contentcuration/tests/test_storage_common.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import codecs
21
import hashlib
32
from datetime import timedelta
43
from io import BytesIO
@@ -12,6 +11,7 @@
1211

1312
from .base import StudioTestCase
1413
from contentcuration.models import generate_object_storage_name
14+
from contentcuration.utils.gcs_storage import GoogleCloudStorage
1515
from contentcuration.utils.storage_common import _get_gcs_presigned_put_url
1616
from contentcuration.utils.storage_common import determine_content_type
1717
from contentcuration.utils.storage_common import get_presigned_upload_url
@@ -79,7 +79,7 @@ def test_raises_error(self):
7979
with pytest.raises(UnknownStorageBackendError):
8080
get_presigned_upload_url(
8181
"nice",
82-
"err",
82+
"d41d8cd98f00b204e9800998ecf8427e",
8383
5,
8484
0,
8585
storage=self.STORAGE,
@@ -157,6 +157,23 @@ def test_generate_signed_url_called_with_required_arguments(self):
157157
content_type=mimetype,
158158
)
159159

160+
def test_create_resumable_session_pins_md5_size_and_returns_url(self):
161+
storage = GoogleCloudStorage(self.client, "bucket")
162+
blob = self.client.get_bucket.return_value.blob.return_value
163+
blob.create_resumable_upload_session.return_value = "https://session.url"
164+
165+
url = storage.create_resumable_upload_session(
166+
"storage/a/b/abc.jpg",
167+
"d41d8cd98f00b204e9800998ecf8427e",
168+
2048,
169+
)
170+
171+
assert url == "https://session.url"
172+
assert blob.md5_hash == "1B2M2Y8AsgTpgAmY7PhCfg==" # hex checksum, b64-encoded
173+
assert blob.content_type == "image/jpeg"
174+
assert blob.metadata == {"declared-size": "2048"}
175+
blob.create_resumable_upload_session.assert_called_once()
176+
160177

161178
class S3StoragePresignedURLUnitTestCase(StudioTestCase):
162179
"""
@@ -177,7 +194,12 @@ def test_returns_string_if_inputs_are_valid(self):
177194

178195
# use a real connection here as a sanity check
179196
ret = get_presigned_upload_url(
180-
"a/b/abc.jpg", "aBc", 10, 1, storage=self.STORAGE, client=None
197+
"a/b/abc.jpg",
198+
"d41d8cd98f00b204e9800998ecf8427e",
199+
10,
200+
1,
201+
storage=self.STORAGE,
202+
client=None,
181203
)
182204
url = ret["uploadURL"]
183205

@@ -189,19 +211,12 @@ def test_can_upload_file_to_presigned_url(self):
189211
"""
190212
file_contents = b"blahfilecontents"
191213
file = BytesIO(file_contents)
192-
# S3 expects a base64-encoded MD5 checksum
193-
md5 = hashlib.md5(file_contents)
194-
md5_checksum = md5.hexdigest()
195-
md5_checksum_base64 = codecs.encode(
196-
codecs.decode(md5_checksum, "hex"), "base64"
197-
).decode()
214+
md5_checksum = hashlib.md5(file_contents).hexdigest()
198215

199216
filename = "blahfile.jpg"
200217
filepath = generate_object_storage_name(md5_checksum, filename)
201218

202-
ret = get_presigned_upload_url(
203-
filepath, md5_checksum_base64, 1000, len(file_contents)
204-
)
219+
ret = get_presigned_upload_url(filepath, md5_checksum, 1000, len(file_contents))
205220
url = ret["uploadURL"]
206221
content_type = ret["mimetype"]
207222

contentcuration/contentcuration/tests/viewsets/test_file.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import uuid
2+
from unittest import mock
23

34
from django.urls import reverse
45
from le_utils.constants import content_kinds
@@ -12,6 +13,8 @@
1213
from contentcuration.tests.viewsets.base import generate_delete_event
1314
from contentcuration.tests.viewsets.base import generate_update_event
1415
from contentcuration.tests.viewsets.base import SyncTestMixin
16+
from contentcuration.viewsets.file import FileUploadURLSerializer
17+
from contentcuration.viewsets.file import MAX_NON_RESUMABLE_UPLOAD_SIZE
1518
from contentcuration.viewsets.sync.constants import CONTENTNODE
1619
from contentcuration.viewsets.sync.constants import FILE
1720

@@ -546,6 +549,9 @@ def test_mismatched_preset_upload(self):
546549

547550
def test_insufficient_storage(self):
548551
self.file["size"] = 100000000000000
552+
self.file[
553+
"resumable"
554+
] = True # resumable bypasses the >500MB guard so this still exercises the quota (412) path
549555

550556
self.client.force_authenticate(user=self.user)
551557
response = self.client.post(
@@ -590,6 +596,26 @@ def test_duration_zero(self):
590596

591597
self.assertEqual(response.status_code, 400)
592598

599+
def test_fractional_size_rejected(self):
600+
s = FileUploadURLSerializer(data={**self.file, "size": 1000.5})
601+
assert not s.is_valid()
602+
assert "size" in s.errors
603+
604+
def test_large_non_resumable_rejected(self):
605+
self.file["size"] = MAX_NON_RESUMABLE_UPLOAD_SIZE + 1
606+
self.client.force_authenticate(user=self.user)
607+
resp = self.client.post(reverse("file-upload-url"), self.file, format="json")
608+
assert resp.status_code == 400
609+
610+
def test_large_resumable_allowed(self):
611+
self.user.disk_space = 10 * 1024 * 1024 * 1024
612+
self.user.save()
613+
self.file["size"] = MAX_NON_RESUMABLE_UPLOAD_SIZE + 1
614+
self.file["resumable"] = True
615+
self.client.force_authenticate(user=self.user)
616+
resp = self.client.post(reverse("file-upload-url"), self.file, format="json")
617+
assert resp.status_code == 200
618+
593619

594620
class ContentIDTestCase(SyncTestMixin, StudioAPITestCase):
595621
def setUp(self):
@@ -763,3 +789,60 @@ def test_content_id__thumbnails_dont_update_content_id(self):
763789
self.assertEqual(
764790
copied_node_content_id_before_upload, copied_node_content_id_after_upload
765791
)
792+
793+
794+
class ResumableUploadURLTestCase(StudioAPITestCase):
795+
def setUp(self):
796+
super(ResumableUploadURLTestCase, self).setUp()
797+
self.user = testdata.user()
798+
# Give user enough quota to handle resumable uploads
799+
self.user.disk_space = 10 * 1024 * 1024 * 1024
800+
self.user.save()
801+
self.file = {
802+
"size": 1000,
803+
"checksum": uuid.uuid4().hex,
804+
"name": "le_studio",
805+
"file_format": file_formats.MP3,
806+
"preset": format_presets.AUDIO,
807+
"duration": 10.123,
808+
"resumable": True,
809+
}
810+
811+
@mock.patch("contentcuration.viewsets.file.default_storage")
812+
def test_resumable_returns_session_when_not_stored(self, mock_storage):
813+
mock_storage.get_stored_object_md5.return_value = None
814+
mock_storage.create_resumable_upload_session.return_value = (
815+
"https://session.url"
816+
)
817+
self.client.force_authenticate(user=self.user)
818+
resp = self.client.post(reverse("file-upload-url"), self.file, format="json")
819+
assert resp.status_code == 200
820+
data = resp.json()
821+
assert data["resumable"] is True
822+
assert data["uploadURL"] == "https://session.url"
823+
assert data["alreadyUploaded"] is False
824+
assert "file" in data
825+
assert data["file"]["id"]
826+
mock_storage.create_resumable_upload_session.assert_called_once()
827+
828+
@mock.patch("contentcuration.viewsets.file.default_storage")
829+
def test_resumable_skips_when_already_stored(self, mock_storage):
830+
mock_storage.get_stored_object_md5.return_value = self.file["checksum"]
831+
self.client.force_authenticate(user=self.user)
832+
resp = self.client.post(reverse("file-upload-url"), self.file, format="json")
833+
data = resp.json()
834+
assert data["resumable"] is True and data["alreadyUploaded"] is True
835+
assert data["uploadURL"] is None
836+
assert "file" in data
837+
assert data["file"]["id"]
838+
mock_storage.create_resumable_upload_session.assert_not_called()
839+
840+
def test_resumable_falls_back_to_single_put_on_s3(self):
841+
# default_storage is S3 in the test env → no resumable support
842+
self.client.force_authenticate(user=self.user)
843+
resp = self.client.post(reverse("file-upload-url"), self.file, format="json")
844+
data = resp.json()
845+
assert data["resumable"] is False
846+
assert "uploadURL" in data
847+
assert "file" in data
848+
assert data["file"]["id"]

contentcuration/contentcuration/utils/files.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import base64
2+
import codecs
23
import copy
4+
import mimetypes
35
import os
46
import re
57
import tempfile
@@ -17,6 +19,12 @@
1719
from contentcuration.models import File
1820
from contentcuration.models import generate_object_storage_name
1921

22+
23+
# Do this to ensure that we infer mimetypes for files properly, specifically
24+
# zip file and epub files.
25+
# to add additional files add them to the mime.types file
26+
mimetypes.init([os.path.join(os.path.dirname(__file__), "mime.types")])
27+
2028
ImageFile.LOAD_TRUNCATED_IMAGES = True
2129
THUMBNAIL_WIDTH = 400
2230

@@ -196,3 +204,27 @@ def create_thumbnail_from_base64(
196204
)
197205
finally:
198206
os.close(fd)
207+
208+
209+
def determine_content_type(filename):
210+
"""
211+
Guesses the content type of a filename. Returns the mimetype of a file.
212+
213+
Returns "application/octet-stream" if the type can't be guessed.
214+
Raises an AssertionError if filename is not a string.
215+
"""
216+
217+
typ, _ = mimetypes.guess_type(filename)
218+
219+
if not typ:
220+
return "application/octet-stream"
221+
return typ
222+
223+
224+
def hex_to_base64(hexdigest):
225+
"""Convert a hex-encoded digest (e.g. an MD5 checksum) to base64."""
226+
return codecs.encode(codecs.decode(hexdigest, "hex"), "base64").decode().strip()
227+
228+
229+
def base64_to_hex(b64):
230+
return codecs.encode(codecs.decode(b64.encode(), "base64"), "hex").decode().strip()

contentcuration/contentcuration/utils/gcs_storage.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
from google.cloud.storage import Client
1212
from google.cloud.storage.blob import Blob
1313

14+
from .files import determine_content_type
15+
from contentcuration.utils.files import base64_to_hex
16+
from contentcuration.utils.files import hex_to_base64
17+
1418
OLD_STUDIO_STORAGE_PREFIX = "/contentworkshop_content/"
1519

1620
CONTENT_DATABASES_MAX_AGE = 5 # seconds
@@ -120,10 +124,6 @@ def save(self, name, fobj, max_length=None, blob_object=None):
120124
blob.content_encoding = "gzip"
121125
fobj = buffer
122126

123-
# determine the current file's mimetype based on the name
124-
# import determine_content_type lazily in here, so we don't get into an infinite loop with circular dependencies
125-
from contentcuration.utils.storage_common import determine_content_type
126-
127127
content_type = determine_content_type(name)
128128

129129
# force the current file to be at file location 0, to
@@ -215,6 +215,18 @@ def _is_file_empty(fobj):
215215
fobj.seek(current_location)
216216
return len(byt) == 0
217217

218+
def create_resumable_upload_session(self, name, md5, size):
219+
blob = self.bucket.blob(name)
220+
md5_b64 = hex_to_base64(md5)
221+
blob.md5_hash = md5_b64.strip()
222+
blob.content_type = determine_content_type(name)
223+
blob.metadata = {"declared-size": str(size)}
224+
return blob.create_resumable_upload_session(client=self.client)
225+
226+
def get_stored_object_md5(self, name):
227+
blob = self.bucket.get_blob(name)
228+
return base64_to_hex(blob.md5_hash) if blob is not None else None
229+
218230

219231
class CompositeGCS(Storage):
220232
def __init__(self):
@@ -283,3 +295,11 @@ def get_created_time(self, name):
283295

284296
def get_modified_time(self, name):
285297
return self._get_readable_backend(name).get_modified_time(name)
298+
299+
def create_resumable_upload_session(self, name, md5_b64, size):
300+
return self._get_writeable_backend().create_resumable_upload_session(
301+
name, md5_b64, size
302+
)
303+
304+
def get_stored_object_md5(self, name):
305+
return self._get_readable_backend(name).get_stored_object_md5(name)

contentcuration/contentcuration/utils/storage_common.py

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,22 @@
1-
import mimetypes
2-
import os
31
from datetime import timedelta
42

53
from django.conf import settings
64
from django.core.files.storage import default_storage
75
from django_s3_storage.storage import S3Storage
86

7+
from .files import determine_content_type
8+
from .files import hex_to_base64
99
from .gcs_storage import CompositeGCS
1010
from .gcs_storage import GoogleCloudStorage
1111

1212

13-
# Do this to ensure that we infer mimetypes for files properly, specifically
14-
# zip file and epub files.
15-
# to add additional files add them to the mime.types file
16-
mimetypes.init([os.path.join(os.path.dirname(__file__), "mime.types")])
17-
18-
1913
class UnknownStorageBackendError(Exception):
2014
pass
2115

2216

23-
def determine_content_type(filename):
24-
"""
25-
Guesses the content type of a filename. Returns the mimetype of a file.
26-
27-
Returns "application/octet-stream" if the type can't be guessed.
28-
Raises an AssertionError if filename is not a string.
29-
"""
30-
31-
typ, _ = mimetypes.guess_type(filename)
32-
33-
if not typ:
34-
return "application/octet-stream"
35-
return typ
36-
37-
3817
def get_presigned_upload_url(
3918
filepath,
40-
md5sum_b64,
19+
md5_hex,
4120
lifetime_sec,
4221
content_length,
4322
storage=default_storage,
@@ -48,9 +27,10 @@ def get_presigned_upload_url(
4827
contents with the contents of your PUT request.
4928
5029
:param: filepath: the file path inside the bucket, to the file.
51-
:param: md5sum_b64: the base64 encoded md5 hash of the file. The holder of the URL will
52-
have to set a Content-MD5 HTTP header matching this md5sum once it
53-
initiates the download.
30+
:param: md5_hex: the hex-encoded md5 hash of the file. The base64 encoding
31+
that GCS requires for the Content-MD5 header is handled internally; the
32+
holder of the URL must set a Content-MD5 HTTP header matching that
33+
base64-encoded value once it initiates the upload.
5434
:param: lifetime_sec: the lifetime of the generated upload url, in seconds.
5535
:param: content_length: the size of the content, in bytes.
5636
:param: client: the storage client that will be used to gennerate the presigned URL.
@@ -67,6 +47,7 @@ def get_presigned_upload_url(
6747
# both storage types are having difficulties enforcing it.
6848

6949
mimetype = determine_content_type(filepath)
50+
md5sum_b64 = hex_to_base64(md5_hex)
7051
if isinstance(storage, (GoogleCloudStorage, CompositeGCS)):
7152
client = client or storage.get_client()
7253
bucket = settings.AWS_S3_BUCKET_NAME

0 commit comments

Comments
 (0)