diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-28 03:43:19 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-28 03:43:19 +0200 |
| commit | ce4e10c4b8bd9c66c375c3a5d5c18d8552655775 (patch) | |
| tree | d72429e99d5a4eb8e6d59d9d41a004aae4cb70e7 /packages/meshbay-node/src/meshbay_node/linkpreview.py | |
| parent | e1f1b65cfac031096e4bae24ccf102ca0dbb86d9 (diff) | |
| download | meshbay-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/linkpreview.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/linkpreview.py | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/linkpreview.py b/packages/meshbay-node/src/meshbay_node/linkpreview.py new file mode 100644 index 0000000..64067d1 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/linkpreview.py @@ -0,0 +1,251 @@ +""" +Link unfurling for chat — fetch a URL a member pasted and pull an +OpenGraph-style card out of it (title, description, site name, image). + +Who does the fetch matters. It is **the node**, not the browser and not the +hub: + + * 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 + every render; + * the hub must not — it never touches group content (draft-v6 §2.5); + * the node already fetches third-party metadata for the Videos and Music + apps (`_fetch_and_cache_poster`), over the same authorised path. + +Because the node makes an outbound request to an address a *member* chose, +this is an SSRF surface. `safe_url()` is the gate: http(s) only, no +credentials, and the resolved address must be globally routable — no +loopback, private, link-local, multicast or reserved range. Redirects are +followed by hand so every hop is re-checked. Residual: a DNS name that +resolves clean here and to something internal microseconds later at connect +time (rebinding) — narrow, and closed properly by pinning the checked IP, +which is a follow-up. + +Nothing is stored durably: the caller keeps an in-memory TTL cache and the +OG image rides the existing `media_cache` thumb store (same as a poster). +""" + +from __future__ import annotations + +import ipaddress +import logging +import socket +from html.parser import HTMLParser +from io import BytesIO +from urllib.parse import urljoin, urlsplit + +import httpx + +log = logging.getLogger(__name__) + +_TIMEOUT = 5.0 +_MAX_REDIRECTS = 3 +_MAX_HTML_BYTES = 512 * 1024 +_MAX_IMAGE_BYTES = 2 * 1024 * 1024 +_IMAGE_MAX_DIM = 600 +_UA = "MeshBayBot/1.0 (+https://meshbay.org; link preview)" + + +class UnsafeURL(ValueError): + """The URL points somewhere the node must not fetch from.""" + + +def _addr_is_public(ip: str) -> bool: + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None: + addr = addr.ipv4_mapped + return not ( + addr.is_private or addr.is_loopback or addr.is_link_local + or addr.is_multicast or addr.is_reserved or addr.is_unspecified + ) + + +def safe_url(url: str) -> str: + """Return the URL unchanged if it is safe to fetch, else raise UnsafeURL.""" + if not isinstance(url, str) or len(url) > 2048: + raise UnsafeURL("missing or oversized") + parts = urlsplit(url) + if parts.scheme not in ("http", "https"): + raise UnsafeURL(f"scheme {parts.scheme!r}") + if parts.username or parts.password: + raise UnsafeURL("credentials in URL") + host = parts.hostname + if not host: + raise UnsafeURL("no host") + # An IP literal is checked directly; a name is resolved and every answer + # must be public — a hostname with one public and one 127.0.0.1 record + # would otherwise be a way in. + try: + infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80), + proto=socket.IPPROTO_TCP) + except socket.gaierror as e: + raise UnsafeURL(f"cannot resolve: {e}") + resolved = {info[4][0] for info in infos} + if not resolved: + raise UnsafeURL("resolves to nothing") + bad = [ip for ip in resolved if not _addr_is_public(ip)] + if bad: + raise UnsafeURL(f"non-public address {bad[0]}") + return url + + +class _HeadParser(HTMLParser): + """Collects <title> text and name/property→content from <meta> in <head>. + + Stops caring once <body> starts: everything a card needs is in the head, + and a 512 KB page of body is not worth walking. + """ + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.metas: dict[str, str] = {} + self.title: str | None = None + self._in_title = False + self.done = False + + def handle_starttag(self, tag, attrs): + if tag == "body": + self.done = True + elif tag == "title": + self._in_title = True + elif tag == "meta": + a = {k.lower(): (v or "") for k, v in attrs} + key = (a.get("property") or a.get("name") or "").lower().strip() + if key and "content" in a and key not in self.metas: + self.metas[key] = a["content"].strip() + + def handle_endtag(self, tag): + if tag == "title": + self._in_title = False + + def handle_data(self, data): + if self._in_title and self.title is None: + text = data.strip() + if text: + self.title = text + + +def _first(metas: dict[str, str], *keys: str) -> str | None: + for k in keys: + v = metas.get(k) + if v: + return v + return None + + +async def _get(client: httpx.AsyncClient, url: str) -> httpx.Response: + """One GET with manual, re-validated redirects.""" + current = safe_url(url) + for _ in range(_MAX_REDIRECTS + 1): + resp = await client.get(current, headers={"User-Agent": _UA}, + follow_redirects=False) + if resp.is_redirect and "location" in resp.headers: + current = safe_url(urljoin(current, resp.headers["location"])) + continue + return resp + raise UnsafeURL("too many redirects") + + +async def fetch_preview(url: str, *, client: httpx.AsyncClient | None = None) -> dict | None: + """ + Return {url, title, description, site_name, image_url} for a URL, or None + if it cannot be unfurled (unreachable, not HTML, nothing worth showing). + Never raises for an ordinary failure — the caller treats "no preview" as + the common case, exactly like a TMDB miss. + """ + own = client is None + if own: + client = httpx.AsyncClient(timeout=_TIMEOUT, max_redirects=0) + try: + safe_url(url) + resp = await _get(client, url) + ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower() + if resp.status_code != 200 or ctype not in ("text/html", "application/xhtml+xml"): + return None + + body = b"" + async for chunk in resp.aiter_bytes(): + body += chunk + if len(body) >= _MAX_HTML_BYTES: + break + final_url = str(resp.url) + + parser = _HeadParser() + try: + parser.feed(body.decode(resp.encoding or "utf-8", errors="replace")) + except Exception: + pass + m = parser.metas + + title = _first(m, "og:title", "twitter:title") or parser.title + description = _first(m, "og:description", "twitter:description", "description") + site_name = _first(m, "og:site_name") or urlsplit(final_url).hostname + image = _first(m, "og:image", "og:image:url", "og:image:secure_url", + "twitter:image", "twitter:image:src") + if image: + image = urljoin(final_url, image) + try: + safe_url(image) + except UnsafeURL: + image = None + + if not title and not description: + return None + + return { + "url": url, + "title": (title or "")[:300] or None, + "description": (description or "")[:600] or None, + "site_name": (site_name or "")[:120] or None, + "image_url": image, + } + except (httpx.HTTPError, UnsafeURL) as e: + log.debug("link preview for %s: %s", url[:80], e) + return None + finally: + if own: + await client.aclose() + + +async def fetch_image(url: str, *, client: httpx.AsyncClient | None = None) -> bytes | None: + """Fetch and re-encode an OG image to a small JPEG. None on any failure.""" + own = client is None + if own: + client = httpx.AsyncClient(timeout=_TIMEOUT, max_redirects=0) + try: + safe_url(url) + resp = await _get(client, url) + ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower() + if resp.status_code != 200 or not ctype.startswith("image/"): + return None + raw = b"" + async for chunk in resp.aiter_bytes(): + raw += chunk + if len(raw) > _MAX_IMAGE_BYTES: + return None + return _downscale(raw) + except (httpx.HTTPError, UnsafeURL) as e: + log.debug("link preview image %s: %s", url[:80], e) + return None + finally: + if own: + await client.aclose() + + +def _downscale(raw: bytes) -> bytes | None: + try: + from PIL import Image + except ImportError: + return None + try: + with Image.open(BytesIO(raw)) as im: + im = im.convert("RGB") + im.thumbnail((_IMAGE_MAX_DIM, _IMAGE_MAX_DIM)) + out = BytesIO() + im.save(out, format="JPEG", quality=80) + return out.getvalue() + except Exception: + return None |