Coverage for ivatar/views.py: 55%
601 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 11:51 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 11:51 +0000
1"""
2views under /
3"""
5import contextlib
6from io import BytesIO
7from os import path
8import hashlib
9import logging
10import threading
11from typing import Dict, Tuple, Optional
12from ivatar.utils import urlopen, Bluesky
13from ivatar.access_stats import stats_manager
14from urllib.error import HTTPError, URLError
15from ssl import SSLError
16from django.conf import settings
17from django.views.generic.base import TemplateView, View
18from django.http import HttpResponse, HttpResponseRedirect
19from django.http import HttpResponseNotFound, JsonResponse
20from django.core.exceptions import ObjectDoesNotExist
21from django.core.cache import cache, caches
22from django.utils.translation import gettext_lazy as _
23from django.urls import reverse_lazy
24from django.db.models import Q
25from django.contrib.auth.models import User
27from PIL import Image
29from monsterid.id import build_monster as BuildMonster
30import Identicon
31from pydenticon5 import Pydenticon5
32from .robohash import create_robohash
33from .pagan_optimized import create_optimized_pagan
35# Access settings through django.conf.settings
36AVATAR_MAX_SIZE = getattr(settings, "AVATAR_MAX_SIZE", 512)
37JPEG_QUALITY = getattr(settings, "JPEG_QUALITY", 85)
38DEFAULT_AVATAR_SIZE = getattr(settings, "DEFAULT_AVATAR_SIZE", 80)
40from .ivataraccount.models import ConfirmedEmail, ConfirmedOpenId
41from .ivataraccount.models import UnconfirmedEmail, UnconfirmedOpenId
42from .ivataraccount.models import Photo
43from .ivataraccount.models import pil_format, file_format
44from .utils import is_trusted_url, mm_ng, resize_animated_gif
46# Import OpenTelemetry with graceful degradation
47from .telemetry_utils import trace_avatar_operation, get_telemetry_metrics
49avatar_metrics = get_telemetry_metrics()
51# Initialize loggers
52logger = logging.getLogger("ivatar")
53security_logger = logging.getLogger("ivatar.security")
56def get_size(request, size=DEFAULT_AVATAR_SIZE):
57 """
58 Get size from the URL arguments
59 """
60 sizetemp = None
61 if "s" in request.GET:
62 sizetemp = request.GET["s"]
63 if "size" in request.GET:
64 sizetemp = request.GET["size"]
65 if sizetemp:
66 if sizetemp not in ["", "0"]:
67 with contextlib.suppress(ValueError):
68 if int(sizetemp) > 0:
69 size = int(sizetemp)
70 size = min(size, int(AVATAR_MAX_SIZE))
71 return size
74# Thread-safe in-memory cache for default avatar PNG bytes
75_default_avatars_png_cache: Dict[Tuple[str, str, int], bytes] = {}
76_default_avatars_png_cache_lock = threading.Lock()
77_MAX_DEFAULT_PNG_CACHE_SIZE = 500 # Bounded cache size
80def _get_cached_default_avatar(
81 style: str, digest: str, size: int
82) -> Optional[BytesIO]:
83 cache_key = (style, digest, size)
84 with _default_avatars_png_cache_lock:
85 if cache_key in _default_avatars_png_cache:
86 return BytesIO(_default_avatars_png_cache[cache_key])
87 return None
90def _set_cached_default_avatar(
91 style: str, digest: str, size: int, png_bytes: bytes
92) -> None:
93 cache_key = (style, digest, size)
94 with _default_avatars_png_cache_lock:
95 if len(_default_avatars_png_cache) >= _MAX_DEFAULT_PNG_CACHE_SIZE:
96 # Evict oldest 50 entries
97 remove_keys = list(_default_avatars_png_cache.keys())[:50]
98 for rk in remove_keys:
99 del _default_avatars_png_cache[rk]
100 _default_avatars_png_cache[cache_key] = png_bytes
103class CachingHttpResponse(HttpResponse):
104 """
105 Handle caching of response
106 """
108 def __init__(
109 self,
110 uri,
111 content=b"",
112 content_type=None,
113 status=200, # pylint: disable=too-many-arguments
114 reason=None,
115 charset=None,
116 ):
117 if settings.CACHE_RESPONSE:
118 cache_alias = getattr(settings, "AVATAR_CACHE_ALIAS", "filesystem")
119 caches[cache_alias].set(
120 uri,
121 {
122 "content": content,
123 "content_type": content_type,
124 "status": status,
125 "reason": reason,
126 "charset": charset,
127 },
128 )
129 super().__init__(content, content_type, status, reason, charset)
132class AvatarImageView(TemplateView):
133 """
134 View to return (binary) image, based on OpenID/Email (both by digest)
135 """
137 # TODO: Do cache resize images!! Memcached?
139 def options(self, request, *args, **kwargs):
140 response = HttpResponse("", content_type="text/plain")
141 response["Allow"] = "404 mm mp retro pagan wavatar monsterid robohash identicon"
142 return response
144 @trace_avatar_operation("avatar_request")
145 def get(
146 self, request, *args, **kwargs
147 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,too-many-return-statements
148 """
149 Override get from parent class
150 """
151 model = ConfirmedEmail
152 size = get_size(request)
153 imgformat = "png"
154 obj = None
155 default = None
156 forcedefault = settings.FORCEDEFAULT
157 gravatarredirect = settings.DEFAULT_GRAVATARREDIRECT
158 gravatarproxy = settings.DEFAULT_GRAVATARPROXY
159 uri = request.build_absolute_uri()
161 # Check the cache first
162 if settings.CACHE_RESPONSE:
163 cache_alias = getattr(settings, "AVATAR_CACHE_ALIAS", "filesystem")
164 if centry := caches[cache_alias].get(uri):
165 # Record cache hit
166 avatar_metrics.record_cache_hit(size=str(size), format_type=imgformat)
167 # For DEBUG purpose only
168 # print('Cached entry for %s' % uri)
169 return HttpResponse(
170 centry["content"],
171 content_type=centry["content_type"],
172 status=centry["status"],
173 reason=centry["reason"],
174 charset=centry["charset"],
175 )
176 else:
177 # Record cache miss
178 avatar_metrics.record_cache_miss(size=str(size), format_type=imgformat)
180 # In case no digest at all is provided, return to home page
181 if "digest" not in kwargs:
182 return HttpResponseRedirect(reverse_lazy("home"))
184 if "d" in request.GET:
185 default = request.GET["d"]
186 if "default" in request.GET:
187 default = request.GET["default"]
189 if default is not None:
190 if settings.TRUSTED_DEFAULT_URLS is None:
191 logger.warning("Query parameter `default` is disabled.")
192 default = None
193 elif default.find("://") > 0:
194 # Check if it's trusted, if not, reset to None
195 trusted_url = is_trusted_url(default, settings.TRUSTED_DEFAULT_URLS)
197 if not trusted_url:
198 security_logger.warning(
199 f"Default URL is not in trusted URLs: '{default}'; Kicking it!"
200 )
201 default = None
203 if "f" in request.GET:
204 if request.GET["f"] == "y":
205 forcedefault = True
206 if "forcedefault" in request.GET:
207 if request.GET["forcedefault"] == "y":
208 forcedefault = True
210 if "gravatarredirect" in request.GET:
211 if request.GET["gravatarredirect"] == "y":
212 gravatarredirect = True
214 if "gravatarproxy" in request.GET:
215 if request.GET["gravatarproxy"] == "n":
216 gravatarproxy = False
218 def redir_default(default=None):
219 url = (
220 reverse_lazy("avatar_view", args=[kwargs["digest"]])
221 + "?s=%i" % size
222 + "&forcedefault=y"
223 )
224 if default is not None:
225 url += f"&default={default}"
226 return HttpResponseRedirect(url)
228 digest = kwargs.get("digest", "")
229 digest_len = len(digest)
231 # Optimization: Only look up in relevant tables/fields based on digest length
232 # MD5 is 32 chars, SHA256 is 64 chars
233 if digest_len == 32:
234 with contextlib.suppress(ObjectDoesNotExist):
235 obj = ConfirmedEmail.objects.select_related("photo").get(digest=digest)
236 elif digest_len == 64:
237 with contextlib.suppress(ObjectDoesNotExist):
238 obj = ConfirmedEmail.objects.select_related("photo").get(
239 digest_sha256=digest
240 )
242 if not obj:
243 # OpenID digests are always SHA256 (64 chars)
244 with contextlib.suppress(Exception):
245 obj = (
246 ConfirmedOpenId.objects.select_related("photo")
247 .filter(
248 Q(digest=digest)
249 | Q(alt_digest1=digest)
250 | Q(alt_digest2=digest)
251 | Q(alt_digest3=digest)
252 )
253 .first()
254 )
256 # Handle the special case of Bluesky
257 if obj:
258 if obj.bluesky_handle:
259 return HttpResponseRedirect(
260 reverse_lazy("blueskyproxy", args=[digest])
261 )
262 # If that mail/openid doesn't exist, or has no photo linked to it
263 if not obj or not obj.photo or forcedefault:
264 gravatar_url = (
265 "https://secure.gravatar.com/avatar/"
266 + kwargs["digest"]
267 + "?s=%i" % size
268 )
270 # If we have redirection to Gravatar enabled, this overrides all
271 # default= settings, except forcedefault!
272 if gravatarredirect and not forcedefault:
273 return HttpResponseRedirect(gravatar_url)
275 # Request to proxy Gravatar image - only if not forcedefault
276 if gravatarproxy and not forcedefault:
277 url = (
278 reverse_lazy("gravatarproxy", args=[kwargs["digest"]])
279 + "?s=%i" % size
280 )
281 # Ensure we do not convert None to string 'None'
282 if default:
283 url += f"&default={default}"
284 return HttpResponseRedirect(url)
286 # Return the default URL, as specified, or 404 Not Found, if default=404
287 if default:
288 # Proxy to gravatar to generate wavatar - lazy me
289 if str(default) == "wavatar":
290 url = (
291 reverse_lazy("gravatarproxy", args=[kwargs["digest"]])
292 + "?s=%i" % size
293 + f"&default={default}&f=y"
294 )
295 return HttpResponseRedirect(url)
297 if str(default) == str(404):
298 return HttpResponseNotFound(_("<h1>Image not found</h1>"))
300 try:
301 if str(default) == "monsterid":
302 data = _get_cached_default_avatar(
303 "monsterid", kwargs["digest"], size
304 )
305 if not data:
306 monsterdata = BuildMonster(
307 seed=kwargs["digest"], size=(size, size)
308 )
309 data = BytesIO()
310 monsterdata.save(data, "PNG")
311 _set_cached_default_avatar(
312 "monsterid", kwargs["digest"], size, data.getvalue()
313 )
314 avatar_metrics.record_avatar_generated(
315 size=str(size), format_type="png", source="monsterid"
316 )
317 return self._return_cached_response(data, uri)
318 if str(default) == "robohash":
319 roboset = request.GET.get("robohash") or "any"
320 data = create_robohash(kwargs["digest"], size, roboset)
321 avatar_metrics.record_avatar_generated(
322 size=str(size), format_type="png", source="robohash"
323 )
324 return self._return_cached_response(data, uri)
325 if str(default) == "retro":
326 data = _get_cached_default_avatar(
327 "retro", kwargs["digest"], size
328 )
329 if not data:
330 identicon = Identicon.render(kwargs["digest"])
331 img = Image.open(BytesIO(identicon))
332 img = img.resize((size, size), Image.LANCZOS)
333 data = BytesIO()
334 img.save(data, "PNG")
335 _set_cached_default_avatar(
336 "retro", kwargs["digest"], size, data.getvalue()
337 )
338 avatar_metrics.record_avatar_generated(
339 size=str(size), format_type="png", source="retro"
340 )
341 return self._return_cached_response(data, uri)
342 if str(default) == "pagan":
343 data = create_optimized_pagan(kwargs["digest"], size)
344 avatar_metrics.record_avatar_generated(
345 size=str(size), format_type="png", source="pagan"
346 )
347 return self._return_cached_response(data, uri)
348 if str(default) == "identicon":
349 data = _get_cached_default_avatar(
350 "identicon", kwargs["digest"], size
351 )
352 if not data:
353 p = Pydenticon5()
354 # In order to make use of the whole 32 bytes digest, we need to redigest them.
355 newdigest = hashlib.md5(
356 bytes(kwargs["digest"], "utf-8")
357 ).hexdigest()
358 img = p.draw(newdigest, size, 0)
359 data = BytesIO()
360 img.save(data, "PNG")
361 _set_cached_default_avatar(
362 "identicon", kwargs["digest"], size, data.getvalue()
363 )
364 avatar_metrics.record_avatar_generated(
365 size=str(size), format_type="png", source="identicon"
366 )
367 return self._return_cached_response(data, uri)
368 if str(default) == "mmng":
369 data = _get_cached_default_avatar(
370 "mmng", kwargs["digest"], size
371 )
372 if not data:
373 mmngimg = mm_ng(idhash=kwargs["digest"], size=size)
374 data = BytesIO()
375 mmngimg.save(data, "PNG")
376 _set_cached_default_avatar(
377 "mmng", kwargs["digest"], size, data.getvalue()
378 )
379 avatar_metrics.record_avatar_generated(
380 size=str(size), format_type="png", source="mmng"
381 )
382 return self._return_cached_response(data, uri)
383 if str(default) == "wavatar":
384 url = (
385 reverse_lazy("gravatarproxy", args=[kwargs["digest"]])
386 + f"?s={size}&default=wavatar"
387 )
388 return HttpResponseRedirect(url)
389 if str(default) in {"mm", "mp"}:
390 return self._redirect_static_w_size("mm", size)
391 except Exception as exc:
392 logger.error(f"Error generating default avatar {default}: {exc}")
393 return self._redirect_static_w_size("nobody", size)
395 return HttpResponseRedirect(default)
397 return self._redirect_static_w_size("nobody", size)
398 try:
399 imgformat = obj.photo.format
400 photodata = Image.open(BytesIO(obj.photo.data))
402 data = BytesIO()
404 # Animated GIFs need additional handling
405 if imgformat == "gif" and photodata.is_animated:
406 # Debug only
407 # print("Object is animated and has %i frames" % photodata.n_frames)
408 data = resize_animated_gif(photodata, (size, size))
409 else:
410 # If the image is smaller than what was requested, we need
411 # to use the function resize
412 if photodata.size[0] < size or photodata.size[1] < size:
413 photodata = photodata.resize((size, size), Image.LANCZOS)
414 else:
415 photodata.thumbnail((size, size), Image.LANCZOS)
416 photodata.save(data, pil_format(imgformat), quality=JPEG_QUALITY)
418 data.seek(0)
419 stats_manager.update_access_count(obj.photo)
420 stats_manager.update_access_count(obj)
421 if imgformat == "jpg":
422 imgformat = "jpeg"
424 # Record avatar generation metrics
425 avatar_metrics.record_avatar_generated(
426 size=str(size),
427 format_type=imgformat,
428 source="uploaded" if obj else "generated",
429 )
431 response = CachingHttpResponse(uri, data, content_type=f"image/{imgformat}")
432 # Remove Vary header for images since language doesn't matter
433 response["Vary"] = ""
434 return response
435 except Exception as exc:
436 logger.error(f"Error processing photo for {kwargs['digest']}: {exc}")
437 return redir_default(default)
439 def _redirect_static_w_size(self, arg0, size):
440 """
441 Helper method to redirect to static image with size i/a
442 """
443 # If mm is explicitly given, we need to catch that
444 static_img = path.join("static", "img", arg0, f"{str(size)}.png")
445 if not path.isfile(static_img):
446 # We trust this exists!!!
447 static_img = path.join("static", "img", arg0, "512.png")
448 # We trust static/ is mapped to /static/
449 return HttpResponseRedirect(f"/{static_img}")
451 def _return_cached_response(self, data, uri):
452 data.seek(0)
453 response = CachingHttpResponse(uri, data, content_type="image/png")
454 # Remove Vary header for images since language doesn't matter
455 response["Vary"] = ""
456 return response
458 @trace_avatar_operation("generate_png")
459 def _return_cached_png(self, arg0, data, uri):
460 arg0.save(data, "PNG")
461 return self._return_cached_response(data, uri)
464class GravatarProxyView(View):
465 """
466 Proxy request to Gravatar and return the image from there
467 """
469 # TODO: Do cache images!! Memcached?
471 @trace_avatar_operation("gravatar_proxy")
472 def get(
473 self, request, *args, **kwargs
474 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements
475 """
476 Override get from parent class
477 """
479 def redir_default(default=None):
480 url = (
481 reverse_lazy("avatar_view", args=[kwargs["digest"]])
482 + "?s=%i" % size
483 + "&forcedefault=y"
484 )
485 if default is not None:
486 url += f"&default={default}"
487 return HttpResponseRedirect(url)
489 size = get_size(request)
490 gravatarimagedata = None
491 default = None
493 with contextlib.suppress(Exception):
494 if str(request.GET["default"]) != "None":
495 default = request.GET["default"]
496 if str(default) != "wavatar":
497 # This part is special/hackish
498 # Check if the image returned by Gravatar is their default image, if so,
499 # redirect to our default instead.
500 gravatar_test_url = (
501 "https://secure.gravatar.com/avatar/"
502 + kwargs["digest"]
503 + "?s=%i&d=%i" % (50, 404)
504 )
505 if cache.get(gravatar_test_url) == "default":
506 # DEBUG only
507 # print("Cached Gravatar response: Default.")
508 return redir_default(default)
509 try:
510 urlopen(gravatar_test_url)
511 # Cache negative check / presence check for 10 minutes (600s)
512 cache.set(gravatar_test_url, "found", 600)
513 except HTTPError as exc:
514 if exc.code == 404:
515 cache.set(gravatar_test_url, "default", 600)
516 else:
517 logger.warning(f"Gravatar test url fetch failed: {exc}")
518 return redir_default(default)
520 gravatar_url = (
521 "https://secure.gravatar.com/avatar/" + kwargs["digest"] + "?s=%i" % size
522 )
523 if default:
524 gravatar_url += f"&d={default}"
526 # Cache key for proxied image payload
527 proxy_cache_key = f"gravatar_proxy_img:{gravatar_url}"
528 cache_alias = getattr(settings, "AVATAR_CACHE_ALIAS", "default")
530 if cached_payload := caches[cache_alias].get(proxy_cache_key):
531 response = HttpResponse(
532 cached_payload["content"], content_type=cached_payload["content_type"]
533 )
534 response["Vary"] = ""
535 return response
537 try:
538 if cache.get(gravatar_url) == "err":
539 logger.warning(
540 f"Cached Gravatar fetch failed with URL error: {gravatar_url}"
541 )
542 avatar_metrics.record_external_request("gravatar", 0) # Cached error
543 return redir_default(default)
545 gravatarimagedata = urlopen(gravatar_url)
546 avatar_metrics.record_external_request("gravatar", 200)
547 except HTTPError as exc:
548 if exc.code not in [404, 503]:
549 logger.warning(
550 f"Gravatar fetch failed with an unexpected {exc.code} HTTP error: {gravatar_url}"
551 )
552 avatar_metrics.record_external_request("gravatar", exc.code)
553 cache.set(gravatar_url, "err", 30)
554 return redir_default(default)
555 except URLError as exc:
556 logger.warning(f"Gravatar fetch failed with URL error: {exc.reason}")
557 avatar_metrics.record_external_request("gravatar", 0) # Network error
558 cache.set(gravatar_url, "err", 30)
559 return redir_default(default)
560 except SSLError as exc:
561 logger.warning(f"Gravatar fetch failed with SSL error: {exc.reason}")
562 avatar_metrics.record_external_request("gravatar", 0) # SSL error
563 cache.set(gravatar_url, "err", 30)
564 return redir_default(default)
565 try:
566 raw_bytes = gravatarimagedata.read()
567 data = BytesIO(raw_bytes)
568 img = Image.open(data)
569 content_type = f"image/{file_format(img.format)}"
571 # Cache successful response payload for 1 hour (3600 seconds)
572 caches[cache_alias].set(
573 proxy_cache_key,
574 {"content": raw_bytes, "content_type": content_type},
575 3600,
576 )
578 response = HttpResponse(raw_bytes, content_type=content_type)
579 # Remove Vary header for images since language doesn't matter
580 response["Vary"] = ""
581 return response
583 except ValueError as exc:
584 logger.error(f"Value error: {exc}")
585 return redir_default(default)
587 # We shouldn't reach this point... But make sure we do something
588 return redir_default(default)
591class BlueskyProxyView(View):
592 """
593 Proxy request to Bluesky and return the image from there
594 """
596 @trace_avatar_operation("bluesky_proxy")
597 def get(
598 self, request, *args, **kwargs
599 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements
600 """
601 Override get from parent class
602 """
604 def redir_default(default=None):
605 url = (
606 reverse_lazy("avatar_view", args=[kwargs["digest"]])
607 + "?s=%i" % size
608 + "&forcedefault=y"
609 )
610 if default is not None:
611 url += f"&default={default}"
612 return HttpResponseRedirect(url)
614 size = get_size(request)
615 logger.debug(f"Bluesky avatar size requested: {size}")
616 blueskyimagedata = None
617 default = None
619 with contextlib.suppress(Exception):
620 if str(request.GET["default"]) != "None":
621 default = request.GET["default"]
622 digest = kwargs.get("digest", "")
623 digest_len = len(digest)
624 identity = None
626 # Optimization: Only look up in relevant tables/fields based on digest length
627 if digest_len == 32:
628 try:
629 identity = ConfirmedEmail.objects.filter(digest=digest).first()
630 except Exception as exc:
631 logger.warning(f"Exception: {exc}")
632 elif digest_len == 64:
633 try:
634 identity = ConfirmedEmail.objects.filter(digest_sha256=digest).first()
635 except Exception as exc:
636 logger.warning(f"Exception: {exc}")
638 if not identity:
639 try:
640 identity = ConfirmedOpenId.objects.filter(
641 Q(digest=digest)
642 | Q(alt_digest1=digest)
643 | Q(alt_digest2=digest)
644 | Q(alt_digest3=digest)
645 ).first()
646 except Exception as exc:
647 logger.warning(f"Exception: {exc}")
649 # If still no identity is found, redirect to the default
650 if not identity:
651 return redir_default(default)
653 bs = Bluesky()
654 bluesky_url = None
655 # Try with the cache first
656 with contextlib.suppress(Exception):
657 if cache.get(identity.bluesky_handle):
658 bluesky_url = cache.get(identity.bluesky_handle)
659 if not bluesky_url:
660 try:
661 bluesky_url = bs.get_avatar(identity.bluesky_handle)
662 cache.set(identity.bluesky_handle, bluesky_url)
663 except Exception: # pylint: disable=bare-except
664 return redir_default(default)
666 try:
667 if cache.get(bluesky_url) == "err":
668 logger.warning(
669 f"Cached Bluesky fetch failed with URL error: {bluesky_url}"
670 )
671 avatar_metrics.record_external_request("bluesky", 0) # Cached error
672 return redir_default(default)
674 blueskyimagedata = urlopen(bluesky_url)
675 avatar_metrics.record_external_request("bluesky", 200)
676 except HTTPError as exc:
677 if exc.code not in [404, 503]:
678 print(
679 f"Bluesky fetch failed with an unexpected {exc.code} HTTP error: {bluesky_url}"
680 )
681 avatar_metrics.record_external_request("bluesky", exc.code)
682 cache.set(bluesky_url, "err", 30)
683 return redir_default(default)
684 except URLError as exc:
685 logger.warning(f"Bluesky fetch failed with URL error: {exc.reason}")
686 avatar_metrics.record_external_request("bluesky", 0) # Network error
687 cache.set(bluesky_url, "err", 30)
688 return redir_default(default)
689 except SSLError as exc:
690 logger.warning(f"Bluesky fetch failed with SSL error: {exc.reason}")
691 avatar_metrics.record_external_request("bluesky", 0) # SSL error
692 cache.set(bluesky_url, "err", 30)
693 return redir_default(default)
694 try:
695 data = BytesIO(blueskyimagedata.read())
696 img = Image.open(data)
697 img_format = img.format
698 if max(img.size) > size:
699 aspect = img.size[0] / float(img.size[1])
700 if aspect > 1:
701 new_size = (size, int(size / aspect))
702 else:
703 new_size = (int(size * aspect), size)
704 img = img.resize(new_size)
705 data = BytesIO()
706 img.save(data, format=img_format)
708 data.seek(0)
709 response = HttpResponse(
710 data.read(), content_type=f"image/{file_format(img_format)}"
711 )
712 # Remove Vary header for images since language doesn't matter
713 response["Vary"] = ""
714 return response
715 except ValueError as exc:
716 logger.error(f"Value error: {exc}")
717 return redir_default(default)
719 # We shouldn't reach this point... But make sure we do something
720 return redir_default(default)
723class StatsView(TemplateView, JsonResponse):
724 """
725 Return stats
726 """
728 def get(
729 self, request, *args, **kwargs
730 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements
731 retval = {
732 "users": User.objects.count(),
733 "mails": ConfirmedEmail.objects.count(),
734 "openids": ConfirmedOpenId.objects.count(), # pylint: disable=no-member
735 "unconfirmed_mails": UnconfirmedEmail.objects.count(), # pylint: disable=no-member
736 "unconfirmed_openids": UnconfirmedOpenId.objects.count(), # pylint: disable=no-member
737 "avatars": Photo.objects.count(), # pylint: disable=no-member
738 }
740 # Top 10 viewed avatars
741 top_photos = Photo.objects.select_related("stat").filter(
742 stat__isnull=False
743 ).order_by("-stat__access_count")[:10]
744 top_photos_data = []
745 for photo in top_photos:
746 # Find the associated email or openid with highest access count
747 associated_emails = photo.emails.select_related("stat").filter(
748 stat__isnull=False
749 ).order_by("-stat__access_count")
750 associated_openids = photo.openids.select_related("stat").filter(
751 stat__isnull=False
752 ).order_by("-stat__access_count")
754 # Get the one with highest access count
755 top_associated = None
756 if associated_emails and associated_openids:
757 if (
758 associated_emails[0].stat.access_count
759 >= associated_openids[0].stat.access_count
760 ):
761 top_associated = associated_emails[0]
762 else:
763 top_associated = associated_openids[0]
764 elif associated_emails:
765 top_associated = associated_emails[0]
766 elif associated_openids:
767 top_associated = associated_openids[0]
769 if top_associated:
770 if hasattr(top_associated, "email"):
771 # It's a ConfirmedEmail
772 top_photos_data.append(
773 {
774 "access_count": top_associated.stat.access_count,
775 "avatar_url": f"https://libravatar.org/avatar/{top_associated.digest_sha256}",
776 }
777 )
778 else:
779 # It's a ConfirmedOpenId
780 top_photos_data.append(
781 {
782 "access_count": top_associated.stat.access_count,
783 "avatar_url": f"https://libravatar.org/avatar/{top_associated.digest}",
784 }
785 )
787 retval["top_viewed_avatars"] = top_photos_data
789 # Top 10 queried email addresses
790 top_emails = ConfirmedEmail.objects.select_related("stat").filter(
791 stat__isnull=False
792 ).order_by("-stat__access_count")[:10]
793 top_emails_data = []
794 for email in top_emails:
795 top_emails_data.append(
796 {
797 "access_count": email.stat.access_count,
798 "avatar_url": f"https://libravatar.org/avatar/{email.digest_sha256}",
799 }
800 )
802 retval["top_queried_emails"] = top_emails_data
804 # Top 10 queried OpenIDs
805 top_openids = ConfirmedOpenId.objects.select_related("stat").filter(
806 stat__isnull=False
807 ).order_by("-stat__access_count")[:10]
808 top_openids_data = []
809 for openid in top_openids:
810 top_openids_data.append(
811 {
812 "access_count": openid.stat.access_count,
813 "avatar_url": f"https://libravatar.org/avatar/{openid.digest}",
814 }
815 )
817 retval["top_queried_openids"] = top_openids_data
819 # Photo format distribution
820 from django.db.models import Count
822 format_distribution = (
823 Photo.objects.values("format")
824 .annotate(count=Count("format"))
825 .order_by("-count")
826 )
827 retval["photo_format_distribution"] = list(format_distribution)
829 # User activity statistics
830 users_with_multiple_photos = (
831 User.objects.annotate(photo_count=Count("photo"))
832 .filter(photo_count__gt=1)
833 .count()
834 )
835 users_with_both_email_and_openid = (
836 User.objects.filter(
837 confirmedemail__isnull=False, confirmedopenid__isnull=False
838 )
839 .distinct()
840 .count()
841 )
843 # Calculate average photos per user
844 total_photos = Photo.objects.count()
845 total_users = User.objects.count()
846 avg_photos_per_user = total_photos / total_users if total_users > 0 else 0
848 retval["user_activity"] = {
849 "users_with_multiple_photos": users_with_multiple_photos,
850 "users_with_both_email_and_openid": users_with_both_email_and_openid,
851 "average_photos_per_user": round(avg_photos_per_user, 2),
852 }
854 # Bluesky handles statistics
855 bluesky_emails = ConfirmedEmail.objects.filter(
856 bluesky_handle__isnull=False
857 ).count()
858 bluesky_openids = ConfirmedOpenId.objects.filter(
859 bluesky_handle__isnull=False
860 ).count()
861 total_bluesky_handles = bluesky_emails + bluesky_openids
863 # Top Bluesky handles by access count
864 retval["bluesky_handles"] = {
865 "total_bluesky_handles": total_bluesky_handles,
866 "bluesky_emails": bluesky_emails,
867 "bluesky_openids": bluesky_openids,
868 }
870 # Average photo size statistics using raw SQL
871 from django.db import connection
873 with connection.cursor() as cursor:
874 # SQL to calculate average photo size
875 cursor.execute("""
876 SELECT
877 COUNT(*) as photo_count,
878 AVG(LENGTH(data)) as avg_size_bytes
879 FROM ivataraccount_photo
880 WHERE data IS NOT NULL
881 """)
882 result = cursor.fetchone()
884 if result and result[0] > 0:
885 photo_count, avg_size_bytes = result
886 # Convert to float in case database returns string
887 avg_size_bytes = float(avg_size_bytes) if avg_size_bytes else 0
888 avg_size_kb = round(avg_size_bytes / 1024, 2) if avg_size_bytes else 0
889 avg_size_mb = (
890 round(avg_size_bytes / (1024 * 1024), 2) if avg_size_bytes else 0
891 )
893 retval["photo_size_stats"] = {
894 "average_size_bytes": (
895 round(avg_size_bytes, 2) if avg_size_bytes else 0
896 ),
897 "average_size_kb": avg_size_kb,
898 "average_size_mb": avg_size_mb,
899 "total_photos_analyzed": photo_count,
900 }
901 else:
902 retval["photo_size_stats"] = {
903 "average_size_bytes": 0,
904 "average_size_kb": 0,
905 "average_size_mb": 0,
906 "total_photos_analyzed": 0,
907 }
909 # For potential duplicate photos, we'll check for photos with the same format and size
910 # Note: This is not definitive - different images can have the same format and size
911 # but it's a good indicator of potential duplicates that might warrant investigation
912 with connection.cursor() as cursor:
913 cursor.execute("""
914 SELECT
915 format,
916 LENGTH(data) as file_size,
917 COUNT(*) as count
918 FROM ivataraccount_photo
919 WHERE data IS NOT NULL
920 GROUP BY format, LENGTH(data)
921 HAVING COUNT(*) > 1
922 ORDER BY count DESC
923 LIMIT 10
924 """)
925 duplicate_groups = cursor.fetchall()
927 total_potential_duplicate_photos = sum(
928 group[2] for group in duplicate_groups
929 )
931 # Convert to list of dictionaries for JSON serialization
932 duplicate_groups_detail = [
933 {"format": group[0], "file_size": group[1], "count": group[2]}
934 for group in duplicate_groups
935 ]
937 retval["potential_duplicate_photos"] = {
938 "potential_duplicate_groups": len(duplicate_groups),
939 "total_potential_duplicate_photos": total_potential_duplicate_photos,
940 "potential_duplicate_groups_detail": duplicate_groups_detail,
941 "note": "Potential duplicates are identified by matching file format and size - not definitive duplicates",
942 }
944 # Cache statistics for avatar generators
945 from .robohash import OptimizedRobohash
946 import ivatar.robohash as robohash_mod
947 from .pagan_optimized import OptimizedPagan
948 import ivatar.pagan_optimized as pagan_mod
950 robohash_stats = OptimizedRobohash.get_cache_stats()
951 robohash_png_cache = getattr(robohash_mod, "_robohash_png_cache", {})
952 robohash_png_lock = getattr(robohash_mod, "_robohash_png_cache_lock", None)
953 if robohash_png_lock:
954 with robohash_png_lock:
955 robohash_png_count = len(robohash_png_cache)
956 else:
957 robohash_png_count = len(robohash_png_cache)
959 pagan_stats = OptimizedPagan.get_cache_stats()
960 pagan_png_cache = getattr(pagan_mod, "_pagan_png_cache", {})
961 pagan_png_lock = getattr(pagan_mod, "_pagan_png_cache_lock", None)
962 if pagan_png_lock:
963 with pagan_png_lock:
964 pagan_png_count = len(pagan_png_cache)
965 else:
966 pagan_png_count = len(pagan_png_cache)
968 with _default_avatars_png_cache_lock:
969 default_avatars_cache_stats = {}
970 for (style, _digest, _size) in _default_avatars_png_cache.keys():
971 default_avatars_cache_stats[style] = default_avatars_cache_stats.get(style, 0) + 1
973 retval["cache_stats"] = {
974 "robohash": {
975 "assembly_cache_items": robohash_stats["cache_size"],
976 "assembly_cache_limit": robohash_stats["max_cache_size"],
977 "png_cache_items": robohash_png_count,
978 "png_cache_limit": getattr(robohash_mod, "_MAX_PNG_CACHE_SIZE", getattr(settings, "ROBOHASH_CACHE_SIZE", 150)),
979 },
980 "pagan": {
981 "avatar_cache_items": pagan_stats["size"],
982 "avatar_cache_limit": pagan_stats["max_size"],
983 "png_cache_items": pagan_png_count,
984 "png_cache_limit": getattr(pagan_mod, "_MAX_PAGAN_PNG_CACHE_SIZE", getattr(settings, "PAGAN_CACHE_SIZE", 100)),
985 },
986 "default_avatars": {
987 "cache_items_total": sum(default_avatars_cache_stats.values()),
988 "cache_limit_total": _MAX_DEFAULT_PNG_CACHE_SIZE,
989 "cache_items_by_style": {
990 "monsterid": default_avatars_cache_stats.get("monsterid", 0),
991 "retro": default_avatars_cache_stats.get("retro", 0),
992 "identicon": default_avatars_cache_stats.get("identicon", 0),
993 "mmng": default_avatars_cache_stats.get("mmng", 0),
994 "wavatar": default_avatars_cache_stats.get("wavatar", 0),
995 },
996 },
997 }
999 return JsonResponse(retval)
1002# Thread-safe version cache - cached indefinitely since container restarts on changes
1003_version_cache = None
1004_version_cache_lock = threading.Lock()
1007def _get_git_info_from_files():
1008 """
1009 Safely extract git information from .git files without subprocess calls
1010 """
1011 try:
1012 # Get the project root directory
1013 project_root = path.dirname(path.dirname(path.abspath(__file__)))
1014 git_dir = path.join(project_root, ".git")
1016 if not path.exists(git_dir):
1017 return None
1019 # Read HEAD to get current branch/commit
1020 head_file = path.join(git_dir, "HEAD")
1021 if not path.exists(head_file):
1022 return None
1024 with open(head_file) as f:
1025 head_content = f.read().strip()
1027 # Parse HEAD content
1028 if head_content.startswith("ref: "):
1029 # We're on a branch
1030 branch_ref = head_content[5:] # Remove 'ref: '
1031 branch_name = path.basename(branch_ref)
1033 # Read the commit hash from the ref
1034 ref_file = path.join(git_dir, branch_ref)
1035 if path.exists(ref_file):
1036 with open(ref_file) as f:
1037 commit_hash = f.read().strip()
1038 else:
1039 return None
1040 else:
1041 # Detached HEAD state
1042 commit_hash = head_content
1043 branch_name = "detached"
1045 # Try to get commit date from git log file (if available)
1046 # Optimize: read only the last line instead of entire file
1047 commit_date = None
1048 log_file = path.join(git_dir, "logs", "HEAD")
1049 if path.exists(log_file):
1050 try:
1051 with open(log_file, "rb") as f:
1052 # Seek to end and read backwards to find last line
1053 f.seek(0, 2) # Seek to end
1054 file_size = f.tell()
1056 # Read backwards in chunks to find the last line
1057 chunk_size = min(1024, file_size)
1058 f.seek(max(0, file_size - chunk_size))
1059 chunk = f.read().decode("utf-8", errors="ignore")
1061 # Find the last non-empty line
1062 lines = chunk.split("\n")
1063 last_line = None
1064 for line in reversed(lines):
1065 if line.strip():
1066 last_line = line.strip()
1067 break
1069 if last_line:
1070 # Git log format: <old_hash> <new_hash> <author> <timestamp> <timezone> <message>
1071 # The format uses spaces, not tabs
1072 parts = last_line.split()
1073 if len(parts) >= 6:
1074 # Extract timestamp and convert to readable date
1075 # Format: <old_hash> <new_hash> <author_name> <author_email> <timestamp> <timezone> <message>
1076 # We need to find the timestamp which is after the author email
1077 for i, part in enumerate(parts):
1078 if part.isdigit() and len(part) == 10: # Unix timestamp
1079 import datetime
1081 timestamp = int(part)
1082 commit_date = datetime.datetime.fromtimestamp(
1083 timestamp
1084 ).strftime("%Y-%m-%d %H:%M:%S %z")
1085 break
1086 except (ValueError, IndexError, UnicodeDecodeError):
1087 pass
1089 # Fallback: try to get date from commit object if available
1090 if not commit_date and len(commit_hash) == 40:
1091 try:
1092 commit_dir = path.join(git_dir, "objects", commit_hash[:2])
1093 commit_file = path.join(commit_dir, commit_hash[2:])
1094 if path.exists(commit_file):
1095 # This would require decompressing the git object, which is complex
1096 # For now, we'll use a placeholder
1097 commit_date = "unknown"
1098 except Exception:
1099 commit_date = "unknown"
1101 # Get deployment date from directory modification time
1102 # Use .git directory as it's updated during deployment (checkout/pull)
1103 deployment_date = None
1104 if path.exists(git_dir):
1105 try:
1106 import datetime
1108 mtime = path.getmtime(git_dir)
1109 deployment_date = datetime.datetime.fromtimestamp(mtime).strftime(
1110 "%Y-%m-%d %H:%M:%S %z"
1111 )
1112 except Exception:
1113 deployment_date = "unknown"
1115 return {
1116 "commit_hash": commit_hash,
1117 "short_hash": commit_hash[:7] if len(commit_hash) >= 7 else commit_hash,
1118 "branch": branch_name,
1119 "commit_date": commit_date or "unknown",
1120 "deployment_date": deployment_date or "unknown",
1121 "deployment_status": "active",
1122 "version": f"{branch_name}-{commit_hash[:7] if len(commit_hash) >= 7 else commit_hash}",
1123 }
1125 except Exception as exc:
1126 logger.warning(f"Failed to read git info from files: {exc}")
1127 return None
1130def _get_cached_version_info():
1131 """
1132 Get cached version information, loading it if not available
1133 Since containers restart on content changes, cache indefinitely
1134 """
1135 global _version_cache
1137 with _version_cache_lock:
1138 # If cache is None or contains an error, try to reload it
1139 if _version_cache is None or "error" in _version_cache:
1140 # First try to read from a baked-in version.json file
1141 # This is preferred for containerized deployments
1142 try:
1143 project_root = path.dirname(path.dirname(path.abspath(__file__)))
1144 version_file = path.join(project_root, "version.json")
1145 if path.exists(version_file):
1146 with open(version_file) as f:
1147 import json
1149 _version_cache = json.load(f)
1150 logger.info("Loaded version info from version.json")
1151 except Exception as exc:
1152 logger.warning(f"Failed to read version.json: {exc}")
1154 # If that fails, try to get version info from git files
1155 if _version_cache is None or "error" in _version_cache:
1156 _version_cache = _get_git_info_from_files()
1158 # If that also fails, return error (but don't cache it indefinitely if we can help it)
1159 if _version_cache is None:
1160 _version_cache = {
1161 "error": "Unable to determine version - .git directory or version.json not found",
1162 "deployment_status": "unknown",
1163 }
1165 return _version_cache
1168class DeploymentVersionView(View):
1169 """
1170 View to return deployment version information for CI/CD verification
1171 Uses cached version info to prevent DDoS attacks and improve performance
1172 """
1174 def get(self, request, *args, **kwargs):
1175 """
1176 Return cached deployment version information including application version
1177 """
1178 from django.conf import settings
1180 version_info = _get_cached_version_info()
1182 if "error" in version_info:
1183 # Even on error, include the application version if available
1184 try:
1185 version_info["application_version"] = getattr(
1186 settings, "IVATAR_VERSION", "unknown"
1187 )
1188 except Exception:
1189 pass
1190 return JsonResponse(version_info, status=500)
1192 # Add application version to the response
1193 try:
1194 version_info["application_version"] = getattr(
1195 settings, "IVATAR_VERSION", "unknown"
1196 )
1197 except Exception:
1198 version_info["application_version"] = "unknown"
1200 return JsonResponse(version_info)