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