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