eleven / messenger Public

server: unfurl in the requester's language (#1237)

The unfurler now forwards the member's Accept-Language (normalised to its
first tag, with a base-language fallback for regioned tags) on the outbound
page fetch, so a link previews in the member's language rather than the
locale Google et al. infer from the fetching box's IP — a Finnish-routed
instance was producing Finnish Google cards for Irish members.

The preview cache varies by that language tag: the tag folds into the sealed
row's hash + key derivation (previewCacheKey), so members with different
languages fetch and cache independent cards, and existing rows simply age
out. No client change needed — browsers and URLSession already send
Accept-Language on every /api/unfurl call; the server was dropping it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6199e7b7949a6bc4e3a6f38cf5ffd2167907f8c6 parent c2dc4e7
5 files changed, +143 −35
  • changelog/2026-07-21-link-preview-language.md +4 −0
  • db.go +8 −6
  • main.go +16 −11
  • unfurl.go +49 −10
  • unfurl_test.go +66 −8
diff --git a/changelog/2026-07-21-link-preview-language.md b/changelog/2026-07-21-link-preview-language.md
new file mode 100644
index 0000000..a139a18
--- /dev/null
+++ b/changelog/2026-07-21-link-preview-language.md
@@ -0,0 +1,4 @@
+🤖 Link previews now show up in your language. Before, a preview card could
+arrive in the language of the country your space's server happens to sit in —
+a link to Google from a space hosted in Finland would preview in Finnish.
+Previews now follow the language your device asks for.
diff --git a/db.go b/db.go
index bccaff5..9991713 100644
--- a/db.go
+++ b/db.go
@@ -1055,15 +1055,16 @@ func (db *DB) previewSalt() (string, error) {
return v, err
}
-func (db *DB) getLinkPreview(u string) (LinkPreview, bool, error) {
+func (db *DB) getLinkPreview(u, lang string) (LinkPreview, bool, error) {
salt, err := db.previewSalt()
if err != nil {
return LinkPreview{}, false, err
}
+ key := previewCacheKey(lang, u)
var blob string
var fetchedAt int64
err = db.sql.QueryRow(
- "SELECT blob, fetched_at FROM link_previews_sealed WHERE url_hash = ?", previewURLHash(salt, u),
+ "SELECT blob, fetched_at FROM link_previews_sealed WHERE url_hash = ?", previewURLHash(salt, key),
).Scan(&blob, &fetchedAt)
if err == sql.ErrNoRows {
return LinkPreview{}, false, nil
@@ -1071,7 +1072,7 @@ func (db *DB) getLinkPreview(u string) (LinkPreview, bool, error) {
if err != nil {
return LinkPreview{}, false, err
}
- pv, ok := openPreview(salt, u, blob)
+ pv, ok := openPreview(salt, key, blob)
if !ok {
// Undecryptable row (corruption): treat as a miss; the next put
// overwrites it under the same hash.
@@ -1082,12 +1083,13 @@ func (db *DB) getLinkPreview(u string) (LinkPreview, bool, error) {
return pv, true, nil
}
-func (db *DB) putLinkPreview(pv LinkPreview) error {
+func (db *DB) putLinkPreview(pv LinkPreview, lang string) error {
salt, err := db.previewSalt()
if err != nil {
return err
}
- blob, err := sealPreview(salt, pv)
+ key := previewCacheKey(lang, pv.URL)
+ blob, err := sealPreview(salt, key, pv)
if err != nil {
return err
}
@@ -1100,7 +1102,7 @@ func (db *DB) putLinkPreview(pv LinkPreview) error {
VALUES(?, ?, ?)
ON CONFLICT(url_hash) DO UPDATE SET
blob=excluded.blob, fetched_at=excluded.fetched_at`,
- previewURLHash(salt, pv.URL), blob, pv.FetchedAt,
+ previewURLHash(salt, key), blob, pv.FetchedAt,
)
return err
}
diff --git a/main.go b/main.go
index 017d7f5..83d0de5 100644
--- a/main.go
+++ b/main.go
@@ -3069,41 +3069,46 @@ func (s *Server) unfurl(w http.ResponseWriter, r *http.Request) {
return
}
norm := u.String()
+ // The member's own request already carries their Accept-Language; forward
+ // it so the page unfurls in their language, not the fetching box's locale.
+ lang := previewLang(r.Header.Get("Accept-Language"))
// Cache hit: serve it now; if it's stale, refresh in the background.
- if pv, ok, _ := s.db.getLinkPreview(norm); ok {
+ if pv, ok, _ := s.db.getLinkPreview(norm, lang); ok {
writeJSON(w, http.StatusOK, pv)
ttl := int64(previewFreshTTL)
if !pv.OK {
ttl = previewFailTTL
}
if time.Now().Unix()-pv.FetchedAt > ttl {
- go s.fetchAndCachePreview(norm)
+ go s.fetchAndCachePreview(norm, lang)
}
return
}
// Miss: fetch synchronously (de-duped), cache, return.
- writeJSON(w, http.StatusOK, s.fetchAndCachePreview(norm))
+ writeJSON(w, http.StatusOK, s.fetchAndCachePreview(norm, lang))
}
-// fetchAndCachePreview fetches+stores a preview, ensuring only one fetch per URL
-// runs at a time. Concurrent callers get whatever is currently cached.
-func (s *Server) fetchAndCachePreview(norm string) LinkPreview {
- if _, busy := s.inflight.LoadOrStore(norm, true); busy {
- if pv, ok, _ := s.db.getLinkPreview(norm); ok {
+// fetchAndCachePreview fetches+stores a preview, ensuring only one fetch per
+// URL+language runs at a time. Concurrent callers get whatever is currently
+// cached.
+func (s *Server) fetchAndCachePreview(norm, lang string) LinkPreview {
+ flightKey := previewCacheKey(lang, norm)
+ if _, busy := s.inflight.LoadOrStore(flightKey, true); busy {
+ if pv, ok, _ := s.db.getLinkPreview(norm, lang); ok {
return pv
}
return LinkPreview{URL: norm}
}
- defer s.inflight.Delete(norm)
+ defer s.inflight.Delete(flightKey)
- pv, err := fetchOG(norm)
+ pv, err := fetchOG(norm, lang)
if err != nil {
pv = LinkPreview{URL: norm, OK: false}
}
pv.URL = norm
pv.FetchedAt = time.Now().Unix()
- _ = s.db.putLinkPreview(pv)
+ _ = s.db.putLinkPreview(pv, lang)
return pv
}
diff --git a/unfurl.go b/unfurl.go
index c535d61..1b28318 100644
--- a/unfurl.go
+++ b/unfurl.go
@@ -52,14 +52,42 @@ type LinkPreview struct {
// precomputation). The server sees URLs transiently at request time — the
// documented unfurl exception — derives, opens or seals, and stores only
// ciphertext, so the cache at rest reveals nothing a reader doesn't already
-// know the URL for.
+// know the URL for. Cards are fetched per requester language (the page can
+// differ), so the derivation input is the cache key — language + URL — not
+// the bare URL.
-// previewURLHash is the row key: hex sha256(salt || url).
+// previewURLHash is the row key: hex sha256(salt || cache key).
func previewURLHash(salt, u string) string {
h := sha256.Sum256([]byte(salt + u))
return hex.EncodeToString(h[:])
}
+// previewCacheKey folds the requester's language into the string the row hash
+// and AEAD derive from, so members with different languages get (and cache)
+// different cards — a card fetched via a Finnish exit never serves to an "en"
+// member. Empty lang (no preference) keys exactly as before.
+func previewCacheKey(lang, u string) string {
+ if lang == "" {
+ return u
+ }
+ return lang + "\n" + u
+}
+
+var langTagRe = regexp.MustCompile(`^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$`)
+
+// previewLang normalises an Accept-Language header to its first tag
+// ("en-IE,en;q=0.9,fi;q=0.8" → "en-IE"): one low-cardinality value to forward
+// upstream and to vary the cache by. "" (no preference) on absent/malformed.
+func previewLang(header string) string {
+ tag, _, _ := strings.Cut(header, ",")
+ tag, _, _ = strings.Cut(tag, ";")
+ tag = strings.TrimSpace(tag)
+ if !langTagRe.MatchString(tag) {
+ return ""
+ }
+ return tag
+}
+
// previewAEAD derives the URL's AES-GCM cipher.
func previewAEAD(salt, u string) (cipher.AEAD, error) {
key, err := hkdf.Key(sha256.New, []byte(u), []byte(salt), "lchat link preview v1", 32)
@@ -83,9 +111,10 @@ type sealedPreview struct {
}
// sealPreview encrypts a card to the base64url iv||ciphertext format
-// (crypto.js's convention, kept for consistency).
-func sealPreview(salt string, pv LinkPreview) (string, error) {
- aead, err := previewAEAD(salt, pv.URL)
+// (crypto.js's convention, kept for consistency). key is the cache key
+// (previewCacheKey), not necessarily the bare URL.
+func sealPreview(salt, key string, pv LinkPreview) (string, error) {
+ aead, err := previewAEAD(salt, key)
if err != nil {
return "", err
}
@@ -103,9 +132,9 @@ func sealPreview(salt string, pv LinkPreview) (string, error) {
}
// openPreview decrypts a stored card; false on any mismatch or corruption
-// (callers treat that as a cache miss).
-func openPreview(salt, u, blob string) (LinkPreview, bool) {
- aead, err := previewAEAD(salt, u)
+// (callers treat that as a cache miss). key matches sealPreview's.
+func openPreview(salt, key, blob string) (LinkPreview, bool) {
+ aead, err := previewAEAD(salt, key)
if err != nil {
return LinkPreview{}, false
}
@@ -189,8 +218,10 @@ var ogClient = &http.Client{
},
}
-// fetchOG downloads a URL and parses its OpenGraph/HTML metadata.
-func fetchOG(rawURL string) (LinkPreview, error) {
+// fetchOG downloads a URL and parses its OpenGraph/HTML metadata. lang (a
+// normalised previewLang tag, may be "") is forwarded so the page negotiates
+// on the member's language, not the geolocation of whichever box fetches.
+func fetchOG(rawURL, lang string) (LinkPreview, error) {
pv := LinkPreview{URL: rawURL}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
@@ -198,6 +229,14 @@ func fetchOG(rawURL string) (LinkPreview, error) {
}
req.Header.Set("User-Agent", "lchat-linkpreview/1.0 (+https://elevenmessenger.com)")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
+ if lang != "" {
+ al := lang
+ // "en-IE" also offers plain "en", for sites that only know base tags.
+ if base, _, found := strings.Cut(lang, "-"); found {
+ al = lang + ", " + base + ";q=0.8"
+ }
+ req.Header.Set("Accept-Language", al)
+ }
resp, err := ogClient.Do(req)
if err != nil {
diff --git a/unfurl_test.go b/unfurl_test.go
index 95d83b8..875c54a 100644
--- a/unfurl_test.go
+++ b/unfurl_test.go
@@ -2,6 +2,8 @@ package main
import (
"encoding/json"
+ "fmt"
+ "html"
"net"
"net/http"
"net/http/httptest"
@@ -93,11 +95,67 @@ func TestUnfurlEndpoint(t *testing.T) {
}
// It should now be in the cache.
- if _, ok, _ := srv.db.getLinkPreview(page.URL); !ok {
+ if _, ok, _ := srv.db.getLinkPreview(page.URL, ""); !ok {
t.Fatal("preview was not cached")
}
}
+// The member's Accept-Language rides along to the unfurled page (so a box in
+// Finland doesn't produce Finnish cards for Irish members), and cards fetched
+// under different languages cache as separate rows.
+func TestUnfurlLanguage(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ allowPrivatePreviewHosts = true
+ t.Cleanup(func() { allowPrivatePreviewHosts = false })
+
+ // The page negotiates: its title echoes the Accept-Language it was sent.
+ page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprintf(w, `<head><meta property="og:title" content="lang=%s"></head>`,
+ html.EscapeString(r.Header.Get("Accept-Language")))
+ }))
+ t.Cleanup(page.Close)
+
+ tok := mintDeviceToken(t, srv.db, "x")
+
+ fetch := func(acceptLang string) LinkPreview {
+ t.Helper()
+ req, _ := http.NewRequest("GET", ts.URL+"/api/unfurl?token="+tok+"&url="+page.URL, nil)
+ if acceptLang != "" {
+ req.Header.Set("Accept-Language", acceptLang)
+ }
+ r, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("unfurl: %v", err)
+ }
+ defer r.Body.Close()
+ var pv LinkPreview
+ json.NewDecoder(r.Body).Decode(&pv)
+ return pv
+ }
+
+ // A regioned tag is forwarded with its base-language fallback.
+ if pv := fetch("en-IE,en;q=0.9,fi;q=0.8"); pv.Title != "lang=en-IE, en;q=0.8" {
+ t.Fatalf("en-IE preview = %+v", pv)
+ }
+ // A different language is a separate fetch, not the cached en-IE card.
+ if pv := fetch("fi"); pv.Title != "lang=fi" {
+ t.Fatalf("fi preview = %+v", pv)
+ }
+ // Both variants sit in the cache independently; neither sees the other's.
+ if pv, ok, _ := srv.db.getLinkPreview(page.URL, "en-IE"); !ok || pv.Title != "lang=en-IE, en;q=0.8" {
+ t.Fatalf("cached en-IE = ok=%v %+v", ok, pv)
+ }
+ if pv, ok, _ := srv.db.getLinkPreview(page.URL, "fi"); !ok || pv.Title != "lang=fi" {
+ t.Fatalf("cached fi = ok=%v %+v", ok, pv)
+ }
+ // Garbage headers degrade to "no preference", never to a poisoned key.
+ if got := previewLang(`" onload="x`); got != "" {
+ t.Fatalf("previewLang(garbage) = %q", got)
+ }
+}
+
// The cache must hold no plaintext at rest: rows are found by a salted hash of
// the URL and opened only with a key derived from the URL itself.
func TestLinkPreviewSealedAtRest(t *testing.T) {
@@ -107,12 +165,12 @@ func TestLinkPreviewSealedAtRest(t *testing.T) {
Description: "Do not share", Image: "https://example.com/chart.png",
OK: true, FetchedAt: time.Now().Unix(),
}
- if err := db.putLinkPreview(pv); err != nil {
+ if err := db.putLinkPreview(pv, ""); err != nil {
t.Fatalf("putLinkPreview: %v", err)
}
// Round-trip.
- got, ok, err := db.getLinkPreview(pv.URL)
+ got, ok, err := db.getLinkPreview(pv.URL, "")
if err != nil || !ok {
t.Fatalf("getLinkPreview: ok=%v err=%v", ok, err)
}
@@ -133,7 +191,7 @@ func TestLinkPreviewSealedAtRest(t *testing.T) {
}
// A different URL neither finds nor opens the row.
- if _, ok, _ := db.getLinkPreview("https://example.com/other"); ok {
+ if _, ok, _ := db.getLinkPreview("https://example.com/other", ""); ok {
t.Fatal("wrong URL matched a cached row")
}
salt, _ := db.previewSalt()
@@ -177,18 +235,18 @@ func TestLinkPreviewEvictionAndLegacyDrop(t *testing.T) {
// Eviction: an ancient row disappears when the next put sweeps.
stale := LinkPreview{URL: "https://example.com/stale", Title: "old", OK: true,
FetchedAt: time.Now().Unix() - previewMaxAge - 60}
- if err := db.putLinkPreview(stale); err != nil {
+ if err := db.putLinkPreview(stale, ""); err != nil {
t.Fatalf("put stale: %v", err)
}
fresh := LinkPreview{URL: "https://example.com/fresh", Title: "new", OK: true,
FetchedAt: time.Now().Unix()}
- if err := db.putLinkPreview(fresh); err != nil {
+ if err := db.putLinkPreview(fresh, ""); err != nil {
t.Fatalf("put fresh: %v", err)
}
- if _, ok, _ := db.getLinkPreview(stale.URL); ok {
+ if _, ok, _ := db.getLinkPreview(stale.URL, ""); ok {
t.Fatal("stale row survived the put-time sweep")
}
- if _, ok, _ := db.getLinkPreview(fresh.URL); !ok {
+ if _, ok, _ := db.getLinkPreview(fresh.URL, ""); !ok {
t.Fatal("fresh row missing")
}
}