admin: show contact photos as avatars
The card's X-ABCROP-RECTANGLE is knowingly ignored — no image
library in the bundle, and CSS cropping needs the source dimensions
the card does not carry — so a cropped photo displays uncropped.
DESIGN.md's "no photography" reads as the app chrome, not contact
content.

Assisted-by: GLM-5.3 via pi
change slqksltlsntmzttkuwpstylotpwpolnk
commit aa0e6a49a8f0d08c4d6c002f72df79643f65acbe
author Alpha Chen <alpha@kejadlen.dev>
date
parent xrkxmzpo
diff --git a/lib/pro_tacts/admin/avatar.rb b/lib/pro_tacts/admin/avatar.rb
new file mode 100644
index 0000000..64310e9
--- /dev/null
+++ b/lib/pro_tacts/admin/avatar.rb
@@ -0,0 +1,44 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/format"
+
+module ProTacts
+  module Admin
+    # A contact's avatar: the card's picture when it carries one a
+    # browser can show, the initials otherwise. One component because
+    # three views render this slot — the dashboard's rows, the
+    # birthdays' rows, the detail header — and they should not disagree
+    # about what an avatar is.
+    #
+    # The picture renders from the photo route rather than the decoded
+    # bytes in hand, so the page carries a URL where it would otherwise
+    # carry megabytes of base64 (see the route in Web). Alt is empty:
+    # the contact's name is adjacent text in every slot, so the image
+    # is decoration, not information.
+    class Avatar < Phlex::HTML
+      # @rbs @contact: Contact
+      # @rbs @size: String?
+
+      #: (contact: Contact, ?size: String?) -> void
+      def initialize(contact:, size: nil)
+        @contact = contact
+        @size = size
+      end
+
+      def view_template
+        if @contact.photo
+          img(class: "avatar", src: "/contacts/#{@contact.id}/photo", alt: "", **attributes)
+        else
+          span(class: "avatar", **attributes) { Format.initials(@contact) }
+        end
+      end
+
+      private
+
+      #: () -> Hash[untyped, untyped]
+      def attributes
+        @size ? {data_size: @size} : {}
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 93ecf32..7fce196 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -1,5 +1,6 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/layout"
 require "pro_tacts/admin/upcoming_birthdays"
@@ -59,7 +60,7 @@ module ProTacts
       def render_row(row)
         li do
           a(href: "/contacts/#{row.contact.id}") do
-            span(class: "avatar") { Format.initials(row.contact) }
+            render Avatar.new(contact: row.contact)
             div(style: "flex: 1; min-width: 0;") do
               div(style: "font-weight: 550;") { row.contact.name || row.contact.id }
             end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index b67dedb..6a3c1ac 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -1,5 +1,6 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/layout"
 
@@ -28,7 +29,7 @@ module ProTacts
           div(class: "card") do
             div(class: "card-body") do
               div(class: "detail-header") do
-                span(class: "avatar", data_size: "lg") { Format.initials(@contact) }
+                render Avatar.new(contact: @contact, size: "lg")
                 h1(class: "type-h2", style: "margin: 0;") { @contact.name || @contact.id }
               end
               # Only rendered when there's something to show: an empty
diff --git a/lib/pro_tacts/admin/upcoming_birthdays.rb b/lib/pro_tacts/admin/upcoming_birthdays.rb
index 8343f6e..b43269a 100644
--- a/lib/pro_tacts/admin/upcoming_birthdays.rb
+++ b/lib/pro_tacts/admin/upcoming_birthdays.rb
@@ -1,5 +1,6 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/format"
 
 module ProTacts
@@ -36,7 +37,7 @@ module ProTacts
         contact = upcoming.contact
         li do
           a(href: "/contacts/#{contact.id}") do
-            span(class: "avatar") { Format.initials(contact) }
+            render Avatar.new(contact: contact)
             div(style: "flex: 1; min-width: 0;") do
               div(style: "font-weight: 550;") { contact.name || contact.id }
               div(class: "type-body-sm") { date_line(upcoming) }
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 01d4b02..43eabfc 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -1,3 +1,4 @@
+require "base64"
 require "digest"
 
 require "pro_tacts/birthday"
@@ -55,6 +56,8 @@ module ProTacts
     # @rbs skip
     Email = Data.define(:value, :type)
     # @rbs skip
+    Photo = Data.define(:mime_type, :bytes)
+    # @rbs skip
     Address = Data.define(
       :po_box,
       :extended,
@@ -94,6 +97,43 @@ module ProTacts
 
     attr_reader :etag
 
+    # The image formats a decoded PHOTO is recognized by, prefix to
+    # mime type. A picture's type is read off the decoded bytes rather
+    # than the TYPE parameter because magic bytes cannot mislabel what
+    # they are and no allowlist of client spellings has to be kept;
+    # binary literals, because the decoded payload is.
+    PHOTO_SIGNATURES = [
+      ["\xFF\xD8\xFF".b, "image/jpeg"],
+      ["\x89PNG\r\n\x1A\n".b, "image/png"],
+      ["GIF87a".b, "image/gif"],
+      ["GIF89a".b, "image/gif"],
+    ].freeze #: Array[[String, String]]
+
+    # The picture the card carries, when it carries one a browser can
+    # show: PHOTO's value base64-decoded and named by its own magic
+    # bytes. The inline spelling is the one macOS sends; a URI-form
+    # PHOTO never reaches the sniff, because a URI's ":" is not in
+    # base64's alphabet and the strict decode refuses it. Nil for no
+    # PHOTO, an undecodable one, or one that sniffs to no known format
+    # — the initials an avatar falls back to, an ordinary absence
+    # rather than an error, exactly like the other accessors' nils.
+    #: () -> Photo?
+    def photo
+      property = properties.find { it.name.casecmp?("PHOTO") }
+      return if property.nil?
+
+      begin
+        bytes = Base64.strict_decode64(property.value)
+      rescue ArgumentError
+        return
+      end
+
+      signature = PHOTO_SIGNATURES.find { bytes.start_with?(it.first) }
+      return if signature.nil?
+
+      Photo.new(mime_type: signature.fetch(1), bytes:)
+    end
+
     # The card's properties — the substrate the typed accessors sit on,
     # and the read for what none of them models. Every line that read,
     # and no complaint about one that did not: a contact is served from
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 07bc75d..9722604 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -89,14 +89,35 @@ module ProTacts
       # trusted" is the whole access model this app has, see README's
       # simplifying assumptions.
       r.on "contacts" do
-        r.get String do |id|
-          contact = store.contact(id)
-
-          # No match falls through to the empty-body 404 the
-          # not_found handler fills in, same as the CardDAV GET.
-          if contact
-            response["Content-Type"] = "text/html; charset=utf-8"
-            Admin::ContactsShow.call(contact:)
+        r.on String do |id|
+          # The picture the avatars render (Admin::Avatar): decoded
+          # bytes under their own content type, served from a route
+          # rather than inlined as base64 so a page of avatars is a
+          # page of cacheable image requests instead of ten 330 KB
+          # payloads stitched into the HTML. The contact's etag, so a
+          # picture changes exactly when its card does.
+          r.get "photo" do
+            contact = store.contact(id)
+
+            # A contact with no picture a browser can show is no
+            # match here, same fall-through-to-404 as the page below —
+            # the views never point at a photo the card lacks.
+            if contact && (photo = contact.photo)
+              response["Content-Type"] = photo.mime_type
+              response["ETag"] = contact.etag
+              photo.bytes
+            end
+          end
+
+          r.get do
+            contact = store.contact(id)
+
+            # No match falls through to the empty-body 404 the
+            # not_found handler fills in, same as the CardDAV GET.
+            if contact
+              response["Content-Type"] = "text/html; charset=utf-8"
+              Admin::ContactsShow.call(contact:)
+            end
           end
         end
       end
diff --git a/public/admin.css b/public/admin.css
index 88e66d6..62718da 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -95,6 +95,15 @@ section > h2 {
   align-self: flex-start;
 }
 
+/* A contact's picture fills the avatar the initials otherwise occupy.
+   Gloss's .avatar is already round and clipped (overflow hidden), so a
+   picture needs only to cover the box — object-fit rather than a
+   resize, because the bytes are the card's own and nothing recodes
+   them. No width/height to add: .avatar sets both. */
+img.avatar {
+  object-fit: cover;
+}
+
 .detail-header {
   display: flex;
   align-items: center;
diff --git a/sig/gems/base64.rbs b/sig/gems/base64.rbs
new file mode 100644
index 0000000..2bb0ecb
--- /dev/null
+++ b/sig/gems/base64.rbs
@@ -0,0 +1,9 @@
+# Base64 ships no signatures (a default gem), so this declares the one
+# method pro-tacts calls: the strict decoder, which raises on any
+# character outside the alphabet rather than skipping it — the refusal
+# is what keeps a URI-form PHOTO from decoding as one.
+#
+# Written against base64 0.3.0 (lib/base64.rb).
+module Base64
+  def self.strict_decode64: (String str) -> String
+end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 1d85267..edc960f 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -1,4 +1,4 @@
-# Contact::Phone, ::Email, and ::Address are Data classes, whose
+# Contact::Phone, ::Email, ::Photo, and ::Address are Data classes, whose
 # members the inline parser cannot read (see sig/pro_tacts/birthday.rbs
 # for the same limit). Each is marked `@rbs skip` in contact.rb and
 # signed here; Contact itself is a plain class and carries its
@@ -19,6 +19,13 @@ module ProTacts
       def self.new: (value: String, type: String?) -> instance
     end
 
+    class Photo
+      attr_reader mime_type: String
+      attr_reader bytes: String
+
+      def self.new: (mime_type: String, bytes: String) -> instance
+    end
+
     class Address
       attr_reader po_box: String?
       attr_reader extended: String?
diff --git a/test/photo_card.rb b/test/photo_card.rb
index 4502f3d..27696bc 100644
--- a/test/photo_card.rb
+++ b/test/photo_card.rb
@@ -75,10 +75,12 @@ module PhotoCard
   # Synthetic image bytes: never decoded — a card's payload is stored
   # and served as the base64 text it arrives as — so a repeating
   # pattern stands in, deterministic so a fixture's etag does not vary
-  # per run.
+  # per run. Binary, as decoded bytes are: the JPEG signature is not
+  # UTF-8, and the equality a test wants against Base64's output is
+  # byte equality, which a UTF-8 flag would break.
   #: (Integer bytes) -> String
   def self.image(bytes)
-    ("\xFF\xD8\xFF\xE0" + "synthetic photo payload" * (bytes / 22 + 1)).byteslice(0, bytes)
+    ("\xFF\xD8\xFF\xE0".b + "synthetic photo payload".b * (bytes / 22 + 1)).byteslice(0, bytes)
   end
 
   # A card with a real photo attached: the 338 KB capture's shape. A
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 4b63de3..5e1ce99 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1,4 +1,5 @@
 require_relative "../../test_helper"
+require_relative "../../photo_card"
 
 require "date"
 require "pathname"
@@ -261,4 +262,61 @@ class AdminContactsPagesTest < Minitest::Test
 
     assert_equal 404, last_response.status
   end
+
+  ## Photos
+
+  # A contact with a picture renders it where the initials were —
+  # here the detail header's large avatar — pointing at the photo
+  # route rather than carrying the bytes in the page. Alt is empty:
+  # the name is the heading right beside it.
+  def test_show_renders_the_cards_picture_as_the_avatar
+    with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do
+      get "/contacts/pic"
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, '<img class="avatar" src="/contacts/pic/photo" alt=""'
+      assert_includes last_response.body, 'data-size="lg"'
+      refute_includes last_response.body, '<span class="avatar"'
+    end
+  end
+
+  # The dashboard rows render the same avatar — one component, every
+  # slot — so a picture shows in recently updated and in results.
+  def test_the_index_rows_render_the_picture_too
+    with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do
+      get "/"
+
+      assert_includes last_response.body, 'src="/contacts/pic/photo"'
+
+      get "/", q: "no-match-for-this"
+      refute_includes last_response.body, 'src="/contacts/pic/photo"'
+    end
+  end
+
+  # The route the avatars point at: the decoded bytes under the type
+  # their own magic bytes name, with the contact's etag — so a picture
+  # changes exactly when its card does.
+  def test_the_photo_route_serves_the_decoded_bytes
+    with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do |store|
+      get "/contacts/pic/photo"
+
+      assert_equal 200, last_response.status
+      assert_equal "image/jpeg", last_response["Content-Type"]
+      assert_equal store.contact("pic").photo.bytes, last_response.body
+      assert_equal store.contact("pic").etag, last_response["ETag"]
+    end
+  end
+
+  # A contact whose card carries no picture a browser can show is no
+  # match at the route — the views never point at one — same 404 as an
+  # unknown contact.
+  def test_the_photo_route_is_404_without_a_picture
+    with_contacts({"ada" => ADA}) do
+      get "/contacts/ada/photo"
+      assert_equal 404, last_response.status
+
+      get "/contacts/nobody/photo"
+      assert_equal 404, last_response.status
+    end
+  end
 end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index a4004c1..c0c1954 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -1,5 +1,7 @@
 require_relative "../test_helper"
+require_relative "../photo_card"
 
+require "base64"
 require "digest"
 
 require "pro_tacts/contact"
@@ -173,4 +175,51 @@ class ContactTest < Minitest::Test
     assert_empty contact("not a vCard at all").properties
     assert_equal "Aiden", contact("FN:Aiden\r\nTEL;HOME:+1-555-1234\r\n").name
   end
+
+  ## Photos
+
+  # The picture the card carries, decoded: PHOTO's base64 payload as
+  # bytes, with the mime type read off the decoded bytes themselves —
+  # magic bytes cannot mislabel what they are — rather than the TYPE
+  # parameter. The synthetic payload carries the JPEG signature the
+  # builder gives every image.
+  def test_a_photo_reads_decoded_with_a_sniffed_type
+    photo = contact(PhotoCard.photo("aiden", bytes: 64)).photo
+
+    assert_equal "image/jpeg", photo.mime_type
+    assert_equal PhotoCard.image(64), photo.bytes
+  end
+
+  # The sniff's other rows: a picture named PNG or GIF reads as what
+  # its bytes are, whatever the TYPE parameter said.
+  def test_png_and_gif_payloads_sniff_to_their_types
+    png = CARD.sub("FN:Aiden\r\n", photo_property("\x89PNG\r\n\x1A\nx"))
+    gif = CARD.sub("FN:Aiden\r\n", photo_property("GIF89ax"))
+
+    assert_equal "image/png", contact(png).photo&.mime_type
+    assert_equal "image/gif", contact(gif).photo&.mime_type
+  end
+
+  def test_a_card_without_a_photo_has_none
+    assert_nil contact.photo
+  end
+
+  # A URI-form PHOTO never reaches the sniff: a URI's ":" is not in
+  # base64's alphabet and the strict decode refuses the value. Same
+  # nil for a payload that is not an image a browser can show — the
+  # initials an avatar falls back to, an ordinary absence.
+  def test_a_photo_that_is_not_showable_has_none
+    uri = CARD.sub("FN:Aiden\r\n", "PHOTO;VALUE=uri:https://example.com/me.jpg\r\n")
+    undecodable = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:not base64!!\r\n")
+    not_an_image = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:#{Base64.strict_encode64("text, not an image")}\r\n")
+
+    assert_nil contact(uri).photo
+    assert_nil contact(undecodable).photo
+    assert_nil contact(not_an_image).photo
+  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"
+  end
 end