Coverage for ivatar/ivataraccount/forms.py: 87%
126 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 00:08 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 00:08 +0000
1"""
2Classes for our ivatar.ivataraccount.forms
3"""
5from urllib.parse import urlsplit, urlunsplit
7from django import forms
8from django.utils.translation import gettext_lazy as _
9from django.core.exceptions import ValidationError
11from ipware import get_client_ip
13from ivatar import settings
14from ivatar.settings import MIN_LENGTH_EMAIL, MAX_LENGTH_EMAIL
15from ivatar.settings import MIN_LENGTH_URL, MAX_LENGTH_URL
16from ivatar.settings import ENABLE_FILE_SECURITY_VALIDATION
17from ivatar.file_security import validate_uploaded_file, FileUploadSecurityError
18from .models import UnconfirmedEmail, ConfirmedEmail, Photo
19from .models import UnconfirmedOpenId, ConfirmedOpenId
20from .models import UserPreference
21import logging
23# Initialize logger
24logger = logging.getLogger("ivatar.ivataraccount.forms")
27MAX_NUM_UNCONFIRMED_EMAILS_DEFAULT = 5
30class AddEmailForm(forms.Form):
31 """
32 Form to handle adding email addresses
33 """
35 email = forms.EmailField(
36 label=_("Email"),
37 min_length=MIN_LENGTH_EMAIL,
38 max_length=MAX_LENGTH_EMAIL,
39 )
41 def clean_email(self):
42 """
43 Enforce lowercase email
44 """
45 # TODO: Domain restriction as in libravatar?
46 return self.cleaned_data["email"].lower()
48 def save(self, request):
49 """
50 Save the model, ensuring some safety
51 """
52 user = request.user
53 # Enforce the maximum number of unconfirmed emails a user can have
54 num_unconfirmed = user.unconfirmedemail_set.count()
56 max_num_unconfirmed_emails = getattr(
57 settings, "MAX_NUM_UNCONFIRMED_EMAILS", MAX_NUM_UNCONFIRMED_EMAILS_DEFAULT
58 )
60 if num_unconfirmed >= max_num_unconfirmed_emails:
61 self.add_error(None, _("Too many unconfirmed mail addresses!"))
62 return False
64 # Check whether or not a confirmation email has been
65 # sent by this user already
66 if UnconfirmedEmail.objects.filter( # pylint: disable=no-member
67 user=user, email=self.cleaned_data["email"]
68 ).exists():
69 self.add_error("email", _("Address already added, currently unconfirmed"))
70 return False
72 # Check whether or not the email is already confirmed (by someone)
73 check_mail = ConfirmedEmail.objects.filter(email=self.cleaned_data["email"])
74 if check_mail.exists():
75 msg = _("Address already confirmed (by someone else)")
76 if check_mail.first().user == request.user:
77 msg = _("Address already confirmed (by you)")
78 self.add_error("email", msg)
79 return False
81 unconfirmed = UnconfirmedEmail()
82 unconfirmed.email = self.cleaned_data["email"]
83 unconfirmed.user = user
84 unconfirmed.save()
85 unconfirmed.send_confirmation_mail(url=request.build_absolute_uri("/")[:-1])
86 return True
89class UploadPhotoForm(forms.Form):
90 """
91 Form handling photo upload with enhanced security validation
92 """
94 photo = forms.FileField(
95 label=_("Photo"),
96 error_messages={"required": _("You must choose an image to upload.")},
97 )
98 not_porn = forms.BooleanField(
99 label=_("suitable for all ages (i.e. no offensive content)"),
100 required=True,
101 error_messages={
102 "required": _(
103 'We only host "G-rated" images and so this field must be checked.'
104 )
105 },
106 )
107 can_distribute = forms.BooleanField(
108 label=_("can be freely copied"),
109 required=True,
110 error_messages={
111 "required": _(
112 "This field must be checked since we need to be able to distribute photos to third parties."
113 )
114 },
115 )
117 def clean_photo(self):
118 """
119 Enhanced photo validation with security checks
120 """
121 photo = self.cleaned_data.get("photo")
123 if not photo:
124 raise ValidationError(_("No file provided"))
126 # Read file data
127 try:
128 # Handle different file types
129 if hasattr(photo, "read"):
130 file_data = photo.read()
131 elif hasattr(photo, "file"):
132 file_data = photo.file.read()
133 else:
134 file_data = bytes(photo)
135 filename = photo.name
136 except Exception as e:
137 logger.error(f"Error reading uploaded file: {e}")
138 raise ValidationError(_("Error reading uploaded file"))
140 # Perform comprehensive security validation (if enabled)
141 if ENABLE_FILE_SECURITY_VALIDATION:
142 try:
143 is_valid, validation_results, sanitized_data = validate_uploaded_file(
144 file_data, filename
145 )
147 if not is_valid:
148 # Log security violation
149 logger.warning(
150 f"File upload security violation: {validation_results['errors']}"
151 )
153 # Only reject truly malicious files at the form level
154 # Allow basic format issues to pass through to Photo.save() for original error handling
155 if validation_results.get("security_score", 100) < 30:
156 raise ValidationError(
157 _("File appears to be malicious and cannot be uploaded")
158 )
159 else:
160 # For format issues, don't raise ValidationError - let Photo.save() handle it
161 # This preserves the original error handling behavior
162 logger.info(
163 f"File format issue detected, allowing Photo.save() to handle: {validation_results['errors']}"
164 )
165 # Store the validation results for potential use, but don't reject the form
166 self.validation_results = validation_results
167 self.file_data = file_data
168 else:
169 # Store sanitized data for later use
170 self.sanitized_data = sanitized_data
171 self.validation_results = validation_results
172 # Store original file data for fallback
173 self.file_data = file_data
175 # Log successful validation
176 logger.info(
177 f"File upload validated successfully: {filename}, security_score: {validation_results.get('security_score', 100)}"
178 )
180 except FileUploadSecurityError as e:
181 logger.error(f"File upload security error: {e}")
182 raise ValidationError(_("File security validation failed"))
183 except Exception as e:
184 logger.error(f"Unexpected error during file validation: {e}")
185 raise ValidationError(_("File validation failed"))
186 else:
187 # Security validation disabled (e.g., in tests)
188 logger.debug(f"File upload security validation disabled for: {filename}")
189 self.file_data = file_data
191 return photo
193 def save(self, request, data):
194 """
195 Save the model and assign it to the current user with enhanced security
196 """
197 # Link this file to the user's profile
198 photo = Photo()
199 photo.user = request.user
200 photo.ip_address = get_client_ip(request)[0]
202 # Use sanitized data if available, otherwise use stored file data
203 if hasattr(self, "sanitized_data"):
204 photo.data = self.sanitized_data
205 elif hasattr(self, "file_data"):
206 photo.data = self.file_data
207 else:
208 # Fallback: try to read from the file object
209 try:
210 photo.data = data.read()
211 except Exception as e:
212 logger.error(f"Failed to read file data: {e}")
213 photo.data = b""
215 photo.save()
216 return photo if photo.pk else None
219class AddOpenIDForm(forms.Form):
220 """
221 Form to handle adding OpenID
222 """
224 openid = forms.URLField(
225 label=_("OpenID"),
226 min_length=MIN_LENGTH_URL,
227 max_length=MAX_LENGTH_URL,
228 initial="http://",
229 )
231 def clean_openid(self):
232 """
233 Enforce restrictions
234 """
235 # Lowercase hostname port of the URL
236 url = urlsplit(self.cleaned_data["openid"])
237 return urlunsplit(
238 (
239 url.scheme.lower(),
240 url.netloc.lower(),
241 url.path,
242 url.query,
243 url.fragment,
244 )
245 )
247 def save(self, user):
248 """
249 Save the model, ensuring some safety
250 """
251 if ConfirmedOpenId.objects.filter( # pylint: disable=no-member
252 openid=self.cleaned_data["openid"]
253 ).exists():
254 self.add_error("openid", _("OpenID already added and confirmed!"))
255 return False
257 if UnconfirmedOpenId.objects.filter( # pylint: disable=no-member
258 openid=self.cleaned_data["openid"]
259 ).exists():
260 self.add_error("openid", _("OpenID already added, but not confirmed yet!"))
261 return False
263 unconfirmed = UnconfirmedOpenId()
264 unconfirmed.openid = self.cleaned_data["openid"]
265 unconfirmed.user = user
266 unconfirmed.save()
268 return unconfirmed.pk
271class UpdatePreferenceForm(forms.ModelForm):
272 """
273 Form for updating user preferences
274 """
276 class Meta: # pylint: disable=too-few-public-methods
277 """
278 Meta class for UpdatePreferenceForm
279 """
281 model = UserPreference
282 fields = ["theme"]
285class UploadLibravatarExportForm(forms.Form):
286 """
287 Form handling libravatar user export upload
288 """
290 export_file = forms.FileField(
291 label=_("Export file"),
292 error_messages={"required": _("You must choose an export file to upload.")},
293 )
294 not_porn = forms.BooleanField(
295 label=_("suitable for all ages (i.e. no offensive content)"),
296 required=True,
297 error_messages={
298 "required": _(
299 'We only host "G-rated" images and so this field must be checked.'
300 )
301 },
302 )
303 can_distribute = forms.BooleanField(
304 label=_("can be freely copied"),
305 required=True,
306 error_messages={
307 "required": _(
308 "This field must be checked since we need to be able to\
309 distribute photos to third parties."
310 )
311 },
312 )
315class DeleteAccountForm(forms.Form):
316 password = forms.CharField(
317 label=_("Password"), required=False, widget=forms.PasswordInput()
318 )