openvibers/openvibe.live Commits

Live: every channel links the streamer's OpenVibe profile (openvibe.network/@username), with a card showing their picture, item count and what they wear; the streamer can hide it (#57)

OP OpenVibers committed 9 Oct 2026 · 8a83840ff6 · 5 files, +75 −6

Changes

ddiff --git a/public/js/channel-socials.js b/public/js/channel-socials.js
iindex 0c8e3083..0e6eebe5 100644
---- a/public/js/channel-socials.js
++++ b/public/js/channel-socials.js
@@@ -13,7 +13,7 @@
for (const [n, u] of [[31536000, 'y'], [2592000, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']]) if (s >= n) return `${Math.floor(s / n)}${u} ago`;
return 'just now';
};
-- const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));
++ const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon', 'openvibe'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));
/** Pills: one line of links (the offline screen). */
function pills(host, links) {
@@@ -105,7 +105,7 @@
<label class="ch-edit-label"><i class="fa-solid fa-link"></i> Social links</label>
${s.connected.length ? `<div class="ch-social-connected">${s.connected.map((c) => `
<label class="ch-social-conn" style="--sc:${escH(c.color)}"><input type="checkbox" data-auto="${escH(c.kind)}" ${s.hidden_auto.includes(c.kind) ? '' : 'checked'}>
-- <i class="${escH(c.icon)}"></i> Show your connected ${escH(c.name)} (${escH(label(c))})</label>`).join('')}</div>` : ''}
++ <i class="${escH(c.icon)}"></i> ${c.kind === 'openvibe' ? 'Show your OpenVibe profile' : `Show your connected ${escH(c.name)}`} (${escH(label(c))})</label>`).join('')}</div>` : ''}
${s.missing.length ? `<p class="muted ch-social-hint">You restream to ${s.missing.map((k) => escH((_catalog.find((p) => p.kind === k) || {}).name || k)).join(', ')}: add ${s.missing.length === 1 ? 'that channel' : 'those channels'} below so viewers can find you there.</p>` : ''}
<div class="ch-social-rows">${s.links.map((l, i) => `
<div class="ch-social-row" data-i="${i}">
ddiff --git a/server/social/links.js b/server/social/links.js
iindex 231f061f..e26c5f32 100644
---- a/server/social/links.js
++++ b/server/social/links.js
@@@ -6,7 +6,8 @@
*
* cleanSocialLinks(input) → the list to store, or null when it is not a valid list
* channelSocialLinks(channel, db) → what viewers see: saved links first, then connected platforms not already
-- * listed and not hidden (channels.social_links.hidden_auto)
++ * listed and not hidden (channels.social_links.hidden_auto), then the
++ * streamer's OpenVibe profile (openvibe.network/@username), also hideable
*/
const MAX_LINKS = 16;
@@@ -32,10 +33,12 @@ const PLATFORMS = {
soundcloud:{ name: 'SoundCloud',icon: 'fa-brands fa-soundcloud',color: '#ff5500', hosts: ['soundcloud.com'], url: (h) => `https://soundcloud.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },
linkedin: { name: 'LinkedIn', icon: 'fa-brands fa-linkedin', color: '#0a66c2', hosts: ['linkedin.com'], url: (h) => `https://www.linkedin.com/in/${h}`, handle: /^\/in\/([A-Za-z0-9-]{3,100})\/?$/ },
mastodon: { name: 'Mastodon', icon: 'fa-brands fa-mastodon', color: '#6364ff', hosts: null, url: null, handle: /^\/@([A-Za-z0-9_]{1,30})\/?$/ },
++ openvibe: { name: 'OpenVibe', icon: 'fa-solid fa-id-card', color: '#3b82f6', hosts: ['openvibe.network'], url: (h) => `https://openvibe.network/@${h}`, handle: /^\/@([A-Za-z0-9_]{3,24})\/?$/ },
website: { name: 'Website', icon: 'fa-solid fa-globe', color: '#60a5fa', hosts: null, url: null, handle: null },
custom: { name: 'Link', icon: 'fa-solid fa-link', color: '#94a3b8', hosts: null, url: null, handle: null },
};
--const AUTO_KINDS = ['twitch', 'youtube', 'kick'];
++// Shown without being added: the restream platforms a channel is connected to, and its OpenVibe profile.
++const AUTO_KINDS = ['twitch', 'youtube', 'kick', 'openvibe'];
const hostMatches = (host, hosts) => hosts.some((h) => host === h || host.endsWith(`.${h}`));
@@@ -127,6 +130,15 @@ async function channelSocialLinks(channel, db, { owner = false } = {}) {
auto.push(d);
if (!d.hidden) { out.push(d); kinds.add(c.platform); }
}
++ // The streamer's OpenVibe profile (plan T21): their picture, bio and the items they wear, one card on every channel.
++ if (channel && channel.username && !kinds.has('openvibe')) {
++ const l = cleanLink({ kind: 'openvibe', handle: channel.username });
++ if (l) {
++ const d = decorate(l, { auto: true, hidden: stored.hidden_auto.includes('openvibe') });
++ auto.push(d);
++ if (!d.hidden) { out.push(d); kinds.add('openvibe'); }
++ }
++ }
const result = { links: out };
if (owner) {
let dests = [];
ddiff --git a/server/social/preview.js b/server/social/preview.js
iindex 546de40f..707485a9 100644
---- a/server/social/preview.js
++++ b/server/social/preview.js
@@@ -9,7 +9,8 @@
*
* Sources, all public and keyless: Bluesky (public.api.bsky.app), Mastodon (the instance's public API), GitHub
* (api.github.com), YouTube (the channel's RSS feed), Twitch (the preview image redirects to a placeholder when the
-- * channel is offline), Kick (its public channel API, best effort), and OpenGraph for everything else. X has no
++ * channel is offline), Kick (its public channel API, best effort), the streamer's OpenVibe profile (the Network's
++ * /api/v1/profiles, on loopback), and OpenGraph for everything else. X has no
* usable free API: its card links out, and the channel page offers X's own timeline embed on click (isolated
* frame, /embed/x-timeline).
*/
@@@ -34,6 +35,7 @@ async function getText(url, { timeoutMs = 6000, accept = 'text/html,application/
const decode = (s) => String(s || '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;|&#x27;/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
++const RARITY = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);
/** OpenGraph / meta tags of a page. */
@@@ -124,6 +126,27 @@ const ADAPTERS = {
items: [],
};
},
++ async openvibe(l) {
++ // The streamer's OpenVibe profile, from the Network's public profile API (ours, on loopback; the handle is a
++ // checked username): their picture, how many items they own and what they wear.
++ const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ const r = await fetch(`${base}/api/v1/profiles/${encodeURIComponent(l.handle)}`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
++ const p = (await r.json()).profile || {};
++ const title = clip(p.display_name || `@${l.handle}`, 80);
++ if (p.private) return { title, subtitle: 'Profile on OpenVibe', image: httpsOnly(p.avatar_url), items: [] };
++ const n = p.items && !p.items.unavailable ? Number(p.items.count) || 0 : null;
++ const since = /^\d{4}/.test(String(p.member_since || '')) ? `on OpenVibe since ${String(p.member_since).slice(0, 4)}` : null;
++ return {
++ title,
++ subtitle: [n === null ? null : `${n.toLocaleString('en-US')}${p.items.more ? '+' : ''} item${n === 1 ? '' : 's'}`, since].filter(Boolean).join(' · ') || 'Profile on OpenVibe',
++ image: httpsOnly(p.avatar_url),
++ items: (Array.isArray(p.showcase) ? p.showcase : []).slice(0, 3).map((s) => ({
++ text: clip(`${s.art && s.art.emoji ? `${String(s.art.emoji).slice(0, 8)} ` : ''}Wearing ${s.name} (${RARITY[s.rarity] || 'Common'} ${String(s.kind_name || '').toLowerCase()})`, 120),
++ url: httpsOnly(s.url),
++ })),
++ };
++ },
async x(l) {
// No free API: the card links out; the page offers X's own embed on click.
return { title: `@${l.handle}`, subtitle: 'Posts on X', image: null, items: [], embed: l.handle ? `/embed/x-timeline?h=${encodeURIComponent(l.handle)}` : null };
@@@ -138,7 +161,7 @@ async function preview(l) {
let value;
try {
const fn = ADAPTERS[l.kind] || (async (x) => await openGraph(x.url));
-- const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x'].includes(l.kind);
++ const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x', 'openvibe'].includes(l.kind);
value = needsHandle && !l.handle ? await openGraph(l.url) : await fn(l);
value = { kind: l.kind, url: l.url, items: [], ...value };
remember(key, value, TTL);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5ec89121..8d01cd0b 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -345,6 +345,7 @@ async function check(name, fn) {
'server/pastes-client.js': 'OpenVibe.Community (configured)',
'server/comments-client.js': 'OpenVibe.Community (configured)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
++ 'server/social/preview.js': 'OpenVibe.Network public profile API (configured OV_NETWORK_INTERNAL_URL, fixed path, username checked); every other preview goes through egress',
'server/monetization/inventory-client.js': 'OpenVibe.Inventory API for Live cosmetics (configured INVENTORY_URL)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
ddiff --git a/test/social-links.test.js b/test/social-links.test.js
iindex 34c72ff7..43955fe1 100644
---- a/test/social-links.test.js
++++ b/test/social-links.test.js
@@@ -46,6 +46,39 @@ async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name
assert.deepStrictEqual(owner.connected.map((c) => [c.kind, c.hidden]), [['twitch', false], ['kick', true]]);
});
++ await check('the OpenVibe profile: every channel shows openvibe.network/@username after its other links, unless listed or hidden', async () => {
++ const db = { all: () => [] };
++ const shown = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [{ kind: 'x', handle: 'goosely' }] }) }, db, { owner: true });
++ assert.deepStrictEqual(shown.links.map((l) => [l.kind, l.url, !!l.auto]), [['x', 'https://x.com/goosely', false], ['openvibe', 'https://openvibe.network/@Goosely', true]]);
++ assert.deepStrictEqual(shown.connected.map((c) => [c.kind, c.hidden]), [['openvibe', false]], 'the editor can hide it');
++ const hidden = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [], hidden_auto: ['openvibe'] }) }, db);
++ assert.deepStrictEqual(hidden.links, []);
++ const listed = await channelSocialLinks({ user_id: 1, username: 'Goosely', social_links: cleanSocialLinks({ links: [{ kind: 'custom', url: 'https://openvibe.network/@Goosely' }] }) }, db);
++ assert.deepStrictEqual(listed.links.map((l) => [l.kind, !!l.auto]), [['openvibe', false]], 'listed by hand: once');
++ assert.strictEqual(cleanLink({ kind: 'openvibe', url: 'https://evil.example/@x' }), null);
++ });
++
++ await check('the OpenVibe profile preview: the Network\'s profile API, the picture, item count and what they wear; a private one says so', async () => {
++ const { preview: pv } = require('../server/social/preview');
++ const realFetch = global.fetch;
++ const seen = [];
++ global.fetch = async (url) => {
++ seen.push(String(url));
++ const name = decodeURIComponent(String(url).split('/').pop());
++ if (name === 'Goosely') return { ok: true, json: async () => ({ profile: { username: 'Goosely', display_name: 'Goosely', avatar_url: 'https://openvibe.network/avatar/Goosely?s=160', private: false, member_since: '2025-03-14', items: { count: 12, more: false }, showcase: [{ name: 'Void Crown', rarity: 'legendary', kind_name: 'Hat', art: { emoji: '🕳️' }, url: 'https://inventory.openvibe.network/items/itd_1' }] } }) };
++ if (name === 'quiet') return { ok: true, json: async () => ({ profile: { username: 'quiet', display_name: 'Quiet', avatar_url: 'https://openvibe.network/avatar/quiet?s=160', private: true } }) };
++ return { ok: false, status: 404, json: async () => ({}) };
++ };
++ try {
++ const p = await pv(cleanLink({ kind: 'openvibe', handle: 'Goosely' }));
++ assert.ok(seen[0].endsWith('/api/v1/profiles/Goosely') && seen[0].startsWith('http://127.0.0.1:4000'), 'loopback Network, fixed path');
++ assert.deepStrictEqual([p.title, p.subtitle, p.image], ['Goosely', '12 items · on OpenVibe since 2025', 'https://openvibe.network/avatar/Goosely?s=160']);
++ assert.deepStrictEqual(p.items, [{ text: '🕳️ Wearing Void Crown (Legendary hat)', url: 'https://inventory.openvibe.network/items/itd_1' }]);
++ const q = await pv(cleanLink({ kind: 'openvibe', handle: 'quiet' }));
++ assert.deepStrictEqual([q.title, q.subtitle, q.items], ['Quiet', 'Profile on OpenVibe', []]);
++ } finally { global.fetch = realFetch; }
++ });
++
const { preview, _cache } = require('../server/social/preview');
const real = { fetchText: egress.fetchText, fetchBuffer: egress.fetchBuffer };
const calls = [];