groups: hold a photo shown on the web pages alone
A family picture sits on the group's own page and never in a card a client syncs;
a chip with a photo wears a small image to its name's left so readers know before they've clicked.
Uploads are judged by their magic bytes and capped at ten megabytes,
and only the dump keeps one once replaced, since the group's change log writes no entry for photos.
Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/Steepfile b/Steepfile
index 9c7c3a8..37793a9 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ target :lib do
# the files stay checked.
ignore(*%w[
avatar contact_card contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
- group_dialog group_filter group_label groups_edit groups_index groups_members groups_show icons import_groups import_original import_saved import_sidebar
+ group_dialog group_filter group_label group_tag groups_edit groups_index groups_members groups_show icons import_groups import_original import_saved import_sidebar
import_target import_upload layout list_item phlex recent_row record_card search_dialog search_field search_page search_results upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/db/migrations/013_group_photos.rb b/db/migrations/013_group_photos.rb
new file mode 100644
index 0000000..6263d64
--- /dev/null
+++ b/db/migrations/013_group_photos.rb
@@ -0,0 +1,23 @@
+# A group's photo: a picture the group's own page shows and nothing
+# else does (docs/plans/2026-10-12-group-photos.md). Not a line in
+# group_properties, because every line there is composed into a
+# member's card and this must never reach a client — so not under that
+# table's CHECK either, which is what keeps a PHOTO line out of it.
+#
+# Its own table rather than a column on groups, so the reads that hand
+# out a group never carry megabytes they will not show, and a group's
+# delete cascades the picture away with it. The bytes only: the mime
+# type is read off them (Contact.photo_of), which the upload already
+# did to accept them.
+#
+# `text: true` on the id because this is a STRICT table, which refuses
+# Sequel's plain String; `File` is a BLOB, which STRICT admits.
+Sequel.migration do
+ change do
+ create_table(:group_photos, strict: true) do
+ foreign_key :group_id, :groups, type: String, text: true, primary_key: true, null: false,
+ on_delete: :cascade
+ File :bytes, null: false
+ end
+ end
+end
diff --git a/docs/plans/2026-10-12-group-photos.md b/docs/plans/2026-10-12-group-photos.md
new file mode 100644
index 0000000..862a1e7
--- /dev/null
+++ b/docs/plans/2026-10-12-group-photos.md
@@ -0,0 +1,31 @@
+# Group photos
+
+2026-10-12. A group can carry one photo, meant as a family picture,
+shown at the top of the group's own page (`/groups/:id`) and nowhere
+else: not on the groups list, not on a member's tags, and never in a
+card a client downloads.
+
+## Why not a lent line
+
+Everything in `group_properties` is composed into every member's card
+(`2026-08-24-vcard-storage-and-groups.md`), so a `PHOTO` line there
+would replace each member's own picture on their devices. The photo
+lives in its own table, `group_photos`, keyed by group and cascading
+with it, which no composition reads.
+
+## What is stored
+
+The uploaded bytes, as they came: no resizing and no recoding, so no
+image library joins the Gemfile or the image. An upload is accepted
+when its magic bytes name a JPEG, PNG, or GIF (`Contact.photo_of`, the
+same sniff a card's `PHOTO` goes through) and it is at most 10 MB
+(`Web::GROUP_PHOTO_LIMIT`). The mime type is read off the bytes on
+every read rather than stored beside them.
+
+## Durability
+
+The photo cannot be rebuilt, so `rake db:dump` writes it as
+`groups/<id>.<jpeg|png|gif>` beside the group's YAML. It is not in the
+group change log: setting or removing one writes no entry, and a
+group's delete tombstone does not carry it. The dump's history is the
+only record of a photo that was replaced or removed.
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 5bdc672..51bf848 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -4,7 +4,7 @@ require "pro_tacts/admin/avatar"
require "pro_tacts/admin/card_form"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_dialog"
-require "pro_tacts/admin/group_label"
+require "pro_tacts/admin/group_tag"
require "pro_tacts/vcard"
module ProTacts
@@ -130,7 +130,7 @@ module ProTacts
dt(class: "type-label") { "notes" }
dd(class: "type-body-sm") do
div(style: "white-space: pre-wrap;") { text }
- group_tag(lent.group)
+ render GroupTag.new(group: lent.group)
end
end
end
@@ -151,17 +151,13 @@ module ProTacts
dd(class: "type-body-sm") do
if @groups.any?
div(class: "tag-set") do
- @groups.each { group_tag(it) }
+ @groups.each { render GroupTag.new(group: it) }
end
end
button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit groups" } if @dialog
end
end
- #: (Group group) -> void
- def group_tag(group)
- a(href: "/groups/#{group.id}", class: "tag") { render GroupLabel.new(group:) }
- end
# The type in one column and the value in the other, with the
# group that lends the row named under the value when one does.
@@ -178,7 +174,7 @@ module ProTacts
# The groups row's own tag, pinned to this row's first line
# at the right edge of the value column (admin.css) — under
# the value it could be read as marking the row below.
- group_tag(lent_by) if lent_by
+ render GroupTag.new(group: lent_by) if lent_by
end
end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index e937a37..c59e197 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -2,7 +2,7 @@ require "pro_tacts/admin/phlex"
require "pro_tacts/admin/avatar"
require "pro_tacts/admin/format"
-require "pro_tacts/admin/group_label"
+require "pro_tacts/admin/group_tag"
require "pro_tacts/admin/layout"
module ProTacts
@@ -75,7 +75,7 @@ module ProTacts
groups = @groups_of[contact.id]
if groups
div(class: "tag-set", style: "margin-top: var(--gl-space-3xs);") do
- groups.each { |group| a(href: "/groups/#{group.id}", class: "tag") { render GroupLabel.new(group:) } }
+ groups.each { |group| render GroupTag.new(group:) }
end
end
end
diff --git a/lib/pro_tacts/admin/group_tag.rb b/lib/pro_tacts/admin/group_tag.rb
new file mode 100644
index 0000000..876c07c
--- /dev/null
+++ b/lib/pro_tacts/admin/group_tag.rb
@@ -0,0 +1,27 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/group_label"
+require "pro_tacts/admin/icons"
+
+module ProTacts
+ module Admin
+ # A contact's group as a chip linking to the group, marked when the
+ # group has a photo so a reader knows its page holds one.
+ class GroupTag < Phlex::HTML
+ # @rbs @group: Group
+
+ #: (group: Group) -> void
+ def initialize(group:)
+ @group = group
+ end
+
+ def view_template
+ a(href: "/groups/#{@group.id}", class: "tag") do
+ render Icon.new(:image, size: 12) if @group.photo
+ render GroupLabel.new(group: @group)
+ span(class: "gl-visually-hidden") { "has a photo" } if @group.photo
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index c786354..af8b1d3 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -32,6 +32,9 @@ module ProTacts
FORM = "group-form" #: String
private_constant :FORM
+ PHOTO_FORM = "group-photo-form" #: String
+ private_constant :PHOTO_FORM
+
#: (group: Group, login: String, ?notice: String?) -> void
def initialize(group:, login:, notice: nil)
@group = group
@@ -86,11 +89,40 @@ module ProTacts
end
end
end
+ photo_section
end
end
private
+ # The group's photo, set or removed on its own rather than with
+ # the form above: it lends nothing, so it has no place in the
+ # version guard or the lines the save splices, and an upload
+ # needs a multipart form the editor's save does not.
+ #: () -> void
+ def photo_section
+ render RecordCard.new(
+ nav: -> {
+ span(class: "type-label") { "photo" }
+ if @group.photo
+ form(action: "/groups/#{@group.id}/photo", method: "post") do
+ button(type: "submit", name: "remove", value: "1", data_size: "sm") { "remove photo" }
+ end
+ end
+ },
+ submit: "Upload", form: PHOTO_FORM,
+ ) do
+ form(action: "/groups/#{@group.id}/photo", method: "post", enctype: "multipart/form-data",
+ class: "field-stack", id: PHOTO_FORM) do
+ img(class: "group-photo", src: "/groups/#{@group.id}/photo", alt: "") if @group.photo
+ label(class: "field") do
+ span { @group.photo ? "Replace" : "Photo" }
+ input(type: "file", name: "photo", accept: "image/jpeg,image/png,image/gif", required: true)
+ end
+ end
+ end
+ end
+
# ContactsEdit#address_row, over a group's line.
#: (Contact::Address address) -> void
def address_row(address)
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 15e3e4b..04fc736 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -48,6 +48,10 @@ module ProTacts
end
div(class: "card") do
div(class: "card-body") do
+ # The group's photo, this page's alone
+ # (docs/plans/2026-10-12-group-photos.md). Alt is empty:
+ # the name is the heading right under it.
+ img(class: "group-photo", src: "/groups/#{@group.id}/photo", alt: "") if @group.photo
div(class: "detail-header") do
h1(class: "type-h2") { render GroupLabel.new(group: @group) }
end
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index 7359302..e7257c7 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -22,8 +22,9 @@ module ProTacts
# picker; the import row's verdict, a circle either way and
# a check in it when the row is in the book;
# chevron-down, the account menu's opening direction;
- # search, the glyph on the header's search button; and house,
- # the phone header's way home from the search page. A tag
+ # search, the glyph on the header's search button; house,
+ # the phone header's way home from the search page; and image,
+ # a group tag's mark that the group has a photo. A tag
# per shape rather than a bare list of path data, because
# calendar's frame is a rect and redrawing it as a path would
# be this file copying something other than what Lucide ships.
@@ -52,6 +53,11 @@ module ProTacts
[:path, {d: "m21 21-4.34-4.34"}],
[:circle, {cx: 11, cy: 11, r: 8}],
],
+ image: [
+ [:rect, {width: 18, height: 18, x: 3, y: 3, rx: 2, ry: 2}],
+ [:circle, {cx: 9, cy: 9, r: 2}],
+ [:path, {d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}],
+ ],
house: [
[:path, {d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"}],
[:path, {d: "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"}],
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 0ea0cbe..b83ac09 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -265,16 +265,23 @@ module ProTacts
return
end
- signature = PHOTO_SIGNATURES.find { bytes.start_with?(it.first) }
- if signature.nil?
+ photo = Contact.photo_of(bytes)
+ if photo.nil?
Sentry.capture_message(
"a card's PHOTO decoded to no image format this server recognizes",
level: :warning,
)
- return
end
+ photo
+ end
- Photo.new(mime_type: signature.fetch(1), bytes:)
+ # 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.
+ #: (String bytes) -> Photo?
+ def self.photo_of(bytes)
+ signature = PHOTO_SIGNATURES.find { bytes.start_with?(it.first) }
+ Photo.new(mime_type: signature.fetch(1), bytes:) if signature
end
# The substrate the typed accessors sit on, and the read for what
diff --git a/lib/pro_tacts/group.rb b/lib/pro_tacts/group.rb
index ca8e6c1..e73b72e 100644
--- a/lib/pro_tacts/group.rb
+++ b/lib/pro_tacts/group.rb
@@ -6,18 +6,26 @@ require "pro_tacts/vcard"
module ProTacts
# A group as the admin screens read one: its own row, the lines it
- # lends in the order it lends them, and its members' card ids. The
+ # lends in the order it lends them, its members' card ids, and
+ # whether it has a photo (docs/plans/2026-10-12-group-photos.md). The
# label is the SQL one Store#group_label computes, so a tag and a
# heading can never disagree about what to call a nameless group.
#
# The signature lives in sig/pro_tacts/group.rbs, this being a Data
# class.
# @rbs skip
- Group = Data.define(:id, :name, :label, :lines, :members)
+ Group = Data.define(:id, :name, :label, :lines, :members, :photo)
# Reopened rather than defined in the block above, for the reason
# CardDiff is.
class Group
+ # A photo is the exception, not the rule, so a group built without
+ # saying is one without.
+ #: (?photo: bool, **untyped) -> void
+ def initialize(photo: false, **)
+ super
+ end
+
# The group names that choose what a client syncs: `sync:*` for
# everyone, `sync:<login>` for one user
# (docs/plans/2026-09-12-per-user-books.md). Unique like every
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index f55814d..e43ab44 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -19,16 +19,17 @@ module ProTacts
# The transactional store: one SQLite database holding each contact's
# card exactly as it was submitted.
#
- # Six kinds of state live here and they are not equally precious.
+ # Seven kinds of state live here and they are not equally precious.
# Only the cards, the change log, the birthdays, the groups, the
- # books, and the group change log cannot be rebuilt, and each is
- # argued where it was decided: the first two in
+ # books, the group change log, and the group photos cannot be
+ # rebuilt, and each is argued where it was decided: the first two in
# docs/plans/2026-08-25-sqlite-schema.md,
# "What the tables are for", the birthdays in
# docs/plans/2026-08-31-partial-birthdays.md, the groups in
# docs/plans/2026-08-24-vcard-storage-and-groups.md, the books in
# docs/plans/2026-09-19-books-in-the-dump.md, the group change log
- # in docs/plans/2026-09-23-group-change-log.md. Everything else
+ # in docs/plans/2026-09-23-group-change-log.md, the group photos in
+ # docs/plans/2026-10-12-group-photos.md. Everything else
# is an index derived from the cards, and #rebuild_index will make
# it again from nothing.
#
@@ -96,11 +97,12 @@ module ProTacts
# @rbs skip
UpcomingBirthday = Data.define(:contact, :occurs_on)
- # stored cards by id, the birthdays by card id, the groups, and the
- # books — the name each named one goes by, keyed by login. What
+ # stored cards by id, the birthdays by card id, the groups, their
+ # photos by group id, and the books — the name each named one goes
+ # by, keyed by login. What
# `rake db:dump` writes (tasks/db.rake).
# @rbs skip
- Snapshot = Data.define(:cards, :birthdays, :groups, :books)
+ Snapshot = Data.define(:cards, :birthdays, :groups, :group_photos, :books)
# A group as a picker names it: enough to show it and to submit it,
# and no more. A Group carries the lines it lends and the ids of
@@ -225,6 +227,10 @@ module ProTacts
},
birthdays: birthdays_by_id,
groups: all_groups,
+ group_photos: group_photos.order(:group_id).select_map(:group_id).to_h { |id|
+ photo = group_photo(id.to_s) || raise("group #{id}'s photo went missing mid-snapshot")
+ [id.to_s, photo] #: [String, Contact::Photo]
+ },
books: all_books,
)
end
@@ -747,6 +753,32 @@ module ProTacts
end
end
+ # A group's photo, or nil for a group without one. The bytes were
+ # judged on the way in (#set_group_photo), so a row that no longer
+ # sniffs to an image is corruption to raise on, the birthdays' rule.
+ #: (String id) -> Contact::Photo?
+ def group_photo(id)
+ bytes = group_photos.where(group_id: id).get(:bytes)
+ return if bytes.nil?
+
+ Contact.photo_of(bytes.b) or raise "group #{id}'s stored photo sniffs to no image format"
+ end
+
+ # Sets a group's photo, replacing any it had. Logged nowhere: the
+ # picture is the admin's alone, and the dump is its backup
+ # (docs/plans/2026-10-12-group-photos.md).
+ #: (String id, Contact::Photo photo) -> void
+ def set_group_photo(id, photo)
+ group_photos
+ .insert_conflict(target: :group_id, update: {bytes: Sequel[:excluded][:bytes]})
+ .insert(group_id: id, bytes: Sequel.blob(photo.bytes))
+ end
+
+ #: (String id) -> void
+ def remove_group_photo(id)
+ group_photos.where(group_id: id).delete
+ end
+
# A card joins a group, and starts serving what the group lends.
# Joining twice is joining once, and only the join that moved
# membership is logged in the group's own log. Joining a `sync:`
@@ -932,6 +964,11 @@ module ProTacts
@database[:group_properties]
end
+ #: () -> Sequel::Dataset
+ def group_photos
+ @database[:group_photos]
+ end
+
# The one-note invariant's raise: a card this server built with
# more than one NOTE is a bug, and a raise reaches Sentry with the
# request (docs/plans/2026-09-25-one-note-per-contact.md, "The
@@ -1062,7 +1099,8 @@ module ProTacts
end
# Groups rows read whole: the lines and members of every group in
- # `rows`, two reads however many groups there are.
+ # `rows`, and which have a photo, three reads however many groups
+ # there are. The photo read takes ids, never bytes.
#: (Array[Hash[Symbol, untyped]] rows) -> Array[Group]
def load_groups(rows)
ids = rows.map { it.fetch(:id).to_s }
@@ -1070,6 +1108,7 @@ module ProTacts
.group_by { it.fetch(:group_id).to_s }
members = group_members.where(group_id: ids).order(:card_id).all
.group_by { it.fetch(:group_id).to_s }
+ photos = group_photos.where(group_id: ids).select_map(:group_id).map(&:to_s)
rows.map { |row|
id = row.fetch(:id).to_s
Group.new(
@@ -1078,6 +1117,7 @@ module ProTacts
label: row.fetch(:label).to_s,
lines: lines.fetch(id, []).map { it.fetch(:line).to_s },
members: members.fetch(id, []).map { it.fetch(:card_id).to_s },
+ photo: photos.include?(id),
)
}
end
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 543d6c0..c1619cc 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -1,3 +1,5 @@
+require "digest"
+
require "pro_tacts/admin/card_form"
require "pro_tacts/admin/groups_edit"
require "pro_tacts/admin/groups_index"
@@ -6,6 +8,10 @@ require "pro_tacts/admin/groups_show"
module ProTacts
class Web < Roda
+ # The largest group photo an upload may carry: a phone's
+ # full-size JPEG fits, and the page shows the bytes as they came.
+ GROUP_PHOTO_LIMIT = 10_000_000 #: Integer
+
# The group screens, the contacts' own shape: the collection's
# GET and create, then a record's GET, its editor, the POST that
# applies it, and the members screen beside both — membership is
@@ -45,6 +51,27 @@ module ProTacts
members_screen(group) if group
end
+ # The group's photo (docs/plans/2026-10-12-group-photos.md):
+ # served for its page's <img>, and set or removed from its
+ # editor. Above the editor's POST, the members route's reason.
+ r.is "photo" do
+ r.get do
+ photo = store.group_photo(id)
+
+ # The page never points at a photo the group lacks, so one
+ # with none is the 404 case.
+ if photo
+ response["Content-Type"] = photo.mime_type
+ response["ETag"] = %("#{Digest::SHA256.hexdigest(photo.bytes)}")
+ photo.bytes
+ end
+ end
+
+ r.post do
+ apply_photo(r, id)
+ end
+ end
+
# Membership from the group's side, the members screen's save
# (Admin::GroupsMembers). Above the editor's POST, whose bare
# verb block would match this path too.
@@ -135,6 +162,33 @@ module ProTacts
r.redirect "/groups/#{id}", 303
end
+ # The editor's photo form: a removal, or an upload judged by its
+ # magic bytes and its size before anything is written.
+ #: (untyped r, String id) -> String?
+ def apply_photo(r, id)
+ group = store.group(id)
+ return if group.nil?
+
+ if r.params.key?("remove")
+ store.remove_group_photo(id)
+ return r.redirect("/groups/#{id}", 303)
+ end
+
+ upload = file_in(r.params["photo"])
+ return group_edit_screen(group, notice: "Choose a photo to upload.") if upload.nil?
+
+ tempfile = upload.fetch(:tempfile)
+ if tempfile.size > GROUP_PHOTO_LIMIT
+ return group_edit_screen(group, notice: "That photo is over #{GROUP_PHOTO_LIMIT / 1_000_000} MB; nothing was saved.")
+ end
+
+ photo = Contact.photo_of(tempfile.read.b)
+ return group_edit_screen(group, notice: "That file is not a JPEG, PNG, or GIF; nothing was saved.") if photo.nil?
+
+ store.set_group_photo(id, photo)
+ r.redirect "/groups/#{id}", 303
+ end
+
#: (Group group, ?notice: String) -> String
def group_edit_screen(group, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
diff --git a/public/admin.css b/public/admin.css
index f8edb76..e80ab55 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -1066,6 +1066,18 @@ span.avatar[data-size="lg"] {
font-size: var(--gl-step-3);
}
+/* A group's photo, at the top of its card: a family picture, shown
+ whole rather than cropped to a box, and never stretched past its
+ own size. The height cap keeps a portrait from pushing the name off
+ the first screen. */
+.group-photo {
+ display: block;
+ max-width: 100%;
+ max-height: 28rem;
+ margin-inline: auto;
+ border-radius: var(--gl-radius-md);
+}
+
/* The detail card's masthead: the name in its own column at the left,
the avatar anchored top-right (grid rather than float because
.card-body is a flex column — floats on its children are ignored),
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index bfd78a5..0021220 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -36,6 +36,9 @@ module Sequel
# the read that lends an unnamed group's lines under something.
def self.function: (Symbol name, *untyped arguments) -> untyped
+ # A string bound as a BLOB rather than TEXT: a group photo's bytes.
+ def self.blob: (String bytes) -> untyped
+
class Database
def []: (Symbol table) -> Dataset
@@ -117,6 +120,8 @@ module Sequel
def empty?: () -> bool
def columns: () -> Array[Symbol]
def single_value: () -> untyped
+ # One column of the first row, or nil for no row.
+ def get: (Symbol column) -> untyped
def select_map: (untyped columns) -> Array[untyped]
def insert: (Sequel::row values) -> Integer
diff --git a/sig/pro_tacts/group.rbs b/sig/pro_tacts/group.rbs
index 09286f5..7c5ebc1 100644
--- a/sig/pro_tacts/group.rbs
+++ b/sig/pro_tacts/group.rbs
@@ -9,7 +9,8 @@ module ProTacts
attr_reader label: String
attr_reader lines: Array[String]
attr_reader members: Array[String]
+ attr_reader photo: bool
- def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String]) -> instance
+ def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool) -> instance
end
end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 7af5774..e7eee48 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -51,9 +51,10 @@ module ProTacts
attr_reader cards: Hash[String, String]
attr_reader birthdays: Hash[String, Birthday]
attr_reader groups: Array[Group]
+ attr_reader group_photos: Hash[String, Contact::Photo]
attr_reader books: Hash[String, String]
- def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group], books: Hash[String, String]) -> instance
+ def self.new: (cards: Hash[String, String], birthdays: Hash[String, Birthday], groups: Array[Group], group_photos: Hash[String, Contact::Photo], books: Hash[String, String]) -> instance
end
end
end
diff --git a/tasks/db.rake b/tasks/db.rake
index 799ef0d..48e8bd9 100644
--- a/tasks/db.rake
+++ b/tasks/db.rake
@@ -1,7 +1,9 @@
# A readable copy of what the store cannot rebuild: each stored card as
-# its bytes, the birthdays, the groups, and the books, split the way
-# the store splits them (docs/plans/2026-09-12-database-dump.md,
-# docs/plans/2026-09-19-books-in-the-dump.md). cards/ and
+# its bytes, the birthdays, the groups and their photos, and the books,
+# split the way the store splits them
+# (docs/plans/2026-09-12-database-dump.md,
+# docs/plans/2026-09-19-books-in-the-dump.md,
+# docs/plans/2026-10-12-group-photos.md). cards/ and
# groups/ are made to hold exactly what the store holds, so a dump kept
# in version control shows deletions too; anything else in the
# directory is left alone.
@@ -40,9 +42,12 @@ namespace :db do
}
dump_file(dump / "birthdays.yml", YAML.dump(birthdays))
+ # A photo beside its group's YAML, named by the type its bytes
+ # sniffed to, so the dump opens as the picture it is.
+ photos = snapshot.group_photos.to_h { |id, photo| ["#{id}.#{photo.mime_type.delete_prefix("image/")}", photo.bytes] }
dump_directory(groups, snapshot.groups.to_h { |group|
["#{group.id}.yml", YAML.dump({"name" => group.name, "lines" => group.lines, "members" => group.members})]
- })
+ }.merge(photos))
dump_file(dump / "books.yml", YAML.dump(snapshot.books))
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index d36bd7a..bc847fe 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -361,6 +361,118 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
+ ## The photo
+
+ JPEG = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\xFF".b #: String
+
+ def upload(bytes, type = "image/jpeg")
+ Rack::Test::UploadedFile.new(StringIO.new(bytes), type, original_filename: "family.jpg")
+ end
+
+ # An upload lands on the group's page, which shows it from the photo
+ # route; no member's card carries it.
+ def test_an_uploaded_photo_shows_on_the_groups_page_alone
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+ george = store.contact("george").vcard.to_s
+
+ post "/groups/#{id}/photo", photo: upload(JPEG)
+
+ assert_equal 303, last_response.status
+ assert_equal "/groups/#{id}", URI(last_response["Location"]).path
+ get "/groups/#{id}"
+ assert_includes last_response.body, %(<img class="group-photo" src="/groups/#{id}/photo" alt="">)
+ get "/groups"
+ refute_includes last_response.body, "/photo"
+ assert_equal george, store.contact("george").vcard.to_s
+ end
+ end
+
+ def test_the_photo_route_serves_the_bytes
+ with_contacts({}) do |store|
+ id = household(store, members: [])
+ post "/groups/#{id}/photo", photo: upload(JPEG)
+
+ get "/groups/#{id}/photo"
+
+ assert_equal 200, last_response.status
+ assert_equal "image/jpeg", last_response["Content-Type"]
+ assert_equal JPEG, last_response.body.b
+ end
+ end
+
+ def test_a_group_without_a_photo_renders_none_and_routes_to_404
+ with_contacts({}) do |store|
+ id = household(store, members: [])
+
+ get "/groups/#{id}"
+ refute_includes last_response.body, "group-photo"
+
+ get "/groups/#{id}/photo"
+ assert_equal 404, last_response.status
+ end
+ end
+
+ # The bytes decide what a file is, not its name or its claimed type.
+ def test_an_upload_that_is_no_image_is_refused
+ with_contacts({}) do |store|
+ id = household(store, members: [])
+
+ post "/groups/#{id}/photo", photo: upload("not a picture", "image/jpeg")
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "That file is not a JPEG, PNG, or GIF; nothing was saved."
+ refute store.group(id).photo
+ end
+ end
+
+ def test_an_upload_over_the_limit_is_refused
+ with_contacts({}) do |store|
+ id = household(store, members: [])
+
+ post "/groups/#{id}/photo", photo: upload(JPEG + ("\x00" * ProTacts::Web::GROUP_PHOTO_LIMIT).b)
+
+ assert_includes last_response.body, "That photo is over 10 MB; nothing was saved."
+ refute store.group(id).photo
+ end
+ end
+
+ # A member's chips mark the group with a photo, on the contact's
+ # page and in the contacts list alike; a group without one is bare.
+ def test_a_groups_chip_marks_its_photo
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+ plain = store.create_group(name: "Logicians")
+ store.add_member(plain, "george")
+ post "/groups/#{id}/photo", photo: upload(JPEG)
+
+ ["/contacts/george", "/contacts"].each do |path|
+ get path
+ assert_match %r{<a href="/groups/#{id}" class="tag"><svg[^>]*>.*?</svg>Booles<span class="gl-visually-hidden">has a photo</span></a>},
+ last_response.body
+ assert_includes last_response.body, %(<a href="/groups/#{plain}" class="tag">Logicians</a>)
+ end
+ end
+ end
+
+ def test_the_editor_offers_an_upload_and_a_removal_once_there_is_one
+ with_contacts({}) do |store|
+ id = household(store, members: [])
+
+ get "/groups/#{id}/edit"
+ assert_includes last_response.body, %(enctype="multipart/form-data")
+ refute_includes last_response.body, "remove photo"
+
+ post "/groups/#{id}/photo", photo: upload(JPEG)
+ get "/groups/#{id}/edit"
+ assert_includes last_response.body, "remove photo"
+
+ post "/groups/#{id}/photo", remove: "1"
+ assert_equal 303, last_response.status
+ refute store.group(id).photo
+ end
+ end
+
## The members screen
def test_the_card_links_to_the_members_screen
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index cbfccfb..49d99d6 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1553,6 +1553,49 @@ class StoreTest < Minitest::Test
end
end
+ JPEG = ProTacts::Contact.photo_of("\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\xFF".b) #: ProTacts::Contact::Photo
+ PNG = ProTacts::Contact.photo_of("\x89PNG\r\n\x1A\n\x00\x00\xFF".b) #: ProTacts::Contact::Photo
+
+ # A group's photo is the page's alone: it reads back byte for byte
+ # under its sniffed type, and no member's card moves for it.
+ def test_a_group_photo_reads_back_and_reaches_no_card
+ with_store({"aiden" => AIDEN}) do |store|
+ id = FixtureData.seed_group(store, members: ["aiden"])
+
+ store.set_group_photo(id, JPEG)
+
+ assert store.group(id).photo
+ assert_equal JPEG, store.group_photo(id)
+ assert_equal AIDEN, store.contact("aiden").vcard.to_s
+ assert_equal %w[put], store.changes_of("aiden").map(&:action)
+ end
+ end
+
+ def test_a_group_photo_is_replaced_and_removed
+ with_store do |store|
+ id = FixtureData.seed_group(store, members: [])
+ store.set_group_photo(id, JPEG)
+
+ store.set_group_photo(id, PNG)
+ assert_equal PNG, store.group_photo(id)
+
+ store.remove_group_photo(id)
+ refute store.group(id).photo
+ assert_nil store.group_photo(id)
+ end
+ end
+
+ def test_a_deleted_group_takes_its_photo_away
+ with_store do |store|
+ id = FixtureData.seed_group(store, members: [])
+ store.set_group_photo(id, JPEG)
+
+ store.delete_group(id)
+
+ assert_nil store.group_photo(id)
+ end
+ end
+
def test_a_regroup_leaves_and_joins
with_store({"aiden" => AIDEN}) do |store|
left = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
diff --git a/test/tasks/test_db.rb b/test/tasks/test_db.rb
index 49ebc81..7a996df 100644
--- a/test/tasks/test_db.rb
+++ b/test/tasks/test_db.rb
@@ -34,6 +34,23 @@ class DbTasksTest < Minitest::Test
end
end
+ # A group's photo is beside its YAML, named by the type it sniffed
+ # to, and leaves the dump when it leaves the store.
+ def test_the_dump_writes_a_groups_photo_beside_it
+ with_store do |store, root|
+ group = store.create_group(name: "Booles")
+ bytes = "\x89PNG\r\n\x1A\n\x00\xFF".b
+ store.set_group_photo(group, ProTacts::Contact.photo_of(bytes))
+
+ dump(root)
+ assert_equal bytes, (root / "dump" / "groups" / "#{group}.png").binread
+
+ store.remove_group_photo(group)
+ dump(root)
+ assert_equal ["#{group}.yml"], (root / "dump" / "groups").children.map { it.basename.to_s }
+ end
+ end
+
# A deployment that has named no book still gets the file, so the
# dump says there are none rather than leaving it to a missing file.
def test_a_store_that_has_named_no_book_dumps_the_file_anyway