openvibers/openvibe.live Commits
Share cards: every page's preview is the network's card (Go live from your browser), with page cards for The Rundown, Content, AI Moments and Chat, instead of the old purple /og-image.png (openvibe-shared v4.6.0) (#271)
Changes
@@@ -176,5 +176,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.203.0- openvibe-sdk: v0.50.1--- openvibe-shared: v4.4.0++- openvibe-shared: v4.6.0<!-- versions:end -->@@@ -7,7 +7,7 @@"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.203.0","sdk": "openvibe-sdk v0.50.1",-- "shared": "openvibe-shared v4.4.0",++ "shared": "openvibe-shared v4.6.0","capabilities": ["live.chat_context.read","live.chat_effects.write",@@@ -25,7 +25,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2688,9 +2688,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.4.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",-- "integrity": "sha512-zhC3wzRdI3HG+TzLEEXTzZquBoHJHzE/67kGgmrYCXmouqcugKA+RZZ3gFQXMsIx79IQNrbE/tlg0hqiyUCJmw==",++ "version": "4.6.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",++ "integrity": "sha512-fWYacR2Oc4/A6LT8ebnfGnPn2Ip0S5BQNWnw/QN/iDoAW55ql7CaiSlOBEmJkinITf1pCcIJKQi382CdgLDHTg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -44,7 +44,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -11,7 +11,7 @@<meta property="og:title" content="DMCA Policy — OpenVibe"><meta property="og:description" content="DMCA and copyright takedown policy for OpenVibe: OpenVibe.Live, openvibe.tools and openvibe.games. How to send a notice and a counter-notice."><meta property="og:url" content="https://openvibe.live/dmca">-- <meta property="og:image" content="https://openvibe.live/og-image.png">++ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg"><meta name="twitter:card" content="summary_large_image"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg"><link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">@@@ -15,7 +15,7 @@<meta property="og:url" content="https://openvibe.live/"><meta property="og:title" content="OpenVibe.Live — Go Live From Your Browser, No OBS Needed"><meta property="og:description" content="Press Go Live, allow your camera and mic, and you're live. No OBS, no downloads, no follower minimum. Chat, VODs, clips and restreaming to Twitch, YouTube and Kick.">-- <meta property="og:image" content="https://openvibe.live/og-image.png">++ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta property="og:image:alt" content="OpenVibe.Live - Live Streaming Platform">@@@ -27,7 +27,7 @@<meta name="twitter:url" content="https://openvibe.live/"><meta name="twitter:title" content="OpenVibe.Live — Go Live From Your Browser, No OBS Needed"><meta name="twitter:description" content="Go live from your browser in seconds. No OBS, no downloads, no follower minimum. Open source and community run.">-- <meta name="twitter:image" content="https://openvibe.live/og-image.png">++ <meta name="twitter:image" content="https://openvibe.live/shared/social/live.jpg"><meta name="twitter:image:alt" content="OpenVibe.Live Platform"><!-- Icons & Preconnect -->@@@ -11,7 +11,7 @@<meta property="og:title" content="Privacy Policy — OpenVibe"><meta property="og:description" content="Privacy Policy for OpenVibe: OpenVibe.Live, openvibe.tools, openvibe.games. We collect the least we can, we don't sell it, and transcription runs locally."><meta property="og:url" content="https://openvibe.live/privacy">-- <meta property="og:image" content="https://openvibe.live/og-image.png">++ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg"><meta name="twitter:card" content="summary_large_image"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg"><link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">@@@ -11,7 +11,7 @@<meta property="og:title" content="Terms of Service — OpenVibe"><meta property="og:description" content="Terms of Service for OpenVibe: OpenVibe.Live, openvibe.tools, openvibe.games. Minimal rules, maximum freedom."><meta property="og:url" content="https://openvibe.live/tos">-- <meta property="og:image" content="https://openvibe.live/og-image.png">++ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg"><meta name="twitter:card" content="summary_large_image"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg"><link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">@@@ -573,6 +573,8 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fires.setHeader('Cache-Control', 'no-cache');res.sendFile(path.join(sharedServePath, 'openvibe-sw.js'));});++ // The share cards (og:image on every page), before the browser-file allowlist below.++ require('./web/share-cards').register(app);// Serve only the whitelisted browser files — don't expose the entire packageconst sharedFileSet = new Set(SHARED_BROWSER_FILES);app.use('/shared', (req, res, next) => {@@@ -152,7 +152,13 @@ const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;const VIDEO_META = `<meta name="ov-video-base" content="${esc(VIDEO_BASE)}">`;const SITE_NAME = 'OpenVibe.Live';--const DEFAULT_OG_IMAGE = '/og-image.png';++// Share cards (openvibe-shared/social, served at /shared/social/<id>.jpg by server/index.js): the site's card, and a page++// card for the big pages. They replaced /og-image.png (a logo on purple, "Free & Open Live Streaming") on 2026-10-20,++// after a friend said openvibe.quest's preview "looks way cooler than openvibe.live".++const social = require('openvibe-shared/social');++const shareCard = (id) => social.url(id) || social.url('live') || '/og-image.png';++const DEFAULT_OG_IMAGE = shareCard('live');++const LIST_CARDS = { content: 'live-content', vods: 'live-content', clips: 'live-content', pastes: 'live-content', moments: 'live-moments' };function baseUrl() {let b = (config && config.baseUrl) || 'https://openvibe.live';@@@ -211,7 +217,7 @@ async function _pageMeta(routePath, { page = 1 } = {}) {// Search results (public/js/app-search.js) are never indexed; the page itself is findable.if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };// The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).-- if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', ogType: 'website', robots: 'index,follow', jsonLd: [] };++ if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', image: shareCard('live-chat'), ogType: 'website', robots: 'index,follow', jsonLd: [] };// The API docs and the deploy log had the home page's title and description and no canonical: duplicates of "/".if (p === '/documentation') return { title: `API docs | ${SITE_NAME}`, description: 'The OpenVibe.Live API: sign-in and tokens, streams and channels, WebRTC and WHIP broadcasting, chat bots, robots, AI, VODs, clips and webhooks.', canonicalPath: '/documentation', ogType: 'website', robots: 'index,follow', jsonLd: [] };if (p === '/updates') return { title: `Updates | ${SITE_NAME}`, description: 'Every change deployed to OpenVibe.Live, newest first: each line is a commit from its open-source repository on GitHub.', canonicalPath: '/updates', ogType: 'website', robots: 'index,follow', jsonLd: [] };@@@ -354,7 +360,7 @@ async function _clipperOf(c) { return c.display_name || c.username || _nameOf(awasync function _rundownMeta(p) {const read = require('../rundown/read');const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];-- const base = { image: DEFAULT_OG_IMAGE, ogType: 'website' };++ const base = { image: shareCard('live-rundown'), ogType: 'website' };let m;if (p === '/rundown') {let home = null;@@@ -570,7 +576,7 @@ async function _listMeta(slug, label, description, itemsFn, { page = 1 } = {}) {if (more !== null && (page > 1 || more)) snapshot += _pager(basePath, page, null, more, label.toLowerCase());return {title: `${label}${pageLabel} — ${SITE_NAME}`, description, canonicalPath: selfPath,-- image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',++ image: shareCard(LIST_CARDS[slug] || 'live'), ogType: 'website', robots: 'index,follow',jsonLd: [list, _breadcrumb([{ name: 'Home', url: '/' }, { name: label, url: basePath }, ...(page > 1 ? [{ name: `Page ${page}`, url: selfPath }] : [])])],snapshot,};nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++/**++ * The share cards: the og:image of every page (server/seo/pages.js), openvibe-shared/social's 1200×630 JPEGs served from++ * the pinned copy at /shared/social/<id>.jpg. `live` is the site's card; `live-rundown`, `live-content`, `live-moments`++ * and `live-chat` are page cards. Only a ?v= that matches the card's own hash is immutable; anything else that is not a++ * card falls through (to /shared's own 404).++ */++const social = require('openvibe-shared/social');++const cache = require('openvibe-shared/cache-policy');++++function register(app) {++ app.get('/shared/social/:file', (req, res, next) => {++ const name = `social/${req.params.file}`;++ if (!social.isCard(name)) return next();++ const hashed = Boolean(req.query.v) && social.url(req.params.file.replace(/\.jpg$/, '')) === `/shared/${name}?v=${req.query.v}`;++ res.setHeader('Access-Control-Allow-Origin', '*');++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');++ res.setHeader('Cache-Control', cache.assetHeaders(req.originalUrl, { hashed }));++ if (hashed) res.setHeader('CDN-Cache-Control', cache.IMMUTABLE);++ res.type('jpg').sendFile(social.path(name));++ });++}++++module.exports = { register };@@@ -239,7 +239,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {const html = fs.readFileSync(path.join(__dirname, '..', 'public', `${name}.html`), 'utf8');assert.ok(html.includes(`<link rel="canonical" href="https://openvibe.live/${name}">`), name);assert.ok(html.includes(`<meta property="og:url" content="https://openvibe.live/${name}">`), name);-- assert.ok(/<meta property="og:title" content="[^"]+">/.test(html) && html.includes('<meta property="og:image" content="https://openvibe.live/og-image.png">'), name);++ assert.ok(/<meta property="og:title" content="[^"]+">/.test(html) && html.includes('<meta property="og:image" content="https://openvibe.live/shared/social/live.jpg">'), name);assert.ok(!/for the OpenVibe\b/.test(html.slice(0, html.indexOf('</head>'))), `${name}: "for the OpenVibe" reads wrong`);}});nnew file mode 100644@@@ -0,0 +1,71 @@++/**++ * Share cards (server/web/share-cards.js, server/seo/pages.js): every page's og:image is an openvibe-shared/social card++ * served from Live's pinned copy, not the old /og-image.png. The site's card by default; The Rundown, the content lists,++ * AI Moments and Chat each have a page card. A hashed URL is immutable, an unknown card falls through.++ *++ * node test/share-cards.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++const express = require('express');++const social = require('openvibe-shared/social');++++(async () => {++ let failures = 0;++ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err.stack}`); } };++ const app = express();++ require('../server/web/share-cards').register(app);++ app.use((req, res) => res.status(404).send('shared 404'));++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const get = (p) => new Promise((resolve, reject) => {++ http.get({ host: '127.0.0.1', port: server.address().port, path: p }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));++ }).on('error', reject);++ });++++ await check('the site card and the page cards are in the pinned openvibe-shared', () => {++ for (const id of ['live', 'live-rundown', 'live-content', 'live-moments', 'live-chat']) assert.ok(social.has(id), id);++ });++++ await check('/shared/social/<id>.jpg answers the JPEG; its own hash is immutable, anything else is not', async () => {++ const url = social.url('live-rundown');++ const r = await get(url);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers['content-type'], 'image/jpeg');++ assert.strictEqual(r.body.readUInt16BE(0), 0xffd8, 'a JPEG');++ assert.match(r.headers['cache-control'], /immutable/);++ assert.ok(r.headers['cdn-cache-control'], 'the CDN keeps it too');++ const plain = await get('/shared/social/live.jpg');++ assert.strictEqual(plain.status, 200);++ assert.ok(!/immutable/.test(plain.headers['cache-control']), 'no hash: revalidated');++ assert.ok(!/immutable/.test((await get('/shared/social/live.jpg?v=000000000000')).headers['cache-control']), 'a wrong hash is not immutable');++ });++++ await check('what is not a card falls through', async () => {++ assert.strictEqual((await get('/shared/social/nope.jpg')).status, 404);++ assert.strictEqual((await get('/shared/social/..%2Fpackage.json')).status, 404);++ assert.strictEqual((await get('/shared/social/live.png')).status, 404);++ });++++ await check('pages name the cards: the site card by default, page cards on the big pages', () => {++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'seo', 'pages.js'), 'utf8');++ assert.match(src, /const DEFAULT_OG_IMAGE = shareCard\('live'\);/);++ assert.match(src, /const base = \{ image: shareCard\('live-rundown'\)/, 'The Rundown');++ assert.match(src, /canonicalPath: '\/chat', image: shareCard\('live-chat'\)/, 'Chat');++ assert.match(src, /LIST_CARDS = \{ content: 'live-content', vods: 'live-content', clips: 'live-content', pastes: 'live-content', moments: 'live-moments' \}/);++ for (const f of ['index.html', 'tos.html', 'privacy.html', 'dmca.html']) {++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', f), 'utf8');++ assert.ok(!html.includes('og-image.png'), `${f} still names /og-image.png`);++ assert.ok(html.includes('content="https://openvibe.live/shared/social/live.jpg"'), f);++ }++ });++++ server.close();++ console.log(failures ? `\n${failures} check(s) failed` : '\nshare cards: all checks passed');++ if (failures) process.exitCode = 1;++})();
@@ -176,5 +176,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -176,5 +176,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.203.0
- openvibe-contracts: v0.203.0
- openvibe-sdk: v0.50.1
- openvibe-sdk: v0.50.1
-- openvibe-shared: v4.4.0
+- openvibe-shared: v4.6.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.203.0",
"contracts": "openvibe-contracts v0.203.0",
"sdk": "openvibe-sdk v0.50.1",
"sdk": "openvibe-sdk v0.50.1",
- "shared": "openvibe-shared v4.4.0",
+ "shared": "openvibe-shared v4.6.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2688,9 +2688,9 @@
@@ -2688,9 +2688,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.4.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
- "integrity": "sha512-zhC3wzRdI3HG+TzLEEXTzZquBoHJHzE/67kGgmrYCXmouqcugKA+RZZ3gFQXMsIx79IQNrbE/tlg0hqiyUCJmw==",
+ "version": "4.6.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",
+ "integrity": "sha512-fWYacR2Oc4/A6LT8ebnfGnPn2Ip0S5BQNWnw/QN/iDoAW55ql7CaiSlOBEmJkinITf1pCcIJKQi382CdgLDHTg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.203.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.50.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.4.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.6.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
<meta property="og:title" content="DMCA Policy — OpenVibe">
<meta property="og:title" content="DMCA Policy — OpenVibe">
<meta property="og:description" content="DMCA and copyright takedown policy for OpenVibe: OpenVibe.Live, openvibe.tools and openvibe.games. How to send a notice and a counter-notice.">
<meta property="og:description" content="DMCA and copyright takedown policy for OpenVibe: OpenVibe.Live, openvibe.tools and openvibe.games. How to send a notice and a counter-notice.">
<meta property="og:url" content="https://openvibe.live/dmca">
<meta property="og:url" content="https://openvibe.live/dmca">
- <meta property="og:image" content="https://openvibe.live/og-image.png">
+ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">
<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
<meta property="og:url" content="https://openvibe.live/">
<meta property="og:url" content="https://openvibe.live/">
<meta property="og:title" content="OpenVibe.Live — Go Live From Your Browser, No OBS Needed">
<meta property="og:title" content="OpenVibe.Live — Go Live From Your Browser, No OBS Needed">
<meta property="og:description" content="Press Go Live, allow your camera and mic, and you're live. No OBS, no downloads, no follower minimum. Chat, VODs, clips and restreaming to Twitch, YouTube and Kick.">
<meta property="og:description" content="Press Go Live, allow your camera and mic, and you're live. No OBS, no downloads, no follower minimum. Chat, VODs, clips and restreaming to Twitch, YouTube and Kick.">
- <meta property="og:image" content="https://openvibe.live/og-image.png">
+ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="OpenVibe.Live - Live Streaming Platform">
<meta property="og:image:alt" content="OpenVibe.Live - Live Streaming Platform">
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
<meta name="twitter:url" content="https://openvibe.live/">
<meta name="twitter:url" content="https://openvibe.live/">
<meta name="twitter:title" content="OpenVibe.Live — Go Live From Your Browser, No OBS Needed">
<meta name="twitter:title" content="OpenVibe.Live — Go Live From Your Browser, No OBS Needed">
<meta name="twitter:description" content="Go live from your browser in seconds. No OBS, no downloads, no follower minimum. Open source and community run.">
<meta name="twitter:description" content="Go live from your browser in seconds. No OBS, no downloads, no follower minimum. Open source and community run.">
- <meta name="twitter:image" content="https://openvibe.live/og-image.png">
+ <meta name="twitter:image" content="https://openvibe.live/shared/social/live.jpg">
<meta name="twitter:image:alt" content="OpenVibe.Live Platform">
<meta name="twitter:image:alt" content="OpenVibe.Live Platform">
<!-- Icons & Preconnect -->
<!-- Icons & Preconnect -->
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
<meta property="og:title" content="Privacy Policy — OpenVibe">
<meta property="og:title" content="Privacy Policy — OpenVibe">
<meta property="og:description" content="Privacy Policy for OpenVibe: OpenVibe.Live, openvibe.tools, openvibe.games. We collect the least we can, we don't sell it, and transcription runs locally.">
<meta property="og:description" content="Privacy Policy for OpenVibe: OpenVibe.Live, openvibe.tools, openvibe.games. We collect the least we can, we don't sell it, and transcription runs locally.">
<meta property="og:url" content="https://openvibe.live/privacy">
<meta property="og:url" content="https://openvibe.live/privacy">
- <meta property="og:image" content="https://openvibe.live/og-image.png">
+ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">
<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
<meta property="og:title" content="Terms of Service — OpenVibe">
<meta property="og:title" content="Terms of Service — OpenVibe">
<meta property="og:description" content="Terms of Service for OpenVibe: OpenVibe.Live, openvibe.tools, openvibe.games. Minimal rules, maximum freedom.">
<meta property="og:description" content="Terms of Service for OpenVibe: OpenVibe.Live, openvibe.tools, openvibe.games. Minimal rules, maximum freedom.">
<meta property="og:url" content="https://openvibe.live/tos">
<meta property="og:url" content="https://openvibe.live/tos">
- <meta property="og:image" content="https://openvibe.live/og-image.png">
+ <meta property="og:image" content="https://openvibe.live/shared/social/live.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">
<link rel="stylesheet" href="/css/vendor/fontawesome-6.5.1.min.css">
@@ -573,6 +573,8 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fi
@@ -573,6 +573,8 @@ console.log(`[Server] /shared: serving ${SHARED_BROWSER_FILES.length} browser fi
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(sharedServePath, 'openvibe-sw.js'));
res.sendFile(path.join(sharedServePath, 'openvibe-sw.js'));
});
});
+ // The share cards (og:image on every page), before the browser-file allowlist below.
+ require('./web/share-cards').register(app);
// Serve only the whitelisted browser files — don't expose the entire package
// Serve only the whitelisted browser files — don't expose the entire package
const sharedFileSet = new Set(SHARED_BROWSER_FILES);
const sharedFileSet = new Set(SHARED_BROWSER_FILES);
app.use('/shared', (req, res, next) => {
app.use('/shared', (req, res, next) => {
@@ -152,7 +152,13 @@ const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
@@ -152,7 +152,13 @@ const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
const VIDEO_META = `<meta name="ov-video-base" content="${esc(VIDEO_BASE)}">`;
const VIDEO_META = `<meta name="ov-video-base" content="${esc(VIDEO_BASE)}">`;
const SITE_NAME = 'OpenVibe.Live';
const SITE_NAME = 'OpenVibe.Live';
-const DEFAULT_OG_IMAGE = '/og-image.png';
+// Share cards (openvibe-shared/social, served at /shared/social/<id>.jpg by server/index.js): the site's card, and a page
+// card for the big pages. They replaced /og-image.png (a logo on purple, "Free & Open Live Streaming") on 2026-10-20,
+// after a friend said openvibe.quest's preview "looks way cooler than openvibe.live".
+const social = require('openvibe-shared/social');
+const shareCard = (id) => social.url(id) || social.url('live') || '/og-image.png';
+const DEFAULT_OG_IMAGE = shareCard('live');
+const LIST_CARDS = { content: 'live-content', vods: 'live-content', clips: 'live-content', pastes: 'live-content', moments: 'live-moments' };
function baseUrl() {
function baseUrl() {
let b = (config && config.baseUrl) || 'https://openvibe.live';
let b = (config && config.baseUrl) || 'https://openvibe.live';
@@ -211,7 +217,7 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
@@ -211,7 +217,7 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
// The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
// The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
- if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', ogType: 'website', robots: 'index,follow', jsonLd: [] };
+ if (p === '/chat') return { title: `Chat | ${SITE_NAME}`, description: 'The global chat room of OpenVibe.Live, open to everyone on the network.', canonicalPath: '/chat', image: shareCard('live-chat'), ogType: 'website', robots: 'index,follow', jsonLd: [] };
// The API docs and the deploy log had the home page's title and description and no canonical: duplicates of "/".
// The API docs and the deploy log had the home page's title and description and no canonical: duplicates of "/".
if (p === '/documentation') return { title: `API docs | ${SITE_NAME}`, description: 'The OpenVibe.Live API: sign-in and tokens, streams and channels, WebRTC and WHIP broadcasting, chat bots, robots, AI, VODs, clips and webhooks.', canonicalPath: '/documentation', ogType: 'website', robots: 'index,follow', jsonLd: [] };
if (p === '/documentation') return { title: `API docs | ${SITE_NAME}`, description: 'The OpenVibe.Live API: sign-in and tokens, streams and channels, WebRTC and WHIP broadcasting, chat bots, robots, AI, VODs, clips and webhooks.', canonicalPath: '/documentation', ogType: 'website', robots: 'index,follow', jsonLd: [] };
if (p === '/updates') return { title: `Updates | ${SITE_NAME}`, description: 'Every change deployed to OpenVibe.Live, newest first: each line is a commit from its open-source repository on GitHub.', canonicalPath: '/updates', ogType: 'website', robots: 'index,follow', jsonLd: [] };
if (p === '/updates') return { title: `Updates | ${SITE_NAME}`, description: 'Every change deployed to OpenVibe.Live, newest first: each line is a commit from its open-source repository on GitHub.', canonicalPath: '/updates', ogType: 'website', robots: 'index,follow', jsonLd: [] };
@@ -354,7 +360,7 @@ async function _clipperOf(c) { return c.display_name || c.username || _nameOf(aw
@@ -354,7 +360,7 @@ async function _clipperOf(c) { return c.display_name || c.username || _nameOf(aw
async function _rundownMeta(p) {
async function _rundownMeta(p) {
const read = require('../rundown/read');
const read = require('../rundown/read');
const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];
const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];
- const base = { image: DEFAULT_OG_IMAGE, ogType: 'website' };
+ const base = { image: shareCard('live-rundown'), ogType: 'website' };
let m;
let m;
if (p === '/rundown') {
if (p === '/rundown') {
let home = null;
let home = null;
@@ -570,7 +576,7 @@ async function _listMeta(slug, label, description, itemsFn, { page = 1 } = {}) {
@@ -570,7 +576,7 @@ async function _listMeta(slug, label, description, itemsFn, { page = 1 } = {}) {
if (more !== null && (page > 1 || more)) snapshot += _pager(basePath, page, null, more, label.toLowerCase());
if (more !== null && (page > 1 || more)) snapshot += _pager(basePath, page, null, more, label.toLowerCase());
return {
return {
title: `${label}${pageLabel} — ${SITE_NAME}`, description, canonicalPath: selfPath,
title: `${label}${pageLabel} — ${SITE_NAME}`, description, canonicalPath: selfPath,
- image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
+ image: shareCard(LIST_CARDS[slug] || 'live'), ogType: 'website', robots: 'index,follow',
jsonLd: [list, _breadcrumb([{ name: 'Home', url: '/' }, { name: label, url: basePath }, ...(page > 1 ? [{ name: `Page ${page}`, url: selfPath }] : [])])],
jsonLd: [list, _breadcrumb([{ name: 'Home', url: '/' }, { name: label, url: basePath }, ...(page > 1 ? [{ name: `Page ${page}`, url: selfPath }] : [])])],
snapshot,
snapshot,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+/**
+ * The share cards: the og:image of every page (server/seo/pages.js), openvibe-shared/social's 1200×630 JPEGs served from
+ * the pinned copy at /shared/social/<id>.jpg. `live` is the site's card; `live-rundown`, `live-content`, `live-moments`
+ * and `live-chat` are page cards. Only a ?v= that matches the card's own hash is immutable; anything else that is not a
+ * card falls through (to /shared's own 404).
+ */
+const social = require('openvibe-shared/social');
+const cache = require('openvibe-shared/cache-policy');
+
+function register(app) {
+ app.get('/shared/social/:file', (req, res, next) => {
+ const name = `social/${req.params.file}`;
+ if (!social.isCard(name)) return next();
+ const hashed = Boolean(req.query.v) && social.url(req.params.file.replace(/\.jpg$/, '')) === `/shared/${name}?v=${req.query.v}`;
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.setHeader('Cache-Control', cache.assetHeaders(req.originalUrl, { hashed }));
+ if (hashed) res.setHeader('CDN-Cache-Control', cache.IMMUTABLE);
+ res.type('jpg').sendFile(social.path(name));
+ });
+}
+
+module.exports = { register };
@@ -239,7 +239,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -239,7 +239,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'public', `${name}.html`), 'utf8');
const html = fs.readFileSync(path.join(__dirname, '..', 'public', `${name}.html`), 'utf8');
assert.ok(html.includes(`<link rel="canonical" href="https://openvibe.live/${name}">`), name);
assert.ok(html.includes(`<link rel="canonical" href="https://openvibe.live/${name}">`), name);
assert.ok(html.includes(`<meta property="og:url" content="https://openvibe.live/${name}">`), name);
assert.ok(html.includes(`<meta property="og:url" content="https://openvibe.live/${name}">`), name);
- assert.ok(/<meta property="og:title" content="[^"]+">/.test(html) && html.includes('<meta property="og:image" content="https://openvibe.live/og-image.png">'), name);
+ assert.ok(/<meta property="og:title" content="[^"]+">/.test(html) && html.includes('<meta property="og:image" content="https://openvibe.live/shared/social/live.jpg">'), name);
assert.ok(!/for the OpenVibe\b/.test(html.slice(0, html.indexOf('</head>'))), `${name}: "for the OpenVibe" reads wrong`);
assert.ok(!/for the OpenVibe\b/.test(html.slice(0, html.indexOf('</head>'))), `${name}: "for the OpenVibe" reads wrong`);
}
}
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+/**
+ * Share cards (server/web/share-cards.js, server/seo/pages.js): every page's og:image is an openvibe-shared/social card
+ * served from Live's pinned copy, not the old /og-image.png. The site's card by default; The Rundown, the content lists,
+ * AI Moments and Chat each have a page card. A hashed URL is immutable, an unknown card falls through.
+ *
+ * node test/share-cards.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+const express = require('express');
+const social = require('openvibe-shared/social');
+
+(async () => {
+ let failures = 0;
+ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err.stack}`); } };
+ const app = express();
+ require('../server/web/share-cards').register(app);
+ app.use((req, res) => res.status(404).send('shared 404'));
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const get = (p) => new Promise((resolve, reject) => {
+ http.get({ host: '127.0.0.1', port: server.address().port, path: p }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
+ }).on('error', reject);
+ });
+
+ await check('the site card and the page cards are in the pinned openvibe-shared', () => {
+ for (const id of ['live', 'live-rundown', 'live-content', 'live-moments', 'live-chat']) assert.ok(social.has(id), id);
+ });
+
+ await check('/shared/social/<id>.jpg answers the JPEG; its own hash is immutable, anything else is not', async () => {
+ const url = social.url('live-rundown');
+ const r = await get(url);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers['content-type'], 'image/jpeg');
+ assert.strictEqual(r.body.readUInt16BE(0), 0xffd8, 'a JPEG');
+ assert.match(r.headers['cache-control'], /immutable/);
+ assert.ok(r.headers['cdn-cache-control'], 'the CDN keeps it too');
+ const plain = await get('/shared/social/live.jpg');
+ assert.strictEqual(plain.status, 200);
+ assert.ok(!/immutable/.test(plain.headers['cache-control']), 'no hash: revalidated');
+ assert.ok(!/immutable/.test((await get('/shared/social/live.jpg?v=000000000000')).headers['cache-control']), 'a wrong hash is not immutable');
+ });
+
+ await check('what is not a card falls through', async () => {
+ assert.strictEqual((await get('/shared/social/nope.jpg')).status, 404);
+ assert.strictEqual((await get('/shared/social/..%2Fpackage.json')).status, 404);
+ assert.strictEqual((await get('/shared/social/live.png')).status, 404);
+ });
+
+ await check('pages name the cards: the site card by default, page cards on the big pages', () => {
+ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'seo', 'pages.js'), 'utf8');
+ assert.match(src, /const DEFAULT_OG_IMAGE = shareCard\('live'\);/);
+ assert.match(src, /const base = \{ image: shareCard\('live-rundown'\)/, 'The Rundown');
+ assert.match(src, /canonicalPath: '\/chat', image: shareCard\('live-chat'\)/, 'Chat');
+ assert.match(src, /LIST_CARDS = \{ content: 'live-content', vods: 'live-content', clips: 'live-content', pastes: 'live-content', moments: 'live-moments' \}/);
+ for (const f of ['index.html', 'tos.html', 'privacy.html', 'dmca.html']) {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'public', f), 'utf8');
+ assert.ok(!html.includes('og-image.png'), `${f} still names /og-image.png`);
+ assert.ok(html.includes('content="https://openvibe.live/shared/social/live.jpg"'), f);
+ }
+ });
+
+ server.close();
+ console.log(failures ? `\n${failures} check(s) failed` : '\nshare cards: all checks passed');
+ if (failures) process.exitCode = 1;
+})();