diff --git a/core/validators.py b/core/validators.py index 36f7830e9..71d1596e5 100644 --- a/core/validators.py +++ b/core/validators.py @@ -46,5 +46,8 @@ def __call__(self, value): # 1 MB max file size max_file_size_validator = MaxFileSizeValidator(max_size=1 * 1024 * 1024) +# 5 MB max file size, for downscalable images +downscale_image_file_size_validator = MaxFileSizeValidator(max_size=5 * 1024 * 1024) + # 50 MB allowed for certain large files - to be used on staff-only fields large_file_max_size_validator = MaxFileSizeValidator(max_size=50 * 1024 * 1024) diff --git a/templates/v3/includes/_button.html b/templates/v3/includes/_button.html index 7469f5ebd..6b3a74b92 100644 --- a/templates/v3/includes/_button.html +++ b/templates/v3/includes/_button.html @@ -21,7 +21,7 @@ - icon-library (uses btn-icon-library styles; ignores btn + btn-* classes) - disabled (optional): if truthy, adds disabled attribute to button - alpine_disabled (optional): Alpine.js expression for dynamic disabled binding (e.g. "hasErrors") - - alpine_click (optional): Alpine.js expression run on click (e.g. "save()") + - alpine_click (optional): Alpine.js expression for a click handler (e.g. "save()"); button must sit inside an x-data scope - extra_classes (optional) : Any extra classes that the button should have, as a string - aria_label (optional): An optional label for assistive technology. Defaults to the button label - js_disabled (optional, boolean): If this value is true, the button is hidden unless Javascript is disabled on the page diff --git a/templates/v3/user_profile_edit.html b/templates/v3/user_profile_edit.html index ff95beff0..1165a024f 100644 --- a/templates/v3/user_profile_edit.html +++ b/templates/v3/user_profile_edit.html @@ -105,8 +105,7 @@ {# Invisible input, toggles the edit box appearing #} - {# File input, shown in the empty state #} - {% include 'v3/includes/_field_file.html' with name=user_profile_form.avatar.name extra_class='user-profile__empty-avatar-input' accept='image/png,image/jpeg' only %} + {% include 'v3/includes/_field_file.html' with id="field-avatar-modal-new" name=user_profile_form.avatar.name extra_class='user-profile__empty-avatar-input' accept='image/png,image/jpeg' only %}
{# Delete and replace buttons, shown in filled state #} @@ -420,6 +419,12 @@ const SLACK_PROFILE_URL_PREFIX = '{{ SLACK_PROFILE_URL_PREFIX|escapejs }}'; const SLACK_MEMBER_ID_PATTERN = /^[A-Z0-9]{9,11}$/i; + const avatarInput = document.querySelector("input[id='field-avatar-modal']") + const avatarInputNew = document.querySelector("input[id='field-avatar-modal-new']") + const avatarDelete = document.querySelector('#user-profile__avatar-delete-checkbox') + const avatarComponent = document.querySelector('#user-profile-editable-avatar') + const avatarImg = avatarComponent.querySelector('img') + return { saving: false, saveStatus: '', // '' | 'saved' | 'error' (button shows "saving" via :disabled) @@ -500,6 +505,7 @@ async save() { if (this.saving) return; + const formData = new FormData() // Validate all links up front; block the save if any fail. let hasError = false; @@ -509,18 +515,31 @@ if (msg) hasError = true; }); if (hasError) return; + formData.append('profile_links', JSON.stringify(this.collectLinks())) + + + let avatar = avatarInput.value + if(avatarDelete.checked) { + avatar = null; + } + + if(!avatarDelete.checked) { + if(avatarInput.files.length > 0) formData.append('profile_image', avatarInput.files[0]) + else if(avatarInputNew.files.length > 0) formData.append('profile_image', avatarInputNew.files[0]) + } + formData.append('delete_profile_image', avatarDelete.checked) this.saving = true; this.saveStatus = ''; this.saveError = ''; + try { const res = await fetch('/api/v1/users/me/', { method: 'PATCH', headers: { 'X-CSRFToken': '{{ csrf_token }}', - 'Content-Type': 'application/json', }, - body: JSON.stringify({ profile_links: this.collectLinks() }), + body: formData, }); if (!res.ok) { let data = {}; @@ -543,6 +562,10 @@ throw new Error(fieldErrors.length ? fieldErrors.join(' ') : `Save failed (${res.status})`); } this.saveStatus = 'saved'; + if(avatarDelete.checked) { + avatarDelete.checked = false; + avatarImg.src = ""; + } } catch (err) { console.error('Profile save failed:', err); this.saveStatus = 'error'; diff --git a/users/forms.py b/users/forms.py index 89040f2eb..7d924cf35 100644 --- a/users/forms.py +++ b/users/forms.py @@ -8,6 +8,7 @@ from allauth.account.forms import ResetPasswordKeyForm, SignupForm from .models import Preferences +from core.validators import max_file_size_validator from news.models import NEWS_MODELS from news.acl import can_approve @@ -131,7 +132,11 @@ class CustomClearableFileInput(forms.ClearableFileInput): class UserProfilePhotoForm(forms.ModelForm): - profile_image = forms.FileField(widget=CustomClearableFileInput, required=False) + profile_image = forms.FileField( + widget=CustomClearableFileInput, + required=False, + validators=[max_file_size_validator], + ) class Meta: model = User diff --git a/users/migrations/0023_alter_user_profile_image.py b/users/migrations/0023_alter_user_profile_image.py new file mode 100644 index 000000000..66b9d4527 --- /dev/null +++ b/users/migrations/0023_alter_user_profile_image.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.2 on 2026-07-21 20:48 + +import core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0022_user_profile_links"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="profile_image", + field=models.FileField( + blank=True, + null=True, + upload_to="profile-images", + validators=[ + core.validators.FileTypeValidator( + extensions=[".jpg", ".jpeg", ".png"] + ), + core.validators.MaxFileSizeValidator(max_size=5242880), + ], + ), + ), + ] diff --git a/users/models.py b/users/models.py index ab538d19e..4d6f0609f 100644 --- a/users/models.py +++ b/users/models.py @@ -22,8 +22,8 @@ from core.validators import ( image_validator, - max_file_size_validator, large_file_max_size_validator, + downscale_image_file_size_validator, ) from core.templatetags.custom_static import large_static @@ -209,7 +209,7 @@ class User(BaseUser): upload_to="profile-images", null=True, blank=True, - validators=[image_validator, max_file_size_validator], + validators=[image_validator, downscale_image_file_size_validator], ) image_thumbnail = ImageSpecField( source="profile_image", diff --git a/users/serializers.py b/users/serializers.py index 280456aa1..fecd8d3f8 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,8 +1,15 @@ import re +import os +import uuid from urllib.parse import urlparse +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile from rest_framework import serializers +from core.validators import downscale_image_file_size_validator +from news.utils import downsize_uploaded_image + from .forms import SLACK_PROFILE_URL_PREFIX, V3ProfileLinkChoices from .models import User @@ -57,6 +64,10 @@ class CurrentUserSerializer(serializers.ModelSerializer): User serializer for the currently logged in user """ + profile_image = serializers.ImageField( + validators=[downscale_image_file_size_validator] + ) + def validate_profile_links(self, value): if not isinstance(value, dict): raise serializers.ValidationError( @@ -85,6 +96,61 @@ def validate_profile_links(self, value): raise serializers.ValidationError(field_errors) return value + def validate_profile_image(self, value): + file_name = value.name + root, ext = os.path.splitext(file_name) + value.name = str(uuid.uuid4()) + ext + if value.size > settings.DOWNSCALE_IMAGE_THRESHOLD: + return downsize_uploaded_image(value) + return value + + def validate(self, data): + user = self.instance + if not user.can_update_image and "profile_image" in data: + raise serializers.ValidationError( + "You do not have permission to update your profile photo." + ) + + return super().validate(data) + + def update(self, instance: User, validated_data): + # Pop the image and apply the file-lifecycle steps ourselves. If we left + # it in validated_data, the parent update() would assign and save it a + # second time, writing a duplicate file to storage and orphaning one. + new_image_data = validated_data.pop("profile_image", None) + has_new_upload = isinstance(new_image_data, UploadedFile) + + if new_image_data: + # If the user is uploading a new image file, we need to do a few special steps + # 1. We need to set the image_uploaded flag correctly, to prevent automatic overwrites. + # 2. We need to delete the old image from file storage, since it is not stored in memory. + # 3. We need to delete their thumbnail to regenerate a new one. + old_image = instance.profile_image + old_image_name = old_image.name if old_image else None + + # Save the new image + if not old_image: + # reset image on image delete checked + instance.image_uploaded = False + elif has_new_upload and old_image_name: + # Delete the old file directly from storage (not via FieldFile.delete(), + # which closes file handles and interferes with the pending upload) + old_image.storage.delete(old_image_name) + + if has_new_upload: + instance.profile_image = new_image_data + instance.image_uploaded = True + + # Applies the remaining fields and persists everything set above + # (image + flags) in a single save. + instance = super().update(instance, validated_data) + + # Invalidate the cached thumbnail so ImageKit regenerates it + if has_new_upload: + instance.delete_cached_thumbnail() + + return instance + class Meta: model = User fields = ( @@ -99,7 +165,6 @@ class Meta: read_only_fields = ( "id", "email", # Users shouldn't change their email this way - "profile_image", "date_joined", ) diff --git a/users/tests/test_models.py b/users/tests/test_models.py index c3c862857..dacbd2447 100644 --- a/users/tests/test_models.py +++ b/users/tests/test_models.py @@ -59,7 +59,7 @@ def test_user_model_image_file_size(user): Test that the `image` field rejects files larger than a specific size. """ valid_image = SimpleUploadedFile( - "test.jpg", b"a" * (1 * 1024 * 1024 - 1), content_type="image/jpeg" + "test.jpg", b"a" * (5 * 1024 * 1024 - 1), content_type="image/jpeg" ) user.profile_image = valid_image # This should not raise any errors @@ -67,7 +67,7 @@ def test_user_model_image_file_size(user): # This should fail (just over 1MB) invalid_image = SimpleUploadedFile( - "too_large.jpg", b"a" * (1 * 1024 * 1024 + 1), content_type="image/jpeg" + "too_large.jpg", b"a" * (5 * 1024 * 1024 + 1), content_type="image/jpeg" ) user.profile_image = invalid_image # This should raise a ValidationError for file size diff --git a/users/views.py b/users/views.py index 38b218274..abbce517f 100644 --- a/users/views.py +++ b/users/views.py @@ -78,6 +78,17 @@ class CurrentUserAPIView(generics.RetrieveUpdateAPIView): serializer_class = CurrentUserSerializer permission_classes = [IsAuthenticated] + def perform_update(self, serializer: CurrentUserSerializer): + instance: User = serializer.save() + # Only allow for image deletion if the user is allowed to update their image. + if ( + self.request.POST.get("delete_profile_image", "").lower() == "true" + and instance.can_update_image + ): + instance.profile_image.delete() + instance.image_uploaded = False + instance.save() + def get_object(self): return self.request.user