From ce4e10c4b8bd9c66c375c3a5d5c18d8552655775 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 03:43:19 +0200 Subject: feat(chat): link previews for pasted URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste an http(s) link in a group's chat and it unfurls into an OpenGraph card — title, description, site name, and image — the way WhatsApp/Signal/ Slack do it. The fetch is the node's, never the browser's or the hub's. The browser cannot: a strict img-src/connect-src and CORS block it, and a direct fetch would leak every reader's IP to the linked host on each render. The hub must not touch group content (draft-v6 §2.5). The node already fetches third-party metadata for the Videos and Music apps, over the same authorised path. Flow mirrors media_meta_req: the client sends `link_preview_req {url}`, the node replies `link_preview_resp` with the card fields (or `ok: false`), and any OG image is stored under its blake3 in the existing media_cache thumb store — the client then fetches it via the normal file_req path, exactly like a poster. Nothing durable is added: the card text lives in a bounded in-memory TTL cache on the node (draft-v6 §2.7 — enrichment on demand, the asking device caches), and MNP goes 0.11 → 0.12 (additive: an older node logs "unknown type" and the client shows the bare link). Because the URL is chosen by a *member* and triggers an outbound request from the operator's machine, `linkpreview.safe_url` is an SSRF gate: http(s) only, no credentials, and every resolved address must be globally routable — no loopback, private, link-local, multicast or reserved range, cloud-metadata included. Redirects are followed by hand so each hop is re-checked. Residual, documented in the module: DNS rebinding between the check and connect, closed properly by pinning the checked IP — a follow-up. Also fixes a long-standing chat annoyance the preview cards made worse: opening the Chat tab landed a screen or two above the newest message because the scroll-to-bottom ran before attachment thumbnails and (now) preview cards had loaded and grown the content. A ResizeObserver keeps the view pinned to the bottom through late content growth, and does nothing once the reader scrolls up. Tests: test_linkpreview.py (the SSRF gate and the OpenGraph parse, incl. redirect re-validation and image downscaling) and test_link_preview_request.py (reply shape, the media_cache image round-trip, the result cache). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi --- packages/meshbay-common/src/meshbay_common/__init__.py | 5 ++++- packages/meshbay-common/src/meshbay_common/protocol.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-common/src') diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 0ca0005..1f82f88 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -49,5 +49,8 @@ __version__ = "0.7.0" # *set*, replaced whole in one signed op — a photo library is routinely # scattered across several folders, not one. Additive: an older client # never sends the op and never expects either field. -MNP_VERSION = "0.11" +# 0.12: added `link_preview_req`/`link_preview_resp` — the node unfurls a URL +# pasted in chat into an OpenGraph card. Additive: an older node logs "unknown +# type" and the client just shows the bare link, as it always did. +MNP_VERSION = "0.12" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index dd377e7..d4e3ccd 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -37,6 +37,11 @@ class MNP: CHAT_ATTACHMENT = "chat_attach" # attachment metadata CHAT_HISTORY = "chat_hist" # request message history (newest, or before a cursor) CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages + # Link unfurl: the node fetches a URL a member pasted and returns an + # OpenGraph card. Additive (0.12) — an older node just logs "unknown type" + # and the client shows the bare link, exactly as before. + LINK_PREVIEW_REQ = "link_preview_req" # client → node: unfurl this URL + LINK_PREVIEW_RESP = "link_preview_resp" # node → client: card fields, or ok:false # Liveness on an *already open* channel. A peer that goes away without # closing leaves a DataChannel that still reads as connected until the next # real request hangs, and there was no way to ask. This is not a discovery -- cgit v1.2.3