Skip to content

Commit e8951f7

Browse files
rtibblesclaude
andcommitted
feat: add GCS resumable upload storage helpers
- supports_resumable flag on the GCS storage backends - get_stored_object_md5: dedup lookup against an object's GCS-computed md5 - create_resumable_upload_session: pins md5 + declared-size metadata - hex_to_base64 checksum helper Part of learningequality#5975. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzUP3UYP4cLyouvssXyekj
1 parent 8a56525 commit e8951f7

4 files changed

Lines changed: 90 additions & 43 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/utils/files.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import copy
3+
import mimetypes
34
import os
45
import re
56
import tempfile
@@ -17,6 +18,12 @@
1718
from contentcuration.models import File
1819
from contentcuration.models import generate_object_storage_name
1920

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

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