eleven / ocho Public

web: invite links render an OpenGraph preview card (#213)

Pasting an invite link into Messages/Slack/WhatsApp now shows a preview
card titled "You're invited to <group name>" with the server avatar
(falling back to the app logo) instead of a bare URL.

The card is server-rendered — crawlers fetch without JS or the #fragment —
so it uses only plaintext branding (group name + server avatar) and never
the inviter's screen name, which stays end-to-end encrypted.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell a58d33b556264a1568226e1a6633cd36debf039e parent c56ee00
4 files changed, +88 −0
  • changelog/2026-07-05-invite-link-preview.md +6 −0
  • handlers_test.go +39 −0
  • main.go +27 −0
  • web/pages/join.html +16 −0
diff --git a/changelog/2026-07-05-invite-link-preview.md b/changelog/2026-07-05-invite-link-preview.md
new file mode 100644
index 0000000..abfcbbc
--- /dev/null
+++ b/changelog/2026-07-05-invite-link-preview.md
@@ -0,0 +1,6 @@
+Invite links now show a proper preview card when you paste them into Messages,
+WhatsApp, Slack and the like. The card reads "You're invited to <your group's
+name>" and shows your server's photo — or the app logo if you haven't set one —
+instead of a bare link. Nothing private leaks: the preview only uses your group's
+name and photo (both already public branding), never anyone's screen name, which
+stays end-to-end encrypted as always.
diff --git a/handlers_test.go b/handlers_test.go
index fdd2bb5..8d8b0f0 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -1056,6 +1056,45 @@ func TestJoinPageAppStoreURL(t *testing.T) {
}
}
+// The join page carries an OpenGraph card so invite links preview nicely. The
+// title is the (plaintext) group name — never the inviter's screen name, which is
+// E2E — and the image is the server avatar when set, else the app logo.
+func TestJoinPageOpenGraph(t *testing.T) {
+ ts, s := newTestServer(t)
+ if err := s.db.setGroupName("Paul's Eleven Server"); err != nil {
+ t.Fatalf("setGroupName: %v", err)
+ }
+ get := func() string {
+ resp, err := http.Get(ts.URL + "/join/join")
+ if err != nil {
+ t.Fatalf("get join: %v", err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return string(b)
+ }
+
+ body := get()
+ if strings.Contains(body, "__OG_IMAGE__") || strings.Contains(body, "__OG_URL__") {
+ t.Fatal("join page leaks an __OG_ placeholder")
+ }
+ if !strings.Contains(body, `<meta property="og:title" content="You're invited to Paul&#39;s Eleven Server" />`) {
+ t.Fatalf("join page missing og:title with the group name:\n%s", body)
+ }
+ // No avatar set → the app logo is the preview image.
+ if !strings.Contains(body, `<meta property="og:image" content="`+ts.URL+`/static/icon-512.png" />`) {
+ t.Fatalf("join page og:image should fall back to the app logo:\n%s", body)
+ }
+
+ // With a branding avatar set, the preview image is the server avatar.
+ if err := s.db.setInstanceAvatar([]byte("\x89PNG\r\n\x1a\n"), "image/png", 1); err != nil {
+ t.Fatalf("setInstanceAvatar: %v", err)
+ }
+ if body := get(); !strings.Contains(body, `<meta property="og:image" content="`+ts.URL+`/api/server-avatar" />`) {
+ t.Fatalf("join page og:image should be the server avatar when set:\n%s", body)
+ }
+}
+
// The chat page exposes the passkey-backup home origin to JS (window.__homeOrigin).
// The default must be substituted and the placeholder must never leak to the client.
func TestChatPageHomeOrigin(t *testing.T) {
diff --git a/main.go b/main.go
index fdbfd14..09b3e50 100644
--- a/main.go
+++ b/main.go
@@ -424,6 +424,20 @@ func loadPages() (map[string][]byte, error) {
return pages, nil
}
+// requestBase reconstructs the external scheme+host (e.g. "https://chat.paulca.com")
+// as the browser reached us. The router terminates TLS and proxies over a unix
+// socket, so the real scheme rides in X-Forwarded-Proto (SetXForwarded, router.go);
+// prod is always TLS, so default to https when unproxied.
+func requestBase(r *http.Request) string {
+ scheme := "https"
+ if xfp := r.Header.Get("X-Forwarded-Proto"); xfp != "" {
+ scheme = xfp
+ } else if r.TLS == nil {
+ scheme = "http"
+ }
+ return scheme + "://" + r.Host
+}
+
func (s *Server) servePage(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// "GET /" matches everything; only the exact root serves the chat page.
@@ -461,6 +475,19 @@ func (s *Server) servePage(name string) http.HandlerFunc {
page = strings.ReplaceAll(page, "__APP_STORE_URL__", html.EscapeString(s.appStoreURL))
// The home server the chat page enrols into for passkey backup.
page = strings.ReplaceAll(page, "__HOME_ORIGIN__", html.EscapeString(s.homeOrigin))
+ // Link-preview (OpenGraph) fields for the join page. Absolute URLs, since
+ // crawlers fetch with no base. The image is the server branding avatar when
+ // set, else the app logo — both plaintext branding, never user content, so
+ // this leaks nothing beyond what the group name already does.
+ if name == "join.html" {
+ base := requestBase(r)
+ ogImage := base + "/static/icon-512.png"
+ if _, _, _, hasAvatar := s.db.getInstanceAvatar(); hasAvatar {
+ ogImage = base + "/api/server-avatar"
+ }
+ page = strings.ReplaceAll(page, "__OG_URL__", html.EscapeString(base+r.URL.EscapedPath()))
+ page = strings.ReplaceAll(page, "__OG_IMAGE__", html.EscapeString(ogImage))
+ }
page = strings.ReplaceAll(page, "__CSP_NONCE__", nonce)
w.Write([]byte(page))
}
diff --git a/web/pages/join.html b/web/pages/join.html
index c9b0af0..29ad4bf 100644
--- a/web/pages/join.html
+++ b/web/pages/join.html
@@ -2,6 +2,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta name="theme-color" content="#5b6cff" />
<title>__GROUP_NAME__</title>
+
+<!-- Link-preview card (iMessage/Slack/WhatsApp/…). Crawlers fetch this URL with no
+ JS and no #fragment, so the card can only show what the server knows in the
+ clear: the group name (plaintext branding) and the server avatar. The inviter's
+ screen name is E2E — never sent to the server — so it deliberately can't appear
+ here. __OG_IMAGE__ is the server avatar when set, else the app logo. -->
+<meta property="og:type" content="website" />
+<meta property="og:site_name" content="__GROUP_NAME__" />
+<meta property="og:url" content="__OG_URL__" />
+<meta property="og:title" content="You're invited to __GROUP_NAME__" />
+<meta property="og:description" content="Private, end-to-end encrypted chat." />
+<meta property="og:image" content="__OG_IMAGE__" />
+<meta name="twitter:card" content="summary" />
+<meta name="twitter:title" content="You're invited to __GROUP_NAME__" />
+<meta name="twitter:description" content="Private, end-to-end encrypted chat." />
+<meta name="twitter:image" content="__OG_IMAGE__" />
<!-- Native wrapper detection: the Mac app's user agent carries "lchatMac". Tag the
document so CSS can adopt native conventions (e.g. drop web hand cursors). -->
<script nonce="__CSP_NONCE__">