Skip to content

Commit 522fcd1

Browse files
committed
Form S3 URLs via storages APIs in TemporaryUpload
This fixes a bug where we would form URLs without the `private-media/` prefix that's set at the django-private-storages config. Instead of directly appending the prefix, we now go through the storage to form the URL
1 parent 2ee8bbe commit 522fcd1

2 files changed

Lines changed: 50 additions & 60 deletions

File tree

aiarena/core/models/temporary_upload.py

Lines changed: 49 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22

33
from django.db import models
44

5-
from botocore.exceptions import ClientError
65
from private_storage.fields import PrivateFileField
6+
from storages.backends.s3boto3 import S3Boto3Storage
7+
from storages.utils import clean_name
78

89
from .user import User
910

1011

1112
class TemporaryUpload(models.Model):
1213
"""
13-
Tracks temporary file uploads to S3 until they are consumed by result submission.
14+
Tracks temporary file uploads until they are consumed by result submission.
1415
Records are deleted when the upload is used (copied to permanent location).
1516
"""
1617

@@ -22,74 +23,63 @@ class Meta:
2223
ordering = ["-created_at"]
2324

2425
def __str__(self):
25-
return f"TemporaryUpload({self.id}, {self.s3_key})"
26-
27-
@property
28-
def _storage(self):
29-
return self.file.storage
30-
31-
@property
32-
def _client(self):
33-
return self._storage.connection.meta.client
34-
35-
@property
36-
def _bucket_name(self):
37-
return self._storage.bucket_name
38-
39-
@property
40-
def s3_key(self):
41-
return self.file.name
26+
return f"TemporaryUpload({self.id}, {self.file.name})"
4227

4328
@classmethod
4429
def create_for_upload(cls, user: User) -> "TemporaryUpload":
45-
"""Create a TemporaryUpload record with a generated S3 key."""
30+
"""Create a TemporaryUpload record with a storage-generated path."""
4631
upload = cls(uploaded_by=user)
47-
upload.file.name = f"temp-uploads/{uuid.uuid4().hex}"
32+
upload.file.name = cls._meta.get_field("file").generate_filename(upload, f"{uuid.uuid4().hex}")
4833
upload.save()
4934
return upload
5035

5136
def generate_presigned_put_url(self) -> str:
52-
"""Generate a presigned PUT URL for direct S3 upload."""
53-
return self._client.generate_presigned_url(
37+
"""Generate a presigned PUT URL for direct S3 upload.
38+
39+
Only meaningful on S3 storage; dev/test environments don't presign.
40+
"""
41+
storage = self.file.storage
42+
if not isinstance(storage, S3Boto3Storage):
43+
raise RuntimeError("Presigned URLs are only supported on S3 storage")
44+
# Storage prepends `location` (e.g. "private-media/") to the field name when
45+
# talking to S3; raw boto3 doesn't know that, so apply the same transform.
46+
s3_key = storage._normalize_name(clean_name(self.file.name))
47+
return storage.connection.meta.client.generate_presigned_url(
5448
"put_object",
55-
Params={
56-
"Bucket": self._bucket_name,
57-
"Key": self.s3_key,
58-
},
49+
Params={"Bucket": storage.bucket.name, "Key": s3_key},
5950
ExpiresIn=3600,
6051
)
6152

62-
def copy_to(self, dest_key: str) -> None:
63-
"""Copy to permanent location. Does not delete the source."""
64-
self._client.copy_object(
65-
Bucket=self._bucket_name,
66-
CopySource={"Bucket": self._bucket_name, "Key": self.s3_key},
67-
Key=dest_key,
68-
)
53+
def exists_in_storage(self) -> bool:
54+
return self.file.storage.exists(self.file.name)
6955

70-
def copy_to_file_field(self, file_field, filename: str) -> None:
71-
"""
72-
Copy the uploaded file to a model's FileField.
56+
def copy_to_file_field(self, dest_file_field, filename: str) -> None:
57+
"""Copy the uploaded file into a model's FileField via S3-to-S3 copy."""
58+
if not self.exists_in_storage():
59+
raise ValueError("Upload not found in storage")
7360

74-
Uses S3-to-S3 copy (no data through the web worker), then sets the field's
75-
name to the destination key.
76-
"""
77-
if not self.exists_in_s3():
78-
raise ValueError("Upload not found in S3")
79-
dest_key = file_field.field.upload_to(file_field.instance, filename)
80-
self.copy_to(dest_key)
81-
file_field.name = dest_key
82-
83-
def delete_s3_object(self) -> None:
84-
"""Delete the S3 object."""
85-
self._client.delete_object(Bucket=self._bucket_name, Key=self.s3_key)
86-
87-
def exists_in_s3(self) -> bool:
88-
"""Check if the file was actually uploaded to S3."""
89-
try:
90-
self._client.head_object(Bucket=self._bucket_name, Key=self.s3_key)
91-
return True
92-
except ClientError as e:
93-
if e.response["Error"]["Code"] == "404":
94-
return False
95-
raise
61+
src_storage = self.file.storage
62+
dst_storage = dest_file_field.storage
63+
64+
if not (isinstance(src_storage, S3Boto3Storage) and isinstance(dst_storage, S3Boto3Storage)):
65+
raise RuntimeError("copy_to_file_field requires S3 storage on both source and destination")
66+
if src_storage.bucket.name != dst_storage.bucket.name:
67+
raise RuntimeError("Cross-bucket copy is not supported")
68+
69+
# Build the destination name through the field's upload_to and storage,
70+
# so any path transforms (location prefix, name conflict resolution) apply.
71+
dest_name = dest_file_field.field.generate_filename(dest_file_field.instance, filename)
72+
73+
client = src_storage.connection.meta.client
74+
client.copy_object(
75+
Bucket=dst_storage.bucket.name,
76+
CopySource={
77+
"Bucket": src_storage.bucket.name,
78+
"Key": src_storage._normalize_name(clean_name(self.file.name)),
79+
},
80+
Key=dst_storage._normalize_name(clean_name(dest_name)),
81+
)
82+
dest_file_field.name = dest_name
83+
84+
def delete_from_storage(self) -> None:
85+
self.file.storage.delete(self.file.name)

aiarena/graphql/mutations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ def cleanup():
535535
# Delete temporary upload S3 objects and DB records
536536
for upload in temp_uploads:
537537
if upload is not None:
538-
upload.delete_s3_object()
538+
upload.delete_from_storage()
539539
upload.delete()
540540
# Delete old bot_data files that were replaced
541541
storage = Bot._meta.get_field("bot_data").storage

0 commit comments

Comments
 (0)