openvibers/openvibe.chat Commits

Chat: draw address previews under a message (#487)

OP OpenVibers committed 4 days ago · 091082195e · 4 files, +88 −6

* Chat: preview the addresses in a message

A message with up to three https addresses now gets link previews, fetched by a
background job after the message is stored (never by the request) through
openvibe-shared/egress: public addresses only, redirects judged hop by hop, 4 s
and 512 KiB caps, no cookies, a plain user agent. The page's Open Graph and
Twitter fields are read, and YouTube and Vimeo resolve through their oEmbed
endpoint. Previews live in link_previews apart from the message and are cached
by address for a day in link_preview_cache.

The message's read carries embeds: [] at once and the stored previews after the
job finishes; a finished job announces room_message_embeds (rooms) or
chat_embeds (channel/global chat). A page's picture is kept only when it is
already on openvibe.media, so a preview never hot-links another site's image.
Text is capped and stored as data, never markup.

* Chat: register the preview tables, review the fetch, and keep tests off the network

The schema test lists every Chat table and the SSRF ratchet lists every file that makes
its own outbound request; both now name the new preview module. A test that does not stub
the network never starts a preview job, so the suite does not fetch the addresses its
fixtures contain.

* Chat: OpenVibe's own links are preview kinds from their public APIs

A Live channel (/@name) resolves through Live's public channel API and a Video watch
page through Video's watchable resolver; neither page is fetched. The preview keeps the
service's title, by, picture (only on openvibe.media) and, for Live, whether the channel
is live. Every other own site (pics, design, community, network, games) is recognised
and not page-fetched yet: it is the list the later kinds add to.

* Merge branch 'chat-embeds-part1-1110' into chat-embeds-part2-1110

* Chat: number the preview migration 0064

0063 went to the room insight migration on main before this branch merged.

* Merge remote-tracking branch 'origin/main' into chat-embeds-part1-1110

* Merge branch 'chat-embeds-part1-1110' into chat-embeds-part2-1110

* Chat: number the preview migration 0065

0064 went to the party pool migration on main in the same window.

* Merge main into the preview branch (party pool migration 0064)

* Merge branch 'chat-embeds-part1-1110' into chat-embeds-part2-1110

* Merge main into the own-kinds branch

* Chat: draw address previews under a message

The message renderer draws up to three previews from a message's embeds: the picture
when it is on openvibe.media, the address's title as the link, its description, and by /
LIVE for OpenVibe's own kinds. All text is escaped server-side and set with textContent in
the client; the markup reuses the existing oc-card classes (no new CSS). A message with no
previews still draws its one known-link card. The room and global sockets take the
room_message_embeds / chat_embeds update and redraw that line's cards.

* Merge main into the renderer branch

Changes

ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex f8ea5a0..734aab6 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -156,7 +156,44 @@
box.appendChild(body);
return box;
}
-- function addCard(li, m) { var c = linkCard(m && m.card); if (c) li.appendChild(c); }
++ // An address preview (server/chat/link-previews.js): the picture when there is one, the title as the link, the
++ // description, and by/LIVE for OpenVibe's own kinds. Every field is data: textContent, and https only for links
++ // and pictures (openvibe.media for the picture).
++ var EMBED_KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
++ function embedCard(e) {
++ if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return null;
++ var url = httpsHref(e.url);
++ var box = el('div', 'oc-card'); box.dataset.kind = EMBED_KINDS.indexOf(e.kind) >= 0 ? e.kind : 'link';
++ var img = typeof e.image === 'string' && /^https:\/\/openvibe\.media\/[^\s"'<>]+$/.test(e.image) ? httpsHref(e.image) : null;
++ if (img) {
++ var picLink = el('a', 'oc-card-pic'); picLink.href = url || img; picLink.target = '_blank'; picLink.rel = 'noopener nofollow';
++ var picture = document.createElement('img'); picture.src = img; picture.alt = ''; picture.loading = 'lazy'; picture.decoding = 'async';
++ picLink.appendChild(picture); box.appendChild(picLink);
++ }
++ var body = el('div', 'oc-card-body');
++ if (url) { var title = el('a', 'oc-card-title', e.title); title.href = url; title.target = '_blank'; title.rel = 'noopener nofollow'; body.appendChild(title); }
++ else body.appendChild(el('span', 'oc-card-title', e.title));
++ if (typeof e.description === 'string' && e.description) body.appendChild(el('p', 'oc-card-line', e.description));
++ if (typeof e.by === 'string' && e.by) body.appendChild(el('p', 'oc-card-line', 'by ' + e.by));
++ if (e.live === true) { var live = el('p', 'oc-card-note'); live.appendChild(el('span', 'oc-badge oc-badge-mod', 'LIVE')); body.appendChild(live); }
++ box.appendChild(body);
++ return box;
++ }
++ function addCard(li, m) {
++ if (m && Array.isArray(m.embeds) && m.embeds.length) {
++ m.embeds.slice(0, 3).forEach(function (e) { var c = embedCard(e); if (c) li.appendChild(c); });
++ return;
++ }
++ var c = linkCard(m && m.card); if (c) li.appendChild(c);
++ }
++ // A preview that landed after its message: replace the line's cards with the finished list.
++ function updateEmbeds(id, embeds) {
++ if (!feed || !Array.isArray(embeds) || !embeds.length) return;
++ var li = feed.querySelector('[data-id="' + Number(id) + '"]');
++ if (!li) return;
++ Array.prototype.forEach.call(li.querySelectorAll('.oc-card'), function (c) { c.remove(); });
++ addCard(li, { embeds: embeds });
++ }
// Display only (the server decides who may do what): the same badges the page renders.
var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };
@@@ -271,6 +308,8 @@
if (m.type === 'chat' && m.is_global) {
if (m.id) latest = Math.max(latest, Number(m.id));
append(globalItem(m), m.id);
++ } else if (m.type === 'chat_embeds' && m.id) {
++ updateEmbeds(m.id, m.embeds);
} else if (m.type === 'delete-messages' && Array.isArray(m.ids || m.message_ids)) {
removeIds(m.ids || m.message_ids);
} else if (m.type === 'error' && m.message) {
@@@ -471,6 +510,8 @@
refreshQueue();
} else if (m.type === 'room_ai_typing' && m.room === cfg.room) {
showThinking(m.name);
++ } else if (m.type === 'room_message_embeds' && m.room === cfg.room) {
++ updateEmbeds(m.id, m.embeds);
} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
} else if (m.type === 'room_error' && m.message) {
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex bd5c3c1..66b2832 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -124,13 +124,13 @@ function globalMessage(m) {
const name = fx ? `<span class="ov-fx ${fx}">${esc(m.display_name || m.username || m.anon_id || 'someone')}</span>` : esc(m.display_name || m.username || m.anon_id || 'someone');
const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="--nc:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${hatHtml(m)}${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
-- return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${ui.messageCards(m)}</li>`;
}
function dmMessage(m, me, view = 'room') {
const t = hhmm(m.created_at);
const mine = me && m.sender_id === me.id;
-- if (view === 'bubbles') return `<li class="oc-bubble-row${mine ? ' oc-bubble-mine' : ''}" data-id="${Number(m.id) || 0}">${!mine ? `<span class="oc-bubble-author">${esc(m.display_name || m.username)}</span>` : ''}<div class="oc-bubble">${linkify(m.message)}</div>${ui.linkCard(m.card)}<time class="oc-bubble-time" datetime="${t.iso}">${t.text}</time></li>`;
-- return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
++ if (view === 'bubbles') return `<li class="oc-bubble-row${mine ? ' oc-bubble-mine' : ''}" data-id="${Number(m.id) || 0}">${!mine ? `<span class="oc-bubble-author">${esc(m.display_name || m.username)}</span>` : ''}<div class="oc-bubble">${linkify(m.message)}</div>${ui.messageCards(m)}<time class="oc-bubble-time" datetime="${t.iso}">${t.text}</time></li>`;
++ return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${ui.messageCards(m)}</li>`;
}
// Vibe, OpenVibe's assistant, as a room's AI member (one click on the room's settings; a person's own chats with it are
// server/rooms/vibe.js): how it should be in a group.
@@@ -932,7 +932,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${linkify(m.message)}</span>`;
-- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${ai ? '' : pictures.pageHtml(m.message)}${ui.linkCard(m.card)}${del}</li>`;
++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${del}</li>`;
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
ddiff --git a/server/web/ui.js b/server/web/ui.js
iindex 58a405b..ab36360 100644
---- a/server/web/ui.js
++++ b/server/web/ui.js
@@@ -127,8 +127,34 @@ function linkCard(card) {
return `<div class="oc-card" data-kind="${esc(typeof card.kind === 'string' ? card.kind : 'link')}">${pic}<div class="oc-card-body">${head}${line ? `<p class="oc-card-line">${esc(line)}</p>` : ''}${note ? `<p class="oc-card-note">${esc(note)}</p>` : ''}${go}</div></div>`;
}
++/**
++ * An address preview under a message (server/chat/link-previews.js): the picture when there is one, the address's
++ * title as the link, its description, and "by" and LIVE for OpenVibe's own kinds. Every field is data: escaped here.
++ */
++function embedCard(e) {
++ if (!e || typeof e !== 'object' || typeof e.title !== 'string' || !e.title) return '';
++ const href = cardHref(e.url);
++ const image = cardImage(e.image);
++ const kind = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'].includes(e.kind) ? e.kind : 'link';
++ const head = href
++ ? `<a class="oc-card-title" href="${esc(href)}" rel="noopener nofollow" target="_blank">${esc(e.title)}</a>`
++ : `<span class="oc-card-title">${esc(e.title)}</span>`;
++ const pic = image ? `<a class="oc-card-pic" href="${esc(href || image)}" rel="noopener nofollow" target="_blank"><img src="${esc(image)}" alt="" loading="lazy" decoding="async"></a>` : '';
++ const description = typeof e.description === 'string' && e.description ? `<p class="oc-card-line">${esc(e.description)}</p>` : '';
++ const by = typeof e.by === 'string' && e.by ? `<p class="oc-card-line">by ${esc(e.by)}</p>` : '';
++ const live = e.live === true ? '<p class="oc-card-note"><span class="oc-badge oc-badge-mod">LIVE</span></p>' : '';
++ return `<div class="oc-card" data-kind="${esc(kind)}">${pic}<div class="oc-card-body">${head}${description}${by}${live}</div></div>`;
++}
++
++/** A message's previews (up to three, in the order the addresses appeared), or its one known-link card when it has none. */
++function messageCards(m) {
++ if (!m || typeof m !== 'object') return '';
++ if (Array.isArray(m.embeds) && m.embeds.length) return m.embeds.slice(0, 3).map(embedCard).filter(Boolean).join('');
++ return linkCard(m.card);
++}
++
const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
--module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard };
++module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards };
ddiff --git a/test/link-previews.test.js b/test/link-previews.test.js
iindex 93be393..944928a 100644
---- a/test/link-previews.test.js
++++ b/test/link-previews.test.js
@@@ -209,4 +209,19 @@ t('an own site with no resolver yet is neither page-fetched nor previewed', asyn
assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, []);
});
++t('the room page draws a preview with its text escaped, never as markup', async () => {
++ previews._setRequest(async () => type(page('<meta property="og:title" content="&lt;b&gt;bold title&lt;/b&gt;"><meta property="og:description" content="&lt;img src=x onerror=alert(1)&gt;">')));
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Markup room' } });
++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://93.184.216.34/draw-me' } });
++ assert.strictEqual(sent.status, 201, sent.text);
++ await previews.idle();
++ const res = await fetch(`${h.base}/r/${made.body.room.slug}`, { headers: { cookie: `ov_token=${alice.token}` } });
++ const html = await res.text();
++ assert.match(html, /<div class="oc-card" data-kind="link">/, 'the card markup is drawn');
++ assert.ok(html.includes('&lt;b&gt;bold title&lt;/b&gt;'), 'the title is escaped as text');
++ assert.ok(!html.includes('<b>bold title</b>'), 'never markup');
++ assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'), 'the description is escaped too');
++ assert.ok(!html.includes('<img src=x onerror='), 'never an injected tag');
++});
++
t.run(async () => { previews._reset(); await h.close(); });