Coverage for ivatar/views.py: 55%

601 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 11:51 +0000

1""" 

2views under / 

3""" 

4 

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 

26 

27from PIL import Image 

28 

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 

34 

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) 

39 

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 

45 

46# Import OpenTelemetry with graceful degradation 

47from .telemetry_utils import trace_avatar_operation, get_telemetry_metrics 

48 

49avatar_metrics = get_telemetry_metrics() 

50 

51# Initialize loggers 

52logger = logging.getLogger("ivatar") 

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

54 

55 

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 

72 

73 

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 

78 

79 

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 

88 

89 

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 

101 

102 

103class CachingHttpResponse(HttpResponse): 

104 """ 

105 Handle caching of response 

106 """ 

107 

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) 

130 

131 

132class AvatarImageView(TemplateView): 

133 """ 

134 View to return (binary) image, based on OpenID/Email (both by digest) 

135 """ 

136 

137 # TODO: Do cache resize images!! Memcached? 

138 

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 

143 

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

160 

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) 

179 

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

183 

184 if "d" in request.GET: 

185 default = request.GET["d"] 

186 if "default" in request.GET: 

187 default = request.GET["default"] 

188 

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) 

196 

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 

202 

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 

209 

210 if "gravatarredirect" in request.GET: 

211 if request.GET["gravatarredirect"] == "y": 

212 gravatarredirect = True 

213 

214 if "gravatarproxy" in request.GET: 

215 if request.GET["gravatarproxy"] == "n": 

216 gravatarproxy = False 

217 

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) 

227 

228 digest = kwargs.get("digest", "") 

229 digest_len = len(digest) 

230 

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 ) 

241 

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 ) 

255 

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 ) 

269 

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) 

274 

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) 

285 

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) 

296 

297 if str(default) == str(404): 

298 return HttpResponseNotFound(_("<h1>Image not found</h1>")) 

299 

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) 

394 

395 return HttpResponseRedirect(default) 

396 

397 return self._redirect_static_w_size("nobody", size) 

398 try: 

399 imgformat = obj.photo.format 

400 photodata = Image.open(BytesIO(obj.photo.data)) 

401 

402 data = BytesIO() 

403 

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) 

417 

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" 

423 

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 ) 

430 

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) 

438 

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

450 

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 

457 

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) 

462 

463 

464class GravatarProxyView(View): 

465 """ 

466 Proxy request to Gravatar and return the image from there 

467 """ 

468 

469 # TODO: Do cache images!! Memcached? 

470 

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

478 

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) 

488 

489 size = get_size(request) 

490 gravatarimagedata = None 

491 default = None 

492 

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) 

519 

520 gravatar_url = ( 

521 "https://secure.gravatar.com/avatar/" + kwargs["digest"] + "?s=%i" % size 

522 ) 

523 if default: 

524 gravatar_url += f"&d={default}" 

525 

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

529 

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 

536 

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) 

544 

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

570 

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 ) 

577 

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 

582 

583 except ValueError as exc: 

584 logger.error(f"Value error: {exc}") 

585 return redir_default(default) 

586 

587 # We shouldn't reach this point... But make sure we do something 

588 return redir_default(default) 

589 

590 

591class BlueskyProxyView(View): 

592 """ 

593 Proxy request to Bluesky and return the image from there 

594 """ 

595 

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

603 

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) 

613 

614 size = get_size(request) 

615 logger.debug(f"Bluesky avatar size requested: {size}") 

616 blueskyimagedata = None 

617 default = None 

618 

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 

625 

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

637 

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

648 

649 # If still no identity is found, redirect to the default 

650 if not identity: 

651 return redir_default(default) 

652 

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) 

665 

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) 

673 

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) 

707 

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) 

718 

719 # We shouldn't reach this point... But make sure we do something 

720 return redir_default(default) 

721 

722 

723class StatsView(TemplateView, JsonResponse): 

724 """ 

725 Return stats 

726 """ 

727 

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 } 

739 

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

753 

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] 

768 

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 ) 

786 

787 retval["top_viewed_avatars"] = top_photos_data 

788 

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 ) 

801 

802 retval["top_queried_emails"] = top_emails_data 

803 

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 ) 

816 

817 retval["top_queried_openids"] = top_openids_data 

818 

819 # Photo format distribution 

820 from django.db.models import Count 

821 

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) 

828 

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 ) 

842 

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 

847 

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 } 

853 

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 

862 

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 } 

869 

870 # Average photo size statistics using raw SQL 

871 from django.db import connection 

872 

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

883 

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 ) 

892 

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 } 

908 

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

926 

927 total_potential_duplicate_photos = sum( 

928 group[2] for group in duplicate_groups 

929 ) 

930 

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 ] 

936 

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 } 

943 

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 

949 

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) 

958 

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) 

967 

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 

972 

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 } 

998 

999 return JsonResponse(retval) 

1000 

1001 

1002# Thread-safe version cache - cached indefinitely since container restarts on changes 

1003_version_cache = None 

1004_version_cache_lock = threading.Lock() 

1005 

1006 

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

1015 

1016 if not path.exists(git_dir): 

1017 return None 

1018 

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 

1023 

1024 with open(head_file) as f: 

1025 head_content = f.read().strip() 

1026 

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) 

1032 

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" 

1044 

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

1055 

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

1060 

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 

1068 

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 

1080 

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 

1088 

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" 

1100 

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 

1107 

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" 

1114 

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 } 

1124 

1125 except Exception as exc: 

1126 logger.warning(f"Failed to read git info from files: {exc}") 

1127 return None 

1128 

1129 

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 

1136 

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 

1148 

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

1153 

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

1157 

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 } 

1164 

1165 return _version_cache 

1166 

1167 

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

1173 

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

1175 """ 

1176 Return cached deployment version information including application version 

1177 """ 

1178 from django.conf import settings 

1179 

1180 version_info = _get_cached_version_info() 

1181 

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) 

1191 

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" 

1199 

1200 return JsonResponse(version_info)