Coverage for ivatar/views.py: 78%

746 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 05:10 +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 

14import urllib.parse 

15from urllib.error import HTTPError, URLError 

16from ssl import SSLError 

17from django.conf import settings 

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

19from django.http import HttpResponse, HttpResponseRedirect 

20from django.http import HttpResponseNotFound, JsonResponse 

21from django.core.exceptions import ObjectDoesNotExist 

22from django.core.cache import cache, caches 

23from django.utils.cache import get_conditional_response 

24from django.utils.http import http_date 

25from django.utils.translation import gettext_lazy as _ 

26from django.urls import reverse_lazy 

27from django.db.models import Q 

28from django.contrib.auth.models import User 

29 

30from PIL import Image 

31 

32from monsterid.id import build_monster as BuildMonster 

33import Identicon 

34from pydenticon5 import Pydenticon5 

35from .robohash import create_robohash 

36from .pagan_optimized import create_optimized_pagan 

37 

38# Access settings through django.conf.settings 

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

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

41DEFAULT_AVATAR_SIZE = getattr(settings, "DEFAULT_AVATAR_SIZE", 80) 

42 

43from .ivataraccount.models import ConfirmedEmail, ConfirmedOpenId 

44from .ivataraccount.models import UnconfirmedEmail, UnconfirmedOpenId 

45from .ivataraccount.models import Photo 

46from .ivataraccount.models import pil_format, file_format 

47from .utils import is_trusted_url, mm_ng, resize_animated_gif, get_resample_filter 

48from .photo_cache import ( 

49 get_cached_photo, 

50 set_cached_photo, 

51 render_photo_thumbnail, 

52) 

53 

54# Import OpenTelemetry with graceful degradation 

55from .telemetry_utils import trace_avatar_operation, get_telemetry_metrics 

56 

57avatar_metrics = get_telemetry_metrics() 

58 

59# Initialize loggers 

60logger = logging.getLogger("ivatar") 

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

62 

63 

64def get_size(request, size=DEFAULT_AVATAR_SIZE): 

65 """ 

66 Get size from the URL arguments 

67 """ 

68 sizetemp = None 

69 if "s" in request.GET: 

70 sizetemp = request.GET["s"] 

71 if "size" in request.GET: 

72 sizetemp = request.GET["size"] 

73 if sizetemp: 

74 if sizetemp not in ["", "0"]: 

75 with contextlib.suppress(ValueError): 

76 if int(sizetemp) > 0: 

77 size = int(sizetemp) 

78 size = min(size, int(AVATAR_MAX_SIZE)) 

79 return size 

80 

81 

82# Thread-safe in-memory cache for default avatar PNG bytes 

83_default_avatars_png_cache: Dict[Tuple[str, str, int], bytes] = {} 

84_default_avatars_png_cache_lock = threading.Lock() 

85_MAX_DEFAULT_PNG_CACHE_SIZE = 500 # Bounded in-process cache size 

86 

87 

88def negotiate_image_format(request, original_format: str) -> Tuple[str, bool]: 

89 """ 

90 Negotiate optimal image format based on client Accept headers. 

91 Returns (negotiated_format, is_negotiated). 

92 If client accepts image/webp and ENABLE_WEBP_CONTENT_NEGOTIATION is enabled, 

93 returns ('webp', True). 

94 Otherwise returns (normalized_original_format, False). 

95 """ 

96 normalized = "jpeg" if original_format in ("jpg", "jpeg") else original_format 

97 if not getattr(settings, "ENABLE_WEBP_CONTENT_NEGOTIATION", True): 

98 return normalized, False 

99 

100 accept = request.headers.get("Accept", request.META.get("HTTP_ACCEPT", "")) 

101 if "image/webp" in accept: 

102 return "webp", True 

103 

104 return normalized, False 

105 

106 

107def _get_cached_default_avatar( 

108 style: str, digest: str, size: int 

109) -> Optional[BytesIO]: 

110 cache_key = (style, digest, size) 

111 # 1. First check fast in-memory process cache 

112 with _default_avatars_png_cache_lock: 

113 if cache_key in _default_avatars_png_cache: 

114 return BytesIO(_default_avatars_png_cache[cache_key]) 

115 

116 # 2. Fall back to shared Redis / Django cache 

117 redis_key = f"default_avatar:{style}:{digest}:{size}" 

118 with contextlib.suppress(Exception): 

119 data_bytes = cache.get(redis_key) 

120 if data_bytes is not None: 

121 # Populate local in-process cache 

122 with _default_avatars_png_cache_lock: 

123 if len(_default_avatars_png_cache) >= _MAX_DEFAULT_PNG_CACHE_SIZE: 

124 remove_keys = list(_default_avatars_png_cache.keys())[:50] 

125 for rk in remove_keys: 

126 del _default_avatars_png_cache[rk] 

127 _default_avatars_png_cache[cache_key] = data_bytes 

128 return BytesIO(data_bytes) 

129 

130 return None 

131 

132 

133def _set_cached_default_avatar( 

134 style: str, digest: str, size: int, png_bytes: bytes 

135) -> None: 

136 cache_key = (style, digest, size) 

137 # 1. Save in in-process memory cache 

138 with _default_avatars_png_cache_lock: 

139 if len(_default_avatars_png_cache) >= _MAX_DEFAULT_PNG_CACHE_SIZE: 

140 # Evict oldest 50 entries 

141 remove_keys = list(_default_avatars_png_cache.keys())[:50] 

142 for rk in remove_keys: 

143 del _default_avatars_png_cache[rk] 

144 _default_avatars_png_cache[cache_key] = png_bytes 

145 

146 # 2. Save in shared Redis / Django cache 

147 redis_key = f"default_avatar:{style}:{digest}:{size}" 

148 with contextlib.suppress(Exception): 

149 timeout = getattr(settings, "DEFAULT_AVATAR_CACHE_TIMEOUT", 604800) # 7 days default 

150 cache.set(redis_key, png_bytes, timeout=timeout) 

151 

152 

153class CachingHttpResponse(HttpResponse): 

154 """ 

155 Handle caching of response 

156 """ 

157 

158 def __init__( 

159 self, 

160 uri, 

161 content=b"", 

162 content_type=None, 

163 status=200, # pylint: disable=too-many-arguments 

164 reason=None, 

165 charset=None, 

166 ): 

167 if settings.CACHE_RESPONSE: 

168 cache_alias = getattr(settings, "AVATAR_CACHE_ALIAS", "filesystem") 

169 caches[cache_alias].set( 

170 uri, 

171 { 

172 "content": content, 

173 "content_type": content_type, 

174 "status": status, 

175 "reason": reason, 

176 "charset": charset, 

177 }, 

178 ) 

179 super().__init__(content, content_type, status, reason, charset) 

180 

181 

182class AvatarImageView(TemplateView): 

183 """ 

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

185 """ 

186 

187 # User photos are cached in Redis / memory via photo_cache module 

188 

189 def options(self, request, *args, **kwargs): 

190 response = HttpResponse("", content_type="text/plain") 

191 response["Allow"] = "404 mm mp retro pagan wavatar monsterid robohash identicon" 

192 return response 

193 

194 @trace_avatar_operation("avatar_request") 

195 def get( 

196 self, request, *args, **kwargs 

197 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,too-many-return-statements 

198 """ 

199 Override get from parent class 

200 """ 

201 model = ConfirmedEmail 

202 size = get_size(request) 

203 imgformat = "png" 

204 obj = None 

205 default = None 

206 forcedefault = settings.FORCEDEFAULT 

207 gravatarredirect = settings.DEFAULT_GRAVATARREDIRECT 

208 gravatarproxy = settings.DEFAULT_GRAVATARPROXY 

209 uri = request.build_absolute_uri() 

210 client_accepts_webp = ( 

211 "image/webp" in request.headers.get("Accept", request.META.get("HTTP_ACCEPT", "")) 

212 and getattr(settings, "ENABLE_WEBP_CONTENT_NEGOTIATION", True) 

213 ) 

214 uri_cache_key = f"{uri}:webp" if client_accepts_webp else f"{uri}:standard" 

215 

216 # Check the cache first 

217 if settings.CACHE_RESPONSE: 

218 cache_alias = getattr(settings, "AVATAR_CACHE_ALIAS", "filesystem") 

219 if centry := caches[cache_alias].get(uri_cache_key): 

220 format_type = "webp" if client_accepts_webp else imgformat 

221 # Record cache hit 

222 avatar_metrics.record_cache_hit(size=str(size), format_type=format_type) 

223 # For DEBUG purpose only 

224 # print('Cached entry for %s' % uri) 

225 resp = HttpResponse( 

226 centry["content"], 

227 content_type=centry["content_type"], 

228 status=centry["status"], 

229 reason=centry["reason"], 

230 charset=centry["charset"], 

231 ) 

232 if getattr(settings, "ENABLE_WEBP_CONTENT_NEGOTIATION", True): 

233 resp["Vary"] = "Accept" 

234 if "digest" in kwargs: 

235 etag = f'"{kwargs["digest"]}"' 

236 resp["Etag"] = etag 

237 conditional_response = get_conditional_response( 

238 request, etag=etag, response=resp 

239 ) 

240 if conditional_response is not resp: 

241 conditional_response["Etag"] = etag 

242 if "Vary" in resp: 

243 conditional_response["Vary"] = resp["Vary"] 

244 return conditional_response 

245 return resp 

246 else: 

247 # Record cache miss 

248 avatar_metrics.record_cache_miss(size=str(size), format_type=imgformat) 

249 

250 # In case no digest at all is provided, return to home page 

251 if "digest" not in kwargs: 

252 return HttpResponseRedirect(reverse_lazy("home")) 

253 

254 if "d" in request.GET: 

255 default = request.GET["d"] 

256 if "default" in request.GET: 

257 default = request.GET["default"] 

258 

259 if default is not None: 

260 if settings.TRUSTED_DEFAULT_URLS is None: 

261 logger.warning("Query parameter `default` is disabled.") 

262 default = None 

263 elif default.find("://") > 0: 

264 # Check if it's trusted, if not, reset to None 

265 trusted_url = is_trusted_url(default, settings.TRUSTED_DEFAULT_URLS) 

266 

267 if not trusted_url: 

268 security_logger.warning( 

269 f"Default URL is not in trusted URLs: '{default}'; Kicking it!" 

270 ) 

271 default = None 

272 

273 if "f" in request.GET: 

274 if request.GET["f"] == "y": 

275 forcedefault = True 

276 if "forcedefault" in request.GET: 

277 if request.GET["forcedefault"] == "y": 

278 forcedefault = True 

279 

280 if "gravatarredirect" in request.GET: 

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

282 gravatarredirect = True 

283 

284 if "gravatarproxy" in request.GET: 

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

286 gravatarproxy = False 

287 

288 def redir_default(default=None): 

289 url = ( 

290 reverse_lazy("avatar_view", args=[kwargs["digest"]]) 

291 + "?s=%i" % size 

292 + "&forcedefault=y" 

293 ) 

294 if default is not None: 

295 encoded_default = urllib.parse.quote( 

296 str(default), safe=":/?#[]@!$&'()*+,;=-_.~%" 

297 ) 

298 url += f"&default={encoded_default}" 

299 return HttpResponseRedirect(url) 

300 

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

302 digest_len = len(digest) 

303 

304 # Optimization: Only look up in relevant tables/fields based on digest length 

305 # MD5 is 32 chars, SHA256 is 64 chars 

306 if digest_len == 32: 

307 with contextlib.suppress(ObjectDoesNotExist): 

308 obj = ( 

309 ConfirmedEmail.objects.select_related("photo") 

310 .defer("photo__data") 

311 .get(digest=digest) 

312 ) 

313 elif digest_len == 64: 

314 with contextlib.suppress(ObjectDoesNotExist): 

315 obj = ( 

316 ConfirmedEmail.objects.select_related("photo") 

317 .defer("photo__data") 

318 .get(digest_sha256=digest) 

319 ) 

320 

321 if not obj: 

322 # OpenID digests are always SHA256 (64 chars) 

323 with contextlib.suppress(Exception): 

324 obj = ( 

325 ConfirmedOpenId.objects.select_related("photo") 

326 .defer("photo__data") 

327 .filter( 

328 Q(digest=digest) 

329 | Q(alt_digest1=digest) 

330 | Q(alt_digest2=digest) 

331 | Q(alt_digest3=digest) 

332 ) 

333 .first() 

334 ) 

335 

336 # Handle the special case of Bluesky 

337 if obj: 

338 if obj.bluesky_handle: 

339 return HttpResponseRedirect( 

340 reverse_lazy("blueskyproxy", args=[digest]) 

341 ) 

342 # If that mail/openid doesn't exist, or has no photo linked to it 

343 if not obj or not obj.photo or forcedefault: 

344 gravatar_url = ( 

345 "https://secure.gravatar.com/avatar/" 

346 + kwargs["digest"] 

347 + "?s=%i" % size 

348 ) 

349 

350 # If we have redirection to Gravatar enabled, this overrides all 

351 # default= settings, except forcedefault! 

352 if gravatarredirect and not forcedefault: 

353 return HttpResponseRedirect(gravatar_url) 

354 

355 # Request to proxy Gravatar image - only if not forcedefault 

356 if gravatarproxy and not forcedefault: 

357 url = ( 

358 reverse_lazy("gravatarproxy", args=[kwargs["digest"]]) 

359 + "?s=%i" % size 

360 ) 

361 # Ensure we do not convert None to string 'None' 

362 if default: 

363 encoded_default = urllib.parse.quote( 

364 str(default), safe=":/?#[]@!$&'()*+,;=-_.~%" 

365 ) 

366 url += f"&default={encoded_default}" 

367 return HttpResponseRedirect(url) 

368 

369 # Return the default URL, as specified, or 404 Not Found, if default=404 

370 if default: 

371 # Proxy to gravatar to generate wavatar - lazy me 

372 if str(default) == "wavatar": 

373 url = ( 

374 reverse_lazy("gravatarproxy", args=[kwargs["digest"]]) 

375 + "?s=%i" % size 

376 + f"&default={default}&f=y" 

377 ) 

378 return HttpResponseRedirect(url) 

379 

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

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

382 

383 try: 

384 if str(default) in {"monsterid", "robohash", "retro", "pagan", "identicon", "mmng"}: 

385 etag = f'"{kwargs["digest"]}"' 

386 conditional_response = get_conditional_response(request, etag=etag) 

387 if conditional_response: 

388 conditional_response["Etag"] = etag 

389 conditional_response["Vary"] = "" 

390 return conditional_response 

391 

392 if str(default) == "monsterid": 

393 data = _get_cached_default_avatar( 

394 "monsterid", kwargs["digest"], size 

395 ) 

396 if not data: 

397 monsterdata = BuildMonster( 

398 seed=kwargs["digest"], size=(size, size) 

399 ) 

400 data = BytesIO() 

401 monsterdata.save(data, "PNG") 

402 _set_cached_default_avatar( 

403 "monsterid", kwargs["digest"], size, data.getvalue() 

404 ) 

405 avatar_metrics.record_avatar_generated( 

406 size=str(size), format_type="png", source="monsterid" 

407 ) 

408 return self._return_cached_response(data, uri_cache_key) 

409 if str(default) == "robohash": 

410 roboset = request.GET.get("robohash") or "any" 

411 data = create_robohash(kwargs["digest"], size, roboset) 

412 avatar_metrics.record_avatar_generated( 

413 size=str(size), format_type="png", source="robohash" 

414 ) 

415 return self._return_cached_response(data, uri_cache_key) 

416 if str(default) == "retro": 

417 data = _get_cached_default_avatar( 

418 "retro", kwargs["digest"], size 

419 ) 

420 if not data: 

421 identicon = Identicon.render(kwargs["digest"]) 

422 img = Image.open(BytesIO(identicon)) 

423 img = img.resize((size, size), get_resample_filter(size, img.size)) 

424 data = BytesIO() 

425 img.save(data, "PNG") 

426 _set_cached_default_avatar( 

427 "retro", kwargs["digest"], size, data.getvalue() 

428 ) 

429 avatar_metrics.record_avatar_generated( 

430 size=str(size), format_type="png", source="retro" 

431 ) 

432 return self._return_cached_response(data, uri_cache_key) 

433 if str(default) == "pagan": 

434 data = create_optimized_pagan(kwargs["digest"], size) 

435 avatar_metrics.record_avatar_generated( 

436 size=str(size), format_type="png", source="pagan" 

437 ) 

438 return self._return_cached_response(data, uri_cache_key) 

439 if str(default) == "identicon": 

440 data = _get_cached_default_avatar( 

441 "identicon", kwargs["digest"], size 

442 ) 

443 if not data: 

444 p = Pydenticon5() 

445 # In order to make use of the whole 32 bytes digest, we need to redigest them. 

446 newdigest = hashlib.md5( 

447 bytes(kwargs["digest"], "utf-8") 

448 ).hexdigest() 

449 img = p.draw(newdigest, size, 0) 

450 data = BytesIO() 

451 img.save(data, "PNG") 

452 _set_cached_default_avatar( 

453 "identicon", kwargs["digest"], size, data.getvalue() 

454 ) 

455 avatar_metrics.record_avatar_generated( 

456 size=str(size), format_type="png", source="identicon" 

457 ) 

458 return self._return_cached_response(data, uri_cache_key) 

459 if str(default) == "mmng": 

460 data = _get_cached_default_avatar( 

461 "mmng", kwargs["digest"], size 

462 ) 

463 if not data: 

464 mmngimg = mm_ng(idhash=kwargs["digest"], size=size) 

465 data = BytesIO() 

466 mmngimg.save(data, "PNG") 

467 _set_cached_default_avatar( 

468 "mmng", kwargs["digest"], size, data.getvalue() 

469 ) 

470 avatar_metrics.record_avatar_generated( 

471 size=str(size), format_type="png", source="mmng" 

472 ) 

473 return self._return_cached_response(data, uri_cache_key) 

474 if str(default) == "wavatar": 

475 url = ( 

476 reverse_lazy("gravatarproxy", args=[kwargs["digest"]]) 

477 + f"?s={size}&default=wavatar" 

478 ) 

479 return HttpResponseRedirect(url) 

480 if str(default) in {"mm", "mp"}: 

481 return self._redirect_static_w_size("mm", size) 

482 except Exception as exc: 

483 logger.error(f"Error generating default avatar {default}: {exc}") 

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

485 

486 return HttpResponseRedirect(default) 

487 

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

489 try: 

490 imgformat = obj.photo.format 

491 target_format, is_negotiated = negotiate_image_format(request, imgformat) 

492 etag = f'"{kwargs["digest"]}"' 

493 last_modified = ( 

494 int(obj.photo.add_date.timestamp()) 

495 if hasattr(obj.photo, "add_date") and obj.photo.add_date 

496 else None 

497 ) 

498 

499 # Fast-path: Check conditional request (If-None-Match / If-Modified-Since) 

500 conditional_response = get_conditional_response( 

501 request, 

502 etag=etag, 

503 last_modified=last_modified, 

504 ) 

505 if conditional_response: 

506 stats_manager.update_access_count(obj.photo) 

507 stats_manager.update_access_count(obj) 

508 conditional_response["Etag"] = etag 

509 if last_modified: 

510 conditional_response["Last-Modified"] = http_date(last_modified) 

511 conditional_response["Vary"] = ( 

512 "Accept" 

513 if getattr(settings, "ENABLE_WEBP_CONTENT_NEGOTIATION", True) 

514 else "" 

515 ) 

516 return conditional_response 

517 

518 # Check cached resized photo in Redis / L1 memory cache 

519 data = get_cached_photo(obj.photo.id, size, target_format) 

520 if data is not None: 

521 avatar_metrics.record_cache_hit( 

522 size=str(size), 

523 format_type=target_format, 

524 ) 

525 else: 

526 avatar_metrics.record_cache_miss( 

527 size=str(size), 

528 format_type=target_format, 

529 ) 

530 thumb_bytes = render_photo_thumbnail( 

531 obj.photo.data, imgformat, size, target_format=target_format 

532 ) 

533 data = BytesIO(thumb_bytes) 

534 

535 set_cached_photo( 

536 obj.photo.id, 

537 size, 

538 target_format, 

539 thumb_bytes, 

540 ) 

541 

542 # Record avatar generation metrics 

543 avatar_metrics.record_avatar_generated( 

544 size=str(size), 

545 format_type=target_format, 

546 source="uploaded" if obj else "generated", 

547 ) 

548 

549 data.seek(0) 

550 stats_manager.update_access_count(obj.photo) 

551 stats_manager.update_access_count(obj) 

552 

553 response = CachingHttpResponse( 

554 uri_cache_key, data, content_type=f"image/{target_format}" 

555 ) 

556 response["Etag"] = etag 

557 if last_modified: 

558 response["Last-Modified"] = http_date(last_modified) 

559 # Set Vary: Accept so edge proxies/CDNs cache WebP and JPEG/PNG separately 

560 response["Vary"] = ( 

561 "Accept" 

562 if getattr(settings, "ENABLE_WEBP_CONTENT_NEGOTIATION", True) 

563 else "" 

564 ) 

565 return response 

566 except Exception as exc: 

567 logger.error(f"Error processing photo for {kwargs['digest']}: {exc}") 

568 return redir_default(default) 

569 

570 def _redirect_static_w_size(self, arg0, size): 

571 """ 

572 Helper method to redirect to static image with size i/a 

573 """ 

574 # If mm is explicitly given, we need to catch that 

575 static_img = path.join("static", "img", arg0, f"{str(size)}.png") 

576 if not path.isfile(static_img): 

577 # We trust this exists!!! 

578 static_img = path.join("static", "img", arg0, "512.png") 

579 # We trust static/ is mapped to /static/ 

580 return HttpResponseRedirect(f"/{static_img}") 

581 

582 def _return_cached_response(self, data, uri): 

583 data.seek(0) 

584 response = CachingHttpResponse(uri, data, content_type="image/png") 

585 # Remove Vary header for images since language doesn't matter 

586 response["Vary"] = "" 

587 return response 

588 

589 @trace_avatar_operation("generate_png") 

590 def _return_cached_png(self, arg0, data, uri): 

591 arg0.save(data, "PNG") 

592 return self._return_cached_response(data, uri) 

593 

594 

595class GravatarProxyView(View): 

596 """ 

597 Proxy request to Gravatar and return the image from there 

598 """ 

599 

600 # TODO: Do cache images!! Memcached? 

601 

602 @trace_avatar_operation("gravatar_proxy") 

603 def get( 

604 self, request, *args, **kwargs 

605 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements 

606 """ 

607 Override get from parent class 

608 """ 

609 

610 def redir_default(default=None): 

611 url = ( 

612 reverse_lazy("avatar_view", args=[kwargs["digest"]]) 

613 + "?s=%i" % size 

614 + "&forcedefault=y" 

615 ) 

616 if default is not None: 

617 encoded_default = urllib.parse.quote( 

618 str(default), safe=":/?#[]@!$&'()*+,;=-_.~%" 

619 ) 

620 url += f"&default={encoded_default}" 

621 return HttpResponseRedirect(url) 

622 

623 size = get_size(request) 

624 gravatarimagedata = None 

625 default = None 

626 

627 with contextlib.suppress(Exception): 

628 if str(request.GET["default"]) != "None": 

629 default = request.GET["default"] 

630 if str(default) != "wavatar": 

631 # This part is special/hackish 

632 # Check if the image returned by Gravatar is their default image, if so, 

633 # redirect to our default instead. 

634 gravatar_test_url = ( 

635 "https://secure.gravatar.com/avatar/" 

636 + kwargs["digest"] 

637 + "?s=%i&d=%i" % (50, 404) 

638 ) 

639 gravatar_test_key = ( 

640 f"gravatar_test:{hashlib.sha256(gravatar_test_url.encode('utf-8')).hexdigest()}" 

641 ) 

642 if cache.get(gravatar_test_key) == "default": 

643 # DEBUG only 

644 # print("Cached Gravatar response: Default.") 

645 return redir_default(default) 

646 try: 

647 urlopen(gravatar_test_url) 

648 # Cache negative check / presence check for 10 minutes (600s) 

649 cache.set(gravatar_test_key, "found", 600) 

650 except HTTPError as exc: 

651 if exc.code == 404: 

652 cache.set(gravatar_test_key, "default", 600) 

653 else: 

654 logger.warning(f"Gravatar test url fetch failed: {exc}") 

655 return redir_default(default) 

656 

657 gravatar_url = ( 

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

659 ) 

660 if default: 

661 encoded_default = urllib.parse.quote( 

662 str(default), safe=":/?#[]@!$&'()*+,;=-_.~%" 

663 ) 

664 gravatar_url += f"&d={encoded_default}" 

665 

666 # Cache key for proxied image payload (hashed to ensure safe characters and bounded length) 

667 gravatar_url_hash = hashlib.sha256(gravatar_url.encode("utf-8")).hexdigest() 

668 proxy_cache_key = f"gravatar_proxy_img:{gravatar_url_hash}" 

669 gravatar_err_key = f"gravatar_err:{gravatar_url_hash}" 

670 cache_alias = getattr(settings, "AVATAR_CACHE_ALIAS", "default") 

671 

672 if cached_payload := caches[cache_alias].get(proxy_cache_key): 

673 response = HttpResponse( 

674 cached_payload["content"], content_type=cached_payload["content_type"] 

675 ) 

676 response["Vary"] = "" 

677 return response 

678 

679 try: 

680 if cache.get(gravatar_err_key) == "err": 

681 logger.warning( 

682 f"Cached Gravatar fetch failed with URL error: {gravatar_url}" 

683 ) 

684 avatar_metrics.record_external_request("gravatar", 0) # Cached error 

685 return redir_default(default) 

686 

687 gravatarimagedata = urlopen(gravatar_url) 

688 avatar_metrics.record_external_request("gravatar", 200) 

689 except HTTPError as exc: 

690 if exc.code not in [404, 503]: 

691 logger.warning( 

692 f"Gravatar fetch failed with an unexpected {exc.code} HTTP error: {gravatar_url}" 

693 ) 

694 avatar_metrics.record_external_request("gravatar", exc.code) 

695 cache.set(gravatar_err_key, "err", 30) 

696 return redir_default(default) 

697 except URLError as exc: 

698 logger.warning(f"Gravatar fetch failed with URL error: {exc.reason}") 

699 avatar_metrics.record_external_request("gravatar", 0) # Network error 

700 cache.set(gravatar_err_key, "err", 30) 

701 return redir_default(default) 

702 except SSLError as exc: 

703 logger.warning(f"Gravatar fetch failed with SSL error: {exc.reason}") 

704 avatar_metrics.record_external_request("gravatar", 0) # SSL error 

705 cache.set(gravatar_err_key, "err", 30) 

706 return redir_default(default) 

707 try: 

708 raw_bytes = gravatarimagedata.read() 

709 data = BytesIO(raw_bytes) 

710 img = Image.open(data) 

711 content_type = f"image/{file_format(img.format)}" 

712 

713 # Cache successful response payload for 1 hour (3600 seconds) 

714 caches[cache_alias].set( 

715 proxy_cache_key, 

716 {"content": raw_bytes, "content_type": content_type}, 

717 3600, 

718 ) 

719 

720 response = HttpResponse(raw_bytes, content_type=content_type) 

721 # Remove Vary header for images since language doesn't matter 

722 response["Vary"] = "" 

723 return response 

724 

725 except ValueError as exc: 

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

727 return redir_default(default) 

728 

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

730 return redir_default(default) 

731 

732 

733class BlueskyProxyView(View): 

734 """ 

735 Proxy request to Bluesky and return the image from there 

736 """ 

737 

738 @trace_avatar_operation("bluesky_proxy") 

739 def get( 

740 self, request, *args, **kwargs 

741 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements 

742 """ 

743 Override get from parent class 

744 """ 

745 

746 def redir_default(default=None): 

747 url = ( 

748 reverse_lazy("avatar_view", args=[kwargs["digest"]]) 

749 + "?s=%i" % size 

750 + "&forcedefault=y" 

751 ) 

752 if default is not None: 

753 url += f"&default={default}" 

754 return HttpResponseRedirect(url) 

755 

756 size = get_size(request) 

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

758 blueskyimagedata = None 

759 default = None 

760 

761 with contextlib.suppress(Exception): 

762 if str(request.GET["default"]) != "None": 

763 default = request.GET["default"] 

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

765 digest_len = len(digest) 

766 identity = None 

767 

768 # Optimization: Only look up in relevant tables/fields based on digest length 

769 if digest_len == 32: 

770 try: 

771 identity = ConfirmedEmail.objects.filter(digest=digest).first() 

772 except Exception as exc: 

773 logger.warning(f"Exception: {exc}") 

774 elif digest_len == 64: 

775 try: 

776 identity = ConfirmedEmail.objects.filter(digest_sha256=digest).first() 

777 except Exception as exc: 

778 logger.warning(f"Exception: {exc}") 

779 

780 if not identity: 

781 try: 

782 identity = ConfirmedOpenId.objects.filter( 

783 Q(digest=digest) 

784 | Q(alt_digest1=digest) 

785 | Q(alt_digest2=digest) 

786 | Q(alt_digest3=digest) 

787 ).first() 

788 except Exception as exc: 

789 logger.warning(f"Exception: {exc}") 

790 

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

792 if not identity: 

793 return redir_default(default) 

794 

795 bs = Bluesky() 

796 bluesky_url = None 

797 # Try with the cache first 

798 with contextlib.suppress(Exception): 

799 if cache.get(identity.bluesky_handle): 

800 bluesky_url = cache.get(identity.bluesky_handle) 

801 if not bluesky_url: 

802 try: 

803 bluesky_url = bs.get_avatar(identity.bluesky_handle) 

804 cache.set(identity.bluesky_handle, bluesky_url) 

805 except Exception: # pylint: disable=bare-except 

806 return redir_default(default) 

807 

808 # Stable ETag identifies the payload by URL + size (Bluesky avatar 

809 # URLs embed the image revision). Evaluate conditional requests first 

810 # so revalidating clients skip both the payload cache and download. 

811 bs_cache_timeout = getattr(settings, "BLUESKY_CACHE_TIMEOUT", 3600) 

812 bs_image_timeout = getattr(settings, "BLUESKY_IMAGE_TIMEOUT", 5) 

813 etag = f'"{hashlib.md5(f"{bluesky_url}:{size}".encode("utf-8")).hexdigest()}"' 

814 conditional_response = get_conditional_response(request, etag=etag) 

815 if conditional_response: 

816 conditional_response["Etag"] = etag 

817 conditional_response["Vary"] = "" 

818 return conditional_response 

819 

820 try: 

821 if cache.get(bluesky_url) == "err": 

822 logger.warning( 

823 f"Cached Bluesky fetch failed with URL error: {bluesky_url}" 

824 ) 

825 avatar_metrics.record_external_request("bluesky", 0) # Cached error 

826 return redir_default(default) 

827 

828 # EXPERIMENTAL: cache resized payload like gravatarproxy 

829 _bs_alias = getattr(settings, "AVATAR_CACHE_ALIAS", "default") 

830 _bs_img_key = ( 

831 f"bluesky_proxy_img:{hashlib.sha256(bluesky_url.encode('utf-8')).hexdigest()}:{size}" 

832 ) 

833 if _bs_cached := caches[_bs_alias].get(_bs_img_key): 

834 response = HttpResponse( 

835 _bs_cached["content"], content_type=_bs_cached["content_type"] 

836 ) 

837 response["Etag"] = etag 

838 response["Cache-Control"] = f"public, max-age={bs_cache_timeout}" 

839 response["Vary"] = "" 

840 return response 

841 

842 blueskyimagedata = urlopen(bluesky_url, timeout=bs_image_timeout) 

843 avatar_metrics.record_external_request("bluesky", 200) 

844 except HTTPError as exc: 

845 if exc.code not in [404, 503]: 

846 print( 

847 f"Bluesky fetch failed with an unexpected {exc.code} HTTP error: {bluesky_url}" 

848 ) 

849 avatar_metrics.record_external_request("bluesky", exc.code) 

850 cache.set(bluesky_url, "err", 30) 

851 return redir_default(default) 

852 except URLError as exc: 

853 logger.warning(f"Bluesky fetch failed with URL error: {exc.reason}") 

854 avatar_metrics.record_external_request("bluesky", 0) # Network error 

855 cache.set(bluesky_url, "err", 30) 

856 return redir_default(default) 

857 except SSLError as exc: 

858 logger.warning(f"Bluesky fetch failed with SSL error: {exc.reason}") 

859 avatar_metrics.record_external_request("bluesky", 0) # SSL error 

860 cache.set(bluesky_url, "err", 30) 

861 return redir_default(default) 

862 try: 

863 data = BytesIO(blueskyimagedata.read()) 

864 img = Image.open(data) 

865 img_format = img.format 

866 if max(img.size) > size: 

867 aspect = img.size[0] / float(img.size[1]) 

868 if aspect > 1: 

869 new_size = (size, int(size / aspect)) 

870 else: 

871 new_size = (int(size * aspect), size) 

872 img = img.resize(new_size, get_resample_filter(size, img.size)) 

873 data = BytesIO() 

874 img.save(data, format=img_format) 

875 

876 data.seek(0) 

877 _bs_payload = data.read() 

878 _bs_content_type = f"image/{file_format(img_format)}" 

879 caches[_bs_alias].set( 

880 _bs_img_key, 

881 {"content": _bs_payload, "content_type": _bs_content_type}, 

882 bs_cache_timeout, 

883 ) 

884 response = HttpResponse(_bs_payload, content_type=_bs_content_type) 

885 response["Etag"] = etag 

886 response["Cache-Control"] = f"public, max-age={bs_cache_timeout}" 

887 # Remove Vary header for images since language doesn't matter 

888 response["Vary"] = "" 

889 return response 

890 except ValueError as exc: 

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

892 return redir_default(default) 

893 

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

895 return redir_default(default) 

896 

897 

898STATS_CACHE_KEY = "stats:view:payload" 

899 

900 

901class StatsView(TemplateView, JsonResponse): 

902 """ 

903 Return stats 

904 """ 

905 

906 def get( 

907 self, request, *args, **kwargs 

908 ): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements 

909 # The payload is identical for all callers and expensive to build 

910 # (full scans over the photo blob table), so serve it from cache. 

911 stats_cache_timeout = getattr(settings, "STATS_CACHE_TIMEOUT", 3600) 

912 with contextlib.suppress(Exception): 

913 cached_stats = cache.get(STATS_CACHE_KEY) 

914 if cached_stats is not None: 

915 return JsonResponse(cached_stats) 

916 

917 retval = { 

918 "users": User.objects.count(), 

919 "mails": ConfirmedEmail.objects.count(), 

920 "openids": ConfirmedOpenId.objects.count(), # pylint: disable=no-member 

921 "unconfirmed_mails": UnconfirmedEmail.objects.count(), # pylint: disable=no-member 

922 "unconfirmed_openids": UnconfirmedOpenId.objects.count(), # pylint: disable=no-member 

923 "avatars": Photo.objects.count(), # pylint: disable=no-member 

924 } 

925 

926 # Top 10 viewed avatars 

927 top_photos = Photo.objects.select_related("stat").filter( 

928 stat__isnull=False 

929 ).order_by("-stat__access_count")[:10] 

930 top_photos_data = [] 

931 for photo in top_photos: 

932 # Find the associated email or openid with highest access count 

933 associated_emails = photo.emails.select_related("stat").filter( 

934 stat__isnull=False 

935 ).order_by("-stat__access_count") 

936 associated_openids = photo.openids.select_related("stat").filter( 

937 stat__isnull=False 

938 ).order_by("-stat__access_count") 

939 

940 # Get the one with highest access count 

941 top_associated = None 

942 if associated_emails and associated_openids: 

943 if ( 

944 associated_emails[0].stat.access_count 

945 >= associated_openids[0].stat.access_count 

946 ): 

947 top_associated = associated_emails[0] 

948 else: 

949 top_associated = associated_openids[0] 

950 elif associated_emails: 

951 top_associated = associated_emails[0] 

952 elif associated_openids: 

953 top_associated = associated_openids[0] 

954 

955 if top_associated: 

956 if hasattr(top_associated, "email"): 

957 # It's a ConfirmedEmail 

958 top_photos_data.append( 

959 { 

960 "access_count": top_associated.stat.access_count, 

961 "avatar_url": f"https://libravatar.org/avatar/{top_associated.digest_sha256}", 

962 } 

963 ) 

964 else: 

965 # It's a ConfirmedOpenId 

966 top_photos_data.append( 

967 { 

968 "access_count": top_associated.stat.access_count, 

969 "avatar_url": f"https://libravatar.org/avatar/{top_associated.digest}", 

970 } 

971 ) 

972 

973 retval["top_viewed_avatars"] = top_photos_data 

974 

975 # Top 10 queried email addresses 

976 top_emails = ConfirmedEmail.objects.select_related("stat").filter( 

977 stat__isnull=False 

978 ).order_by("-stat__access_count")[:10] 

979 top_emails_data = [] 

980 for email in top_emails: 

981 top_emails_data.append( 

982 { 

983 "access_count": email.stat.access_count, 

984 "avatar_url": f"https://libravatar.org/avatar/{email.digest_sha256}", 

985 } 

986 ) 

987 

988 retval["top_queried_emails"] = top_emails_data 

989 

990 # Top 10 queried OpenIDs 

991 top_openids = ConfirmedOpenId.objects.select_related("stat").filter( 

992 stat__isnull=False 

993 ).order_by("-stat__access_count")[:10] 

994 top_openids_data = [] 

995 for openid in top_openids: 

996 top_openids_data.append( 

997 { 

998 "access_count": openid.stat.access_count, 

999 "avatar_url": f"https://libravatar.org/avatar/{openid.digest}", 

1000 } 

1001 ) 

1002 

1003 retval["top_queried_openids"] = top_openids_data 

1004 

1005 # Photo format distribution 

1006 from django.db.models import Count 

1007 

1008 format_distribution = ( 

1009 Photo.objects.values("format") 

1010 .annotate(count=Count("format")) 

1011 .order_by("-count") 

1012 ) 

1013 retval["photo_format_distribution"] = list(format_distribution) 

1014 

1015 # User activity statistics 

1016 users_with_multiple_photos = ( 

1017 User.objects.annotate(photo_count=Count("photo")) 

1018 .filter(photo_count__gt=1) 

1019 .count() 

1020 ) 

1021 users_with_both_email_and_openid = ( 

1022 User.objects.filter( 

1023 confirmedemail__isnull=False, confirmedopenid__isnull=False 

1024 ) 

1025 .distinct() 

1026 .count() 

1027 ) 

1028 

1029 # Calculate average photos per user 

1030 total_photos = Photo.objects.count() 

1031 total_users = User.objects.count() 

1032 avg_photos_per_user = total_photos / total_users if total_users > 0 else 0 

1033 

1034 retval["user_activity"] = { 

1035 "users_with_multiple_photos": users_with_multiple_photos, 

1036 "users_with_both_email_and_openid": users_with_both_email_and_openid, 

1037 "average_photos_per_user": round(avg_photos_per_user, 2), 

1038 } 

1039 

1040 # Bluesky handles statistics 

1041 bluesky_emails = ConfirmedEmail.objects.filter( 

1042 bluesky_handle__isnull=False 

1043 ).count() 

1044 bluesky_openids = ConfirmedOpenId.objects.filter( 

1045 bluesky_handle__isnull=False 

1046 ).count() 

1047 total_bluesky_handles = bluesky_emails + bluesky_openids 

1048 

1049 # Top Bluesky handles by access count 

1050 retval["bluesky_handles"] = { 

1051 "total_bluesky_handles": total_bluesky_handles, 

1052 "bluesky_emails": bluesky_emails, 

1053 "bluesky_openids": bluesky_openids, 

1054 } 

1055 

1056 # Average photo size statistics using raw SQL 

1057 from django.db import connection 

1058 

1059 with connection.cursor() as cursor: 

1060 # SQL to calculate average photo size 

1061 cursor.execute(""" 

1062 SELECT 

1063 COUNT(*) as photo_count, 

1064 AVG(LENGTH(data)) as avg_size_bytes 

1065 FROM ivataraccount_photo 

1066 WHERE data IS NOT NULL 

1067 """) 

1068 result = cursor.fetchone() 

1069 

1070 if result and result[0] > 0: 

1071 photo_count, avg_size_bytes = result 

1072 # Convert to float in case database returns string 

1073 avg_size_bytes = float(avg_size_bytes) if avg_size_bytes else 0 

1074 avg_size_kb = round(avg_size_bytes / 1024, 2) if avg_size_bytes else 0 

1075 avg_size_mb = ( 

1076 round(avg_size_bytes / (1024 * 1024), 2) if avg_size_bytes else 0 

1077 ) 

1078 

1079 retval["photo_size_stats"] = { 

1080 "average_size_bytes": ( 

1081 round(avg_size_bytes, 2) if avg_size_bytes else 0 

1082 ), 

1083 "average_size_kb": avg_size_kb, 

1084 "average_size_mb": avg_size_mb, 

1085 "total_photos_analyzed": photo_count, 

1086 } 

1087 else: 

1088 retval["photo_size_stats"] = { 

1089 "average_size_bytes": 0, 

1090 "average_size_kb": 0, 

1091 "average_size_mb": 0, 

1092 "total_photos_analyzed": 0, 

1093 } 

1094 

1095 # For potential duplicate photos, we'll check for photos with the same format and size 

1096 # Note: This is not definitive - different images can have the same format and size 

1097 # but it's a good indicator of potential duplicates that might warrant investigation 

1098 with connection.cursor() as cursor: 

1099 cursor.execute(""" 

1100 SELECT 

1101 format, 

1102 LENGTH(data) as file_size, 

1103 COUNT(*) as count 

1104 FROM ivataraccount_photo 

1105 WHERE data IS NOT NULL 

1106 GROUP BY format, LENGTH(data) 

1107 HAVING COUNT(*) > 1 

1108 ORDER BY count DESC 

1109 LIMIT 10 

1110 """) 

1111 duplicate_groups = cursor.fetchall() 

1112 

1113 total_potential_duplicate_photos = sum( 

1114 group[2] for group in duplicate_groups 

1115 ) 

1116 

1117 # Convert to list of dictionaries for JSON serialization 

1118 duplicate_groups_detail = [ 

1119 {"format": group[0], "file_size": group[1], "count": group[2]} 

1120 for group in duplicate_groups 

1121 ] 

1122 

1123 retval["potential_duplicate_photos"] = { 

1124 "potential_duplicate_groups": len(duplicate_groups), 

1125 "total_potential_duplicate_photos": total_potential_duplicate_photos, 

1126 "potential_duplicate_groups_detail": duplicate_groups_detail, 

1127 "note": "Potential duplicates are identified by matching file format and size - not definitive duplicates", 

1128 } 

1129 

1130 # Cache statistics for avatar generators 

1131 from .robohash import OptimizedRobohash 

1132 import ivatar.robohash as robohash_mod 

1133 from .pagan_optimized import OptimizedPagan 

1134 import ivatar.pagan_optimized as pagan_mod 

1135 

1136 robohash_stats = OptimizedRobohash.get_cache_stats() 

1137 robohash_png_cache = getattr(robohash_mod, "_robohash_png_cache", {}) 

1138 robohash_png_lock = getattr(robohash_mod, "_robohash_png_cache_lock", None) 

1139 if robohash_png_lock: 

1140 with robohash_png_lock: 

1141 robohash_png_count = len(robohash_png_cache) 

1142 else: 

1143 robohash_png_count = len(robohash_png_cache) 

1144 

1145 pagan_stats = OptimizedPagan.get_cache_stats() 

1146 pagan_png_cache = getattr(pagan_mod, "_pagan_png_cache", {}) 

1147 pagan_png_lock = getattr(pagan_mod, "_pagan_png_cache_lock", None) 

1148 if pagan_png_lock: 

1149 with pagan_png_lock: 

1150 pagan_png_count = len(pagan_png_cache) 

1151 else: 

1152 pagan_png_count = len(pagan_png_cache) 

1153 

1154 with _default_avatars_png_cache_lock: 

1155 default_avatars_cache_stats = {} 

1156 for (style, _digest, _size) in _default_avatars_png_cache.keys(): 

1157 default_avatars_cache_stats[style] = default_avatars_cache_stats.get(style, 0) + 1 

1158 

1159 retval["cache_stats"] = { 

1160 "robohash": { 

1161 "assembly_cache_items": robohash_stats["cache_size"], 

1162 "assembly_cache_limit": robohash_stats["max_cache_size"], 

1163 "png_cache_items": robohash_png_count, 

1164 "png_cache_limit": getattr(robohash_mod, "_MAX_PNG_CACHE_SIZE", getattr(settings, "ROBOHASH_CACHE_SIZE", 150)), 

1165 }, 

1166 "pagan": { 

1167 "avatar_cache_items": pagan_stats["size"], 

1168 "avatar_cache_limit": pagan_stats["max_size"], 

1169 "png_cache_items": pagan_png_count, 

1170 "png_cache_limit": getattr(pagan_mod, "_MAX_PAGAN_PNG_CACHE_SIZE", getattr(settings, "PAGAN_CACHE_SIZE", 100)), 

1171 }, 

1172 "default_avatars": { 

1173 "cache_items_total": sum(default_avatars_cache_stats.values()), 

1174 "cache_limit_total": _MAX_DEFAULT_PNG_CACHE_SIZE, 

1175 "cache_items_by_style": { 

1176 "monsterid": default_avatars_cache_stats.get("monsterid", 0), 

1177 "retro": default_avatars_cache_stats.get("retro", 0), 

1178 "identicon": default_avatars_cache_stats.get("identicon", 0), 

1179 "mmng": default_avatars_cache_stats.get("mmng", 0), 

1180 "wavatar": default_avatars_cache_stats.get("wavatar", 0), 

1181 }, 

1182 }, 

1183 } 

1184 

1185 with contextlib.suppress(Exception): 

1186 cache.set(STATS_CACHE_KEY, retval, stats_cache_timeout) 

1187 

1188 return JsonResponse(retval) 

1189 

1190 

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

1192_version_cache = None 

1193_version_cache_lock = threading.Lock() 

1194 

1195 

1196def _get_git_info_from_files(): 

1197 """ 

1198 Safely extract git information from .git files without subprocess calls 

1199 """ 

1200 try: 

1201 # Get the project root directory 

1202 project_root = path.dirname(path.dirname(path.abspath(__file__))) 

1203 git_dir = path.join(project_root, ".git") 

1204 

1205 if not path.exists(git_dir): 

1206 return None 

1207 

1208 # Read HEAD to get current branch/commit 

1209 head_file = path.join(git_dir, "HEAD") 

1210 if not path.exists(head_file): 

1211 return None 

1212 

1213 with open(head_file) as f: 

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

1215 

1216 # Parse HEAD content 

1217 if head_content.startswith("ref: "): 

1218 # We're on a branch 

1219 branch_ref = head_content[5:] # Remove 'ref: ' 

1220 branch_name = path.basename(branch_ref) 

1221 

1222 # Read the commit hash from the ref 

1223 ref_file = path.join(git_dir, branch_ref) 

1224 if path.exists(ref_file): 

1225 with open(ref_file) as f: 

1226 commit_hash = f.read().strip() 

1227 else: 

1228 return None 

1229 else: 

1230 # Detached HEAD state 

1231 commit_hash = head_content 

1232 branch_name = "detached" 

1233 

1234 # Try to get commit date from git log file (if available) 

1235 # Optimize: read only the last line instead of entire file 

1236 commit_date = None 

1237 log_file = path.join(git_dir, "logs", "HEAD") 

1238 if path.exists(log_file): 

1239 try: 

1240 with open(log_file, "rb") as f: 

1241 # Seek to end and read backwards to find last line 

1242 f.seek(0, 2) # Seek to end 

1243 file_size = f.tell() 

1244 

1245 # Read backwards in chunks to find the last line 

1246 chunk_size = min(1024, file_size) 

1247 f.seek(max(0, file_size - chunk_size)) 

1248 chunk = f.read().decode("utf-8", errors="ignore") 

1249 

1250 # Find the last non-empty line 

1251 lines = chunk.split("\n") 

1252 last_line = None 

1253 for line in reversed(lines): 

1254 if line.strip(): 

1255 last_line = line.strip() 

1256 break 

1257 

1258 if last_line: 

1259 # Git log format: <old_hash> <new_hash> <author> <timestamp> <timezone> <message> 

1260 # The format uses spaces, not tabs 

1261 parts = last_line.split() 

1262 if len(parts) >= 6: 

1263 # Extract timestamp and convert to readable date 

1264 # Format: <old_hash> <new_hash> <author_name> <author_email> <timestamp> <timezone> <message> 

1265 # We need to find the timestamp which is after the author email 

1266 for i, part in enumerate(parts): 

1267 if part.isdigit() and len(part) == 10: # Unix timestamp 

1268 import datetime 

1269 

1270 timestamp = int(part) 

1271 commit_date = datetime.datetime.fromtimestamp( 

1272 timestamp 

1273 ).strftime("%Y-%m-%d %H:%M:%S %z") 

1274 break 

1275 except (ValueError, IndexError, UnicodeDecodeError): 

1276 pass 

1277 

1278 # Fallback: try to get date from commit object if available 

1279 if not commit_date and len(commit_hash) == 40: 

1280 try: 

1281 commit_dir = path.join(git_dir, "objects", commit_hash[:2]) 

1282 commit_file = path.join(commit_dir, commit_hash[2:]) 

1283 if path.exists(commit_file): 

1284 # This would require decompressing the git object, which is complex 

1285 # For now, we'll use a placeholder 

1286 commit_date = "unknown" 

1287 except Exception: 

1288 commit_date = "unknown" 

1289 

1290 # Get deployment date from directory modification time 

1291 # Use .git directory as it's updated during deployment (checkout/pull) 

1292 deployment_date = None 

1293 if path.exists(git_dir): 

1294 try: 

1295 import datetime 

1296 

1297 mtime = path.getmtime(git_dir) 

1298 deployment_date = datetime.datetime.fromtimestamp(mtime).strftime( 

1299 "%Y-%m-%d %H:%M:%S %z" 

1300 ) 

1301 except Exception: 

1302 deployment_date = "unknown" 

1303 

1304 return { 

1305 "commit_hash": commit_hash, 

1306 "short_hash": commit_hash[:7] if len(commit_hash) >= 7 else commit_hash, 

1307 "branch": branch_name, 

1308 "commit_date": commit_date or "unknown", 

1309 "deployment_date": deployment_date or "unknown", 

1310 "deployment_status": "active", 

1311 "version": f"{branch_name}-{commit_hash[:7] if len(commit_hash) >= 7 else commit_hash}", 

1312 } 

1313 

1314 except Exception as exc: 

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

1316 return None 

1317 

1318 

1319def _get_cached_version_info(): 

1320 """ 

1321 Get cached version information, loading it if not available 

1322 Since containers restart on content changes, cache indefinitely 

1323 """ 

1324 global _version_cache 

1325 

1326 with _version_cache_lock: 

1327 # If cache is None or contains an error, try to reload it 

1328 if _version_cache is None or "error" in _version_cache: 

1329 # First try to read from a baked-in version.json file 

1330 # This is preferred for containerized deployments 

1331 try: 

1332 project_root = path.dirname(path.dirname(path.abspath(__file__))) 

1333 version_file = path.join(project_root, "version.json") 

1334 if path.exists(version_file): 

1335 with open(version_file) as f: 

1336 import json 

1337 

1338 _version_cache = json.load(f) 

1339 logger.info("Loaded version info from version.json") 

1340 except Exception as exc: 

1341 logger.warning(f"Failed to read version.json: {exc}") 

1342 

1343 # If that fails, try to get version info from git files 

1344 if _version_cache is None or "error" in _version_cache: 

1345 _version_cache = _get_git_info_from_files() 

1346 

1347 # If that also fails, return error (but don't cache it indefinitely if we can help it) 

1348 if _version_cache is None: 

1349 _version_cache = { 

1350 "error": "Unable to determine version - .git directory or version.json not found", 

1351 "deployment_status": "unknown", 

1352 } 

1353 

1354 return _version_cache 

1355 

1356 

1357class DeploymentVersionView(View): 

1358 """ 

1359 View to return deployment version information for CI/CD verification 

1360 Uses cached version info to prevent DDoS attacks and improve performance 

1361 """ 

1362 

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

1364 """ 

1365 Return cached deployment version information including application version 

1366 """ 

1367 from django.conf import settings 

1368 

1369 version_info = _get_cached_version_info() 

1370 

1371 if "error" in version_info: 

1372 # Even on error, include the application version if available 

1373 try: 

1374 version_info["application_version"] = getattr( 

1375 settings, "IVATAR_VERSION", "unknown" 

1376 ) 

1377 except Exception: 

1378 pass 

1379 return JsonResponse(version_info, status=500) 

1380 

1381 # Add application version to the response 

1382 try: 

1383 version_info["application_version"] = getattr( 

1384 settings, "IVATAR_VERSION", "unknown" 

1385 ) 

1386 except Exception: 

1387 version_info["application_version"] = "unknown" 

1388 

1389 return JsonResponse(version_info) 

1390import time 

1391import hashlib 

1392from django.contrib.auth.mixins import LoginRequiredMixin 

1393from django.views.generic import TemplateView 

1394from django.test import RequestFactory 

1395 

1396class PerfTestView(LoginRequiredMixin, TemplateView): 

1397 template_name = "perf_test.html" 

1398 

1399 def get_context_data(self, **kwargs): 

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

1401 

1402 email = "test@example.com" 

1403 md5_digest = hashlib.md5(email.encode()).hexdigest() 

1404 sha256_digest = hashlib.sha256(email.encode()).hexdigest() 

1405 

1406 factory = RequestFactory() 

1407 view = AvatarImageView.as_view() 

1408 

1409 results = [] 

1410 total_duration_ms = 0.0 

1411 

1412 scenarios = [ 

1413 {"name": "Dummy MD5, Size 80", "digest": md5_digest, "params": {"s": 80}}, 

1414 {"name": "Dummy MD5, Size 512", "digest": md5_digest, "params": {"s": 512}}, 

1415 {"name": "Dummy SHA256, Size 80", "digest": sha256_digest, "params": {"s": 80}}, 

1416 {"name": "Dummy SHA256, Size 512", "digest": sha256_digest, "params": {"s": 512}}, 

1417 ] 

1418 

1419 default_types = ['robohash', 'wavatar', 'monsterid', 'identicon', 'mm', 'mp', 'retro', 'pagan'] 

1420 for d in default_types: 

1421 scenarios.append({ 

1422 "name": f"Dummy MD5, Size 80, Default: {d}", 

1423 "digest": md5_digest, 

1424 "params": {"s": 80, "d": d, "f": "y"} 

1425 }) 

1426 

1427 from ivatar.ivataraccount.models import ConfirmedEmail, ConfirmedOpenId 

1428 for email in ConfirmedEmail.objects.filter(user=self.request.user): 

1429 scenarios.append({ 

1430 "name": f"User Email MD5 ({email.email}), Size 80", 

1431 "digest": email.digest, 

1432 "params": {"s": 80} 

1433 }) 

1434 scenarios.append({ 

1435 "name": f"User Email SHA256 ({email.email}), Size 512", 

1436 "digest": email.digest_sha256, 

1437 "params": {"s": 512} 

1438 }) 

1439 for d in default_types: 

1440 scenarios.append({ 

1441 "name": f"User Email SHA256 ({email.email}), Size 80, Default: {d}", 

1442 "digest": email.digest_sha256, 

1443 "params": {"s": 80, "d": d, "f": "y"} 

1444 }) 

1445 

1446 for openid in ConfirmedOpenId.objects.filter(user=self.request.user): 

1447 scenarios.append({ 

1448 "name": f"User OpenID SHA256, Size 80", 

1449 "digest": openid.digest, 

1450 "params": {"s": 80} 

1451 }) 

1452 scenarios.append({ 

1453 "name": f"User OpenID SHA256, Size 512", 

1454 "digest": openid.digest, 

1455 "params": {"s": 512} 

1456 }) 

1457 for d in default_types: 

1458 scenarios.append({ 

1459 "name": f"User OpenID SHA256, Size 80, Default: {d}", 

1460 "digest": openid.digest, 

1461 "params": {"s": 80, "d": d, "f": "y"} 

1462 }) 

1463 

1464 for sc in scenarios: 

1465 query_string = "&".join([f"{k}={v}" for k, v in sc["params"].items()]) 

1466 url = f"/avatar/{sc['digest']}?{query_string}" 

1467 request = factory.get(url) 

1468 

1469 start_time = time.time() 

1470 try: 

1471 response = view(request, digest=sc['digest']) 

1472 status = response.status_code 

1473 except Exception as e: 

1474 status = str(e) 

1475 end_time = time.time() 

1476 

1477 duration_ms = (end_time - start_time) * 1000 

1478 total_duration_ms += duration_ms 

1479 

1480 results.append({ 

1481 "name": sc["name"], 

1482 "digest": sc["digest"], 

1483 "params": sc["params"], 

1484 "duration_ms": round(duration_ms, 2), 

1485 "duration_str": f"{duration_ms:.2f}", 

1486 "status": status, 

1487 }) 

1488 

1489 context['perf_results'] = results 

1490 context['total_duration_str'] = f"{total_duration_ms:.2f}" 

1491 return context