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>
4 files changed,
+88
−0
changelog/2026-07-05-invite-link-preview.md+6 −0handlers_test.go+39 −0main.go+27 −0web/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'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__"> |