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>
16 files changed,
+661
−15
CLAUDE.md+10 −1changelog/2026-07-04-server-avatar.md+5 −0db.go+42 −0handlers_test.go+112 −0home/web/pages/enroll.html+7 −2home/web/static/enroll.js+19 −0ios/LChat/LChatAPI.swift+19 −0ios/LChat/Views/JoinView.swift+82 −2ios/LChatMac/AddChatView.swift+97 −8main.go+88 −1web/pages/admin.html+17 −0web/pages/join.html+3 −1web/static/admin.js+89 −0web/static/backup.js+40 −0web/static/join.js+26 −0web/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 { |