Skip to content

Commit fdd63e7

Browse files
authored
Merge pull request #1164 from mattburnett-repo/670
Issue 670. Image Upload Validation.
2 parents 7664f40 + 977f4f8 commit fdd63e7

File tree

14 files changed

+351
-54
lines changed

14 files changed

+351
-54
lines changed

.env.dev

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
FRONTEND_PORT="3000"
22
VITE_FRONTEND_URL="http://localhost:3000"
3+
VITE_BACKEND_URL="http://localhost:8000"
34
VITE_API_URL="http://localhost:8000/v1"
45

56
ADMIN_PATH="admin/"

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ venv/
1818
.mypy_cache
1919

2020
# Don't include migrations for now.
21-
*migrations/*
21+
**/migrations/*
2222
!*migrations/__init__.py
2323

2424
# Testing

backend/authentication/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class Support(models.Model):
8383
creation_date = models.DateTimeField(auto_now_add=True)
8484

8585
def __str__(self) -> str:
86-
return f"{self.id}"
86+
return str(self.id)
8787

8888

8989
class UserModel(AbstractUser, PermissionsMixin):

backend/communities/groups/models.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class GroupImage(models.Model):
5151
sequence_index = models.IntegerField()
5252

5353
def __str__(self) -> str:
54-
return f"{self.id}"
54+
return str(self.id)
5555

5656

5757
class GroupMember(models.Model):
@@ -68,7 +68,7 @@ class GroupMember(models.Model):
6868
is_comms = models.BooleanField(default=False)
6969

7070
def __str__(self) -> str:
71-
return f"{self.id}"
71+
return str(self.id)
7272

7373

7474
class GroupSocialLink(SocialLink):

backend/communities/organizations/models.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class OrganizationApplication(models.Model):
6868
creation_date = models.DateTimeField(auto_now_add=True)
6969

7070
def __str__(self) -> str:
71-
return f"{self.creation_date}"
71+
return str(self.creation_date)
7272

7373

7474
class OrganizationApplicationStatus(models.Model):
@@ -85,7 +85,7 @@ class OrganizationImage(models.Model):
8585
sequence_index = models.IntegerField()
8686

8787
def __str__(self) -> str:
88-
return f"{self.id}"
88+
return str(self.id)
8989

9090

9191
class OrganizationMember(models.Model):
@@ -96,7 +96,7 @@ class OrganizationMember(models.Model):
9696
is_comms = models.BooleanField(default=False)
9797

9898
def __str__(self) -> str:
99-
return f"{self.id}"
99+
return str(self.id)
100100

101101

102102
class OrganizationSocialLink(SocialLink):
@@ -114,7 +114,7 @@ class OrganizationTask(models.Model):
114114
)
115115

116116
def __str__(self) -> str:
117-
return f"{self.id}"
117+
return str(self.id)
118118

119119

120120
class OrganizationText(models.Model):

backend/content/models.py

+18-6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
Models for the content app.
44
"""
55

6+
import os
7+
from typing import Any
68
from uuid import uuid4
79

810
from django.contrib.postgres.fields import ArrayField
@@ -24,7 +26,7 @@ class Discussion(models.Model):
2426
tags = models.ManyToManyField("content.Tag", blank=True)
2527

2628
def __str__(self) -> str:
27-
return f"{self.id}"
29+
return str(self.id)
2830

2931

3032
class Faq(models.Model):
@@ -40,15 +42,25 @@ def __str__(self) -> str:
4042
return self.question
4143

4244

45+
# This is used to set the filename to the UUID of the model, in the Image model.
46+
def set_filename_to_uuid(instance: Any, filename: str) -> str:
47+
"""Generate a new filename using the model's UUID and keep the original extension."""
48+
ext = os.path.splitext(filename)[1] # extract file extension
49+
new_filename = f"{instance.id}{ext}" # use model UUID as filename
50+
51+
return os.path.join("images/", new_filename) # store in 'images/' folder
52+
53+
4354
class Image(models.Model):
4455
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
4556
file_object = models.ImageField(
46-
upload_to="images/", validators=[validate_image_file_extension]
57+
upload_to=set_filename_to_uuid,
58+
validators=[validate_image_file_extension],
4759
)
4860
creation_date = models.DateTimeField(auto_now_add=True)
4961

5062
def __str__(self) -> str:
51-
return f"{self.id}"
63+
return str(self.id)
5264

5365

5466
class Location(models.Model):
@@ -61,7 +73,7 @@ class Location(models.Model):
6173
display_name = models.CharField(max_length=255)
6274

6375
def __str__(self) -> str:
64-
return f"{self.id}"
76+
return str(self.id)
6577

6678

6779
class Resource(models.Model):
@@ -117,7 +129,7 @@ class Tag(models.Model):
117129
creation_date = models.DateTimeField(auto_now_add=True)
118130

119131
def __str__(self) -> str:
120-
return f"{self.id}"
132+
return str(self.id)
121133

122134

123135
class Task(models.Model):
@@ -163,4 +175,4 @@ class DiscussionEntry(models.Model):
163175
deletion_date = models.DateTimeField(blank=True, null=True)
164176

165177
def __str__(self) -> str:
166-
return f"{self.id}"
178+
return str(self.id)

backend/content/serializers.py

+69-9
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
Serializers for the content app.
44
"""
55

6+
from io import BytesIO
67
from typing import Any, Dict, Union
78

9+
from django.conf import settings
10+
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
811
from django.utils.translation import gettext as _
12+
from PIL import Image as PILImage
913
from rest_framework import serializers
1014

15+
from communities.organizations.models import OrganizationImage
1116
from content.models import (
1217
Discussion,
1318
DiscussionEntry,
@@ -34,31 +39,86 @@ class Meta:
3439
fields = "__all__"
3540

3641

42+
# MARK: Clear Metadata
43+
44+
45+
def scrub_exif(image_file: InMemoryUploadedFile) -> InMemoryUploadedFile:
46+
"""
47+
Remove EXIF metadata from JPEGs and text metadata from PNGs.
48+
"""
49+
try:
50+
img: PILImage.Image = PILImage.open(image_file)
51+
output_format = img.format
52+
53+
if output_format == "JPEG":
54+
img = img.convert("RGB")
55+
56+
elif output_format == "PNG":
57+
img = img.copy()
58+
img.info = {}
59+
60+
else:
61+
return image_file # return as-is if it's not JPEG or PNG
62+
63+
# Save the cleaned image into a buffer.
64+
output = BytesIO()
65+
img.save(
66+
output,
67+
format=output_format,
68+
quality=95 if output_format == "JPEG" else None, # set JPEG quality
69+
optimize=output_format == "JPEG", # optimize JPEG
70+
)
71+
output.seek(0)
72+
73+
# Return a new InMemoryUploadedFile
74+
return InMemoryUploadedFile(
75+
output,
76+
image_file.field_name, # use original field name
77+
image_file.name,
78+
f"image/{output_format.lower()}",
79+
output.getbuffer().nbytes,
80+
image_file.charset, # preserve charset (if applicable)
81+
)
82+
83+
except Exception as e:
84+
print(f"Error scrubbing EXIF: {e}")
85+
return image_file # return original file in case of error
86+
87+
3788
class ImageSerializer(serializers.ModelSerializer[Image]):
3889
class Meta:
3990
model = Image
4091
fields = ["id", "file_object", "creation_date"]
4192
read_only_fields = ["id", "creation_date"]
4293

43-
def validate(self, data: Dict[str, Union[str, int]]) -> Dict[str, Union[str, int]]:
44-
# Remove string validation since we're getting a file object.
94+
def validate(self, data: Dict[str, UploadedFile]) -> Dict[str, UploadedFile]:
4595
if "file_object" not in data:
4696
raise serializers.ValidationError("No file was submitted.")
4797

98+
# DATA_UPLOAD_MAX_MEMORY_SIZE and IMAGE_UPLOAD_MAX_FILE_SIZE are set in core/settings.py.
99+
# The file size limit is not being enforced. We're checking the file size here.
100+
if (
101+
data["file_object"].size is not None
102+
and data["file_object"].size > settings.IMAGE_UPLOAD_MAX_FILE_SIZE
103+
):
104+
raise serializers.ValidationError(
105+
f"The file size ({data['file_object'].size} bytes) is too large. The maximum file size is {settings.IMAGE_UPLOAD_MAX_FILE_SIZE} bytes."
106+
)
107+
48108
return data
49109

50110
# Using 'Any' type until a more correct type is determined.
51111
def create(self, validated_data: Dict[str, Any]) -> Image:
52-
if file_obj := self.context["request"].FILES.get("file_object"):
53-
validated_data["file_object"] = file_obj
112+
request = self.context["request"]
54113

55-
# Create the image first.
56-
image = super().create(validated_data)
114+
if file_obj := request.FILES.get("file_object"):
115+
validated_data["file_object"] = scrub_exif(file_obj)
57116

58-
if organization_id := self.context["request"].data.get("organization_id"):
59-
# Create OrganizationImage with next sequence index.
60-
from communities.organizations.models import OrganizationImage
117+
# Create the image instance.
118+
image = super().create(validated_data)
61119

120+
# Handle organization image indexing if applicable.
121+
if organization_id := request.data.get("organization_id"):
62122
next_index = OrganizationImage.objects.filter(
63123
org_id=organization_id
64124
).count()

0 commit comments

Comments
 (0)