summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-28 03:43:19 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-28 03:43:19 +0200
commitce4e10c4b8bd9c66c375c3a5d5c18d8552655775 (patch)
treed72429e99d5a4eb8e6d59d9d41a004aae4cb70e7 /packages/meshbay-node/src/meshbay_node/transport
parente1f1b65cfac031096e4bae24ccf102ca0dbb86d9 (diff)
downloadmeshbay-ce4e10c4b8bd9c66c375c3a5d5c18d8552655775.tar.gz
feat(chat): link previews for pasted URLs
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py79
1 files changed, 78 insertions, 1 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 252e202..4958b92 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -97,7 +97,7 @@ from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP, index_entry_wire
from meshbay_node.indexer import GroupIndex
from meshbay_node.indexer.indexer import DirectoryIndexer
-from meshbay_node import ops
+from meshbay_node import linkpreview, ops
# Re-imported under its original name: every call site and existing test in
# this module still refers to it as `_probe_video`. The implementation lives
# in media_probe.py so the indexer package (imported just above) can call it
@@ -115,6 +115,34 @@ log = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 1024
MAX_MSG = 64 * 1024 * 1024
+# Chat link-preview results, kept in memory only (draft-v6 §2.7: the node
+# produces enrichment on demand and keeps nothing durable — the asking device
+# caches). Bounded and time-limited so a busy group cannot grow it without end
+# and a page that changed its card is picked up within the hour.
+_LINK_PREVIEW_TTL = 3600
+_LINK_PREVIEW_MAX = 256
+_link_preview_cache: dict[str, tuple[float, dict]] = {}
+
+
+def _link_preview_cache_get(url: str) -> dict | None:
+ hit = _link_preview_cache.get(url)
+ if hit is None:
+ return None
+ ts, value = hit
+ if time.time() - ts > _LINK_PREVIEW_TTL:
+ _link_preview_cache.pop(url, None)
+ return None
+ return value
+
+
+def _link_preview_cache_put(url: str, value: dict) -> None:
+ if not url:
+ return
+ if len(_link_preview_cache) >= _LINK_PREVIEW_MAX:
+ oldest = min(_link_preview_cache, key=lambda k: _link_preview_cache[k][0])
+ _link_preview_cache.pop(oldest, None)
+ _link_preview_cache[url] = (time.time(), value)
+
# Upload limits (finding C5a). Uploads used to land directly in the shared root under
# a name the client chose, overwriting whatever was already there — which both violated
# node sovereignty and defeated the delete authorization (overwrite a file, become its
@@ -396,6 +424,8 @@ class WebRTCPeerSession:
self._do_chat_message(msg)
elif mtype == MNP.CHAT_HISTORY:
self._do_chat_history(msg)
+ elif mtype == MNP.LINK_PREVIEW_REQ:
+ self._spawn(self._do_link_preview_request(msg))
elif mtype == MNP.PING:
self._do_ping(msg)
elif mtype == MNP.FILE_UPLOAD:
@@ -3409,6 +3439,53 @@ class WebRTCPeerSession:
],
})
+ async def _do_link_preview_request(self, msg: dict) -> None:
+ """
+ Unfurl a URL a member pasted into chat (draft-v6 §2.7 enrichment rule:
+ the client asks, the node produces on demand, the asking device
+ caches — nothing durable here).
+
+ `linkpreview.safe_url` is the SSRF gate: the URL a *member* chose
+ decides an outbound request from the operator's machine, so http(s)
+ only and the resolved address must be globally routable. Failure of
+ any kind — blocked, unreachable, not HTML, nothing worth showing —
+ comes back as `ok: false`, the way a TMDB miss does; the client then
+ just shows the bare link.
+ """
+ url = msg.get("url")
+ key = url if isinstance(url, str) else ""
+ cached = _link_preview_cache_get(key)
+ if cached is not None:
+ self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION})
+ return
+
+ resp: dict = {"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION,
+ "url": key, "ok": False}
+ try:
+ meta = await linkpreview.fetch_preview(url)
+ if meta is not None:
+ resp.update(ok=True, title=meta["title"],
+ description=meta["description"],
+ site_name=meta["site_name"])
+ image_url = meta.get("image_url")
+ media_cache = self._ctx.get("media_cache")
+ if image_url and media_cache is not None:
+ synthetic_id = f"linkpreview:{image_url}"
+ thumb_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id)
+ if thumb_hash is None:
+ jpeg = await linkpreview.fetch_image(image_url)
+ if jpeg:
+ thumb_hash = blake3.blake3(jpeg).hexdigest()
+ await media_cache.put_thumb(thumb_hash, synthetic_id, jpeg)
+ if thumb_hash:
+ resp["image_thumb_hash"] = thumb_hash
+ except Exception as e:
+ log.debug("link_preview_req %s: %s", key[:80], e)
+
+ _link_preview_cache_put(key, {k: v for k, v in resp.items()
+ if k not in ("type", "v")})
+ self._send(resp)
+
def _do_file_upload(self, msg: dict) -> None:
ctx = self._group_ctx()
filename = msg.get("filename", "")