Coverage for ivatar/ivataraccount/models.py: 85%
462 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
1"""
2Our models for ivatar.ivataraccount
3"""
5import base64
6import contextlib
7import hashlib
8import time
9from io import BytesIO
10from os import urandom
11from typing import List
12from urllib.error import HTTPError, URLError
13from ivatar.utils import urlopen, Bluesky
14from urllib.parse import urlsplit, urlunsplit, quote
15import logging
17from PIL import Image
18from django.contrib.auth.models import User
19from django.contrib import messages
20from django.db import models
21from django.utils import timezone
22from django.http import HttpResponseRedirect
23from django.urls import reverse_lazy, reverse
24from django.utils.translation import gettext_lazy as _
25from django.core.cache import cache
26from django.core.exceptions import ObjectDoesNotExist
27from django.core.mail import send_mail
28from django.template.loader import render_to_string
29from openid.association import Association as OIDAssociation
30from openid.store import nonce as oidnonce
31from openid.store.interface import OpenIDStore
32from django.db.models.signals import post_save, post_delete, pre_delete
33from django.dispatch import receiver
35from libravatar import libravatar_url
37from django.conf import settings
38from ivatar.photo_cache import (
39 invalidate_photo_cache,
40 invalidate_digest_cache,
41 pregenerate_photo_thumbnails,
42)
43from ivatar.utils import openid_variations
44from .gravatar import get_photo as get_gravatar_photo
46# Initialize logger
47logger = logging.getLogger("ivatar")
49# Access settings through django.conf.settings
50MAX_LENGTH_EMAIL = getattr(settings, "MAX_LENGTH_EMAIL", 254)
51MAX_PIXELS = getattr(settings, "MAX_PIXELS", 7000)
52AVATAR_MAX_SIZE = getattr(settings, "AVATAR_MAX_SIZE", 512)
53JPEG_QUALITY = getattr(settings, "JPEG_QUALITY", 85)
54MAX_LENGTH_URL = getattr(settings, "MAX_LENGTH_URL", 255)
55SECURE_BASE_URL = getattr(settings, "SECURE_BASE_URL", "https://avatars.linux-kernel.at/avatar/")
56SITE_NAME = getattr(settings, "SITE_NAME", "libravatar")
57DEFAULT_FROM_EMAIL = getattr(settings, "DEFAULT_FROM_EMAIL", "ivatar@mg.linux-kernel.at")
60def file_format(image_type):
61 """
62 Helper method returning a short image type
63 """
64 if image_type in ("JPEG", "MPO"):
65 return "jpg"
66 elif image_type == "PNG":
67 return "png"
68 elif image_type == "GIF":
69 return "gif"
70 elif image_type == "WEBP":
71 return "webp"
72 return None
75def pil_format(image_type):
76 """
77 Helper method returning the 'encoder name' for PIL
78 """
79 if image_type in ("jpg", "jpeg", "mpo"):
80 return "JPEG"
81 elif image_type == "png":
82 return "PNG"
83 elif image_type == "gif":
84 return "GIF"
85 elif image_type == "webp":
86 return "WEBP"
88 logger.info("Unsupported file format: %s", image_type)
89 return None
92class UserPreference(models.Model):
93 """
94 Holds the user users preferences
95 """
97 THEMES = (
98 ("default", "Default theme"),
99 ("clime", "climes theme"),
100 ("green", "green theme"),
101 ("red", "red theme"),
102 )
104 theme = models.CharField(
105 max_length=10,
106 choices=THEMES,
107 default="default",
108 )
110 user = models.OneToOneField(
111 User,
112 on_delete=models.deletion.CASCADE,
113 primary_key=True,
114 )
116 def __str__(self):
117 return "Preference (%i) for %s" % (self.pk, self.user)
120class BaseAccountModel(models.Model):
121 """
122 Base, abstract model, holding fields we use in all cases
123 """
125 user = models.ForeignKey(
126 User,
127 on_delete=models.deletion.CASCADE,
128 )
129 ip_address = models.GenericIPAddressField(unpack_ipv4=True, null=True)
130 add_date = models.DateTimeField(default=timezone.now)
132 class Meta: # pylint: disable=too-few-public-methods
133 """
134 Class attributes
135 """
137 abstract = True
140class AccessStat(models.Model):
141 """
142 Abstract base model for per-object access statistics.
144 Stores the access_count in a separate, narrow table so that frequent
145 counter updates do not generate dead tuples on the wide parent tables
146 and remain eligible for PostgreSQL HOT (Heap Only Tuple) updates.
147 Subclasses add a OneToOneField (used as primary key) to the parent model.
148 """
150 access_count = models.BigIntegerField(default=0, editable=False)
151 # No index on access_count — keeps HOT updates possible on these rows.
153 class Meta: # pylint: disable=too-few-public-methods
154 """
155 Class attributes
156 """
158 abstract = True
161class Photo(BaseAccountModel):
162 """
163 Model holding the photos and information about them
164 """
166 ip_address = models.GenericIPAddressField(unpack_ipv4=True)
167 data = models.BinaryField()
168 format = models.CharField(max_length=4)
169 class Meta: # pylint: disable=too-few-public-methods
170 """
171 Class attributes
172 """
174 verbose_name = _("photo")
175 verbose_name_plural = _("photos")
177 def import_image(self, service_name, email_address):
178 """
179 Allow to import image from other (eg. Gravatar) service
180 """
181 image_url = False
183 if service_name == "Gravatar":
184 if gravatar := get_gravatar_photo(email_address):
185 image_url = gravatar["image_url"]
187 if service_name == "Libravatar":
188 image_url = libravatar_url(email_address, size=AVATAR_MAX_SIZE)
190 if not image_url:
191 return False # pragma: no cover
192 try:
193 image = urlopen(image_url)
194 except HTTPError as exc:
195 logger.warning(
196 f"{service_name} import failed with an HTTP error: {exc.code}"
197 )
198 return False
199 except URLError as exc:
200 logger.warning(f"{service_name} import failed: {exc.reason}")
201 return False
202 data = image.read()
204 try:
205 img = Image.open(BytesIO(data))
206 # How am I supposed to test this?
207 except ValueError: # pragma: no cover
208 return False # pragma: no cover
210 self.format = file_format(img.format)
211 if not self.format:
212 logger.warning(f"Unable to determine format: {img}")
213 return False # pragma: no cover
214 self.data = data
215 super().save()
216 return True
218 def save(
219 self, force_insert=False, force_update=False, using=None, update_fields=None
220 ):
221 """
222 Override save from parent, taking care about the image
223 """
224 # Use PIL to read the file format
225 try:
226 img = Image.open(BytesIO(self.data))
227 except Exception as exc: # pylint: disable=broad-except
228 # For debugging only
229 logger.error(f"Exception caught in Photo.save(): {exc}")
230 return False
231 self.format = file_format(img.format)
232 if not self.format:
233 logger.error("Format not recognized")
234 return False
235 result = super().save(
236 force_insert=force_insert,
237 force_update=force_update,
238 using=using,
239 update_fields=update_fields,
240 )
241 return result
243 def delete(self, using=None, keep_parents=False):
244 """
245 Override delete from parent, invalidating cached photo variants
246 """
247 photo_id = self.pk
248 result = super().delete(using=using, keep_parents=keep_parents)
249 if photo_id:
250 invalidate_photo_cache(photo_id)
251 return result
253 def perform_crop(self, request, dimensions, email, openid):
254 """
255 Helper to crop the image
256 """
257 if request.user.photo_set.count() == 1:
258 # This is the first photo, assign to all confirmed addresses
259 for addr in request.user.confirmedemail_set.all():
260 addr.photo = self
261 addr.save()
263 for addr in request.user.confirmedopenid_set.all():
264 addr.photo = self
265 addr.save()
267 if email:
268 # Explicitly asked
269 email.photo = self
270 email.save()
272 if openid:
273 # Explicitly asked
274 openid.photo = self
275 openid.save()
277 # Do the real work cropping
278 img = Image.open(BytesIO(self.data))
280 # This should be anyway checked during save...
281 dimensions["a"], dimensions["b"] = img.size # pylint: disable=invalid-name
282 if dimensions["a"] > MAX_PIXELS or dimensions["b"] > MAX_PIXELS:
283 messages.error(
284 request,
285 _(
286 "Image dimensions are too big (max: %(max_pixels)s x %(max_pixels)s"
287 % {
288 "max_pixels": MAX_PIXELS,
289 }
290 ),
291 )
292 return HttpResponseRedirect(reverse_lazy("profile"))
294 if dimensions["w"] == 0 and dimensions["h"] == 0:
295 dimensions["w"], dimensions["h"] = dimensions["a"], dimensions["b"]
296 min_from_w_h = min(dimensions["w"], dimensions["h"])
297 dimensions["w"], dimensions["h"] = min_from_w_h, min_from_w_h
298 elif (
299 (dimensions["w"] < 0)
300 or ((dimensions["x"] + dimensions["w"]) > dimensions["a"])
301 or (dimensions["h"] < 0)
302 or ((dimensions["y"] + dimensions["h"]) > dimensions["b"])
303 ):
304 messages.error(request, _("Crop outside of original image bounding box"))
305 return HttpResponseRedirect(reverse_lazy("profile"))
307 cropped = img.crop(
308 (
309 dimensions["x"],
310 dimensions["y"],
311 dimensions["x"] + dimensions["w"],
312 dimensions["y"] + dimensions["h"],
313 )
314 )
315 # cropped.load()
316 # Resize the image only if it's larger than the specified max width.
317 cropped_w, cropped_h = cropped.size
318 max_w = AVATAR_MAX_SIZE
319 if cropped_w > max_w or cropped_h > max_w:
320 cropped = cropped.resize((max_w, max_w), Image.LANCZOS)
322 data = BytesIO()
323 cropped.save(data, pil_format(self.format), quality=JPEG_QUALITY)
324 data.seek(0)
326 # Overwrite the existing image
327 self.data = data.read()
328 self.save()
330 return HttpResponseRedirect(reverse_lazy("profile"))
332 @property
333 def access_count(self):
334 """
335 Property to access access_count from the related stat object for backwards compatibility.
336 """
337 try:
338 return self.stat.access_count
339 except ObjectDoesNotExist:
340 return 0
342 def __str__(self):
343 return "%s (%i) from %s" % (self.format, self.pk or 0, self.user)
346# pylint: disable=too-few-public-methods
347class ConfirmedEmailManager(models.Manager):
348 """
349 Manager for our confirmed email addresses model
350 """
352 @staticmethod
353 def create_confirmed_email(user, email_address, is_logged_in):
354 """
355 Helper method to create confirmed email address
356 """
357 confirmed = ConfirmedEmail()
358 confirmed.user = user
359 confirmed.ip_address = "0.0.0.0"
360 confirmed.email = email_address
361 confirmed.save()
363 external_photos = []
364 if is_logged_in:
365 if gravatar := get_gravatar_photo(confirmed.email):
366 external_photos.append(gravatar)
368 return (confirmed.pk, external_photos)
371from django.db.models.functions import MD5, SHA256, Lower, Trim
373# ... (other code)
375class ConfirmedEmail(BaseAccountModel):
376 """
377 Model holding our confirmed email addresses, as well as the relation
378 to the assigned photo
379 """
381 email = models.EmailField(unique=True, max_length=MAX_LENGTH_EMAIL)
382 photo = models.ForeignKey(
383 Photo,
384 related_name="emails",
385 blank=True,
386 null=True,
387 on_delete=models.deletion.SET_NULL,
388 )
389 # Alternative assignment - use Bluesky handle
390 bluesky_handle = models.CharField(max_length=256, null=True, blank=True)
391 digest = models.GeneratedField(
392 expression=MD5(Lower(Trim("email"))),
393 output_field=models.CharField(max_length=32),
394 db_persist=True,
395 )
396 digest_sha256 = models.GeneratedField(
397 expression=SHA256(Lower(Trim("email"))),
398 output_field=models.CharField(max_length=64),
399 db_persist=True,
400 )
401 objects = ConfirmedEmailManager()
403 class Meta: # pylint: disable=too-few-public-methods
404 """
405 Class attributes
406 """
408 verbose_name = _("confirmed email")
409 verbose_name_plural = _("confirmed emails")
411 indexes = [
412 models.Index(fields=["digest"], name="idx_cemail_digest"),
413 models.Index(fields=["digest_sha256"], name="idx_cemail_digest_sha256"),
414 models.Index(fields=["bluesky_handle"], name="idx_cemail_bluesky_handle"),
415 ]
417 def set_photo(self, photo):
418 """
419 Helper method to set photo
420 """
421 self.photo = photo
422 self.save()
424 def set_bluesky_handle(self, handle):
425 """
426 Helper method to set Bluesky handle
427 """
429 bs = Bluesky()
430 handle = bs.normalize_handle(handle)
431 avatar = bs.get_profile(handle)
432 if not avatar:
433 raise ValueError("Invalid Bluesky handle")
434 self.bluesky_handle = handle
435 self.save()
437 def save(
438 self, force_insert=False, force_update=False, using=None, update_fields=None
439 ):
440 """
441 Override save from parent, add digest
442 """
443 # We need to manually expire the page caches
444 # TODO: Verify this works as expected
445 # First check if we already have an ID
446 if self.pk:
447 cache_url = reverse_lazy(
448 "assign_photo_email", kwargs={"email_id": int(self.pk)}
449 )
451 cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}"
452 try:
453 if cache.has_key(cache_key):
454 cache.delete(cache_key)
455 logger.debug("Successfully cleaned up cached page: %s" % cache_key)
456 except Exception as exc:
457 logger.warning(
458 "Failed to clean up cached page {}: {}".format(cache_key, exc)
459 )
461 # Invalidate Bluesky avatar URL cache if bluesky_handle changed
462 if hasattr(self, "bluesky_handle") and self.bluesky_handle:
463 try:
464 cache.delete(self.bluesky_handle)
465 logger.debug(
466 "Successfully cleaned up Bluesky avatar cache for handle: %s"
467 % self.bluesky_handle
468 )
469 except Exception as exc:
470 logger.warning(
471 "Failed to clean up Bluesky avatar cache for handle %s: %s"
472 % (self.bluesky_handle, exc)
473 )
475 return super().save(
476 force_insert=force_insert,
477 force_update=force_update,
478 using=using,
479 update_fields=update_fields,
480 )
482 @property
483 def access_count(self):
484 """
485 Property to access access_count from the related stat object for backwards compatibility.
486 """
487 try:
488 return self.stat.access_count
489 except ObjectDoesNotExist:
490 return 0
492 def __str__(self):
493 return "%s (%i) from %s" % (self.email, self.pk, self.user)
496class UnconfirmedEmail(BaseAccountModel):
497 """
498 Model holding unconfirmed email addresses as well as the verification key
499 """
501 email = models.EmailField(max_length=MAX_LENGTH_EMAIL)
502 verification_key = models.CharField(max_length=64)
503 last_send_date = models.DateTimeField(null=True, blank=True)
504 last_status = models.TextField(max_length=2047, null=True, blank=True)
506 class Meta: # pylint: disable=too-few-public-methods
507 """
508 Class attributes
509 """
511 verbose_name = _("unconfirmed email")
512 verbose_name_plural = _("unconfirmed emails")
514 def save(
515 self, force_insert=False, force_update=False, using=None, update_fields=None
516 ):
517 if not self.verification_key:
518 hash_object = hashlib.new("sha256")
519 hash_object.update(
520 urandom(1024)
521 + self.user.username.encode("utf-8") # pylint: disable=no-member
522 ) # pylint: disable=no-member
523 self.verification_key = hash_object.hexdigest()
524 super().save(
525 force_insert=force_insert,
526 force_update=force_update,
527 using=using,
528 update_fields=update_fields,
529 )
531 def send_confirmation_mail(self, url=SECURE_BASE_URL):
532 """
533 Send confirmation mail to that mail address
534 """
535 link = url + reverse(
536 "confirm_email", kwargs={"verification_key": self.verification_key}
537 )
538 email_subject = _("Confirm your email address on %s") % SITE_NAME
539 email_body = render_to_string(
540 "email_confirmation.txt",
541 {
542 "verification_link": link,
543 "site_name": SITE_NAME,
544 },
545 )
546 self.last_send_date = timezone.now()
547 self.last_status = "OK"
548 # if settings.DEBUG:
549 # print('DEBUG: %s' % link)
550 try:
551 send_mail(email_subject, email_body, DEFAULT_FROM_EMAIL, [self.email])
552 except Exception as e:
553 self.last_status = f"{e}"
554 self.save()
555 return True
557 def __str__(self):
558 return "%s (%i) from %s" % (self.email, self.pk, self.user)
561class UnconfirmedOpenId(BaseAccountModel):
562 """
563 Model holding unconfirmed OpenIDs
564 """
566 openid = models.URLField(unique=False, max_length=MAX_LENGTH_URL)
568 class Meta: # pylint: disable=too-few-public-methods
569 """
570 Meta class
571 """
573 verbose_name = _("unconfirmed OpenID")
574 verbose_name_plural = "unconfirmed_OpenIDs"
576 def __str__(self):
577 return "%s (%i) from %s" % (self.openid, self.pk, self.user)
580class ConfirmedOpenId(BaseAccountModel):
581 """
582 Model holding confirmed OpenIDs, as well as the relation to
583 the assigned photo
584 """
586 openid = models.URLField(unique=True, max_length=MAX_LENGTH_URL)
587 photo = models.ForeignKey(
588 Photo,
589 related_name="openids",
590 blank=True,
591 null=True,
592 on_delete=models.deletion.SET_NULL,
593 )
594 # http://<id>/ base version - http w/ trailing slash
595 digest = models.CharField(max_length=64, db_index=True)
596 # http://<id> - http w/o trailing slash
597 alt_digest1 = models.CharField(max_length=64, null=True, blank=True, default=None, db_index=True)
598 # https://<id>/ - https w/ trailing slash
599 alt_digest2 = models.CharField(max_length=64, null=True, blank=True, default=None, db_index=True)
600 # https://<id> - https w/o trailing slash
601 alt_digest3 = models.CharField(max_length=64, null=True, blank=True, default=None, db_index=True)
602 # Alternative assignment - use Bluesky handle
603 bluesky_handle = models.CharField(max_length=256, null=True, blank=True)
605 class Meta: # pylint: disable=too-few-public-methods
606 """
607 Meta class
608 """
610 verbose_name = _("confirmed OpenID")
611 verbose_name_plural = _("confirmed OpenIDs")
613 def set_photo(self, photo):
614 """
615 Helper method to save photo
616 """
617 self.photo = photo
618 self.save()
620 def set_bluesky_handle(self, handle):
621 """
622 Helper method to set Bluesky handle
623 """
624 bs = Bluesky()
625 handle = bs.normalize_handle(handle)
626 avatar = bs.get_profile(handle)
627 if not avatar:
628 raise ValueError("Invalid Bluesky handle")
629 self.bluesky_handle = handle
630 self.save()
632 def save(
633 self, force_insert=False, force_update=False, using=None, update_fields=None
634 ):
635 url = urlsplit(self.openid)
636 if url.username: # pragma: no cover
637 password = url.password or ""
638 netloc = f"{url.username}:{password}@{url.hostname}"
639 else:
640 netloc = url.hostname
641 lowercase_url = urlunsplit(
642 (url.scheme.lower(), netloc, url.path, url.query, url.fragment)
643 )
644 self.openid = lowercase_url
646 self.digest = hashlib.sha256(
647 openid_variations(lowercase_url)[0].encode("utf-8")
648 ).hexdigest()
649 self.alt_digest1 = hashlib.sha256(
650 openid_variations(lowercase_url)[1].encode("utf-8")
651 ).hexdigest()
652 self.alt_digest2 = hashlib.sha256(
653 openid_variations(lowercase_url)[2].encode("utf-8")
654 ).hexdigest()
655 self.alt_digest3 = hashlib.sha256(
656 openid_variations(lowercase_url)[3].encode("utf-8")
657 ).hexdigest()
659 # Invalidate page caches and Bluesky avatar cache
660 if self.pk:
661 # Invalidate assign_photo_openid page cache
662 cache_url = reverse_lazy(
663 "assign_photo_openid", kwargs={"openid_id": int(self.pk)}
664 )
665 cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}"
666 try:
667 if cache.has_key(cache_key):
668 cache.delete(cache_key)
669 logger.debug("Successfully cleaned up cached page: %s" % cache_key)
670 except Exception as exc:
671 logger.warning(
672 "Failed to clean up cached page {}: {}".format(cache_key, exc)
673 )
675 # Invalidate Bluesky avatar URL cache if bluesky_handle exists
676 if hasattr(self, "bluesky_handle") and self.bluesky_handle:
677 try:
678 cache.delete(self.bluesky_handle)
679 logger.debug(
680 "Successfully cleaned up Bluesky avatar cache for handle: %s"
681 % self.bluesky_handle
682 )
683 except Exception as exc:
684 logger.warning(
685 "Failed to clean up Bluesky avatar cache for handle %s: %s"
686 % (self.bluesky_handle, exc)
687 )
689 return super().save(
690 force_insert=force_insert,
691 force_update=force_update,
692 using=using,
693 update_fields=update_fields,
694 )
696 @property
697 def access_count(self):
698 """
699 Property to access access_count from the related stat object for backwards compatibility.
700 """
701 try:
702 return self.stat.access_count
703 except ObjectDoesNotExist:
704 return 0
706 def __str__(self):
707 return "%s (%i) (%s)" % (self.openid, self.pk, self.user)
710class PhotoAccessStat(AccessStat):
711 """
712 Access statistics for Photo objects.
713 """
715 photo = models.OneToOneField(
716 Photo,
717 on_delete=models.CASCADE,
718 primary_key=True,
719 related_name="stat",
720 )
722 class Meta: # pylint: disable=too-few-public-methods
723 """
724 Class attributes
725 """
727 verbose_name = _("photo access stat")
728 verbose_name_plural = _("photo access stats")
731@receiver(post_save, sender=Photo)
732def create_photo_stat(sender, instance, created, **kwargs):
733 """
734 Automatically create a PhotoAccessStat row when a new Photo is created.
735 """
736 if created:
737 PhotoAccessStat.objects.get_or_create(photo=instance)
740def _get_email_digests(instance) -> List[str]:
741 """Helper to extract MD5 and SHA256 digests for a ConfirmedEmail instance."""
742 digests = []
743 if getattr(instance, "digest", None):
744 digests.append(instance.digest)
745 if getattr(instance, "digest_sha256", None):
746 digests.append(instance.digest_sha256)
747 if not digests and getattr(instance, "email", None):
748 normalized = instance.email.strip().lower().encode("utf-8")
749 digests.append(hashlib.md5(normalized).hexdigest())
750 digests.append(hashlib.sha256(normalized).hexdigest())
751 return digests
754def _get_openid_digests(instance) -> List[str]:
755 """Helper to extract all OpenID SHA256 variation digests for a ConfirmedOpenId instance."""
756 digests = []
757 for attr in ("digest", "alt_digest1", "alt_digest2", "alt_digest3"):
758 val = getattr(instance, attr, None)
759 if val:
760 digests.append(val)
761 if not digests and getattr(instance, "openid", None):
762 with contextlib.suppress(Exception):
763 url = urlsplit(instance.openid)
764 if url.username:
765 password = url.password or ""
766 netloc = f"{url.username}:{password}@{url.hostname}"
767 else:
768 netloc = url.hostname
769 lowercase_url = urlunsplit(
770 (url.scheme.lower(), netloc, url.path, url.query, url.fragment)
771 )
772 for var in openid_variations(lowercase_url):
773 digests.append(hashlib.sha256(var.encode("utf-8")).hexdigest())
774 return digests
777@receiver(pre_delete, sender=User)
778def invalidate_cache_on_user_delete(sender, instance, **kwargs):
779 """
780 Invalidate all photo caches and digest response caches when a User is deleted.
781 """
782 try:
783 digests = []
784 for email in instance.confirmedemail_set.all():
785 digests.extend(_get_email_digests(email))
786 for openid in instance.confirmedopenid_set.all():
787 digests.extend(_get_openid_digests(openid))
788 for photo in instance.photo_set.all():
789 invalidate_photo_cache(photo.pk)
790 if digests:
791 invalidate_digest_cache(digests)
792 except Exception as exc: # pylint: disable=broad-exception-caught
793 logger.warning("Error invalidating cache on user delete: %s", exc)
796@receiver(post_save, sender=Photo)
797def invalidate_photo_cache_on_save(sender, instance, **kwargs):
798 """
799 Invalidate cached resized variants when a photo is saved or updated,
800 and pre-generate standard thumbnail sizes.
801 """
802 if instance.pk:
803 invalidate_photo_cache(instance.pk)
804 # Also invalidate digest response cache for linked emails/openids
805 try:
806 digests = []
807 for email in instance.emails.all():
808 digests.extend(_get_email_digests(email))
809 for openid in instance.openids.all():
810 digests.extend(_get_openid_digests(openid))
811 if digests:
812 invalidate_digest_cache(digests)
813 except Exception: # pylint: disable=broad-exception-caught
814 pass
815 if instance.data and getattr(settings, "PREGENERATE_THUMBNAILS_ON_SAVE", True):
816 pregenerate_photo_thumbnails(instance)
819@receiver(pre_delete, sender=Photo)
820def invalidate_photo_digests_on_pre_delete(sender, instance, **kwargs):
821 """
822 Invalidate response cache for emails and openids associated with this photo before deletion.
823 """
824 try:
825 digests = []
826 for email in instance.emails.all():
827 digests.extend(_get_email_digests(email))
828 for openid in instance.openids.all():
829 digests.extend(_get_openid_digests(openid))
830 if digests:
831 invalidate_digest_cache(digests)
832 except Exception: # pylint: disable=broad-exception-caught
833 pass
836@receiver(post_delete, sender=Photo)
837def invalidate_photo_cache_on_delete(sender, instance, **kwargs):
838 """
839 Invalidate cached resized variants when a photo is deleted.
840 """
841 if instance.pk:
842 invalidate_photo_cache(instance.pk)
845class ConfirmedEmailAccessStat(AccessStat):
846 """
847 Access statistics for ConfirmedEmail objects.
848 """
850 email = models.OneToOneField(
851 ConfirmedEmail,
852 on_delete=models.CASCADE,
853 primary_key=True,
854 related_name="stat",
855 )
857 class Meta: # pylint: disable=too-few-public-methods
858 """
859 Class attributes
860 """
862 verbose_name = _("confirmed email access stat")
863 verbose_name_plural = _("confirmed email access stats")
866@receiver(post_save, sender=ConfirmedEmail)
867def create_confirmed_email_stat(sender, instance, created, **kwargs):
868 """
869 Automatically create a ConfirmedEmailAccessStat row when a new ConfirmedEmail is created.
870 """
871 if created:
872 ConfirmedEmailAccessStat.objects.get_or_create(email=instance)
875@receiver(post_delete, sender=ConfirmedEmail)
876def invalidate_digest_cache_on_email_delete(sender, instance, **kwargs):
877 """
878 Invalidate response and default avatar caches when a ConfirmedEmail is deleted.
879 """
880 digests = _get_email_digests(instance)
881 if digests:
882 invalidate_digest_cache(digests)
885@receiver(post_save, sender=ConfirmedEmail)
886def invalidate_digest_cache_on_email_save(sender, instance, **kwargs):
887 """
888 Invalidate response and default avatar caches when a ConfirmedEmail is created or updated.
889 """
890 digests = _get_email_digests(instance)
891 if digests:
892 invalidate_digest_cache(digests)
895class ConfirmedOpenIdAccessStat(AccessStat):
896 """
897 Access statistics for ConfirmedOpenId objects.
898 """
900 openid = models.OneToOneField(
901 ConfirmedOpenId,
902 on_delete=models.CASCADE,
903 primary_key=True,
904 related_name="stat",
905 )
907 class Meta: # pylint: disable=too-few-public-methods
908 """
909 Class attributes
910 """
912 verbose_name = _("confirmed openid access stat")
913 verbose_name_plural = _("confirmed openid access stats")
916@receiver(post_save, sender=ConfirmedOpenId)
917def create_confirmed_openid_stat(sender, instance, created, **kwargs):
918 """
919 Automatically create a ConfirmedOpenIdAccessStat row when a new ConfirmedOpenId is created.
920 """
921 if created:
922 ConfirmedOpenIdAccessStat.objects.get_or_create(openid=instance)
925@receiver(post_delete, sender=ConfirmedOpenId)
926def invalidate_digest_cache_on_openid_delete(sender, instance, **kwargs):
927 """
928 Invalidate response and default avatar caches when a ConfirmedOpenId is deleted.
929 """
930 digests = _get_openid_digests(instance)
931 if digests:
932 invalidate_digest_cache(digests)
935@receiver(post_save, sender=ConfirmedOpenId)
936def invalidate_digest_cache_on_openid_save(sender, instance, **kwargs):
937 """
938 Invalidate response and default avatar caches when a ConfirmedOpenId is created or updated.
939 """
940 digests = _get_openid_digests(instance)
941 if digests:
942 invalidate_digest_cache(digests)
946class OpenIDNonce(models.Model):
947 """
948 Model holding OpenID Nonces
949 See also: https://github.com/edx/django-openid-auth/
950 """
952 server_url = models.CharField(max_length=255)
953 timestamp = models.IntegerField()
954 salt = models.CharField(max_length=128)
956 def __str__(self):
957 return "%s (%i) (timestamp: %i)" % (self.server_url, self.pk, self.timestamp)
960class OpenIDAssociation(models.Model):
961 """
962 Model holding the relation/association about OpenIDs
963 """
965 server_url = models.TextField(max_length=2047)
966 handle = models.CharField(max_length=255)
967 secret = models.TextField(max_length=255) # stored base64 encoded
968 issued = models.IntegerField()
969 lifetime = models.IntegerField()
970 assoc_type = models.TextField(max_length=64)
972 def __str__(self):
973 return "%s (%i) (%s, lifetime: %i)" % (
974 self.server_url,
975 self.pk,
976 self.assoc_type,
977 self.lifetime,
978 )
981class DjangoOpenIDStore(OpenIDStore):
982 """
983 The Python openid library needs an OpenIDStore subclass to persist data
984 related to OpenID authentications. This one uses our Django models.
985 """
987 @staticmethod
988 def storeAssociation(server_url, association): # pragma: no cover
989 """
990 Helper method to store associations
991 """
992 assoc = OpenIDAssociation(
993 server_url=server_url,
994 handle=association.handle,
995 secret=base64.encodebytes(association.secret),
996 issued=association.issued,
997 lifetime=association.issued,
998 assoc_type=association.assoc_type,
999 )
1000 assoc.save()
1002 def getAssociation(self, server_url, handle=None): # pragma: no cover
1003 """
1004 Helper method to get associations
1005 """
1006 assocs = []
1007 if handle is not None:
1008 assocs = OpenIDAssociation.objects.filter( # pylint: disable=no-member
1009 server_url=server_url, handle=handle
1010 )
1011 else:
1012 assocs = OpenIDAssociation.objects.filter( # pylint: disable=no-member
1013 server_url=server_url
1014 )
1015 if not assocs:
1016 return None
1017 associations = []
1018 for assoc in assocs:
1019 if isinstance(assoc.secret, str):
1020 assoc.secret = assoc.secret.split("b'")[1].split("'")[0]
1021 assoc.secret = bytes(assoc.secret, "utf-8")
1022 association = OIDAssociation(
1023 assoc.handle,
1024 base64.decodebytes(assoc.secret),
1025 assoc.issued,
1026 assoc.lifetime,
1027 assoc.assoc_type,
1028 )
1029 expires = 0
1030 try:
1031 # pylint: disable=no-member
1032 expires = association.getExpiresIn()
1033 except AttributeError:
1034 expires = association.expiresIn
1035 if expires == 0:
1036 self.removeAssociation(server_url, assoc.handle)
1037 else:
1038 associations.append((association.issued, association))
1039 return associations[-1][1] if associations else None
1041 @staticmethod
1042 def removeAssociation(server_url, handle): # pragma: no cover
1043 """
1044 Helper method to remove associations
1045 """
1046 assocs = list(
1047 OpenIDAssociation.objects.filter( # pylint: disable=no-member
1048 server_url=server_url, handle=handle
1049 )
1050 )
1051 assocs_exist = len(assocs) > 0
1052 for assoc in assocs:
1053 assoc.delete()
1054 return assocs_exist
1056 @staticmethod
1057 def useNonce(server_url, timestamp, salt): # pragma: no cover
1058 """
1059 Helper method to 'use' nonces
1060 """
1061 # Has nonce expired?
1062 if abs(timestamp - time.time()) > oidnonce.SKEW:
1063 return False
1064 try:
1065 nonce = OpenIDNonce.objects.get( # pylint: disable=no-member
1066 server_url__exact=server_url,
1067 timestamp__exact=timestamp,
1068 salt__exact=salt,
1069 )
1070 except ObjectDoesNotExist:
1071 nonce = OpenIDNonce.objects.create( # pylint: disable=no-member
1072 server_url=server_url, timestamp=timestamp, salt=salt
1073 )
1074 return True
1075 nonce.delete()
1076 return False
1078 @staticmethod
1079 def cleanupNonces(): # pragma: no cover
1080 """
1081 Helper method to cleanup nonces
1082 """
1083 timestamp = int(time.time()) - oidnonce.SKEW
1084 # pylint: disable=no-member
1085 OpenIDNonce.objects.filter(timestamp__lt=timestamp).delete()
1087 @staticmethod
1088 def cleanupAssociations(): # pragma: no cover
1089 """
1090 Helper method to cleanup associations
1091 """
1092 OpenIDAssociation.objects.extra(
1093 where=[f"issued + lifetimeint < ({time.time()})"]
1094 ).delete()