Coverage for ivatar/ivataraccount/views.py: 78%

729 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 05:10 +0000

1""" 

2View classes for ivatar/ivataraccount/ 

3""" 

4 

5from io import BytesIO 

6from ivatar.utils import urlopen, Bluesky 

7import base64 

8import binascii 

9import contextlib 

10from xml.sax import saxutils 

11import gzip 

12import hashlib 

13import logging 

14 

15from PIL import Image 

16 

17from django.db.models import ProtectedError 

18from django.core.exceptions import ObjectDoesNotExist 

19from django.contrib.auth.decorators import login_required 

20from django.contrib.auth.models import User 

21from django.utils.decorators import method_decorator 

22from django.contrib.messages.views import SuccessMessageMixin 

23from django.contrib import messages 

24from django.views.generic.edit import FormView, UpdateView 

25from django.views.generic.base import View, TemplateView 

26from django.views.generic.detail import DetailView 

27from django.contrib.auth import authenticate, login 

28from django.contrib.auth.forms import UserCreationForm, SetPasswordForm 

29from django.contrib.auth.views import LoginView 

30from django.contrib.auth.views import ( 

31 PasswordResetView as PasswordResetViewOriginal, 

32) 

33from django.utils.crypto import get_random_string 

34from django.utils.translation import gettext_lazy as _ 

35from django.http import HttpResponseRedirect, HttpResponse, HttpResponseNotModified 

36from django.urls import reverse_lazy, reverse 

37from django.shortcuts import render 

38from django_openid_auth.models import UserOpenID 

39 

40from openid import oidutil 

41from openid.consumer import consumer 

42 

43from ipware import get_client_ip 

44 

45from email_validator import validate_email 

46 

47from libravatar import libravatar_url 

48from django.conf import settings 

49from ivatar.photo_cache import ( 

50 get_cached_photo, 

51 set_cached_photo, 

52 render_photo_thumbnail, 

53) 

54from .gravatar import get_photo as get_gravatar_photo 

55 

56# Access settings through django.conf.settings 

57MAX_NUM_PHOTOS = getattr(settings, "MAX_NUM_PHOTOS", 5) 

58MAX_PHOTO_SIZE = getattr(settings, "MAX_PHOTO_SIZE", 10485760) 

59JPEG_QUALITY = getattr(settings, "JPEG_QUALITY", 85) 

60AVATAR_MAX_SIZE = getattr(settings, "AVATAR_MAX_SIZE", 512) 

61SOCIAL_AUTH_FEDORA_KEY = getattr(settings, "SOCIAL_AUTH_FEDORA_KEY", None) 

62SITE_NAME = getattr(settings, "SITE_NAME", "libravatar") 

63IVATAR_VERSION = getattr(settings, "IVATAR_VERSION", "2.0") 

64MAX_NUM_UNCONFIRMED_EMAILS = getattr(settings, "MAX_NUM_UNCONFIRMED_EMAILS", 5) 

65SECURE_BASE_URL = getattr(settings, "SECURE_BASE_URL", "https://avatars.linux-kernel.at/avatar/") 

66BASE_URL = getattr(settings, "BASE_URL", "http://avatars.linux-kernel.at/avatar/") 

67IVATAR_MAILGUN_API_KEY = getattr(settings, "IVATAR_MAILGUN_API_KEY", None) 

68IVATAR_MAILGUN_SENDER_DOMAIN = getattr(settings, "IVATAR_MAILGUN_SENDER_DOMAIN", None) 

69 

70from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm 

71from .forms import UpdatePreferenceForm, UploadLibravatarExportForm 

72from .forms import DeleteAccountForm 

73from .models import UnconfirmedEmail, ConfirmedEmail, Photo 

74from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore 

75from .models import UserPreference 

76from .models import file_format 

77from .read_libravatar_export import read_gzdata as libravatar_read_gzdata 

78 

79# Initialize loggers 

80logger = logging.getLogger("ivatar") 

81security_logger = logging.getLogger("ivatar.security") 

82 

83# Import OpenTelemetry with graceful degradation 

84from ..telemetry_utils import ( 

85 trace_file_upload, 

86 trace_authentication, 

87 get_telemetry_metrics, 

88) 

89 

90avatar_metrics = get_telemetry_metrics() 

91 

92 

93def openid_logging(message, level=0): 

94 """ 

95 Helper method for openid logging 

96 """ 

97 # Normal messages are not that important 

98 # No need for coverage here 

99 if level > 0: # pragma: no cover 

100 logger.debug(message) 

101 

102 

103class CreateView(SuccessMessageMixin, FormView): 

104 """ 

105 View class for creating a new user 

106 """ 

107 

108 template_name = "new.html" 

109 form_class = UserCreationForm 

110 

111 @trace_authentication("user_registration") 

112 def form_valid(self, form): 

113 form.save() 

114 user = authenticate( 

115 username=form.cleaned_data["username"], 

116 password=form.cleaned_data["password1"], 

117 ) 

118 if user is not None: 

119 # If the username looks like a mail address, automagically 

120 # add it as unconfirmed mail and set it also as user's 

121 # email address 

122 with contextlib.suppress(Exception): 

123 self._extracted_from_form_valid_(form, user) 

124 login(self.request, user) 

125 pref = UserPreference.objects.create( 

126 user_id=user.pk 

127 ) # pylint: disable=no-member 

128 pref.save() 

129 return HttpResponseRedirect(reverse_lazy("profile")) 

130 return HttpResponseRedirect(reverse_lazy("login")) # pragma: no cover 

131 

132 def _extracted_from_form_valid_(self, form, user): 

133 # This will error out if it's not a valid address 

134 valid = validate_email(form.cleaned_data["username"]) 

135 user.email = valid.email 

136 user.save() 

137 # The following will also error out if it already exists 

138 unconfirmed = UnconfirmedEmail() 

139 unconfirmed.email = valid.email 

140 unconfirmed.user = user 

141 unconfirmed.save() 

142 unconfirmed.send_confirmation_mail( 

143 url=self.request.build_absolute_uri("/")[:-1] 

144 ) 

145 

146 def get(self, request, *args, **kwargs): 

147 """ 

148 Handle get for create view 

149 """ 

150 if request.user and request.user.is_authenticated: 

151 return HttpResponseRedirect(reverse_lazy("profile")) 

152 return super().get(self, request, args, kwargs) 

153 

154 

155@method_decorator(login_required, name="dispatch") 

156class PasswordSetView(SuccessMessageMixin, FormView): 

157 """ 

158 View class for changing the password 

159 """ 

160 

161 template_name = "password_change.html" 

162 form_class = SetPasswordForm 

163 success_message = _("password changed successfully - please login again") 

164 success_url = reverse_lazy("profile") 

165 

166 def get_form_kwargs(self): 

167 kwargs = super().get_form_kwargs() 

168 kwargs["user"] = self.request.user 

169 return kwargs 

170 

171 def form_valid(self, form): 

172 form.save() 

173 super().form_valid(form) 

174 return HttpResponseRedirect(reverse_lazy("login")) 

175 

176 

177@method_decorator(login_required, name="dispatch") 

178class AddEmailView(SuccessMessageMixin, FormView): 

179 """ 

180 View class for adding email addresses 

181 """ 

182 

183 template_name = "add_email.html" 

184 form_class = AddEmailForm 

185 success_url = reverse_lazy("profile") 

186 

187 def form_valid(self, form): 

188 if not form.save(self.request): 

189 return render(self.request, self.template_name, {"form": form}) 

190 

191 messages.success(self.request, _("Address added successfully")) 

192 return super().form_valid(form) 

193 

194 

195@method_decorator(login_required, name="dispatch") 

196class RemoveUnconfirmedEmailView(SuccessMessageMixin, View): 

197 """ 

198 View class for removing a unconfirmed email address 

199 """ 

200 

201 @staticmethod 

202 def post(request, *args, **kwargs): # pylint: disable=unused-argument 

203 """ 

204 Handle post request - removing unconfirmed email 

205 """ 

206 try: 

207 email = UnconfirmedEmail.objects.get( # pylint: disable=no-member 

208 user=request.user, id=kwargs["email_id"] 

209 ) 

210 email.delete() 

211 messages.success(request, _("Address removed")) 

212 except UnconfirmedEmail.DoesNotExist: # pylint: disable=no-member 

213 messages.error(request, _("Address does not exist")) 

214 return HttpResponseRedirect(reverse_lazy("profile")) 

215 

216 

217class ConfirmEmailView(SuccessMessageMixin, TemplateView): 

218 """ 

219 View class for confirming an unconfirmed email address 

220 """ 

221 

222 template_name = "email_confirmed.html" 

223 

224 def get(self, request, *args, **kwargs): 

225 # be tolerant of extra crap added by mail clients 

226 key = kwargs["verification_key"].replace(" ", "") 

227 

228 if len(key) != 64: 

229 messages.error(request, _("Verification key incorrect")) 

230 return HttpResponseRedirect(reverse_lazy("profile")) 

231 

232 try: 

233 unconfirmed = UnconfirmedEmail.objects.get( 

234 verification_key=key 

235 ) # pylint: disable=no-member 

236 except UnconfirmedEmail.DoesNotExist: # pylint: disable=no-member 

237 messages.error(request, _("Verification key does not exist")) 

238 return HttpResponseRedirect(reverse_lazy("profile")) 

239 

240 if ConfirmedEmail.objects.filter(email=unconfirmed.email).count() > 0: 

241 messages.error( 

242 request, 

243 _("This mail address has been taken already and cannot be confirmed"), 

244 ) 

245 return HttpResponseRedirect(reverse_lazy("profile")) 

246 

247 # TODO: Check for a reasonable expiration time in unconfirmed email 

248 

249 (confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email( 

250 unconfirmed.user, unconfirmed.email, not request.user.is_anonymous 

251 ) 

252 

253 unconfirmed.delete() 

254 

255 # if there's a single image in this user's profile, 

256 # assign it to the new email 

257 confirmed = ConfirmedEmail.objects.get(id=confirmed_id) 

258 if confirmed.user.photo_set.count() == 1: 

259 confirmed.set_photo(confirmed.user.photo_set.first()) 

260 kwargs["photos"] = external_photos 

261 kwargs["email_id"] = confirmed_id 

262 return super().get(request, *args, **kwargs) 

263 

264 

265@method_decorator(login_required, name="dispatch") 

266class RemoveConfirmedEmailView(SuccessMessageMixin, View): 

267 """ 

268 View class for removing a confirmed email address 

269 """ 

270 

271 @staticmethod 

272 def post(request, *args, **kwargs): # pylint: disable=unused-argument 

273 """ 

274 Handle post request - removing confirmed email 

275 """ 

276 try: 

277 email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"]) 

278 email.delete() 

279 messages.success(request, _("Address removed")) 

280 except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member 

281 messages.error(request, _("Address does not exist")) 

282 return HttpResponseRedirect(reverse_lazy("profile")) 

283 

284 

285@method_decorator(login_required, name="dispatch") 

286class AssignPhotoEmailView(SuccessMessageMixin, TemplateView): 

287 """ 

288 View class for assigning a photo to an email address 

289 """ 

290 

291 model = Photo 

292 template_name = "assign_photo_email.html" 

293 

294 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

295 """ 

296 Handle post request - assign photo to email 

297 """ 

298 photo = None 

299 

300 try: 

301 email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"]) 

302 except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member 

303 messages.error(request, _("Invalid request")) 

304 return HttpResponseRedirect(reverse_lazy("profile")) 

305 

306 if "photoNone" in request.POST: 

307 email.photo = None 

308 email.bluesky_handle = None 

309 elif "photoBluesky" in request.POST: 

310 # Keep the existing Bluesky handle, clear the photo 

311 email.photo = None 

312 # Don't clear bluesky_handle - keep it as is 

313 else: 

314 if "photo_id" not in request.POST: 

315 messages.error(request, _("Invalid request [photo_id] missing")) 

316 return HttpResponseRedirect(reverse_lazy("profile")) 

317 

318 if request.POST["photo_id"] == "bluesky": 

319 # Handle Bluesky photo selection 

320 email.photo = None 

321 # Don't clear bluesky_handle - keep it as is 

322 else: 

323 try: 

324 photo = self.model.objects.get( # pylint: disable=no-member 

325 id=request.POST["photo_id"], user=request.user 

326 ) 

327 except self.model.DoesNotExist: # pylint: disable=no-member 

328 messages.error(request, _("Photo does not exist")) 

329 return HttpResponseRedirect(reverse_lazy("profile")) 

330 email.photo = photo 

331 email.bluesky_handle = None 

332 email.save() 

333 

334 messages.success(request, _("Successfully changed photo")) 

335 return HttpResponseRedirect(reverse_lazy("profile")) 

336 

337 def get_context_data(self, **kwargs): 

338 data = super().get_context_data(**kwargs) 

339 data["email"] = ConfirmedEmail.objects.get(pk=kwargs["email_id"]) 

340 return data 

341 

342 

343@method_decorator(login_required, name="dispatch") 

344class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView): 

345 """ 

346 View class for assigning a photo to an openid address 

347 """ 

348 

349 model = Photo 

350 template_name = "assign_photo_openid.html" 

351 

352 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

353 """ 

354 Handle post - assign photo to openid 

355 """ 

356 photo = None 

357 

358 try: 

359 openid = ConfirmedOpenId.objects.get( # pylint: disable=no-member 

360 user=request.user, id=kwargs["openid_id"] 

361 ) 

362 except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member 

363 messages.error(request, _("Invalid request")) 

364 return HttpResponseRedirect(reverse_lazy("profile")) 

365 

366 if "photoNone" in request.POST: 

367 openid.photo = None 

368 else: 

369 if "photo_id" not in request.POST: 

370 messages.error(request, _("Invalid request [photo_id] missing")) 

371 return HttpResponseRedirect(reverse_lazy("profile")) 

372 

373 try: 

374 photo = self.model.objects.get( # pylint: disable=no-member 

375 id=request.POST["photo_id"], user=request.user 

376 ) 

377 except self.model.DoesNotExist: # pylint: disable=no-member 

378 messages.error(request, _("Photo does not exist")) 

379 return HttpResponseRedirect(reverse_lazy("profile")) 

380 openid.photo = photo 

381 openid.bluesky_handle = None 

382 openid.save() 

383 

384 messages.success(request, _("Successfully changed photo")) 

385 return HttpResponseRedirect(reverse_lazy("profile")) 

386 

387 def get_context_data(self, **kwargs): 

388 data = super().get_context_data(**kwargs) 

389 data["openid"] = ConfirmedOpenId.objects.get( 

390 pk=kwargs["openid_id"] 

391 ) # pylint: disable=no-member 

392 return data 

393 

394 

395@method_decorator(login_required, name="dispatch") 

396class AssignBlueskyHandleToEmailView(SuccessMessageMixin, TemplateView): 

397 """ 

398 View class for assigning a Bluesky handle to an email address 

399 """ 

400 

401 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

402 """ 

403 Handle post request - assign bluesky handle to email 

404 """ 

405 

406 try: 

407 email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"]) 

408 except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member 

409 messages.error(request, _("Invalid request")) 

410 return HttpResponseRedirect(reverse_lazy("profile")) 

411 

412 if "bluesky_handle" not in request.POST: 

413 messages.error(request, _("Invalid request [bluesky_handle] missing")) 

414 return HttpResponseRedirect(reverse_lazy("profile")) 

415 bluesky_handle = request.POST["bluesky_handle"] 

416 

417 try: 

418 bs = Bluesky() 

419 

420 bs.get_avatar(bluesky_handle) 

421 except Exception as e: 

422 messages.error(request, _(f"Handle '{bluesky_handle}' not found: {e}")) 

423 return HttpResponseRedirect( 

424 reverse_lazy( 

425 "assign_photo_email", kwargs={"email_id": int(kwargs["email_id"])} 

426 ) 

427 ) 

428 try: 

429 email.set_bluesky_handle(bluesky_handle) 

430 except Exception as e: 

431 messages.error(request, _(f"Error: {e}")) 

432 return HttpResponseRedirect( 

433 reverse_lazy( 

434 "assign_photo_email", kwargs={"email_id": int(kwargs["email_id"])} 

435 ) 

436 ) 

437 email.photo = None 

438 email.save() 

439 

440 messages.success(request, _("Successfully assigned Bluesky handle")) 

441 return HttpResponseRedirect(reverse_lazy("profile")) 

442 

443 def get_context_data(self, **kwargs): 

444 data = super().get_context_data(**kwargs) 

445 data["email"] = ConfirmedEmail.objects.get(pk=kwargs["email_id"]) 

446 return data 

447 

448 

449@method_decorator(login_required, name="dispatch") 

450class AssignBlueskyHandleToOpenIdView(SuccessMessageMixin, TemplateView): 

451 """ 

452 View class for assigning a Bluesky handle to an email address 

453 """ 

454 

455 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

456 """ 

457 Handle post request - assign bluesky handle to email 

458 """ 

459 

460 try: 

461 openid = ConfirmedOpenId.objects.get( 

462 user=request.user, id=kwargs["open_id"] 

463 ) 

464 except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member 

465 messages.error(request, _("Invalid request")) 

466 return HttpResponseRedirect(reverse_lazy("profile")) 

467 

468 if "bluesky_handle" not in request.POST: 

469 messages.error(request, _("Invalid request [bluesky_handle] missing")) 

470 return HttpResponseRedirect(reverse_lazy("profile")) 

471 bluesky_handle = request.POST["bluesky_handle"] 

472 

473 try: 

474 bs = Bluesky() 

475 

476 bs.get_avatar(bluesky_handle) 

477 except Exception as e: 

478 messages.error(request, _(f"Handle '{bluesky_handle}' not found: {e}")) 

479 return HttpResponseRedirect( 

480 reverse_lazy( 

481 "assign_photo_openid", kwargs={"openid_id": int(kwargs["open_id"])} 

482 ) 

483 ) 

484 try: 

485 openid.set_bluesky_handle(bluesky_handle) 

486 except Exception as e: 

487 messages.error(request, _(f"Error: {e}")) 

488 return HttpResponseRedirect( 

489 reverse_lazy( 

490 "assign_photo_openid", kwargs={"openid_id": int(kwargs["open_id"])} 

491 ) 

492 ) 

493 openid.photo = None 

494 openid.save() 

495 

496 messages.success(request, _("Successfully assigned Bluesky handle")) 

497 return HttpResponseRedirect(reverse_lazy("profile")) 

498 

499 def get_context_data(self, **kwargs): 

500 data = super().get_context_data(**kwargs) 

501 data["openid"] = ConfirmedOpenId.objects.get(pk=kwargs["open_id"]) 

502 return data 

503 

504 

505@method_decorator(login_required, name="dispatch") 

506class ImportPhotoView(SuccessMessageMixin, TemplateView): 

507 """ 

508 View class to import a photo from another service 

509 Currently only Gravatar is supported 

510 """ 

511 

512 template_name = "import_photo.html" 

513 

514 def get_context_data(self, **kwargs): 

515 context = super().get_context_data(**kwargs) 

516 context["photos"] = [] 

517 addr = None 

518 if "email_id" in kwargs: 

519 try: 

520 addr = ConfirmedEmail.objects.get(pk=kwargs["email_id"]).email 

521 except ConfirmedEmail.ObjectDoesNotExist: # pylint: disable=no-member 

522 messages.error(self.request, _("Address does not exist")) 

523 return context 

524 

525 if addr := kwargs.get("email_addr", None): 

526 if gravatar := get_gravatar_photo(addr): 

527 context["photos"].append(gravatar) 

528 

529 if libravatar_service_url := libravatar_url( 

530 email=addr, 

531 default=404, 

532 size=AVATAR_MAX_SIZE, 

533 ): 

534 try: 

535 urlopen(libravatar_service_url) 

536 except OSError as exc: 

537 logger.warning(f"Exception caught during photo import: {exc}") 

538 else: 

539 context["photos"].append( 

540 { 

541 "service_url": libravatar_service_url, 

542 "thumbnail_url": f"{libravatar_service_url}&s=80", 

543 "image_url": f"{libravatar_service_url}&s=512", 

544 "width": 80, 

545 "height": 80, 

546 "service_name": "Libravatar", 

547 } 

548 ) 

549 

550 return context 

551 

552 def post( 

553 self, request, *args, **kwargs 

554 ): # pylint: disable=no-self-use,unused-argument,too-many-branches,line-too-long 

555 """ 

556 Handle post to photo import 

557 """ 

558 

559 imported = None 

560 

561 email_id = kwargs.get("email_id", request.POST.get("email_id", None)) 

562 addr = kwargs.get("email", request.POST.get("email_addr", None)) 

563 

564 if email_id: 

565 email = ConfirmedEmail.objects.filter(id=email_id, user=request.user) 

566 if email.exists(): 

567 addr = email.first().email 

568 else: 

569 messages.error(request, _("Address does not exist")) 

570 return HttpResponseRedirect(reverse_lazy("profile")) 

571 

572 if "photo_Gravatar" in request.POST: 

573 photo = Photo() 

574 photo.user = request.user 

575 photo.ip_address = get_client_ip(request)[0] 

576 if photo.import_image("Gravatar", addr): 

577 messages.success(request, _("Gravatar image successfully imported")) 

578 else: 

579 # Honestly, I'm not sure how to test this... 

580 messages.error( 

581 request, _("Gravatar image import not successful") 

582 ) # pragma: no cover 

583 imported = True 

584 

585 if "photo_Libravatar" in request.POST: 

586 photo = Photo() 

587 photo.user = request.user 

588 photo.ip_address = get_client_ip(request)[0] 

589 if photo.import_image("Libravatar", addr): 

590 messages.success(request, _("Libravatar image successfully imported")) 

591 else: 

592 # Honestly, I'm not sure how to test this... 

593 messages.error( 

594 request, _("Libravatar image import not successful") 

595 ) # pragma: no cover 

596 imported = True 

597 if not imported: 

598 messages.warning(request, _("Nothing importable")) 

599 return HttpResponseRedirect(reverse_lazy("profile")) 

600 

601 

602@method_decorator(login_required, name="dispatch") 

603class RawImageView(DetailView): 

604 """ 

605 View to return (binary) raw image data or resized thumbnail, for use in <img/>-tags 

606 """ 

607 

608 model = Photo 

609 

610 def get(self, request, *args, **kwargs): 

611 photo = self.model.objects.get(pk=kwargs["pk"]) # pylint: disable=no-member 

612 if photo.user.id != request.user.id and not request.user.is_staff: 

613 return HttpResponseRedirect(reverse_lazy("home")) 

614 

615 size_param = request.GET.get("size") or request.GET.get("s") 

616 size = None 

617 if size_param: 

618 with contextlib.suppress(ValueError, TypeError): 

619 parsed = int(size_param) 

620 if parsed > 0: 

621 size = max(1, min(parsed, AVATAR_MAX_SIZE)) 

622 

623 # Retrieve resized thumbnail from cache/generator or raw photo 

624 if size: 

625 cached_stream = get_cached_photo(photo.id, size, photo.format) 

626 if cached_stream is not None: 

627 image_bytes = cached_stream.getvalue() 

628 else: 

629 image_bytes = render_photo_thumbnail(bytes(photo.data), photo.format, size) 

630 set_cached_photo(photo.id, size, photo.format, image_bytes) 

631 else: 

632 image_bytes = bytes(photo.data) 

633 

634 # Generate stable ETag 

635 etag_seed = f"{photo.id}:{photo.format}:{size or 0}:{len(image_bytes)}" 

636 etag = f'"{hashlib.md5(etag_seed.encode()).hexdigest()}"' 

637 

638 # Conditional request check (If-None-Match) 

639 if_none_match = request.headers.get("If-None-Match") or request.META.get("HTTP_IF_NONE_MATCH") 

640 if if_none_match: 

641 client_etags = [t.strip().strip("W/") for t in if_none_match.split(",")] 

642 if "*" in client_etags or etag.strip("W/") in client_etags or etag in client_etags: 

643 resp = HttpResponseNotModified() 

644 resp["ETag"] = etag 

645 resp["Cache-Control"] = "private, max-age=86400, must-revalidate" 

646 return resp 

647 

648 response = HttpResponse(image_bytes, content_type=f"image/{photo.format}") 

649 response["ETag"] = etag 

650 response["Cache-Control"] = "private, max-age=86400, must-revalidate" 

651 response["Content-Length"] = str(len(image_bytes)) 

652 return response 

653 

654 

655@method_decorator(login_required, name="dispatch") 

656class DeletePhotoView(SuccessMessageMixin, View): 

657 """ 

658 View class for deleting a photo 

659 """ 

660 

661 model = Photo 

662 

663 def get(self, request, *args, **kwargs): # pylint: disable=unused-argument 

664 """ 

665 Handle get - delete photo 

666 """ 

667 try: 

668 photo = self.model.objects.get( # pylint: disable=no-member 

669 pk=kwargs["pk"], user=request.user 

670 ) 

671 photo.delete() 

672 except (self.model.DoesNotExist, ProtectedError): # pylint: disable=no-member 

673 messages.error(request, _("No such image or no permission to delete it")) 

674 return HttpResponseRedirect(reverse_lazy("profile")) 

675 messages.success(request, _("Photo deleted successfully")) 

676 return HttpResponseRedirect(reverse_lazy("profile")) 

677 

678 

679@method_decorator(login_required, name="dispatch") 

680class UploadPhotoView(SuccessMessageMixin, FormView): 

681 """ 

682 View class responsible for photo upload with enhanced security 

683 """ 

684 

685 model = Photo 

686 template_name = "upload_photo.html" 

687 form_class = UploadPhotoForm 

688 success_message = _("Successfully uploaded") 

689 success_url = reverse_lazy("profile") 

690 

691 def post(self, request, *args, **kwargs): 

692 # Check maximum number of photos 

693 num_photos = request.user.photo_set.count() 

694 if num_photos >= MAX_NUM_PHOTOS: 

695 messages.error( 

696 request, _("Maximum number of photos (%i) reached" % MAX_NUM_PHOTOS) 

697 ) 

698 return HttpResponseRedirect(reverse_lazy("profile")) 

699 

700 return super().post(request, *args, **kwargs) 

701 

702 @trace_file_upload("photo_upload") 

703 def form_valid(self, form): 

704 photo_data = self.request.FILES["photo"] 

705 

706 # Additional size check (redundant but good for security) 

707 if photo_data.size > MAX_PHOTO_SIZE: 

708 messages.error(self.request, _("Image too big")) 

709 avatar_metrics.record_file_upload( 

710 file_size=photo_data.size, 

711 content_type=photo_data.content_type, 

712 success=False, 

713 ) 

714 return HttpResponseRedirect(reverse_lazy("profile")) 

715 

716 # Enhanced security logging 

717 security_logger.info( 

718 f"Photo upload attempt by user {self.request.user.id} " 

719 f"from IP {get_client_ip(self.request)[0]}, " 

720 f"file size: {photo_data.size} bytes" 

721 ) 

722 

723 photo = form.save(self.request, photo_data) 

724 

725 if not photo: 

726 security_logger.warning( 

727 f"Photo upload failed for user {self.request.user.id} - invalid format" 

728 ) 

729 messages.error(self.request, _("Invalid Format")) 

730 avatar_metrics.record_file_upload( 

731 file_size=photo_data.size, 

732 content_type=photo_data.content_type, 

733 success=False, 

734 ) 

735 return HttpResponseRedirect(reverse_lazy("profile")) 

736 

737 # Log successful upload 

738 security_logger.info( 

739 f"Photo uploaded successfully by user {self.request.user.id}, " 

740 f"photo ID: {photo.pk}" 

741 ) 

742 

743 # Record successful file upload metrics 

744 avatar_metrics.record_file_upload( 

745 file_size=photo_data.size, 

746 content_type=photo_data.content_type, 

747 success=True, 

748 ) 

749 

750 # Override success URL -> Redirect to crop page. 

751 self.success_url = reverse_lazy("crop_photo", args=[photo.pk]) 

752 return super().form_valid(form) 

753 

754 

755@method_decorator(login_required, name="dispatch") 

756class AddOpenIDView(SuccessMessageMixin, FormView): 

757 """ 

758 View class for adding OpenID 

759 """ 

760 

761 template_name = "add_openid.html" 

762 form_class = AddOpenIDForm 

763 success_url = reverse_lazy("profile") 

764 

765 def form_valid(self, form): 

766 if openid_id := form.save(self.request.user): 

767 # At this point we have an unconfirmed OpenID, but 

768 # we do not add the message, that we successfully added it, 

769 # since this is misleading 

770 return HttpResponseRedirect( 

771 reverse_lazy("openid_redirection", args=[openid_id]) 

772 ) 

773 else: 

774 return render(self.request, self.template_name, {"form": form}) 

775 

776 

777@method_decorator(login_required, name="dispatch") 

778class RemoveUnconfirmedOpenIDView(View): 

779 """ 

780 View class for removing a unconfirmed OpenID 

781 """ 

782 

783 model = UnconfirmedOpenId 

784 

785 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

786 """ 

787 Handle post - remove unconfirmed openid 

788 """ 

789 try: 

790 openid = self.model.objects.get( # pylint: disable=no-member 

791 user=request.user, id=kwargs["openid_id"] 

792 ) 

793 openid.delete() 

794 messages.success(request, _("ID removed")) 

795 except ( 

796 self.model.DoesNotExist 

797 ): # pragma: no cover pylint: disable=no-member,line-too-long 

798 messages.error(request, _("ID does not exist")) 

799 return HttpResponseRedirect(reverse_lazy("profile")) 

800 

801 

802@method_decorator(login_required, name="dispatch") 

803class RemoveConfirmedOpenIDView(View): 

804 """ 

805 View class for removing a confirmed OpenID 

806 """ 

807 

808 model = ConfirmedOpenId 

809 

810 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

811 """ 

812 Handle post - remove confirmed openid 

813 """ 

814 try: 

815 openid = self.model.objects.get( # pylint: disable=no-member 

816 user=request.user, id=kwargs["openid_id"] 

817 ) 

818 try: 

819 openidobj = ( 

820 UserOpenID.objects.get( # pylint: disable=no-member,line-too-long 

821 user_id=request.user.id, claimed_id=openid.openid 

822 ) 

823 ) 

824 openidobj.delete() 

825 except Exception as exc: # pylint: disable=broad-except 

826 # Why it is not there? 

827 logger.warning(f"How did we get here: {exc}") 

828 openid.delete() 

829 messages.success(request, _("ID removed")) 

830 except self.model.DoesNotExist: # pylint: disable=no-member 

831 messages.error(request, _("ID does not exist")) 

832 return HttpResponseRedirect(reverse_lazy("profile")) 

833 

834 

835@method_decorator(login_required, name="dispatch") 

836class RedirectOpenIDView(View): 

837 """ 

838 Redirect view for OpenID 

839 """ 

840 

841 model = UnconfirmedOpenId 

842 

843 def get(self, request, *args, **kwargs): # pylint: disable=unused-argument 

844 """ 

845 Handle get for OpenID redirect view 

846 """ 

847 try: 

848 unconfirmed = self.model.objects.get( # pylint: disable=no-member 

849 user=request.user, id=kwargs["openid_id"] 

850 ) 

851 except ( 

852 self.model.DoesNotExist 

853 ): # pragma: no cover pylint: disable=no-member,line-too-long 

854 messages.error(request, _("ID does not exist")) 

855 return HttpResponseRedirect(reverse_lazy("profile")) 

856 

857 user_url = unconfirmed.openid 

858 session = {"id": request.session.session_key} 

859 

860 oidutil.log = openid_logging 

861 openid_consumer = consumer.Consumer(session, DjangoOpenIDStore()) 

862 

863 try: 

864 auth_request = openid_consumer.begin(user_url) 

865 except consumer.DiscoveryFailure as exc: 

866 messages.error(request, _(f"OpenID discovery failed: {exc}")) 

867 return HttpResponseRedirect(reverse_lazy("profile")) 

868 except UnicodeDecodeError as exc: # pragma: no cover 

869 msg = _( 

870 "OpenID discovery failed (userid=%(userid)s) for " 

871 "%(userurl)s: %(message)s" 

872 % { 

873 "userid": request.user.id, 

874 "userurl": user_url.encode("utf-8"), 

875 "message": exc, 

876 } 

877 ) 

878 logger.error(f"message: {msg}") 

879 messages.error(request, msg) 

880 

881 if auth_request is None: # pragma: no cover 

882 messages.error(request, _("OpenID discovery failed")) 

883 return HttpResponseRedirect(reverse_lazy("profile")) 

884 

885 realm = request.build_absolute_uri("/")[:-1] # pragma: no cover 

886 return_url = realm + reverse( # pragma: no cover 

887 "confirm_openid", args=[kwargs["openid_id"]] 

888 ) 

889 return HttpResponseRedirect( # pragma: no cover 

890 auth_request.redirectURL(realm, return_url) 

891 ) 

892 

893 

894@method_decorator(login_required, name="dispatch") 

895class ConfirmOpenIDView(View): # pragma: no cover 

896 """ 

897 Confirm OpenID view 

898 """ 

899 

900 model = UnconfirmedOpenId 

901 model_confirmed = ConfirmedOpenId 

902 

903 def do_request(self, data, *args, **kwargs): # pylint: disable=unused-argument 

904 """ 

905 Handle request, called by get() or post() 

906 """ 

907 session = {"id": self.request.session.session_key} 

908 current_url = self.request.build_absolute_uri("/")[:-1] + self.request.path 

909 openid_consumer = consumer.Consumer(session, DjangoOpenIDStore()) 

910 info = openid_consumer.complete(data, current_url) 

911 if info.status == consumer.FAILURE: 

912 messages.error( 

913 self.request, _('Confirmation failed: "') + str(info.message) + '"' 

914 ) 

915 return HttpResponseRedirect(reverse_lazy("profile")) 

916 

917 if info.status == consumer.CANCEL: 

918 messages.error(self.request, _("Cancelled by user")) 

919 return HttpResponseRedirect(reverse_lazy("profile")) 

920 

921 if info.status != consumer.SUCCESS: 

922 messages.error(self.request, _("Unknown verification error")) 

923 return HttpResponseRedirect(reverse_lazy("profile")) 

924 

925 try: 

926 unconfirmed = self.model.objects.get( # pylint: disable=no-member 

927 user=self.request.user, id=kwargs["openid_id"] 

928 ) 

929 except self.model.DoesNotExist: # pylint: disable=no-member 

930 messages.error(self.request, _("ID does not exist")) 

931 return HttpResponseRedirect(reverse_lazy("profile")) 

932 

933 # TODO: Check for a reasonable expiration time 

934 confirmed = self.model_confirmed() 

935 confirmed.user = unconfirmed.user 

936 confirmed.ip_address = get_client_ip(self.request)[0] 

937 confirmed.openid = unconfirmed.openid 

938 confirmed.save() 

939 

940 unconfirmed.delete() 

941 

942 # If there is a single image in this user's profile 

943 # assign it to the new id 

944 if self.request.user.photo_set.count() == 1: 

945 confirmed.set_photo(self.request.user.photo_set.first()) 

946 

947 # Also allow user to login using this OpenID (if not already taken) 

948 if not UserOpenID.objects.filter( # pylint: disable=no-member 

949 claimed_id=confirmed.openid 

950 ).exists(): 

951 user_openid = UserOpenID() 

952 user_openid.user = self.request.user 

953 user_openid.claimed_id = confirmed.openid 

954 user_openid.display_id = confirmed.openid 

955 user_openid.save() 

956 return HttpResponseRedirect(reverse_lazy("profile")) 

957 

958 def get(self, request, *args, **kwargs): 

959 """ 

960 Handle get - confirm openid 

961 """ 

962 return self.do_request(request.GET, *args, **kwargs) 

963 

964 def post(self, request, *args, **kwargs): 

965 """ 

966 Handle post - confirm openid 

967 """ 

968 return self.do_request(request.POST, *args, **kwargs) 

969 

970 

971@method_decorator(login_required, name="dispatch") 

972class CropPhotoView(TemplateView): 

973 """ 

974 View class for cropping photos 

975 """ 

976 

977 template_name = "crop_photo.html" 

978 success_url = reverse_lazy("profile") 

979 model = Photo 

980 

981 def get(self, request, *args, **kwargs): 

982 photo = self.model.objects.get( 

983 pk=kwargs["pk"], user=request.user 

984 ) # pylint: disable=no-member 

985 email = request.GET.get("email") 

986 openid = request.GET.get("openid") 

987 return render( 

988 self.request, 

989 self.template_name, 

990 { 

991 "photo": photo, 

992 "email": email, 

993 "openid": openid, 

994 }, 

995 ) 

996 

997 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

998 """ 

999 Handle post - crop photo 

1000 """ 

1001 photo = self.model.objects.get( 

1002 pk=kwargs["pk"], user=request.user 

1003 ) # pylint: disable=no-member 

1004 dimensions = { 

1005 "x": int(float(request.POST["x"])), 

1006 "y": int(float(request.POST["y"])), 

1007 "w": int(float(request.POST["w"])), 

1008 "h": int(float(request.POST["h"])), 

1009 } 

1010 email = openid = None 

1011 if "email" in request.POST: 

1012 with contextlib.suppress(ConfirmedEmail.DoesNotExist): 

1013 email = ConfirmedEmail.objects.get(email=request.POST["email"]) 

1014 if "openid" in request.POST: 

1015 with contextlib.suppress(ConfirmedOpenId.DoesNotExist): 

1016 openid = ConfirmedOpenId.objects.get( # pylint: disable=no-member 

1017 openid=request.POST["openid"] 

1018 ) 

1019 return photo.perform_crop(request, dimensions, email, openid) 

1020 

1021 

1022@method_decorator(login_required, name="dispatch") # pylint: disable=too-many-ancestors 

1023class UserPreferenceView(FormView, UpdateView): 

1024 """ 

1025 View class for user preferences view/update 

1026 """ 

1027 

1028 template_name = "preferences.html" 

1029 model = UserPreference 

1030 form_class = UpdatePreferenceForm 

1031 success_url = reverse_lazy("user_preference") 

1032 

1033 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

1034 """ 

1035 Process POST-ed data from this form 

1036 """ 

1037 userpref = None 

1038 try: 

1039 userpref = self.request.user.userpreference 

1040 except ObjectDoesNotExist: 

1041 userpref = UserPreference(user=self.request.user) 

1042 userpref.theme = request.POST["theme"] 

1043 userpref.save() 

1044 try: 

1045 if request.POST["email"] != self.request.user.email: 

1046 addresses = list( 

1047 self.request.user.confirmedemail_set.all().values_list( 

1048 "email", flat=True 

1049 ) 

1050 ) 

1051 if request.POST["email"] not in addresses: 

1052 messages.error( 

1053 self.request, 

1054 _(f'Mail address not allowed: {request.POST["email"]}'), 

1055 ) 

1056 else: 

1057 self.request.user.email = request.POST["email"] 

1058 self.request.user.save() 

1059 messages.info(self.request, _("Mail address changed.")) 

1060 except Exception as e: # pylint: disable=broad-except 

1061 messages.error(self.request, _(f"Error setting new mail address: {e}")) 

1062 

1063 try: 

1064 if request.POST["first_name"] or request.POST["last_name"]: 

1065 if request.POST["first_name"] != self.request.user.first_name: 

1066 self.request.user.first_name = request.POST["first_name"] 

1067 messages.info(self.request, _("First name changed.")) 

1068 if request.POST["last_name"] != self.request.user.last_name: 

1069 self.request.user.last_name = request.POST["last_name"] 

1070 messages.info(self.request, _("Last name changed.")) 

1071 self.request.user.save() 

1072 except Exception as e: # pylint: disable=broad-except 

1073 messages.error(self.request, _(f"Error setting names: {e}")) 

1074 

1075 return HttpResponseRedirect(reverse_lazy("user_preference")) 

1076 

1077 def get(self, request, *args, **kwargs): 

1078 return render( 

1079 self.request, 

1080 self.template_name, 

1081 { 

1082 "THEMES": UserPreference.THEMES, 

1083 }, 

1084 ) 

1085 

1086 def get_object(self, queryset=None): 

1087 (obj, created) = UserPreference.objects.get_or_create( 

1088 user=self.request.user 

1089 ) # pylint: disable=no-member,unused-variable 

1090 return obj 

1091 

1092 

1093@method_decorator(login_required, name="dispatch") 

1094class UploadLibravatarExportView(SuccessMessageMixin, FormView): 

1095 """ 

1096 View class responsible for libravatar user data export upload 

1097 """ 

1098 

1099 template_name = "upload_libravatar_export.html" 

1100 form_class = UploadLibravatarExportForm 

1101 success_message = _("Successfully uploaded") 

1102 success_url = reverse_lazy("profile") 

1103 model = User 

1104 

1105 def post(self, request, *args, **kwargs): # pylint: disable=unused-argument 

1106 """ 

1107 Handle post request - choose items to import 

1108 """ 

1109 if "save" in kwargs: # pylint: disable=too-many-nested-blocks 

1110 if kwargs["save"] == "save": 

1111 for arg in request.POST: 

1112 if arg.startswith("email_"): 

1113 email = request.POST[arg] 

1114 if not ConfirmedEmail.objects.filter( 

1115 email=email 

1116 ) and not UnconfirmedEmail.objects.filter( 

1117 email=email 

1118 ): # pylint: disable=no-member 

1119 try: 

1120 unconfirmed = UnconfirmedEmail.objects.create( # pylint: disable=no-member 

1121 user=request.user, email=email 

1122 ) 

1123 unconfirmed.save() 

1124 unconfirmed.send_confirmation_mail( 

1125 url=request.build_absolute_uri("/")[:-1] 

1126 ) 

1127 messages.info( 

1128 request, 

1129 "%s: %s" 

1130 % ( 

1131 email, 

1132 _( 

1133 "address added successfully,\ 

1134 confirmation mail sent" 

1135 ), 

1136 ), 

1137 ) 

1138 except Exception as exc: # pylint: disable=broad-except 

1139 # DEBUG 

1140 print( 

1141 f"Exception during adding mail address ({email}): {exc}" 

1142 ) 

1143 

1144 if arg.startswith("photo"): 

1145 try: 

1146 data = base64.decodebytes(bytes(request.POST[arg], "utf-8")) 

1147 except binascii.Error as exc: 

1148 logger.warning(f"Cannot decode photo: {exc}") 

1149 continue 

1150 try: 

1151 pilobj = Image.open(BytesIO(data)) 

1152 out = BytesIO() 

1153 pilobj.save(out, pilobj.format, quality=JPEG_QUALITY) 

1154 out.seek(0) 

1155 photo = Photo() 

1156 photo.user = request.user 

1157 photo.ip_address = get_client_ip(request)[0] 

1158 photo.format = file_format(pilobj.format) 

1159 photo.data = out.read() 

1160 photo.save() 

1161 except Exception as exc: # pylint: disable=broad-except 

1162 logger.error(f"Exception during save: {exc}") 

1163 continue 

1164 

1165 return HttpResponseRedirect(reverse_lazy("profile")) 

1166 return super().post(request, args, kwargs) 

1167 

1168 def form_valid(self, form): 

1169 data = self.request.FILES["export_file"] 

1170 try: 

1171 items = libravatar_read_gzdata(data.read()) 

1172 # DEBUG print(items) 

1173 return render( 

1174 self.request, 

1175 "choose_libravatar_export.html", 

1176 { 

1177 "emails": items["emails"], 

1178 "photos": items["photos"], 

1179 }, 

1180 ) 

1181 except Exception as e: 

1182 messages.error(self.request, _(f"Unable to parse file: {e}")) 

1183 return HttpResponseRedirect(reverse_lazy("upload_export")) 

1184 

1185 

1186@method_decorator(login_required, name="dispatch") 

1187class ResendConfirmationMailView(View): 

1188 """ 

1189 View class for resending confirmation mail 

1190 """ 

1191 

1192 model = UnconfirmedEmail 

1193 

1194 def get(self, request, *args, **kwargs): # pylint: disable=unused-argument 

1195 """ 

1196 Handle post - resend confirmation mail for unconfirmed e-mail address 

1197 """ 

1198 try: 

1199 email = self.model.objects.get( # pylint: disable=no-member 

1200 user=request.user, id=kwargs["email_id"] 

1201 ) 

1202 except self.model.DoesNotExist: # pragma: no cover pylint: disable=no-member 

1203 messages.error(request, _("ID does not exist")) 

1204 else: 

1205 try: 

1206 email.send_confirmation_mail(url=request.build_absolute_uri("/")[:-1]) 

1207 messages.success( 

1208 request, f'{_("Confirmation mail sent to")}: {email.email}' 

1209 ) 

1210 except Exception as exc: # pylint: disable=broad-except 

1211 messages.error( 

1212 request, 

1213 f'{_("Unable to send confirmation email for")} {email.email}: {exc}', 

1214 ) 

1215 return HttpResponseRedirect(reverse_lazy("profile")) 

1216 

1217 

1218class IvatarLoginView(LoginView): 

1219 """ 

1220 View class for login 

1221 """ 

1222 

1223 template_name = "login.html" 

1224 

1225 @trace_authentication("login_attempt") 

1226 def get(self, request, *args, **kwargs): 

1227 """ 

1228 Handle get for login view 

1229 """ 

1230 if request.user: 

1231 if request.user.is_authenticated: 

1232 # Respect the 'next' parameter if present 

1233 next_url = request.GET.get("next") 

1234 if next_url: 

1235 return HttpResponseRedirect(next_url) 

1236 return HttpResponseRedirect(reverse_lazy("profile")) 

1237 return super().get(self, request, args, kwargs) 

1238 

1239 @trace_authentication("login_post") 

1240 def post(self, request, *args, **kwargs): 

1241 """ 

1242 Handle login form submission 

1243 """ 

1244 return super().post(request, *args, **kwargs) 

1245 

1246 def get_context_data(self, **kwargs): 

1247 context = super().get_context_data(**kwargs) 

1248 context["with_fedora"] = SOCIAL_AUTH_FEDORA_KEY is not None 

1249 return context 

1250 

1251 

1252@method_decorator(login_required, name="dispatch") 

1253class ProfileView(TemplateView): 

1254 """ 

1255 View class for profile 

1256 """ 

1257 

1258 template_name = "profile.html" 

1259 

1260 def get(self, request, *args, **kwargs): 

1261 if "profile_username" in kwargs: 

1262 if not request.user.is_staff: 

1263 return HttpResponseRedirect(reverse_lazy("profile")) 

1264 with contextlib.suppress(Exception): 

1265 u = User.objects.get(username=kwargs["profile_username"]) 

1266 request.user = u 

1267 self._confirm_claimed_openid() 

1268 return super().get(self, request, args, kwargs) 

1269 

1270 def get_context_data(self, **kwargs): 

1271 """ 

1272 Provide additional context data, like if max_photos is reached 

1273 already or not. 

1274 """ 

1275 context = super().get_context_data(**kwargs) 

1276 context["max_photos"] = False 

1277 if self.request.user: 

1278 if self.request.user.photo_set.all().count() >= MAX_NUM_PHOTOS: 

1279 context["max_photos"] = True 

1280 return context 

1281 

1282 def _confirm_claimed_openid(self): 

1283 openids = self.request.user.useropenid_set.all() 

1284 # If there is only one OpenID, we eventually need to add it to 

1285 # the user account 

1286 if openids.count() == 1: 

1287 # Already confirmed, skip 

1288 if ConfirmedOpenId.objects.filter( # pylint: disable=no-member 

1289 openid=openids.first().claimed_id 

1290 ).exists(): 

1291 return 

1292 # For whatever reason, this is in unconfirmed state, skip 

1293 if UnconfirmedOpenId.objects.filter( # pylint: disable=no-member 

1294 openid=openids.first().claimed_id 

1295 ).exists(): 

1296 return 

1297 logger.debug(f"need to confirm: {openids.first()}") 

1298 confirmed = ConfirmedOpenId() 

1299 confirmed.user = self.request.user 

1300 confirmed.ip_address = get_client_ip(self.request)[0] 

1301 confirmed.openid = openids.first().claimed_id 

1302 confirmed.save() 

1303 

1304 

1305class PasswordResetView(PasswordResetViewOriginal): 

1306 """ 

1307 View class for password reset 

1308 """ 

1309 

1310 def post(self, request, *args, **kwargs): 

1311 """ 

1312 Since we have the mail addresses in ConfirmedEmail model, 

1313 we need to set the email on the user object in order for the 

1314 PasswordResetView class to pick up the correct user. 

1315 In case we have the mail address in the User object, we still 

1316 need to assign a random password in order for PasswordResetView 

1317 class to pick up the user - else it will silently do nothing. 

1318 """ 

1319 if "email" in request.POST: 

1320 user = None 

1321 

1322 # Try to find the user via the normal user class 

1323 # TODO: How to handle the case that multiple user accounts 

1324 # could have the same password set? 

1325 user = User.objects.filter(email=request.POST["email"]).first() 

1326 

1327 # If we didn't find the user in the previous step, 

1328 # try the ConfirmedEmail class instead. 

1329 # If we find the user there, we need to set the mail 

1330 # attribute on the user object accordingly 

1331 if not user: 

1332 with contextlib.suppress(ObjectDoesNotExist): 

1333 confirmed_email = ConfirmedEmail.objects.get( 

1334 email=request.POST["email"] 

1335 ) 

1336 user = confirmed_email.user 

1337 user.email = confirmed_email.email 

1338 user.save() 

1339 # If we found the user, set a random password. Else, the 

1340 # ResetPasswordView class will silently ignore the password 

1341 # reset request 

1342 if user: 

1343 if not user.password or user.password.startswith("!"): 

1344 random_pass = get_random_string(12) 

1345 user.set_password(random_pass) 

1346 user.save() 

1347 

1348 # Whatever happens above, let the original function handle the rest 

1349 return super().post(self, request, args, kwargs) 

1350 

1351 

1352@method_decorator(login_required, name="dispatch") 

1353class DeleteAccountView(SuccessMessageMixin, FormView): 

1354 """ 

1355 View class for account deletion 

1356 """ 

1357 

1358 template_name = "delete.html" 

1359 form_class = DeleteAccountForm 

1360 success_url = reverse_lazy("home") 

1361 

1362 def get(self, request, *args, **kwargs): 

1363 return super().get(self, request, args, kwargs) 

1364 

1365 def post(self, request, *args, **kwargs): 

1366 """ 

1367 Handle account deletion 

1368 """ 

1369 if request.user.password: 

1370 if "password" in request.POST: 

1371 if not request.user.check_password(request.POST["password"]): 

1372 messages.error(request, _("Incorrect password")) 

1373 return HttpResponseRedirect(reverse_lazy("delete")) 

1374 else: 

1375 messages.error(request, _("No password given")) 

1376 return HttpResponseRedirect(reverse_lazy("delete")) 

1377 

1378 # should delete all confirmed/unconfirmed/photo objects 

1379 request.user.delete() 

1380 return super().post(self, request, args, kwargs) 

1381 

1382 

1383@method_decorator(login_required, name="dispatch") 

1384class ExportView(SuccessMessageMixin, TemplateView): 

1385 """ 

1386 View class responsible for libravatar user data export 

1387 """ 

1388 

1389 template_name = "export.html" 

1390 model = User 

1391 

1392 def get(self, request, *args, **kwargs): 

1393 return super().get(self, request, args, kwargs) 

1394 

1395 def post(self, request, *args, **kwargs): 

1396 """ 

1397 Handle real export 

1398 """ 

1399 SCHEMA_ROOT = "https://www.libravatar.org/schemas/export/0.2" 

1400 SCHEMA_XSD = f"{SCHEMA_ROOT}/export.xsd" 

1401 

1402 def xml_header(): 

1403 return ( 

1404 """<?xml version="1.0" encoding="UTF-8"?>""" 

1405 '''<user xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"''' 

1406 ''' xsi:schemaLocation="%s %s"''' 

1407 """ xmlns="%s">\n""" % (SCHEMA_ROOT, SCHEMA_XSD, SCHEMA_ROOT) 

1408 ) 

1409 

1410 def xml_footer(): 

1411 return "</user>\n" 

1412 

1413 def xml_account(user): 

1414 escaped_username = saxutils.quoteattr(user.username) 

1415 escaped_password = saxutils.quoteattr(user.password) 

1416 return " <account username={} password={}/>\n".format( 

1417 escaped_username, 

1418 escaped_password, 

1419 ) 

1420 

1421 def xml_email(user): 

1422 returnstring = " <emails>\n" 

1423 for email in user.confirmedemail_set.all(): 

1424 returnstring += ( 

1425 ' <email photo_id="' 

1426 + str(email.photo_id) 

1427 + '">' 

1428 + str(email.email) 

1429 + "</email>" 

1430 + "\n" 

1431 ) 

1432 returnstring += " </emails>\n" 

1433 return returnstring 

1434 

1435 def xml_openid(user): 

1436 returnstring = " <openids>\n" 

1437 for openid in user.confirmedopenid_set.all(): 

1438 returnstring += ( 

1439 ' <openid photo_id="' 

1440 + str(openid.photo_id) 

1441 + '">' 

1442 + str(openid.openid) 

1443 + "</openid>" 

1444 + "\n" 

1445 ) 

1446 returnstring += " </openids>\n" 

1447 return returnstring 

1448 

1449 def xml_photos(user): 

1450 s = " <photos>\n" 

1451 for photo in user.photo_set.all(): 

1452 encoded_photo = base64.b64encode(photo.data) 

1453 if encoded_photo: 

1454 s += ( 

1455 """ <photo id="%s" encoding="base64" format=%s>""" 

1456 """%s""" 

1457 """</photo>\n""" 

1458 % (photo.id, saxutils.quoteattr(photo.format), encoded_photo) 

1459 ) 

1460 s += " </photos>\n" 

1461 return s 

1462 

1463 user = request.user 

1464 

1465 photos = [] 

1466 for photo in user.photo_set.all(): 

1467 photo_details = {"data": photo.data, "format": photo.format} 

1468 photos.append(photo_details) 

1469 

1470 bytesobj = BytesIO() 

1471 data = gzip.GzipFile(fileobj=bytesobj, mode="w") 

1472 data.write(bytes(xml_header(), "utf-8")) 

1473 data.write(bytes(xml_account(user), "utf-8")) 

1474 data.write(bytes(xml_email(user), "utf-8")) 

1475 data.write(bytes(xml_openid(user), "utf-8")) 

1476 data.write(bytes(xml_photos(user), "utf-8")) 

1477 data.write(bytes(xml_footer(), "utf-8")) 

1478 data.close() 

1479 bytesobj.seek(0) 

1480 

1481 response = HttpResponse(content_type="application/gzip") 

1482 response["Content-Disposition"] = ( 

1483 f'attachment; filename="libravatar-export_{user.username}.xml.gz"' 

1484 ) 

1485 response.write(bytesobj.read()) 

1486 return response