openvibers/openvibe.chat Commits
Chat: draw address previews under a message (#487)
Changes
@@@ -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) {@@@ -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);@@@ -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 };@@@ -209,4 +209,19 @@ t('an own site with no resolver yet is neither page-fetched nor previewed', asynassert.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="<b>bold title</b>"><meta property="og:description" content="<img src=x onerror=alert(1)>">')));++ 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('<b>bold title</b>'), 'the title is escaped as text');++ assert.ok(!html.includes('<b>bold title</b>'), 'never markup');++ assert.ok(html.includes('<img src=x onerror=alert(1)>'), '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(); });
@@ -156,7 +156,44 @@
@@ -156,7 +156,44 @@
box.appendChild(body);
box.appendChild(body);
return box;
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.
// 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'] };
var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };
@@ -271,6 +308,8 @@
@@ -271,6 +308,8 @@
if (m.type === 'chat' && m.is_global) {
if (m.type === 'chat' && m.is_global) {
if (m.id) latest = Math.max(latest, Number(m.id));
if (m.id) latest = Math.max(latest, Number(m.id));
append(globalItem(m), 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)) {
} else if (m.type === 'delete-messages' && Array.isArray(m.ids || m.message_ids)) {
removeIds(m.ids || m.message_ids);
removeIds(m.ids || m.message_ids);
} else if (m.type === 'error' && m.message) {
} else if (m.type === 'error' && m.message) {
@@ -471,6 +510,8 @@
@@ -471,6 +510,8 @@
refreshQueue();
refreshQueue();
} else if (m.type === 'room_ai_typing' && m.room === cfg.room) {
} else if (m.type === 'room_ai_typing' && m.room === cfg.room) {
showThinking(m.name);
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) {
} else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
} else if (m.type === 'room_error' && m.message) {
} else if (m.type === 'room_error' && m.message) {
@@ -124,13 +124,13 @@ function globalMessage(m) {
@@ -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 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 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>` : '';
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') {
function dmMessage(m, me, view = 'room') {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const mine = me && m.sender_id === me.id;
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
// 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.
// 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
@@ -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)}`;
: `<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.
// 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>`;
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);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -127,8 +127,34 @@ function linkCard(card) {
@@ -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>`;
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 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 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>`;
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 };
@@ -209,4 +209,19 @@ t('an own site with no resolver yet is neither page-fetched nor previewed', asyn
@@ -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, []);
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="<b>bold title</b>"><meta property="og:description" content="<img src=x onerror=alert(1)>">')));
+ 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('<b>bold title</b>'), 'the title is escaped as text');
+ assert.ok(!html.includes('<b>bold title</b>'), 'never markup');
+ assert.ok(html.includes('<img src=x onerror=alert(1)>'), '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(); });
t.run(async () => { previews._reset(); await h.close(); });