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 --- .../meshbay-hub/src/meshbay_hub/static/chat-app.js | 114 +++++++++++++++++++-- .../meshbay-hub/src/meshbay_hub/static/style.css | 52 ++++++++++ .../src/meshbay_hub/static/transport.js | 27 +++++ 3 files changed, 186 insertions(+), 7 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 0babf51..3d518b3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -15,15 +15,20 @@ import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; */ const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; +function _trimUrl(url) { + let tail = ''; + while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } + return url; +} + function linkify(text) { const out = []; let last = 0; for (const m of String(text).matchAll(URL_RE)) { if (m.index > last) out.push(text.slice(last, m.index)); // Trailing punctuation is almost never part of the address. - let url = m[0]; - let tail = ''; - while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } + const url = _trimUrl(m[0]); + const tail = m[0].slice(url.length); out.push(html`${url}`); if (tail) out.push(tail); @@ -33,6 +38,79 @@ function linkify(text) { return out; } +/** The first http(s) URL in a message, address only, or null. */ +function firstUrl(text) { + const m = String(text).match(URL_RE); + return m ? _trimUrl(m[0]) : null; +} + +// Unfurled cards and their images, per session. The node keeps nothing +// durable (draft-v6 §2.7) and re-answers on request; this stops the same +// message re-asking on every scroll/rerender. `_previewCache` values are the +// node's response object, or the string 'pending' / 'failed'. +const _previewCache = new Map(); +const _previewImgCache = new Map(); + +function LinkPreview({ url, transportRef, gekRef }) { + const [data, setData] = useState(() => { + const c = _previewCache.get(url); + return c && typeof c === 'object' ? c : null; + }); + const [imgUrl, setImgUrl] = useState(() => null); + + useEffect(() => { + let cancelled = false; + const cached = _previewCache.get(url); + if (cached === 'failed') return; + if (cached && typeof cached === 'object') { setData(cached); return; } + if (cached === 'pending') return; + + const transport = transportRef.current; + if (!transport || !transport.connected) return; + _previewCache.set(url, 'pending'); + transport.fetchLinkPreview(url) + .then(resp => { + const value = resp && resp.ok ? resp : 'failed'; + _previewCache.set(url, value); + if (!cancelled && typeof value === 'object') setData(value); + }) + .catch(() => { _previewCache.set(url, 'failed'); }); + return () => { cancelled = true; }; + }, [url]); + + const thumbHash = data && data.image_thumb_hash; + useEffect(() => { + if (!thumbHash) return; + const cached = _previewImgCache.get(thumbHash); + if (cached) { setImgUrl(cached); return; } + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1); + if (cancelled) return; + const blobUrl = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' })); + _previewImgCache.set(thumbHash, blobUrl); + setImgUrl(blobUrl); + } catch { /* card renders without the image */ } + })(); + return () => { cancelled = true; }; + }, [thumbHash]); + + if (!data || !data.ok) return null; + return html` + + ${imgUrl && html``} + + ${data.site_name && html`${data.site_name}`} + ${data.title && html`${data.title}`} + ${data.description && html`${data.description}`} + + + `; +} + function formatTime(ts) { const d = new Date(ts * 1000); const now = new Date(); @@ -217,6 +295,27 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, if (atBottomRef.current) list.scrollTop = list.scrollHeight; }, [messages]); + // Keep the view pinned to the newest message while the reader is at the + // bottom, through everything that grows the content *after* the initial + // paint: attachment thumbnails and link-preview cards fetched over the + // network, late layout, and the panel resizing itself with `fit()` below. + // Without this, opening the Chat tab reliably lands a screen or two above + // the last message — the layout effect above ran when the list was still + // short. Does nothing once the reader scrolls up (`atBottomRef` is false), + // so it never fights reading back or the "load older" anchor. + useEffect(() => { + const list = listRef.current; + if (!list || typeof ResizeObserver === 'undefined') return; + const stick = () => { + if (atBottomRef.current) list.scrollTop = list.scrollHeight; + }; + const ro = new ResizeObserver(stick); + ro.observe(list); + for (const child of list.children) ro.observe(child); + stick(); + return () => ro.disconnect(); + }, [messages]); + // The panel was `calc(100vh - 220px)`: a guess at how much sits above it. On a // phone the group header — title, description, edit link, delete button, tabs // — is closer to 430px, so the panel ran past the fold and the composer ended @@ -374,6 +473,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, ? _dayLabel(m.timestamp) : null; const parsed = _parsePayload(m.payload); const att = parsed && parsed.attachment; + const msgText = parsed && typeof parsed.text === 'string' ? parsed.text : m.payload; + const msgUrl = att ? null : firstUrl(msgText); return html` ${daySep && html`
${daySep}
@@ -403,10 +504,9 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
${formatSize(att.size)}
` : html` - - ${linkify(parsed && typeof parsed.text === 'string' - ? parsed.text : m.payload)} - + ${linkify(msgText)} + ${msgUrl && html`<${LinkPreview} url=${msgUrl} + transportRef=${transportRef} gekRef=${gekRef} />`} `} ${formatTime(m.timestamp)} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 975f47c..61a5e3b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -581,6 +581,58 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .chat-text { white-space: pre-wrap; } +/* Link unfurl card. Sits on its own line inside the bubble and carries its + own surface colours so it stays readable inside an own-message bubble too. */ +.chat-link-preview { + flex: 0 0 100%; + display: flex; + gap: 10px; + margin-top: 4px; + padding: 8px; + max-width: 380px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 8px; + background: var(--bg-base); + color: var(--text); + text-decoration: none; + overflow: hidden; +} +.chat-link-preview:hover { background: var(--bg-surface); } +.clp-img { + width: 72px; + height: 72px; + flex-shrink: 0; + object-fit: cover; + border-radius: 6px; + background: var(--bg-surface); +} +.clp-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.clp-site { + font-size: 0.7em; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.clp-title { + font-weight: 600; + font-size: 0.9em; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.clp-desc { + font-size: 0.82em; + color: var(--text-secondary); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + .chat-time { font-size: 0.65em; color: var(--text-dim); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 9f85ee1..1a146ce 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -846,6 +846,20 @@ class MeshBayTransport { return msg; } + /** + * Unfurl a URL pasted in chat. The node fetches it (the browser cannot — + * CSP and CORS — and would leak every reader's IP), parses an OpenGraph + * card, and caches any image in its thumb store; `image_thumb_hash` then + * rides the normal file_req path like a poster. `ok: false` means "no + * preview" (blocked, unreachable, not HTML) — the caller just shows the + * bare link. Keyed by url: a message with several links fires one each. + */ + async fetchLinkPreview(url) { + const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + /** * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's * per-season view) — a show's own tmdb_meta is one static field that does @@ -1854,6 +1868,9 @@ class MeshBayTransport { ? `chunk:${obj.file_id}:${obj.chunk_index}` : obj.type === 'ping' ? `ping:${obj.token}` : obj.type === 'media_meta_req' ? `media_meta:${obj.file_id}` + // One chat message can carry several links, each unfurled on its + // own; matching by arrival order would swap two cards. + : obj.type === 'link_preview_req' ? `link_preview:${obj.url}` // Same reordering hazard as media_meta_req: an album grid fires // one music_meta_req per visible tile, several at a time. : obj.type === 'music_meta_req' ? `music_meta:${obj.file_id}` @@ -2171,6 +2188,16 @@ class MeshBayTransport { return; } + // Same reasoning as media_meta_resp: keyed by url, and "nobody's waiting" + // must not fall through. + if (msg.type === 'link_preview_resp') { + const key = `link_preview:${msg.url}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + return; + } + // Same reasoning as media_meta_resp: keyed, not arrival-order, and // "nobody's waiting any more" must not fall through either. if (msg.type === 'music_meta_resp') { -- cgit v1.2.3