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