avatar: choose the picture from the photo's first bytes
An avatar decoded every picture on a page, megabytes of base64 for a
list, to decide between an image and initials. Contact#photo? sniffs
the signature off the payload's first bytes; a PHOTO broken past them
now renders as an image the photo route refuses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNVLyiMrZJ7sZctEYq6WCM
change
commit dc2787bd39a0a9b57cdec54f982a8c35edec874e
author Claude <noreply@anthropic.com>
date
parent bec0ad63
diff --git a/lib/pro_tacts/admin/avatar.rb b/lib/pro_tacts/admin/avatar.rb
index 5668b72..c6f9770 100644
--- a/lib/pro_tacts/admin/avatar.rb
+++ b/lib/pro_tacts/admin/avatar.rb
@@ -27,7 +27,7 @@ module ProTacts
       end
 
       def view_template
-        if @contact.photo
+        if @contact.photo?
           img(class: "avatar", src: "/contacts/#{@contact.id}/photo", alt: "", **attributes)
         else
           span(class: "avatar", **attributes) { Format.initials(@contact) }
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index c24b17d..d1ef160 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -77,7 +77,7 @@ module ProTacts
           # beside the name in type-h2 would repeat what the name
           # already says. The dashboard rows keep theirs — there
           # the avatar is the row's visual anchor, not a caption.
-          render Avatar.new(contact: @contact, size: "xl") if @contact.photo
+          render Avatar.new(contact: @contact, size: "xl") if @contact.photo?
         end
         dl(class: "detail-grid") { rows }
       end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index d78946d..94d01c8 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -272,6 +272,27 @@ module ProTacts
       photo
     end
 
+    # Whether the card carries a picture an avatar should ask the photo
+    # route for: #photo's answer read off the first bytes alone, the
+    # magic number being all the sniff looks at. The rest of the
+    # payload is not decoded, so a PHOTO broken past its first bytes
+    # renders as an image the route then refuses — the price of not
+    # decoding every picture on a page to draw a list of them.
+    #: () -> bool
+    def photo?
+      value = vcard.property("PHOTO")&.value
+      return false if value.nil?
+
+      head = Base64.strict_decode64(value[0, PHOTO_HEAD].to_s)
+      PHOTO_SIGNATURES.any? { head.start_with?(it.first) }
+    rescue ArgumentError
+      false
+    end
+
+    # Base64 characters enough to decode past the longest signature:
+    # twelve bytes, four characters to every three.
+    PHOTO_HEAD = 16 #: Integer
+
     # Bytes as a picture a browser can show, named by their magic
     # bytes (PHOTO_SIGNATURES), or nil for a format the list does not
     # cover. A card's PHOTO and a group's upload are judged alike.
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index be3127d..84368ff 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -598,6 +598,24 @@ class ContactTest < Minitest::Test
     assert_match(/PHOTO decoded to no image format/, sentry_messages.fetch(0))
   end
 
+  # The avatar's question, answered from the first bytes: the same
+  # yes and no as #photo for a picture and for none, and a yes for a
+  # payload broken past its signature, which only the full decode can
+  # see. Nothing reports on the way, the photo route's decode being
+  # where a broken picture is news.
+  def test_photo_asks_only_the_payloads_first_bytes
+    broken_tail = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:#{Base64.strict_encode64("\xFF\xD8\xFF\xE0" + "x" * 20)}!!\r\n")
+    not_an_image = CARD.sub("FN:Aiden\r\n", photo_property("text, not an image"))
+    undecodable = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:not base64!!\r\n")
+
+    assert_predicate contact(PhotoCard.photo("aiden", bytes: 64)), :photo?
+    assert_predicate contact(broken_tail), :photo?
+    refute_predicate contact, :photo?
+    refute_predicate contact(not_an_image), :photo?
+    refute_predicate contact(undecodable), :photo?
+    assert_empty sentry_events
+  end
+
   # One PHOTO property carrying a PNG payload, for the sniff's rows.
   def photo_property(payload)
     "PHOTO;ENCODING=b;TYPE=JPEG:#{Base64.strict_encode64(payload)}\r\n"