openvibers/openvibe.chat Commits

Chat: Pics, Community, Network and Games links are preview kinds (#503)

OP OpenVibers committed 3 days ago · c2aee8f52d · 2 files, +202 −5

* Chat: Pics, Community, Network and Games links are preview kinds

* Merge remote-tracking branch 'origin/main' into chat-own-kinds

Changes

ddiff --git a/server/chat/link-previews.js b/server/chat/link-previews.js
iindex 2a93231..24401b0 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', 'live'];
++const KINDS = ['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game'];
const USER_AGENT = 'OpenVibe-Chat-LinkPreview/1 (+https://openvibe.chat)';
const PREVIEWS_ON = String(process.env.CHAT_LINK_PREVIEWS || '').toLowerCase() !== 'off';
@@@ -217,8 +217,13 @@ function baseOf(value, production) {
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');
++const baseFor = (name, production) => () => baseOf(process.env[name], production);
++const liveBase = baseFor('CHAT_LINK_LIVE_URL', 'https://openvibe.live');
++const videoBase = baseFor('CHAT_LINK_VIDEO_URL', 'https://openvibe.video');
++const picsBase = baseFor('CHAT_LINK_PICS_URL', 'https://openvibe.pics');
++const communityBase = baseFor('CHAT_LINK_COMMUNITY_URL', 'https://openvibe.community');
++const networkBase = baseFor('CHAT_LINK_NETWORK_URL', 'https://openvibe.network');
++const gamesBase = baseFor('CHAT_LINK_GAMES_URL', 'https://openvibe.games');
/** 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) {
@@@ -234,6 +239,22 @@ function ownLink(u) {
if (/^\/[vc]\/[0-9]{1,12}$/.test(u.pathname)) return { site, page: u.href };
return { site };
}
++ if (site === 'picture') {
++ const m = /^\/i\/([2-9a-km-zA-HJ-NP-Z]{8})(?:\.[A-Za-z0-9]+)?\/?$/.exec(u.pathname);
++ return m ? { site, slug: m[1] } : { site };
++ }
++ if (site === 'post') {
++ const m = /^\/s\/([^/]{1,80})\/t\/([^/]{1,200})\/?$/.exec(u.pathname);
++ return m ? { site, space: decodeURIComponent(m[1]), thread: decodeURIComponent(m[2]) } : { site };
++ }
++ if (site === 'profile') {
++ const m = /^\/@([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname);
++ return m ? { site, username: m[1] } : { site };
++ }
++ if (site === 'game') {
++ const m = /^\/worlds\/(w[0-9a-z]{10})\/?$/.exec(u.pathname);
++ return m ? { site, world: m[1] } : { site };
++ }
return { site };
}
@@@ -288,6 +309,104 @@ async function videoPreview(url, page) {
};
}
++/** A Pics picture (/i/<slug>, no page fetch): Pics' public image read. Its file lives on openvibe.pics, not Media,
++ * so the picture itself is left out (a page's image is never hot-linked); the title and size still make a card. */
++async function picturePreview(url, slug) {
++ const base = picsBase();
++ if (!base) return null;
++ const item = await readJsonUrl(`${base}/api/v1/images/${encodeURIComponent(slug)}`);
++ if (!item || typeof item !== 'object' || item.state === 'removed') return null;
++ const title = textOf(item.title || item.alt || 'A picture on OpenVibe.Pics', MAX_TITLE);
++ if (!title) return null;
++ return {
++ url,
++ site_name: 'OpenVibe.Pics',
++ title,
++ description: textOf(item.description || item.alt, MAX_DESCRIPTION),
++ image: imageOf(item.links && item.links.direct),
++ kind: 'picture',
++ width: intOf(item.width),
++ height: intOf(item.height),
++ };
++}
++
++/** A Community thread (/s/<space>/t/<slug>, no page fetch): Community's public thread read, with its first post. */
++async function communityPreview(url, space, thread) {
++ const base = communityBase();
++ if (!base) return null;
++ const item = await readJsonUrl(`${base}/api/v1/spaces/${encodeURIComponent(space)}/threads/${encodeURIComponent(thread)}`);
++ const found = item && item.thread;
++ if (!found || typeof found !== 'object') return null;
++ const title = textOf(found.title, MAX_TITLE);
++ if (!title) return null;
++ const first = Array.isArray(item.posts) && item.posts[0] ? item.posts[0] : null;
++ const attachments = first && Array.isArray(first.attachments) ? first.attachments : [];
++ const media = attachments.map((a) => imageOf(a && a.url)).find(Boolean) || null;
++ const author = found.author || {};
++ const by = textOf(author.username || author.display_name, 80);
++ const preview = {
++ url,
++ site_name: 'OpenVibe.Community',
++ title,
++ description: textOf(first && first.body_markdown, MAX_DESCRIPTION),
++ image: media,
++ kind: 'post',
++ width: 0,
++ height: 0,
++ };
++ if (by) preview.by = by;
++ return preview;
++}
++
++/** A Network profile (/@name, no page fetch): Network's public profile read. Its avatar is served by Network, not
++ * Media, so it is left out; the name and bio still make a card. */
++async function profilePreview(url, username) {
++ const base = networkBase();
++ if (!base) return null;
++ const item = await readJsonUrl(`${base}/api/v1/profiles/${encodeURIComponent(username)}`);
++ const profile = item && item.profile;
++ if (!profile || typeof profile !== 'object') return null;
++ const title = textOf(profile.display_name || profile.username || username, MAX_TITLE);
++ if (!title) return null;
++ const preview = {
++ url,
++ site_name: 'OpenVibe.Network',
++ title,
++ description: textOf(profile.bio, MAX_DESCRIPTION),
++ image: imageOf(profile.avatar_url),
++ kind: 'profile',
++ width: 0,
++ height: 0,
++ };
++ const by = textOf(profile.username || username, 80);
++ if (by) preview.by = by;
++ return preview;
++}
++
++/** A Games creator world (/worlds/<id>, no page fetch): Games' public world read. The built-in Play titles have no
++ * public game read of their own, so a /play address stays unpreviewed rather than read as a page. */
++async function gamePreview(url, world) {
++ const base = gamesBase();
++ if (!base) return null;
++ const item = await readJsonUrl(`${base}/api/worlds/${encodeURIComponent(world)}`);
++ if (!item || typeof item !== 'object') return null;
++ const title = textOf(item.title, MAX_TITLE);
++ if (!title) return null;
++ const preview = {
++ url,
++ site_name: 'OpenVibe.Games',
++ title,
++ description: textOf(item.summary, MAX_DESCRIPTION),
++ image: null,
++ kind: 'game',
++ width: 0,
++ height: 0,
++ };
++ const by = textOf(item.owner, 80);
++ if (by) preview.by = by;
++ return preview;
++}
++
/** 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));
@@@ -351,8 +470,12 @@ async function fetchFollowing(url) {
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);
++ if (own.site === 'live' && own.username) return await livePreview(url, own.username);
++ if (own.site === 'video' && own.page) return await videoPreview(url, own.page);
++ if (own.site === 'picture' && own.slug) return await picturePreview(url, own.slug);
++ if (own.site === 'post' && own.space && own.thread) return await communityPreview(url, own.space, own.thread);
++ if (own.site === 'profile' && own.username) return await profilePreview(url, own.username);
++ if (own.site === 'game' && own.world) return await gamePreview(url, own.world);
return null;
}
const oembed = oembedFor(new URL(url));
ddiff --git a/test/link-previews.test.js b/test/link-previews.test.js
iindex 3cb7641..d69f72e 100644
---- a/test/link-previews.test.js
++++ b/test/link-previews.test.js
@@@ -280,6 +280,80 @@ t('a moderator removes any preview; another member may not', async () => {
assert.deepStrictEqual(removed.body.embeds, []);
});
++// ── Own kinds through each service's public API, never a page fetch ─────────────────────────────────────────
++
++t('Pics, Community and Network resolve through their public APIs, never their pages', async () => {
++ process.env.CHAT_LINK_PICS_URL = 'https://93.184.216.34';
++ process.env.CHAT_LINK_COMMUNITY_URL = 'https://93.184.216.34';
++ process.env.CHAT_LINK_NETWORK_URL = 'https://93.184.216.34';
++ previews._setLookup((host, options, callback) => callback(null, [{ address: '93.184.216.34', family: 4 }]));
++ const calls = [];
++ previews._setRequest(async (url) => {
++ calls.push(url);
++ if (url.includes('/api/v1/images/abcd2345')) {
++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
++ title: 'Harbour at dusk', description: 'Boats and a red sky', width: 1200, height: 800, state: 'active',
++ links: { direct: 'https://openvibe.pics/i/abcd2345.jpg' },
++ }) };
++ }
++ if (url.includes('/api/v1/spaces/feedback/threads/hello-post')) {
++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
++ thread: { title: 'A Community post', author: { username: 'alice', display_name: 'Alice' } },
++ posts: [{ body_markdown: 'Hello **world**', attachments: [{ url: 'https://openvibe.media/att/1.jpg' }] }],
++ }) };
++ }
++ if (url.includes('/api/v1/profiles/alice')) {
++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
++ profile: { username: 'alice', display_name: 'Alice', bio: 'Builder of things', avatar_url: 'https://openvibe.network/avatar/alice?s=160' },
++ }) };
++ }
++ return { status: 404, headers: {}, body: '' };
++ });
++ await new Promise((r) => setTimeout(r, 10500)); // the room's per-person posting budget
++ const sent = await h.http('POST', `/api/chat/rooms/${room.slug}/messages`, { token: alice.token, body: {
++ message: 'https://openvibe.pics/i/abcd2345 https://openvibe.community/s/feedback/t/hello-post https://openvibe.network/@alice',
++ } });
++ assert.strictEqual(sent.status, 201, sent.text);
++ await previews.idle();
++ const got = await h.http('GET', `/api/chat/rooms/${room.slug}/messages`, { token: alice.token });
++ const embeds = got.body.messages.find((m) => m.id === sent.body.message.id).embeds;
++ assert.deepStrictEqual(embeds.map((e) => e.kind), ['picture', 'post', 'profile']);
++ assert.strictEqual(embeds[0].title, 'Harbour at dusk');
++ assert.strictEqual(embeds[0].width, 1200);
++ assert.strictEqual(embeds[0].image, null, 'a Pics file is not on Media: never hot-linked');
++ assert.strictEqual(embeds[1].title, 'A Community post');
++ assert.strictEqual(embeds[1].by, 'alice');
++ assert.strictEqual(embeds[1].image, 'https://openvibe.media/att/1.jpg');
++ assert.strictEqual(embeds[2].title, 'Alice');
++ assert.strictEqual(embeds[2].by, 'alice');
++ assert.strictEqual(embeds[2].image, null, 'a Network avatar is not on Media: left out');
++ assert.deepStrictEqual(calls, [
++ 'https://93.184.216.34/api/v1/images/abcd2345',
++ 'https://93.184.216.34/api/v1/spaces/feedback/threads/hello-post',
++ 'https://93.184.216.34/api/v1/profiles/alice',
++ ], 'each service is asked through its API, never its page');
++});
++
++t('a Games creator world resolves through the public API; a built-in Play title has no public read and is not fetched', async () => {
++ process.env.CHAT_LINK_GAMES_URL = 'https://93.184.216.34';
++ const calls = [];
++ previews._setRequest(async (url) => {
++ calls.push(url);
++ if (url.includes('/api/worlds/w0123456789')) {
++ return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({
++ id: 'w0123456789', title: 'Sky world', summary: 'Build in the clouds', owner: 'alice', plays: 12,
++ }) };
++ }
++ return { status: 200, headers: { 'content-type': 'text/html' }, body: '<meta property="og:title" content="Should not be read">' };
++ });
++ const world = await previews.previewOf('https://openvibe.games/worlds/w0123456789');
++ assert.strictEqual(world.kind, 'game');
++ assert.strictEqual(world.title, 'Sky world');
++ assert.strictEqual(world.by, 'alice');
++ assert.strictEqual(await previews.previewOf('https://openvibe.games/play?game=scraplandia'), null, 'the built-in Play title has no public game read');
++ assert.deepStrictEqual(calls, ['https://93.184.216.34/api/worlds/w0123456789'], 'the /play page is never read');
++});
++
t.run(async () => { previews._reset(); await h.close(); });
// ── Part 4: privacy in direct messages ───────────────────────────────────────────────────────────────────────