aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_link_preview_request.py
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/tests/test_link_preview_request.py
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/tests/test_link_preview_request.py')
-rw-r--r--packages/meshbay-node/tests/test_link_preview_request.py98
1 files changed, 98 insertions, 0 deletions
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)