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

1""" 

2Classes for our ivatar.ivataraccount.forms 

3""" 

4 

5from urllib.parse import urlsplit, urlunsplit 

6 

7from django import forms 

8from django.utils.translation import gettext_lazy as _ 

9from django.core.exceptions import ValidationError 

10 

11from ipware import get_client_ip 

12 

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 

19 

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) 

26 

27# Initialize logger 

28logger = logging.getLogger("ivatar.ivataraccount.forms") 

29 

30 

31MAX_NUM_UNCONFIRMED_EMAILS_DEFAULT = 5 

32 

33 

34class AddEmailForm(forms.Form): 

35 """ 

36 Form to handle adding email addresses 

37 """ 

38 

39 email = forms.EmailField( 

40 label=_("Email"), 

41 min_length=MIN_LENGTH_EMAIL, 

42 max_length=MAX_LENGTH_EMAIL, 

43 ) 

44 

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

51 

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

59 

60 max_num_unconfirmed_emails = getattr( 

61 settings, "MAX_NUM_UNCONFIRMED_EMAILS", MAX_NUM_UNCONFIRMED_EMAILS_DEFAULT 

62 ) 

63 

64 if num_unconfirmed >= max_num_unconfirmed_emails: 

65 self.add_error(None, _("Too many unconfirmed mail addresses!")) 

66 return False 

67 

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 

75 

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 

84 

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 

91 

92 

93class UploadPhotoForm(forms.Form): 

94 """ 

95 Form handling photo upload with enhanced security validation 

96 """ 

97 

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 ) 

120 

121 def clean_photo(self): 

122 """ 

123 Enhanced photo validation with security checks 

124 """ 

125 photo = self.cleaned_data.get("photo") 

126 

127 if not photo: 

128 raise ValidationError(_("No file provided")) 

129 

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

143 

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 ) 

150 

151 if not is_valid: 

152 # Log security violation 

153 logger.warning( 

154 f"File upload security violation: {validation_results['errors']}" 

155 ) 

156 

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 

178 

179 # Log successful validation 

180 logger.info( 

181 f"File upload validated successfully: {filename}, security_score: {validation_results.get('security_score', 100)}" 

182 ) 

183 

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 

194 

195 return photo 

196 

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] 

205 

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"" 

218 

219 photo.save() 

220 return photo if photo.pk else None 

221 

222 

223class AddOpenIDForm(forms.Form): 

224 """ 

225 Form to handle adding OpenID 

226 """ 

227 

228 openid = forms.URLField( 

229 label=_("OpenID"), 

230 min_length=MIN_LENGTH_URL, 

231 max_length=MAX_LENGTH_URL, 

232 initial="http://", 

233 ) 

234 

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 ) 

250 

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 

260 

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 

266 

267 unconfirmed = UnconfirmedOpenId() 

268 unconfirmed.openid = self.cleaned_data["openid"] 

269 unconfirmed.user = user 

270 unconfirmed.save() 

271 

272 return unconfirmed.pk 

273 

274 

275class UpdatePreferenceForm(forms.ModelForm): 

276 """ 

277 Form for updating user preferences 

278 """ 

279 

280 class Meta: # pylint: disable=too-few-public-methods 

281 """ 

282 Meta class for UpdatePreferenceForm 

283 """ 

284 

285 model = UserPreference 

286 fields = ["theme"] 

287 

288 

289class UploadLibravatarExportForm(forms.Form): 

290 """ 

291 Form handling libravatar user export upload 

292 """ 

293 

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 ) 

317 

318 

319class DeleteAccountForm(forms.Form): 

320 password = forms.CharField( 

321 label=_("Password"), required=False, widget=forms.PasswordInput() 

322 )