Skip to content
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

Open
wants to merge 18 commits into
base: master
Choose a base branch
from

Conversation

jacovkim
Copy link

@jacovkim jacovkim commented Dec 5, 2024

Added validation for extension and file size before reading.
Added validation after reading.
Added file size compression.

Some other considerations:

  1. Crop the image after resizing to preserve the ratio
  2. Placeholder image for missing profiles
  3. Might need to replace the current compression method as it's an iterative approach
  4. (Optional) Change public S3 bucket to private with AWS signing

Things to do:

  1. Should disable the "upload" button when the file exceeds the limit when editing
  2. Also clear form after uploading

Copy link

cypress bot commented Dec 5, 2024

csm_web    Run #395

Run Properties:  status check failed Failed #395  •  git commit b57621271c: Image validation and size optimization for Profile image upload from both front-...
Project csm_web
Branch Review feat/profiles2024/images
Run status status check failed Failed #395
Run duration 02m 04s
Commit git commit b57621271c: Image validation and size optimization for Profile image upload from both front-...
Committer Jacob Taegon Kim
View all properties for this run ↗︎

Test results
Tests that failed  Failures 10
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 74
View all changes introduced in this branch ↗︎

Tests for review

Failed  student-course.cy.ts • 2 failed tests • Tests on Firefox

View Output

Test Artifacts
... > should be able to enroll in a section Screenshots
... > should be able to enroll in a section Screenshots
Failed  restricted-courses.cy.ts • 3 failed tests • Tests on Firefox

View Output

Test Artifacts
unwhitelisted courses > should still show unrestricted courses Screenshots
whitelisted courses > should see and enroll in whitelisted courses and sections Screenshots
whitelisted courses > should see whitelisted courses among unrestricted courses Screenshots
Failed  student-course.cy.ts • 2 failed tests • Tests on Chrome

View Output

Test Artifacts
... > should be able to enroll in a section Test Replay Screenshots
... > should be able to enroll in a section Test Replay Screenshots
Failed  restricted-courses.cy.ts • 3 failed tests • Tests on Chrome

View Output

Test Artifacts
unwhitelisted courses > should still show unrestricted courses Test Replay Screenshots
whitelisted courses > should see and enroll in whitelisted courses and sections Test Replay Screenshots
whitelisted courses > should see whitelisted courses among unrestricted courses Test Replay Screenshots

Copy link
Member

@smartspot2 smartspot2 left a 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?

runtime.txt Outdated Show resolved Hide resolved
docker-compose.yml Outdated Show resolved Hide resolved
Comment on lines +171 to +175
# Check file size
image_file.seek(0, os.SEEK_END)
file_size = image_file.tell()
# reset file pointer
image_file.seek(0)
Copy link
Member

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.

Comment on lines 255 to 270
# 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
Copy link
Member

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.

Copy link
Author

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?

Copy link
Member

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.

Copy link
Member

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.

@edwardneo edwardneo force-pushed the feat/profiles2024/images branch from fdfc29d to 9a79f9b Compare February 1, 2025 07:39
@isabellaalps isabellaalps self-requested a review February 3, 2025 01:43
@isabellaalps
Copy link

  • think it would be nice to add a short description/heading on the profiles landing page to explain what profiles is/does
  • unable to add image to profiles without triggering fatal error

Copy link
Member

@smartspot2 smartspot2 left a 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 is null; 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.

    (for example)

    image

  • 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.

    (for example)

    image
    image

Comment on lines +196 to +202
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.")
Copy link
Member

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.

Comment on lines +46 to +50
{
// TODO: add route for profiles (/profile/:id/* element = {UserProfile})
// TODO: add route for your own profile /profile/*
// reference Section
}
Copy link
Member

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

Comment on lines +309 to +311
<button className="primary-btn" onClick={handleCancelToggle}>
Cancel
</button>
Copy link
Member

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).

Comment on lines 90 to 105
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>
);
Copy link
Member

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)
Copy link
Member

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})
Copy link
Member

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.

Comment on lines +17 to +18
const MAX_SIZE_MB = 2;
const MAX_FILE_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
Copy link
Member

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.

Comment on lines +215 to +224
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)
Copy link
Member

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.

Suggested change
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"
Copy link
Member

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.

Comment on lines +99 to +103
<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>
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants