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