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 --- .../tests/test_link_preview_request.py | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/meshbay-node/tests/test_link_preview_request.py (limited to 'packages/meshbay-node/tests/test_link_preview_request.py') diff --git a/packages/meshbay-node/tests/test_link_preview_request.py b/packages/meshbay-node/tests/test_link_preview_request.py new file mode 100644 index 0000000..d975f91 --- /dev/null +++ b/packages/meshbay-node/tests/test_link_preview_request.py @@ -0,0 +1,98 @@ +""" +`_do_link_preview_request` — routing, the media_cache image round-trip, and +the in-memory result cache. + +The fetch itself (`linkpreview.fetch_preview` / `fetch_image`) is covered by +test_linkpreview.py and stubbed here, so this is purely about what the +handler does with a result: reply shape, storing the OG image under its +blake3 in the thumb store, and not re-fetching a URL it has already seen. +""" + +import blake3 +import pytest +from meshbay_common.protocol import MNP +from meshbay_node import linkpreview +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport import webrtc_server +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +@pytest.fixture(autouse=True) +def _clear_cache(): + webrtc_server._link_preview_cache.clear() + yield + webrtc_server._link_preview_cache.clear() + + +def _session(media_cache): + s = WebRTCPeerSession.__new__(WebRTCPeerSession) + s._ctx = {"media_cache": media_cache} + s.sent = [] + s._send = s.sent.append + return s + + +async def test_reply_carries_the_card_and_caches_the_image(media_cache, monkeypatch): + async def fake_preview(url, **k): + return {"url": url, "title": "Hello", "description": "World", + "site_name": "Example", "image_url": "https://example.com/c.png"} + + async def fake_image(url, **k): + return b"jpeg-bytes" + monkeypatch.setattr(linkpreview, "fetch_preview", fake_preview) + monkeypatch.setattr(linkpreview, "fetch_image", fake_image) + + s = _session(media_cache) + await s._do_link_preview_request({"url": "https://example.com/p"}) + + (resp,) = s.sent + assert resp["type"] == MNP.LINK_PREVIEW_RESP + assert resp["ok"] is True + assert resp["title"] == "Hello" + assert resp["site_name"] == "Example" + want_hash = blake3.blake3(b"jpeg-bytes").hexdigest() + assert resp["image_thumb_hash"] == want_hash + # And the bytes are in the thumb store, servable via the normal file_req path. + assert await media_cache.get_thumb(want_hash) == b"jpeg-bytes" + + +async def test_unfurlable_failure_is_ok_false(media_cache, monkeypatch): + async def none_preview(url, **k): + return None + monkeypatch.setattr(linkpreview, "fetch_preview", none_preview) + + s = _session(media_cache) + await s._do_link_preview_request({"url": "http://169.254.169.254/"}) + + (resp,) = s.sent + assert resp["ok"] is False + assert "image_thumb_hash" not in resp + + +async def test_second_request_for_the_same_url_is_served_from_cache(media_cache, monkeypatch): + calls = {"n": 0} + + async def counting_preview(url, **k): + calls["n"] += 1 + return {"url": url, "title": "Once", "description": None, + "site_name": None, "image_url": None} + monkeypatch.setattr(linkpreview, "fetch_preview", counting_preview) + + s = _session(media_cache) + await s._do_link_preview_request({"url": "https://example.com/a"}) + await s._do_link_preview_request({"url": "https://example.com/a"}) + + assert calls["n"] == 1 + assert len(s.sent) == 2 + assert s.sent[0]["title"] == s.sent[1]["title"] == "Once" + assert all(r["type"] == MNP.LINK_PREVIEW_RESP for r in s.sent) -- cgit v1.2.3