eleven / messenger Public

server: add a per-instance server avatar (onboarding, home, native) (#154)

An admin can set a branding photo for the instance from the admin console. Like
the group name it's public plaintext branding — it must render before any key
exists (the join/onboarding cards and the home Save-passkey card), so it can't
be E2E-encrypted. It's admin-set, never user content; message/profile/group
photo E2E is untouched.

- server: instance_avatar table + GET /api/server-avatar (public), POST/DELETE
  /api/admin/server-avatar (admin, type sniffed, 1MB cap); /api/group reports an
  `avatar` flag so clients know whether to fetch it.
- admin: a "Server photo" section — in-browser downscale, upload/remove, inline
  status.
- web onboarding: the photo replaces the emoji on the join welcome + screen-name
  cards.
- home: the instance passes a small data: icon in the enrol fragment; the
  Save-passkey card shows it (untrusted -> validated, .src only, and stripped
  before the size-capped backup blob is persisted).
- native iOS + Mac: a branded "You're joining <Name>" confirmation showing the
  server photo when adding a chat.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 2bcfc2900ea7c2d68320c63f8851d440e20eae71 parent d05042d
16 files changed, +661 −15
  • CLAUDE.md +10 −1
  • changelog/2026-07-04-server-avatar.md +5 −0
  • db.go +42 −0
  • handlers_test.go +112 −0
  • home/web/pages/enroll.html +7 −2
  • home/web/static/enroll.js +19 −0
  • ios/LChat/LChatAPI.swift +19 −0
  • ios/LChat/Views/JoinView.swift +82 −2
  • ios/LChatMac/AddChatView.swift +97 −8
  • main.go +88 −1
  • web/pages/admin.html +17 −0
  • web/pages/join.html +3 −1
  • web/static/admin.js +89 −0
  • web/static/backup.js +40 −0
  • web/static/join.js +26 −0
  • web/static/style.css +5 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 858782f..212e9bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,6 +34,15 @@ fetches a posted URL to build an OpenGraph card, so it briefly sees URLs (not
message text). This was an explicit, accepted trade-off and is easy to gate
behind a setting later.
+Instance **branding** is plaintext by design and doesn't count against the rule —
+it's admin-set, never user content. Two things: the **group name** (`config`
+`group_name`), and the optional **server avatar** (`instance_avatar` table, served
+in the clear at `GET /api/server-avatar`). Both must render where no key exists
+yet — the join/onboarding cards, the native add-chat screens, and the home
+Save-passkey card — so they can't be E2E-encrypted like profile/group photos.
+Keep branding to this narrow set; anything that is *user* content still gets
+encrypted client-side.
+
## Architecture
Single static Go binary; the frontend and a pure-Go SQLite engine are compiled
@@ -61,7 +70,7 @@ Frontend module layout:
- `format.js`, `ui.js` — **pure, framework-free helpers kept separate so they're
unit-testable in Node** (`tokenizeText`, `formatBytes`, `shouldSend`).
- `push.js` / `sw.js` — push subscription + service worker.
-- `admin.js` — admin console (invites, group name, encryption key).
+- `admin.js` — admin console (invites, group name, server photo, encryption key).
## Conventions
diff --git a/changelog/2026-07-04-server-avatar.md b/changelog/2026-07-04-server-avatar.md
new file mode 100644
index 0000000..9e2095c
--- /dev/null
+++ b/changelog/2026-07-04-server-avatar.md
@@ -0,0 +1,5 @@
+Your chat can now have its own photo. An admin can set one from the admin console
+(under "Server photo"), and it shows up when someone's invited and picking their
+screen name, and on the "Save securely" passkey-backup screen — a warmer, more
+recognisable welcome than a generic icon. Like the chat's name, the photo is
+public branding, so it's the one thing here that isn't encrypted.
diff --git a/db.go b/db.go
index 2bcd061..dc6891a 100644
--- a/db.go
+++ b/db.go
@@ -30,6 +30,37 @@ func (db *DB) setGroupName(name string) error {
return db.setConfig("group_name", name)
}
+// getInstanceAvatar returns the admin-set instance branding photo (bytes + MIME +
+// an updated-at stamp for cache validation), or ok=false when none is set. Like the
+// group name, this is plaintext branding the server serves in the clear.
+func (db *DB) getInstanceAvatar() (data []byte, mime string, updatedAt int64, ok bool) {
+ err := db.sql.QueryRow("SELECT data, mime, updated_at FROM instance_avatar WHERE id = 1").
+ Scan(&data, &mime, &updatedAt)
+ if err == sql.ErrNoRows {
+ return nil, "", 0, false
+ }
+ if err != nil {
+ return nil, "", 0, false
+ }
+ return data, mime, updatedAt, true
+}
+
+// setInstanceAvatar stores (or replaces) the instance branding photo.
+func (db *DB) setInstanceAvatar(data []byte, mime string, updatedAt int64) error {
+ _, err := db.sql.Exec(
+ `INSERT INTO instance_avatar(id, data, mime, updated_at) VALUES(1, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, mime = excluded.mime, updated_at = excluded.updated_at`,
+ data, mime, updatedAt,
+ )
+ return err
+}
+
+// clearInstanceAvatar removes the branding photo (reverting to the default icon).
+func (db *DB) clearInstanceAvatar() error {
+ _, err := db.sql.Exec("DELETE FROM instance_avatar WHERE id = 1")
+ return err
+}
+
// usersCanInvite reports whether ordinary members (not just the admin) may invite
// new people. Off by default — the safest posture — and loosened only as an
// explicit admin opt-in.
@@ -193,6 +224,17 @@ CREATE TABLE IF NOT EXISTS bot_configs (
system_prompt TEXT NOT NULL DEFAULT '',
docs TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
+);
+-- One optional per-instance avatar: an admin-set branding photo shown where a
+-- person first meets the instance (onboarding, native add-chat, the home
+-- Save-passkey card). Like the group name, this is plaintext instance branding
+-- the server holds and serves in the clear — never user content, so it doesn't
+-- weaken E2E. Single row (id = 1); absent until an admin uploads one.
+CREATE TABLE IF NOT EXISTS instance_avatar (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ data BLOB NOT NULL,
+ mime TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
);`)
if err != nil {
return err
diff --git a/handlers_test.go b/handlers_test.go
index 298c40d..a73227c 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -1,6 +1,7 @@
package main
import (
+ "bytes"
"encoding/json"
"io"
"net/http"
@@ -487,6 +488,117 @@ func TestGroupNameEscapedInHTML(t *testing.T) {
}
}
+// adminReq issues a request with a raw body and the admin bearer token.
+func adminReq(t *testing.T, method, url, contentType string, body []byte) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest(method, url, bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer admintok")
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ return resp
+}
+
+// The server avatar is admin-set, public plaintext branding: set/clear are admin
+// only; read is unauthenticated and reports the stored bytes + type, and the
+// group endpoint flags its presence so clients know whether to fetch it.
+func TestServerAvatar(t *testing.T) {
+ ts, _ := newTestServer(t)
+
+ // A minimal payload that http.DetectContentType recognises as a PNG.
+ png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
+
+ groupAvatarFlag := func() bool {
+ resp, _ := http.Get(ts.URL + "/api/group")
+ var g struct{ Avatar bool }
+ json.NewDecoder(resp.Body).Decode(&g)
+ return g.Avatar
+ }
+
+ // Unset: 404 on read, and the group endpoint reports no avatar.
+ if resp, _ := http.Get(ts.URL + "/api/server-avatar"); resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("unset read: want 404, got %d", resp.StatusCode)
+ }
+ if groupAvatarFlag() {
+ t.Fatal("group avatar flag true before any upload")
+ }
+
+ // Set requires admin.
+ if resp := adminReqNoAuth(t, "POST", ts.URL+"/api/admin/server-avatar", png); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("set without auth: want 401, got %d", resp.StatusCode)
+ }
+ // Non-image bytes are rejected (type sniffed, not trusted).
+ if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", []byte("just text, not an image")); resp.StatusCode != http.StatusUnsupportedMediaType {
+ t.Fatalf("non-image: want 415, got %d", resp.StatusCode)
+ }
+ // Oversize is rejected before storage.
+ if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", make([]byte, maxServerAvatarBytes+16)); resp.StatusCode != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversize: want 413, got %d", resp.StatusCode)
+ }
+
+ // A valid PNG stores.
+ if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", png); resp.StatusCode != http.StatusOK {
+ t.Fatalf("set avatar: %d", resp.StatusCode)
+ }
+
+ // Read serves the exact bytes, the sniffed type, and an ETag; group flags it.
+ resp, _ := http.Get(ts.URL + "/api/server-avatar")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("read: %d", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
+ t.Fatalf("content-type = %q", ct)
+ }
+ got, _ := io.ReadAll(resp.Body)
+ if !bytes.Equal(got, png) {
+ t.Fatal("served bytes differ from stored")
+ }
+ etag := resp.Header.Get("ETag")
+ if etag == "" {
+ t.Fatal("missing ETag")
+ }
+ if !groupAvatarFlag() {
+ t.Fatal("group avatar flag false after upload")
+ }
+
+ // A matching If-None-Match revalidates to 304.
+ req, _ := http.NewRequest("GET", ts.URL+"/api/server-avatar", nil)
+ req.Header.Set("If-None-Match", etag)
+ resp, _ = http.DefaultClient.Do(req)
+ if resp.StatusCode != http.StatusNotModified {
+ t.Fatalf("If-None-Match: want 304, got %d", resp.StatusCode)
+ }
+
+ // Clear requires admin, then read 404s again and the flag clears.
+ if resp := adminReqNoAuth(t, "DELETE", ts.URL+"/api/admin/server-avatar", nil); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("clear without auth: want 401, got %d", resp.StatusCode)
+ }
+ if resp := adminReq(t, "DELETE", ts.URL+"/api/admin/server-avatar", "", nil); resp.StatusCode != http.StatusOK {
+ t.Fatalf("clear: %d", resp.StatusCode)
+ }
+ if resp, _ := http.Get(ts.URL + "/api/server-avatar"); resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("read after clear: want 404, got %d", resp.StatusCode)
+ }
+ if groupAvatarFlag() {
+ t.Fatal("group avatar flag true after clear")
+ }
+}
+
+// adminReqNoAuth issues a raw-body request WITHOUT the admin token (to prove the gate).
+func adminReqNoAuth(t *testing.T, method, url string, body []byte) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest(method, url, bytes.NewReader(body))
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ return resp
+}
+
// Starting a DM you already have must reopen the same thread, not spawn a
// second. The client relies on the server returning the existing thread with
// existing:true on a repeat POST (see createThread / existingDM in chat.js).
diff --git a/home/web/pages/enroll.html b/home/web/pages/enroll.html
index fbc122f..8558cf7 100644
--- a/home/web/pages/enroll.html
+++ b/home/web/pages/enroll.html
@@ -19,9 +19,11 @@
/* The card names exactly what's about to be added, so an injected/spoofed
entry is visible before anything is written (the confirmation gate). */
.entry {
+ display: flex; align-items: center; gap: 12px;
border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
}
+ .entry .icon { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; flex: none; }
.entry .name { font-weight: 600; font-size: 1.05rem; }
.entry .host { opacity: .7; font-size: .9rem; overflow-wrap: anywhere; }
.actions { display: flex; flex-direction: column; gap: .6rem; }
@@ -48,8 +50,11 @@
read your backup.</p>
<div class="entry" id="entry">
- <div class="name" id="entry-name">This chat</div>
- <div class="host" id="entry-host"></div>
+ <img class="icon hidden" id="entry-icon" alt="" />
+ <div>
+ <div class="name" id="entry-name">This chat</div>
+ <div class="host" id="entry-host"></div>
+ </div>
</div>
<div class="actions" id="actions">
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index dca2455..1479ac6 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -47,6 +47,20 @@ const payload = readPayload();
// Drop the secret-bearing fragment from the address bar as soon as we've read it.
try { history.replaceState(null, "", location.pathname); } catch {}
+// The instance may pass a small branding photo for the confirmation card. It's
+// display-only chrome and untrusted (anyone can craft a fragment), so we accept
+// it only if it's an inline image data: URI, render it via .src (never innerHTML),
+// and keep it OUT of the persisted entry — the backup blob is size-capped, and the
+// photo is public branding, not part of the credentials.
+let entryIcon = null;
+if (payload && payload.entry) {
+ if (typeof payload.entry.icon === "string" &&
+ /^data:image\/(png|jpeg|webp);base64,/.test(payload.entry.icon)) {
+ entryIcon = payload.entry.icon;
+ }
+ delete payload.entry.icon;
+}
+
// Where to send the user back to. The instance passes its origin as `ret`; fall
// back to the entry URL's origin. Only ever a bare origin, and we append only a
// non-secret nonce marker — never blob contents, tokens, or keys leave here.
@@ -307,6 +321,11 @@ if (!payload) {
const host = new URL(payload.entry.url).host;
$("entry-name").textContent = payload.entry.name || host;
$("entry-host").textContent = host;
+ if (entryIcon) {
+ const img = $("entry-icon");
+ img.src = entryIcon; // validated data:image URI; set via .src, never innerHTML
+ img.classList.remove("hidden");
+ }
// Pre-fetch ceremony options now, while the user reads the card — so the click
// reaches the authenticator inside the gesture (Safari activation, see prefetch).
prefetch();
diff --git a/ios/LChat/LChatAPI.swift b/ios/LChat/LChatAPI.swift
index 8988e2c..02ef781 100644
--- a/ios/LChat/LChatAPI.swift
+++ b/ios/LChat/LChatAPI.swift
@@ -81,6 +81,25 @@ struct LChatAPI {
return (try? await getJSON("/api/group", Resp.self, authed: false))?.name ?? "lchat"
}
+ /// Public per-instance branding: the display name plus whether a server photo
+ /// is set. Both are public (shown during add-a-chat, before any key exists).
+ func serverInfo() async -> (name: String, hasAvatar: Bool) {
+ struct Resp: Codable { let name: String?; let avatar: Bool? }
+ let r = try? await getJSON("/api/group", Resp.self, authed: false)
+ return (r?.name ?? "lchat", r?.avatar ?? false)
+ }
+
+ /// The instance's public branding photo bytes, or nil if none is set. Served
+ /// in the clear (no key exists yet at add-a-chat time), so this needs no token.
+ func serverAvatar() async -> Data? {
+ guard let url = URL(string: "/api/server-avatar", relativeTo: baseURL),
+ let (data, resp) = try? await session.data(from: url),
+ let http = resp as? HTTPURLResponse, http.statusCode == 200,
+ !data.isEmpty
+ else { return nil }
+ return data
+ }
+
// MARK: Plumbing
private func getJSON<T: Decodable>(
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift
index 73b1c77..526f4a3 100644
--- a/ios/LChat/Views/JoinView.swift
+++ b/ios/LChat/Views/JoinView.swift
@@ -24,6 +24,12 @@ struct AddInstanceView: View {
@State private var origin = ""
@State private var error: String?
@State private var pending: Task<Void, Never>?
+ // A valid link resolves to a branded confirmation ("You're joining <Name>",
+ // with the server's photo when it has one) before the web join flow opens.
+ @State private var confirming = false
+ @State private var serverName = ""
+ @State private var serverPhoto: Data?
+ @State private var brandingTask: Task<Void, Never>?
#if !targetEnvironment(macCatalyst)
@State private var showScanner = false
#endif
@@ -45,6 +51,8 @@ struct AddInstanceView: View {
error = msg
self.joinURL = nil
})
+ } else if confirming {
+ confirmCard
} else {
pasteForm
}
@@ -83,7 +91,7 @@ struct AddInstanceView: View {
guard parseJoinLink(new) != nil else { return }
pending = Task {
try? await Task.sleep(for: .milliseconds(500))
- if !Task.isCancelled { start() }
+ if !Task.isCancelled { prepareConfirm() }
}
}
@@ -126,7 +134,7 @@ struct AddInstanceView: View {
error = "That QR code doesn't look like an invite link."
return
}
- start()
+ prepareConfirm()
}
#endif
@@ -142,6 +150,78 @@ struct AddInstanceView: View {
}
}
+ // A valid link → a branded confirmation card. The card is populated instantly
+ // (host as a placeholder name) and enriched with the real name + photo when the
+ // public branding fetch returns — a failed fetch just leaves the placeholder,
+ // so an unreachable/plain instance can still be joined.
+ private func prepareConfirm() {
+ error = nil
+ guard let parsed = parseJoinLink(link), let url = URL(string: parsed.origin) else { return }
+ origin = parsed.origin
+ serverName = url.host ?? parsed.origin
+ serverPhoto = nil
+ confirming = true
+ brandingTask?.cancel()
+ brandingTask = Task {
+ let api = LChatAPI(baseURL: url, token: "")
+ let info = await api.serverInfo()
+ if Task.isCancelled { return }
+ serverName = info.name
+ if info.hasAvatar {
+ let data = await api.serverAvatar()
+ if Task.isCancelled { return }
+ serverPhoto = data
+ }
+ }
+ }
+
+ private var confirmCard: some View {
+ VStack(spacing: 16) {
+ Spacer(minLength: 0)
+ serverBadge
+ Text("You're joining").font(.callout).foregroundStyle(.secondary)
+ Text(serverName).font(.title2.weight(.semibold)).multilineTextAlignment(.center)
+ Button {
+ start() // proceed to the web join flow (pick a screen name)
+ } label: {
+ Text("Join").frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ Button("Use a different link") {
+ brandingTask?.cancel()
+ confirming = false
+ serverPhoto = nil
+ link = ""
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 0)
+ }
+ .padding(24)
+ }
+
+ @ViewBuilder private var serverBadge: some View {
+ if let data = serverPhoto, let ui = UIImage(data: data) {
+ Image(uiImage: ui).resizable().scaledToFill()
+ .frame(width: 84, height: 84)
+ .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ } else {
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .fill(Color.accentColor.opacity(0.15))
+ .frame(width: 84, height: 84)
+ .overlay(
+ Text(firstLetter(serverName))
+ .font(.largeTitle.weight(.semibold))
+ .foregroundStyle(Color.accentColor)
+ )
+ }
+ }
+
+ private func firstLetter(_ s: String) -> String {
+ s.first.map { String($0).uppercased() } ?? "?"
+ }
+
private func start() {
error = nil
guard let parsed = parseJoinLink(link) else { return }
diff --git a/ios/LChatMac/AddChatView.swift b/ios/LChatMac/AddChatView.swift
index 13b4a49..4d18aeb 100644
--- a/ios/LChatMac/AddChatView.swift
+++ b/ios/LChatMac/AddChatView.swift
@@ -9,24 +9,36 @@ struct AddChatView: View {
@Environment(\.dismiss) private var dismiss
@State private var link = ""
@FocusState private var focused: Bool
+ // A valid link resolves to a branded confirmation ("You're joining <Name>",
+ // with the server's photo when it has one) before the chat is added.
+ @State private var confirming = false
+ @State private var serverName = ""
+ @State private var serverPhoto: Data?
+ @State private var brandingTask: Task<Void, Never>?
var body: some View {
+ Group {
+ if confirming { confirmCard } else { pasteForm }
+ }
+ .padding(20)
+ .frame(width: 460)
+ .onAppear { focused = true }
+ }
+
+ private var pasteForm: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Add a chat").font(.title2.weight(.semibold))
VStack(alignment: .leading, spacing: 10) {
step(1, "In an existing chat, open the **⋮** menu and choose **Add a device**.")
- step(2, "Paste the link here — it's added as soon as it's valid.")
+ step(2, "Paste the link here — you'll confirm the chat before joining.")
}
TextField("https://…/join/…", text: $link)
.textFieldStyle(.roundedBorder)
.focused($focused)
.onChange(of: link) { _, new in
- if isValidInvite(new) {
- app.add(url: new.trimmingCharacters(in: .whitespacesAndNewlines))
- dismiss()
- }
+ if !confirming, isValidInvite(new) { prepareConfirm(new) }
}
// Only nag once they've typed something that isn't (yet) a link.
@@ -42,9 +54,86 @@ struct AddChatView: View {
.keyboardShortcut(.cancelAction)
}
}
- .padding(20)
- .frame(width: 460)
- .onAppear { focused = true }
+ }
+
+ private var confirmCard: some View {
+ VStack(spacing: 16) {
+ serverBadge
+ Text("You're joining").font(.callout).foregroundStyle(.secondary)
+ Text(serverName).font(.title2.weight(.semibold)).multilineTextAlignment(.center)
+ HStack {
+ Button("Use a different link") {
+ brandingTask?.cancel()
+ confirming = false
+ serverPhoto = nil
+ link = ""
+ }
+ .keyboardShortcut(.cancelAction)
+ Spacer()
+ Button("Join") {
+ app.add(url: link.trimmingCharacters(in: .whitespacesAndNewlines))
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+
+ @ViewBuilder private var serverBadge: some View {
+ if let data = serverPhoto, let ns = NSImage(data: data) {
+ Image(nsImage: ns).resizable().scaledToFill()
+ .frame(width: 72, height: 72)
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ } else {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(Color.accentColor.opacity(0.15))
+ .frame(width: 72, height: 72)
+ .overlay(
+ Text(firstLetter(serverName))
+ .font(.largeTitle.weight(.semibold))
+ .foregroundStyle(Color.accentColor)
+ )
+ }
+ }
+
+ // A valid link → a branded confirmation. Populated instantly (host as a
+ // placeholder name) and enriched with the real name + photo when the public
+ // branding fetch returns; a failed fetch leaves the placeholder, so a plain or
+ // unreachable instance can still be joined.
+ private func prepareConfirm(_ raw: String) {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let originURL = originOf(trimmed) else { return }
+ confirming = true // set first so the link reassignment below can't recurse
+ link = trimmed
+ serverName = originURL.host ?? trimmed
+ serverPhoto = nil
+ brandingTask?.cancel()
+ brandingTask = Task {
+ let api = LChatAPI(baseURL: originURL, token: "")
+ let info = await api.serverInfo()
+ if Task.isCancelled { return }
+ serverName = info.name
+ if info.hasAvatar {
+ let data = await api.serverAvatar()
+ if Task.isCancelled { return }
+ serverPhoto = data
+ }
+ }
+ }
+
+ // The bare origin (scheme://host[:port]) of an invite link — where the public
+ // branding endpoints live.
+ private func originOf(_ raw: String) -> URL? {
+ guard let c = URLComponents(string: raw), let scheme = c.scheme, let host = c.host
+ else { return nil }
+ var out = "\(scheme)://\(host)"
+ if let port = c.port { out += ":\(port)" }
+ return URL(string: out)
+ }
+
+ private func firstLetter(_ s: String) -> String {
+ s.first.map { String($0).uppercased() } ?? "?"
}
private func step(_ n: Int, _ markdown: String) -> some View {
diff --git a/main.go b/main.go
index 50e8d76..ca9cc1a 100644
--- a/main.go
+++ b/main.go
@@ -27,6 +27,11 @@ import (
// ciphertext — the server can't tell an image from a document.
const maxUploadBytes = 26 << 20
+// maxServerAvatarBytes caps the admin-set instance branding photo. It's tiny by
+// design (the admin console downscales it to ~400px first), so a small cap is
+// plenty and keeps this plaintext asset from being abused as bulk storage.
+const maxServerAvatarBytes = 1 << 20
+
//go:embed web/pages/*.html web/static/*
var webFS embed.FS
@@ -202,6 +207,12 @@ func (s *Server) handler() http.Handler {
// Group name (public read so the page/manifest can show it; admin write)
mux.HandleFunc("GET /api/group", s.getGroup)
+ // Server avatar: per-instance branding photo. Public read (shown in onboarding,
+ // the home Save-passkey card, and native add-chat — all before any key exists);
+ // admin write/clear. Served in the clear like the group name (see getServerAvatar).
+ mux.HandleFunc("GET /api/server-avatar", s.getServerAvatar)
+ mux.HandleFunc("POST /api/admin/server-avatar", s.setServerAvatar)
+ mux.HandleFunc("DELETE /api/admin/server-avatar", s.deleteServerAvatar)
// Frontend build version — clients poll this on returning to the app and reload
// if it changed (auto-update). No auth: it's just a build hash.
mux.HandleFunc("GET /api/version", s.getVersion)
@@ -1845,7 +1856,83 @@ func (s *Server) serveManifest(w http.ResponseWriter, r *http.Request) {
// ---- group name ---------------------------------------------------------
func (s *Server) getGroup(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, http.StatusOK, map[string]any{"name": s.db.getGroupName()})
+ // `avatar` lets clients decide whether to render /api/server-avatar without a
+ // speculative image fetch (used by the join cards, home Save-passkey card, and
+ // the native add-chat screens).
+ _, _, _, hasAvatar := s.db.getInstanceAvatar()
+ writeJSON(w, http.StatusOK, map[string]any{"name": s.db.getGroupName(), "avatar": hasAvatar})
+}
+
+// ---- server avatar (instance branding photo) ----------------------------
+//
+// A per-instance branding photo, admin-set and served in the clear like the
+// group name — it must render before any key exists (onboarding) and to the
+// instance-agnostic home server, so it can't be E2E-encrypted. Never user
+// content; message/profile-photo E2E is untouched.
+
+// getServerAvatar serves the branding photo unauthenticated (like getGroup). 404
+// when unset so clients fall back to the built-in app icon.
+func (s *Server) getServerAvatar(w http.ResponseWriter, r *http.Request) {
+ data, mime, updatedAt, ok := s.db.getInstanceAvatar()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ // ETag from the update stamp so an admin's change propagates but unchanged
+ // photos revalidate cheaply. X-Content-Type-Options: nosniff is set globally.
+ etag := fmt.Sprintf(`"%d"`, updatedAt)
+ if match := r.Header.Get("If-None-Match"); match == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ w.Header().Set("Content-Type", mime)
+ w.Header().Set("ETag", etag)
+ w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate")
+ w.Write(data)
+}
+
+// setServerAvatar (admin) stores a new branding photo. The body is the raw image
+// bytes; the type is sniffed from the content, not trusted from a header.
+func (s *Server) setServerAvatar(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxServerAvatarBytes)
+ data, err := io.ReadAll(r.Body)
+ if err != nil {
+ httpError(w, http.StatusRequestEntityTooLarge, "image too large (max 1 MB)")
+ return
+ }
+ if len(data) == 0 {
+ httpError(w, http.StatusBadRequest, "empty upload")
+ return
+ }
+ // Sniff the real type rather than trusting the client. Only raster images we
+ // know are safe to serve inline — no SVG (it can carry script).
+ mime := http.DetectContentType(data)
+ switch mime {
+ case "image/png", "image/jpeg", "image/webp":
+ default:
+ httpError(w, http.StatusUnsupportedMediaType, "must be a PNG, JPEG, or WebP image")
+ return
+ }
+ if err := s.db.setInstanceAvatar(data, mime, time.Now().Unix()); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// deleteServerAvatar (admin) clears the branding photo (reverts to the app icon).
+func (s *Server) deleteServerAvatar(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ if err := s.db.clearInstanceAvatar(); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) {
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 8054a7f..1894504 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -49,6 +49,23 @@
</form>
</section>
+ <section>
+ <h2>Server photo</h2>
+ <p class="sub" style="margin:0 0 8px">
+ An optional photo for this chat, shown when people are invited, while they
+ join, and on the passkey-backup screen. Like the name, it's public
+ branding — it isn't encrypted, so don't use anything private.
+ </p>
+ <div class="row" style="align-items:center;gap:12px;margin:0 0 8px">
+ <img id="serverAvatarPreview" alt="" class="hidden"
+ style="width:56px;height:56px;border-radius:14px;object-fit:cover" />
+ <button type="button" id="serverAvatarPick">Upload photo</button>
+ <button type="button" class="secondary hidden" id="serverAvatarRemove">Remove</button>
+ </div>
+ <input type="file" id="serverAvatarFile" accept="image/png,image/jpeg,image/webp" style="display:none" />
+ <p id="serverAvatarStatus" class="sub" style="margin:0" aria-live="polite"></p>
+ </section>
+
<section>
<h2>Who can invite</h2>
<p class="sub" style="margin:0 0 8px">
diff --git a/web/pages/join.html b/web/pages/join.html
index aca7c97..c9b0af0 100644
--- a/web/pages/join.html
+++ b/web/pages/join.html
@@ -65,6 +65,7 @@
</template>
<template id="form-tpl">
+ <img id="form-photo" class="onboard-photo hidden" alt="" />
<h1>Pick a screen name</h1>
<p>This is what everyone in the chat will see. You can use your first name or a nickname.</p>
<form id="join-form" autocomplete="off">
@@ -85,7 +86,8 @@
the URL fragment. data-store-url is per-instance config (LCHAT_APP_STORE_URL),
empty when no app link is configured. -->
<template id="welcome-tpl" data-store-url="__APP_STORE_URL__">
- <div class="onboard-icon">💬</div>
+ <img id="welcome-photo" class="onboard-photo hidden" alt="" />
+ <div class="onboard-icon" id="welcome-icon">💬</div>
<h1 id="welcome-title">You're invited to __GROUP_NAME__</h1>
<p id="welcome-sub">Private, end-to-end encrypted chat.</p>
<div class="row"><button id="welcome-app">Open in the app</button></div>
diff --git a/web/static/admin.js b/web/static/admin.js
index ba75105..6bb435f 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -36,6 +36,7 @@ async function init() {
document.getElementById("copyMaster").onclick = () =>
copy(`${location.origin}/admin#token=${adminToken}`, "Admin link copied");
wireGroupName();
+ wireServerAvatar();
wireUserInvites();
wireTestPush();
wireCreate();
@@ -78,6 +79,94 @@ async function wireGroupName() {
};
}
+// ---- server photo -------------------------------------------------------
+
+// The instance branding photo (public, unencrypted — like the group name). The
+// admin picks an image; we downscale it in-browser (small + EXIF-stripped) and
+// POST the raw bytes to /api/admin/server-avatar. Feedback is inline (no toast).
+function wireServerAvatar() {
+ const preview = document.getElementById("serverAvatarPreview");
+ const pick = document.getElementById("serverAvatarPick");
+ const remove = document.getElementById("serverAvatarRemove");
+ const file = document.getElementById("serverAvatarFile");
+ const status = document.getElementById("serverAvatarStatus");
+
+ const setStatus = (msg, isError = false) => {
+ status.textContent = msg || "";
+ status.style.color = isError ? "#c0392b" : "";
+ };
+
+ // Reflect current state: show the photo (cache-busted) + Remove when one is set.
+ async function refresh() {
+ let has = false;
+ try {
+ has = !!(await (await fetch("/api/group")).json()).avatar;
+ } catch {}
+ if (has) {
+ preview.src = "/api/server-avatar?t=" + Date.now();
+ preview.classList.remove("hidden");
+ remove.classList.remove("hidden");
+ pick.textContent = "Change photo";
+ } else {
+ preview.removeAttribute("src");
+ preview.classList.add("hidden");
+ remove.classList.add("hidden");
+ pick.textContent = "Upload photo";
+ }
+ }
+ refresh();
+
+ pick.onclick = () => file.click();
+ file.onchange = async () => {
+ const f = file.files[0];
+ file.value = "";
+ if (!f) return;
+ setStatus("Uploading…");
+ try {
+ const bytes = await downscaleImage(f, 400);
+ const res = await fetch("/api/admin/server-avatar", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + adminToken },
+ body: bytes,
+ });
+ if (!res.ok) throw new Error("upload");
+ setStatus("Server photo saved.");
+ await refresh();
+ } catch {
+ setStatus("Couldn't save that photo. Try a different image.", true);
+ }
+ };
+ remove.onclick = async () => {
+ setStatus("Removing…");
+ const res = await fetch("/api/admin/server-avatar", {
+ method: "DELETE",
+ headers: { Authorization: "Bearer " + adminToken },
+ });
+ if (res.ok) {
+ setStatus("Server photo removed.");
+ await refresh();
+ } else {
+ setStatus("Couldn't remove the photo.", true);
+ }
+ };
+}
+
+// Downscale + re-encode an image to a small JPEG (caps the long edge, strips
+// EXIF/GPS) — mirrors chat.js processImage, kept local so the admin console needs
+// no shared module. Returns the encoded bytes.
+async function downscaleImage(file, maxDim) {
+ const bitmap = await createImageBitmap(file);
+ const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
+ const w = Math.round(bitmap.width * scale);
+ const h = Math.round(bitmap.height * scale);
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h);
+ const blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85));
+ return await blob.arrayBuffer();
+}
+
// ---- notifications ------------------------------------------------------
function wireTestPush() {
diff --git a/web/static/backup.js b/web/static/backup.js
index 3b24951..4b1e450 100644
--- a/web/static/backup.js
+++ b/web/static/backup.js
@@ -67,6 +67,41 @@ function groupName() {
return t || location.host;
}
+// The instance branding photo as a small data: URI, prefetched when the backup
+// banner renders so launchEnrol can hand it to the home server *synchronously*
+// (awaiting a fetch inside launchEnrol would risk the click gesture the native
+// window.open relies on). Stays null when this instance has no photo set.
+let serverIconDataURI = null;
+
+async function prefetchServerIcon() {
+ if (serverIconDataURI) return;
+ try {
+ const g = await (await fetch("/api/group")).json();
+ if (!g.avatar) return;
+ const blob = await (await fetch("/api/server-avatar")).blob();
+ serverIconDataURI = await shrinkToDataURI(blob, 96);
+ } catch {}
+}
+
+// Shrink an image blob to a small JPEG data: URI for the handoff fragment (kept
+// tiny so the URL stays short — it's display-only chrome). Returns null on any
+// failure, so the icon is simply omitted.
+async function shrinkToDataURI(blob, maxDim) {
+ try {
+ const bitmap = await createImageBitmap(blob);
+ const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
+ const w = Math.round(bitmap.width * scale);
+ const h = Math.round(bitmap.height * scale);
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h);
+ return canvas.toDataURL("image/jpeg", 0.8);
+ } catch {
+ return null;
+ }
+}
+
function bannerEl() {
return document.getElementById("backup-banner");
}
@@ -99,6 +134,7 @@ export function initBackupBanner(session, opts = {}) {
function renderBanner(el, session) {
el.textContent = "";
+ prefetchServerIcon(); // ready the branding photo before the user taps "Save securely"
const title = document.createElement("div");
title.className = "backup-banner-title";
@@ -161,6 +197,10 @@ export function launchEnrol(session) {
entry: {
url: location.origin, // the instance being enrolled
name: groupName(), // best-effort, for the confirmation UI
+ // Small data: URI of the instance photo for the confirmation card. Omitted
+ // when unset/not-yet-prefetched (JSON.stringify drops undefined). Display
+ // only — enroll.js strips it before persisting the backup blob.
+ icon: serverIconDataURI || undefined,
credentials: {
token: session.token,
userPub: session.userPub,
diff --git a/web/static/join.js b/web/static/join.js
index 0ba5df1..4adf461 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -27,6 +27,11 @@ function init() {
// claiming/adopting). Set once in start(); read by finish()/adoptExisting().
let inviteClaimSecret = "";
+// Whether this instance has a branding photo (public, unencrypted — like the
+// group name). Fetched in start() and shown on the onboarding cards in place of
+// the generic emoji. See docs/onboarding-and-home-server.md.
+let serverHasAvatar = false;
+
async function start() {
let id, wrapKey, info;
try {
@@ -42,6 +47,12 @@ async function start() {
return showError("This invite has been used or is no longer valid. Ask for a new one.");
}
+ // Note whether this instance has a branding photo (a public flag), so the
+ // onboarding cards below can show it instead of the placeholder emoji.
+ try {
+ serverHasAvatar = !!(await (await fetch("/api/group")).json()).avatar;
+ } catch {}
+
// Everything below only *decides* what continuing means for this link — the
// claim itself waits for an explicit choice on the welcome card. Claiming on
// page-load burned the one-time secret in whatever context rendered the link
@@ -108,6 +119,7 @@ function showWelcome(kind, onContinue) {
card.innerHTML = "";
const tpl = document.getElementById("welcome-tpl");
card.appendChild(tpl.content.cloneNode(true));
+ applyServerPhoto(card);
if (kind === "device") {
// document.title carries the substituted group name.
card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`;
@@ -217,10 +229,24 @@ function loadSession() {
// ---- views --------------------------------------------------------------
+// Reveal the instance branding photo on a freshly-rendered onboarding card (and
+// hide the placeholder emoji) when one is set. The <img> src is our own
+// same-origin endpoint — never fragment or user input.
+function applyServerPhoto(root) {
+ if (!serverHasAvatar) return;
+ const img = root.querySelector(".onboard-photo");
+ if (!img) return;
+ img.src = "/api/server-avatar";
+ img.classList.remove("hidden");
+ const emoji = root.querySelector(".onboard-icon");
+ if (emoji) emoji.classList.add("hidden");
+}
+
function showForm(defaultName, onSubmit) {
card.classList.remove("onboard", "status");
card.innerHTML = "";
card.appendChild(document.getElementById("form-tpl").content.cloneNode(true));
+ applyServerPhoto(card);
const input = card.querySelector("#name");
if (defaultName) input.value = defaultName;
input.focus();
diff --git a/web/static/style.css b/web/static/style.css
index df3b195..f20079c 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1161,6 +1161,11 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.card.onboard .row { justify-content: center; }
.card.onboard p { margin-bottom: 14px; }
.onboard-icon { font-size: 46px; line-height: 1; margin-bottom: 10px; }
+/* the instance branding photo, when set — replaces the emoji on onboarding cards */
+.onboard-photo {
+ width: 72px; height: 72px; border-radius: 18px; object-fit: cover;
+ margin: 0 auto 12px; display: block;
+}
/* the browser-only caveat on the welcome card: present but quiet */
.welcome-caveat { font-size: 13px; margin: 12px 0 0; }
.steps {