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>
5 files changed,
+143
−35
changelog/2026-07-21-link-preview-language.md+4 −0db.go+8 −6main.go+16 −11unfurl.go+49 −10unfurl_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") |
| } |
| } |