-
Notifications
You must be signed in to change notification settings - Fork 10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Image validation and size optimization for Profile image upload from both front-end and back-end #513
base: master
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just did a cursory skim over the PR, but here's some of the feedback I have so far.
Also, did you mean for the target branch to be master
here, or is this supposed to be targeting a main feature branch?
# Check file size | ||
image_file.seek(0, os.SEEK_END) | ||
file_size = image_file.tell() | ||
# reset file pointer | ||
image_file.seek(0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not a good way of validating the file size; these lines will read in the entire file, regardless of how big it is. That means that if a user uploads a 100G file (ex. directly through the HTTP endpoint), you'll still end up reading the entire file in.
I'd recommend changing this to read in from a file stream, and manually stopping the stream when it exceeds the file size limit.
csm_web/scheduler/views/user.py
Outdated
# not very efficient but way to compress image until it meets the file target size. | ||
def compress_image(image, target_size_bytes, img_format): | ||
"""Compress the image until it's smaller than target_size_bytes.""" | ||
buffer = BytesIO() | ||
quality = 95 # start with high quality | ||
while True: | ||
buffer.seek(0) | ||
if img_format == "JPEG" or img_format == "JPG": | ||
image.save(buffer, format=img_format, quality=quality) | ||
else: | ||
image.save(buffer, format="PNG", optimze=True) | ||
if buffer.tell() <= target_size_bytes or quality <= 50: | ||
break | ||
quality -= 5 # decrease quality to reduce file size | ||
buffer.seek(0) | ||
return buffer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any particular reason why we're compressing the file? We have a file size limit anyways, which should be sufficient (and can be lowered) if this is for storage concerns.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
usually, selfies or phone images tend to be larger than 5MB so allowing 10MB input size and compressing it to 5MB was what I was initially thinking but just realized I didn't change the target size and input limit. But should allowing 10MB then compressing to 5MB to save space make sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There should be tests added for uploading images; in particular one should be added where a request is sent using a file stream that never ends, and ensuring that the request is rejected after only reading in the file size limit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These migration files should be merged together at the end prior to merging to master, to reduce on the number of migration files in the repo.
Co-authored-by: Edward Lee <edwardneo@berkeley.edu>
fdfc29d
to
9a79f9b
Compare
…ated image upload UI, added hyperlinks to section UI, added tooltip
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Made a bunch of comments in the thread. Overall, looks pretty good!
Some other miscellaneous things as I tested manually:
-
Coordinators are able to edit anybody else's profile, which shouldn't be happening; I mentioned this as a review comment as well pointing to the section of code responsible. In general, there should be additional testing to ensure that negative constraints are satisfied. (To test, I had to remove the superuser exception in the code.)
-
Putting a blank name does not default to the user's full name. Interestingly, this only occurs when there is no image uploaded. Otherwise, the name defaults as usual. (This is probably because of some query invalidation to display the image once it's uploaded.)
-
I think there should be a button to clear the image if the user wants to delete their profile picture; currently, there is no way to do that, and if a user wants to change their profile picture to the default, they'd need to upload the CSM logo. For initial release, this can probably be deprioritized, but this can probably be relatively easily implemented by adding an "X" icon overlaid with the uplaod icon, and the PUT request can specify
null
for the file (the backend would then check if the file isnull
; a file that is not updated would not have a key in the request). -
The upload icon is a little bit faint when there's an image uploaded; I think either (a) make the icon less transparent, or (b) put a white background on the icon so that the image itself is a little more faint, bringing out the icon more.
-
The links look a little bit out of place; styling the links to fit in a little more with the color scheme could be good? The links on the resources page for worksheets could be good to model off of here. If you'd like, you can use a lighter blue (kinda like the csmentors.org website links) instead of the green to make it more clearly link-like. One big thing is that there shouldn't be a difference between normal links and
:visited
links on the page.
def get_accessed_time(self, name): | ||
# Implement logic to get the last accessed time | ||
raise NotImplementedError("This backend does not support this method.") | ||
|
||
def get_created_time(self, name): | ||
# Implement logic to get the creation time | ||
raise NotImplementedError("This backend does not support this method.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this going to be an issue? Not sure when these methods are called.
{ | ||
// TODO: add route for profiles (/profile/:id/* element = {UserProfile}) | ||
// TODO: add route for your own profile /profile/* | ||
// reference Section | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODOs leftover that should be deleted
<button className="primary-btn" onClick={handleCancelToggle}> | ||
Cancel | ||
</button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cancel button should always be the secondary button (in gray).
const iconWidth = "8em"; | ||
const iconHeight = "8em"; | ||
if (enrollmentSuccessful) { | ||
const inlineIconWidth = "1.3em"; | ||
const inlineIconHeight = "1.3em"; | ||
return ( | ||
<div className="enroll-confirm-modal-contents"> | ||
<CheckCircle height={iconHeight} width={iconWidth} /> | ||
<h3>Successfully enrolled</h3> | ||
<ModalCloser> | ||
<button className="primary-btn">OK</button> | ||
</ModalCloser> | ||
<h4>To view and update your profile, click the button below</h4> | ||
<Link className="primary-btn" to="/profile"> | ||
<UserIcon width={inlineIconWidth} height={inlineIconHeight} /> | ||
Profile | ||
</Link> | ||
</div> | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Icon sizes should be defined in the SCSS files, not directly in the HTML; I'd recommend adding a CSS class and setting the size there.
(The icon width/height variables are also referenced below.)
preferred_name = models.TextField(default="", blank=True) | ||
pronouns = models.CharField(max_length=20, default="", blank=True) | ||
pronunciation = models.CharField(max_length=50, default="", blank=True) | ||
# uploaded_at = models.DateTimeField(auto_now_add=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we aren't adding an uploaded_at
field, this line should be deleted.
if "file" in request.FILES: | ||
user.profile_image.save(file.name, file) | ||
serializer.save() | ||
# print({**serializer.data, "profileImage": user.profile_image}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commented print statement should be deleted.
const MAX_SIZE_MB = 2; | ||
const MAX_FILE_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is out of sync with the backend limit; the backend enforces a 5MB limit.
if file_size > TARGET_FILE_SIZE_BYTES: | ||
file = compress_image(image, TARGET_FILE_SIZE_BYTES, img_format) | ||
else: | ||
buffer = BytesIO() | ||
if img_format in ["JPEG", "JPG"]: | ||
image.save(buffer, format=img_format) | ||
else: | ||
image.save(buffer, format="PNG", optimze=True) | ||
buffer.seek(0) | ||
file = ContentFile(buffer.read(), name=image_file.name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This section of code currently throws an error when trying to save the image on line 155.
Error details
csm_web-django-1 | "PUT /api/user/3342/update/ HTTP/1.1" 500 13988
csm_web-django-1 | Internal Server Error: /api/user/3342/update/
csm_web-django-1 | Traceback (most recent call last):
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/django/core/handlers/exception.py", line 55, in inner
csm_web-django-1 | response = get_response(request)
csm_web-django-1 | ^^^^^^^^^^^^^^^^^^^^^
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/django/core/handlers/base.py", line 197, in _get_response
csm_web-django-1 | response = wrapped_callback(request, *callback_args, **callback_kwargs)
csm_web-django-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/django/views/decorators/csrf.py", line 65, in _view_wrapper
csm_web-django-1 | return view_func(request, *args, **kwargs)
csm_web-django-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/django/views/generic/base.py", line 104, in view
csm_web-django-1 | return self.dispatch(request, *args, **kwargs)
csm_web-django-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/rest_framework/views.py", line 509, in dispatch
csm_web-django-1 | response = self.handle_exception(exc)
csm_web-django-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/rest_framework/views.py", line 469, in handle_exception
csm_web-django-1 | self.raise_uncaught_exception(exc)
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
csm_web-django-1 | raise exc
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/rest_framework/views.py", line 506, in dispatch
csm_web-django-1 | response = handler(request, *args, **kwargs)
csm_web-django-1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
csm_web-django-1 | File "/venv/lib/python3.12/site-packages/rest_framework/decorators.py", line 50, in handler
csm_web-django-1 | return func(*args, **kwargs)
csm_web-django-1 | ^^^^^^^^^^^^^^^^^^^^^
csm_web-django-1 | File "/opt/csm_web/csm_web/scheduler/views/user.py", line 155, in user_update
csm_web-django-1 | user.profile_image.save(file.name, file)
csm_web-django-1 | ^^^^^^^^^
csm_web-django-1 | AttributeError: '_io.BytesIO' object has no attribute 'name'
Putting the ContentFile
wrapper around the buffer returned by compress_image
would resolve this.
if file_size > TARGET_FILE_SIZE_BYTES: | |
file = compress_image(image, TARGET_FILE_SIZE_BYTES, img_format) | |
else: | |
buffer = BytesIO() | |
if img_format in ["JPEG", "JPG"]: | |
image.save(buffer, format=img_format) | |
else: | |
image.save(buffer, format="PNG", optimze=True) | |
buffer.seek(0) | |
file = ContentFile(buffer.read(), name=image_file.name) | |
if file_size > TARGET_FILE_SIZE_BYTES: | |
buffer = compress_image(image, TARGET_FILE_SIZE_BYTES, img_format) | |
else: | |
buffer = BytesIO() | |
if img_format in ["JPEG", "JPG"]: | |
image.save(buffer, format=img_format) | |
else: | |
image.save(buffer, format="PNG", optimze=True) | |
buffer.seek(0) | |
file = ContentFile(buffer.read(), name=image_file.name) |
|
||
|
||
class ProfileImageStorage(S3Boto3Storage): | ||
bucket_name = "csm-web-profile-pictures" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This bucket name should be extracted from an environment variable; during development, we generally want to keep the files separate from prod.
I've created a csm-web-profile-pictures-dev
bucket for this as well. The csm-web-dev
user was also updated to include access to the development bucket.
<h4>To view and update your profile, click the button below</h4> | ||
<Link className="primary-btn" to="/profile"> | ||
<UserIcon width={inlineIconWidth} height={inlineIconHeight} /> | ||
Profile | ||
</Link> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change is the cause of the Cypress test failures; the Cypress tests should change to look for Profile
instead, and should make sure that the user is redirected as desired.
Added validation for extension and file size before reading.
Added validation after reading.
Added file size compression.
Some other considerations:
Things to do: