openvibers/openvibe.chat Commits

Chat: OpenVibe's own links are preview kinds (#486)

OP OpenVibers committed 4 days ago · 77a6f3c39f · 2 files, +174 −3

* 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

Changes

ddiff --git a/server/chat/link-previews.js b/server/chat/link-previews.js
iindex 6337dcb..9d5774c 100644
---- a/server/chat/link-previews.js
++++ b/server/chat/link-previews.js
@@@ -34,7 +34,7 @@ const MAX_DESCRIPTION = 300;
const MAX_SITE = 80;
const MAX_JOBS = 2;
const MAX_QUEUE = 200;
--const KINDS = ['link', 'video', 'image'];
++const KINDS = ['link', 'video', 'image', 'live'];
const USER_AGENT = 'OpenVibe-Chat-LinkPreview/1 (+https://openvibe.chat)';
const PREVIEWS_ON = String(process.env.CHAT_LINK_PREVIEWS || '').toLowerCase() !== 'off';
@@@ -198,6 +198,96 @@ function oembedFor(url) {
return null;
}
++/**
++ * OpenVibe's own sites are first-class kinds and are never page-fetched: an address there is resolved through that
++ * service's public API, or its oEmbed where it has one. Windows whose service has no resolver yet return null (and
++ * are not page-fetched either): that is the list of kinds a later part adds.
++ */
++const OWN_HOSTS = new Map([
++ ['openvibe.live', 'live'], ['openvibe.video', 'video'], ['openvibe.pics', 'picture'],
++ ['openvibe.design', 'design'], ['openvibe.community', 'post'], ['openvibe.network', 'profile'],
++ ['openvibe.games', 'game'],
++]);
++
++function baseOf(value, production) {
++ const raw = String(value || (process.env.NODE_ENV === 'test' ? '' : production)).replace(/\/+$/, '');
++ if (!raw) return '';
++ try {
++ const u = new URL(raw);
++ return u.protocol === 'https:' || u.protocol === 'http:' ? raw : '';
++ } catch { return ''; }
++}
++const liveBase = () => baseOf(process.env.CHAT_LINK_LIVE_URL, 'https://openvibe.live');
++const videoBase = () => baseOf(process.env.CHAT_LINK_VIDEO_URL, 'https://openvibe.video');
++
++/** What an address on one of our sites asks that site for, or null for host/path shapes we do not know. */
++function ownLink(u) {
++ const host = u.hostname.toLowerCase().replace(/^www\./, '');
++ if (!OWN_HOSTS.has(host)) return null;
++ const site = OWN_HOSTS.get(host);
++ if (site === 'live') {
++ const m = /^\/@([A-Za-z0-9_]{3,24})(?:\/[^/]+)?\/?$/.exec(u.pathname);
++ return m ? { site, username: m[1] } : { site };
++ }
++ if (site === 'video') {
++ if (u.pathname === '/watch' && /^[A-Za-z0-9_-]{11}$/.test(String(u.searchParams.get('v') || ''))) return { site, page: u.href };
++ if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { site, page: u.href };
++ return { site };
++ }
++ return { site };
++}
++
++async function readJsonUrl(url) {
++ const res = await fetchFollowing(url);
++ if (!res) return null;
++ try { return JSON.parse(String(res.body || '')); } catch { return null; }
++}
++
++/** A live channel (/@name, no page fetch): Live's public channel API says who and whether they are live now. */
++async function livePreview(url, username) {
++ const base = liveBase();
++ if (!base) return null;
++ const item = await readJsonUrl(`${base}/api/streams/channel/${encodeURIComponent(username)}?pollOnly=1`);
++ const channel = item && item.channel;
++ if (!channel || typeof channel !== 'object') return null;
++ const title = textOf(channel.display_name || channel.username || username, MAX_TITLE);
++ if (!title) return null;
++ return {
++ url,
++ site_name: 'OpenVibe.Live',
++ title,
++ description: textOf(channel.bio, MAX_DESCRIPTION),
++ image: imageOf(channel.avatar_url),
++ kind: 'live',
++ width: 0,
++ height: 0,
++ by: textOf(channel.username || username, 80),
++ live: Array.isArray(item.streams) ? item.streams.length > 0 : !!item.stream,
++ };
++}
++
++/** A Video watch page (no page fetch): Video's own watchable resolver. */
++async function videoPreview(url, page) {
++ const base = videoBase();
++ if (!base) return null;
++ const item = await readJsonUrl(`${base}/api/v1/video/watchable?url=${encodeURIComponent(page)}`);
++ if (!item || typeof item !== 'object') return null;
++ const title = textOf(item.title, MAX_TITLE);
++ if (!title) return null;
++ return {
++ url,
++ site_name: 'OpenVibe.Video',
++ title,
++ description: '',
++ image: imageOf(item.thumbnail_url),
++ kind: 'video',
++ width: 0,
++ height: 0,
++ by: textOf(item.channel, 80),
++ live: false,
++ };
++}
++
/** One GET, https only, through the egress lookup, no redirects followed here, at most FETCH_TIMEOUT_MS and MAX_BYTES. */
function requestOnce(url) {
if (_request) return Promise.resolve(_request(url));
@@@ -259,6 +349,12 @@ async function fetchFollowing(url) {
/** Read one address: oEmbed when it is on the short allow-list, the page otherwise. A failure is no preview. */
async function readUrl(url) {
++ const own = ownLink(new URL(url));
++ if (own) {
++ if (own.username) return await livePreview(url, own.username);
++ if (own.page) return await videoPreview(url, own.page);
++ return null;
++ }
const oembed = oembedFor(new URL(url));
if (oembed) {
try {
@@@ -281,7 +377,7 @@ function parsePreview(value) {
if (typeof p === 'string') { try { p = JSON.parse(p); } catch { return null; } }
if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
if (!KINDS.includes(p.kind) || typeof p.title !== 'string' || !p.title || typeof p.url !== 'string') return null;
-- return {
++ const preview = {
url: p.url,
site_name: textOf(p.site_name, MAX_SITE),
title: textOf(p.title, MAX_TITLE),
@@@ -291,6 +387,9 @@ function parsePreview(value) {
width: intOf(p.width),
height: intOf(p.height),
};
++ if (p.by != null) preview.by = textOf(p.by, 80);
++ if (p.live === true || p.live === false) preview.live = p.live;
++ return preview;
}
async function cached(value) {
@@@ -435,7 +534,7 @@ async function attach(rows, scope = 'room') {
module.exports = {
addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
-- previewOf, schedule, embedsOf, attach, idle,
++ previewOf, schedule, embedsOf, attach, idle, ownLink,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
_setRequest(fn) { _request = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
ddiff --git a/test/link-previews.test.js b/test/link-previews.test.js
iindex c515828..93be393 100644
---- a/test/link-previews.test.js
++++ b/test/link-previews.test.js
@@@ -137,4 +137,76 @@ t('three addresses at most, and a redirect is followed only once it is judged pu
assert.ok(calls.includes('https://93.184.216.34/target'));
});
++t('a Live channel is a live kind from its public API, with no page fetch', async () => {
++ process.env.CHAT_LINK_LIVE_URL = 'https://93.184.216.34';
++ const calls = [];
++ previews._setRequest(async (url) => {
++ calls.push(url);
++ if (url.includes('/api/streams/channel/streamer')) {
++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
++ channel: { username: 'streamer', display_name: 'The Streamer', bio: 'Live every Friday', avatar_url: 'https://openvibe.media/avatars/s.jpg' },
++ streams: [{ id: 7 }],
++ }) };
++ }
++ return { status: 404, headers: {}, body: '' };
++ });
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Live kinds' } });
++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'watch https://openvibe.live/@streamer tonight' } });
++ assert.strictEqual(sent.status, 201, sent.text);
++ await previews.idle();
++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
++ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];
++ assert.strictEqual(embed.kind, 'live');
++ assert.strictEqual(embed.title, 'The Streamer');
++ assert.strictEqual(embed.by, 'streamer');
++ assert.strictEqual(embed.live, true);
++ assert.strictEqual(embed.image, 'https://openvibe.media/avatars/s.jpg');
++ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/streams/channel/streamer?pollOnly=1'], 'the page itself is never fetched');
++});
++
++t('an offline Live channel is the same kind with live: false', async () => {
++ previews._setRequest(async () => ({ status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
++ channel: { username: 'quiet', display_name: 'Quiet Corner' }, streams: [],
++ }) }));
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Offline kind' } });
++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.live/@quiet' } });
++ assert.strictEqual(sent.status, 201, sent.text);
++ await previews.idle();
++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
++ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];
++ assert.strictEqual(embed.kind, 'live');
++ assert.strictEqual(embed.live, false);
++});
++
++t('a Video watch page is resolved by Video and never page-fetched', async () => {
++ process.env.CHAT_LINK_VIDEO_URL = 'https://93.184.216.34';
++ const calls = [];
++ previews._setRequest(async (url) => {
++ calls.push(url);
++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Friday film', channel: 'ann', thumbnail_url: 'https://openvibe.media/vod/friday.jpg', duration_s: 3725 }) };
++ });
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Video kind' } });
++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.video/watch?v=dQw4w9WgXcQ' } });
++ assert.strictEqual(sent.status, 201, sent.text);
++ await previews.idle();
++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
++ const embed = got.body.messages.find((m) => m.id === sent.body.message.id).embeds[0];
++ assert.strictEqual(embed.kind, 'video');
++ assert.strictEqual(embed.title, 'Friday film');
++ assert.strictEqual(embed.by, 'ann');
++ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/v1/video/watchable?url=https%3A%2F%2Fopenvibe.video%2Fwatch%3Fv%3DdQw4w9WgXcQ']);
++});
++
++t('an own site with no resolver yet is neither page-fetched nor previewed', async () => {
++ let called = false;
++ previews._setRequest(async () => { called = true; return { status: 200, headers: { 'content-type': 'text/html' }, body: '<meta property="og:title" content="Should not be read">' }; });
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Unreached kind' } });
++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://openvibe.pics/p/some-picture' } });
++ assert.strictEqual(sent.status, 201, sent.text);
++ await previews.idle();
++ assert.strictEqual(called, false);
++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, []);
++});
++
t.run(async () => { previews._reset(); await h.close(); });