openvibers/openvibe.live Commits

One canonical page per paste (roadmap 32.2): the PASTES_ON_COMMUNITY /p/<slug> handover moves to server/web/paste-handover.js and is mounted before the SEO middleware, so crawlers and browsers get the same 301 to openvibe.community (302 through its silent sign-in when signed in) instead of Live's own self-canonical copy for every HTML client. The SEO middleware also skips /p/ while pastes are on Community, in case the mount order ever changes. Live's sitemap lists no /p/ URL (Community's sitemap holds them), and the paste links Live renders on its home, /pastes and /content snapshots point straight at Community. test/paste-canonical.test.js

OP OpenVibers committed 24 Sept 2026 · 57a83a2757 · 5 files, +171 −29


Changes

ddiff --git a/server/index.js b/server/index.js
iindex d7aba339..76f2d355 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -527,6 +527,9 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
// SEO: per-route <head> meta/OG/JSON-LD injection + dynamic sitemap. MUST be before the public
// static below (so it can intercept "/") and before the SPA catch-all. Only touches the SPA
// HTML routes (home, vods/clips/pastes lists, vod/clip/paste detail); everything else falls through.
++// Pastes on openvibe.community (PASTES_ON_COMMUNITY=1): /p/<slug> redirects there, and it is mounted
++// before SEO so crawlers and people get the same redirect and Community is the one canonical page.
++require('./web/paste-handover').register(app);
try { require('./seo/seo').register(app); } catch (e) { console.warn('[SEO] not registered:', e.message); }
// Standalone HTML pages (popout chat, kiosk, legal, OBS overlays…) go through the same asset rewrite
// as the SPA shell, so their script and stylesheet URLs can never drift out of date again.
@@@ -626,23 +629,6 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
});
--// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live paste URLs keep
--// working as permanent redirects, so links in chat, search results and clipboards survive.
--// /pastes itself stays here: it is the Content feed with the Pastes filter (public/js/content-feed.js).
--const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
--if (process.env.PASTES_ON_COMMUNITY === '1') {
-- // Someone signed in here should arrive signed in there: they go through Community's silent sign-in
-- // (one quiet round trip; it skips itself when Community already has a valid session and falls back
-- // to the plain page when the Network session is gone). Guests and crawlers get the permanent redirect.
-- const handOver = (req, res, target) => {
-- const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''));
-- if (!signedIn) return res.redirect(301, `${COMMUNITY_URL}${target}`);
-- res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' });
-- res.redirect(302, `${COMMUNITY_URL}/auth/login?silent=1&next=${encodeURIComponent(target)}`);
-- };
-- app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));
--}
--
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
// against THIS origin — bounce them to the Media public host, where the
// canonical paste page lives.
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 8e905c5f..c271508e 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -45,7 +45,7 @@ const _feedList = (feed) => _cached(`feed:${feed}`, async () => {
const out = await require('../content/feed').page(feed, { limit: 24 });
const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
return (out.items || []).map((it) => ({
-- url: it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
++ url: it.kind === 'paste' && PASTES_BASE ? pasteHref(it.id) : it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
desc: it.excerpt,
}));
@@@ -71,6 +71,13 @@ function _overlayAiState(row, kind) {
} catch { /* */ }
}
++// Pastes live on openvibe.community when PASTES_ON_COMMUNITY=1: /p/<slug> is a redirect there
++// (server/web/paste-handover.js, mounted before this middleware), the SPA router sends it there
++// too, and every paste link Live renders points straight at Community, the paste's one canonical
++// page. Live's sitemap never lists /p/ (roadmap 32.2).
++const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';
++const pasteHref = (slug) => `${PASTES_BASE}/p/${encodeURIComponent(slug)}`;
++
const SITE_NAME = 'OpenVibe.Live';
const DEFAULT_OG_IMAGE = '/og-image.png';
@@@ -129,7 +136,7 @@ async function _pageMeta(routePath) {
if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
if (p === '/content') return _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', () => _feedList('content'));
if (p === '/moments') return _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', () => _feedList('moments'));
-- if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: `/p/${x.slug}`, name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
++ if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
// Detail pages
let m;
@@@ -271,7 +278,7 @@ async function _homeMeta() {
const liveItems = live.map(s => ({ url: `/@${s.username}`, name: s.title || `${s.display_name || s.username} live`, by: s.display_name || s.username, meta: s.category || 'live' }));
const vodItems = vods.map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short }));
const clipItems = clips.map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration) }));
-- const pasteItems = pastes.map(x => ({ url: `/p/${x.slug}`, name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));
++ const pasteItems = pastes.map(x => ({ url: pasteHref(x.slug), name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text') }));
const statLine = stats ? `<p>${SITE_NAME} hosts ${stats.streamers || 0} streamers, ${stats.vods || 0} VODs, ${stats.clips || 0} clips, ${stats.pastes || 0} pastes and ${stats.chatMessages || 0} chat messages.</p>` : '';
const snapshot =
@@@ -610,9 +617,6 @@ function _base() {
}
return doc.html;
}
--// Pastes are moving to openvibe.community; when PASTES_ON_COMMUNITY=1 the SPA router sends
--// /p/<slug> there (server/index.js answers the same URL with a 301 for bots and direct hits).
--const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';
function _headBlock(meta) {
const canonical = abs(meta.canonicalPath || '/');
@@@ -702,6 +706,9 @@ async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
const p = (req.path || '/').replace(/\/+$/, '') || '/';
if (!SEO_ROUTE_RE.test(p)) return next();
++ // A paste Community owns is never rendered here (the handover answers it first; this keeps it
++ // so if the mount order ever changes).
++ if (PASTES_BASE && p.startsWith('/p/')) return next();
// Only rewrite HTML navigations, not fetch()/XHR/asset probes.
if (req.headers.accept && req.headers.accept.indexOf('text/html') === -1) return next();
try {
@@@ -761,7 +768,7 @@ async function buildSitemap() {
urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));
if (v.username) seenChannels.add(v.username);
});
-- // People's clips and pastes only: AI Moments are noindex and never listed (the /moments
++ // People's clips only: AI Moments are noindex and never listed (the /moments
// collection above is their indexable form). The filter is checked again per row, so an
// upstream that ignores it still cannot put an AI item here.
await page((l, o) => media.listClips({ limit: l, offset: o, auto_generated: 0 }).then(r => r?.clips || []), 200, SITEMAP_CAP, (c) => {
@@@ -769,10 +776,7 @@ async function buildSitemap() {
urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
if (c.username) seenChannels.add(c.username);
});
-- await page((l, o) => require('../pastes-client').listPastes({ limit: l, offset: o, visibility: 'public', origin: 'user' }).then(r => r?.pastes || []), 200, SITEMAP_CAP, (x) => {
-- if (isAiPaste(x)) return;
-- if (!Number(x.is_nsfw)) urls.push(_urlTag(`/p/${x.slug}`, isoDate(x.created_at), 'monthly', '0.4'));
-- });
++ // No /p/ here: pastes are OpenVibe.Community's, and its sitemap lists them under their canonical URL.
for (const u of seenChannels) if (u) urls.push(_urlTag(`/@${u}`, null, 'daily', '0.6'));
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`;
}
ddiff --git a/server/web/paste-handover.js b/server/web/paste-handover.js
nnew file mode 100644
iindex 00000000..3921e332
---- /dev/null
++++ b/server/web/paste-handover.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * Pastes live on openvibe.community (PASTES_ON_COMMUNITY=1): the old Live paste URLs keep working
++ * as redirects, so links in chat, search results and clipboards survive.
++ *
++ * Mounted BEFORE the SEO middleware (server/seo/seo.js). It used to be mounted after it, so every
++ * HTML navigation, crawlers included, got Live's own server-rendered copy of the paste, with a
++ * canonical on openvibe.live, while only non-HTML clients got the redirect: two indexable pages,
++ * each naming itself canonical, and a different answer for people and machines. Now every client
++ * gets the same answer and openvibe.community is the one canonical home (roadmap 32.2).
++ *
++ * Someone signed in here should arrive signed in there: they go through Community's silent sign-in
++ * (one quiet round trip; it skips itself when Community already has a valid session and falls back
++ * to the plain page when the Network session is gone). Guests and crawlers get the permanent redirect.
++ *
++ * /pastes itself stays on Live: it is the Content feed with the Pastes filter (public/js/content-feed.js).
++ */
++
++const onCommunity = () => process.env.PASTES_ON_COMMUNITY === '1';
++const communityUrl = () => (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
++
++function handOver(req, res, target) {
++ const signedIn = /(?:^|;\s*)ov_sso_hint=account(?:;|$)/.test(String(req.headers.cookie || ''));
++ if (!signedIn) return res.redirect(301, `${communityUrl()}${target}`);
++ res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie' });
++ return res.redirect(302, `${communityUrl()}/auth/login?silent=1&next=${encodeURIComponent(target)}`);
++}
++
++/** Mount the /p/:slug handover when pastes are on Community. Returns whether it was mounted. */
++function register(app) {
++ if (!onCommunity()) return false;
++ app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));
++ return true;
++}
++
++module.exports = { register, onCommunity, communityUrl };
ddiff --git a/test/paste-canonical.test.js b/test/paste-canonical.test.js
nnew file mode 100644
iindex 00000000..cbb8226b
---- /dev/null
++++ b/test/paste-canonical.test.js
@@@ -0,0 +1,116 @@
++/**
++ * One canonical page per paste (roadmap 32.2): with PASTES_ON_COMMUNITY=1, /p/<slug> on Live is a
++ * redirect to openvibe.community for every client, crawlers and browsers alike.
++ *
++ * The handover used to be mounted after the SEO middleware, so an HTML navigation (every crawler)
++ * got Live's own rendered copy with a canonical on openvibe.live, and only a non-HTML client got
++ * the 301: two self-canonical pages and a different answer for people and machines. Checked here:
++ * - server/index.js mounts server/web/paste-handover.js before server/seo/seo.js;
++ * - /p/<slug> answers 301 to Community whatever the Accept header, and a signed-in visitor goes
++ * through Community's silent sign-in (302);
++ * - Live's sitemap lists no /p/ URL, and the paste links Live renders point at Community.
++ *
++ * node test/paste-canonical.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = path.join(os.tmpdir(), `ov-paste-canonical-${process.pid}.db`);
++process.env.DB_PATH = tmp;
++process.env.NODE_ENV = 'test';
++process.env.PASTES_ON_COMMUNITY = '1';
++process.env.OV_COMMUNITY_URL = 'https://openvibe.community';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++const db = require('../server/db/database');
++db.initDb();
++
++const media = require('../server/media-client');
++media.listVods = async () => ({ vods: [] });
++media.listClips = async () => ({ clips: [] });
++media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
++const pastesClient = require('../server/pastes-client');
++const PASTES = [{ slug: 'abc123', origin: 'user', type: 'paste', title: 'Hello', content: 'hi', visibility: 'public', username: 'bob' }];
++let pasteReads = 0;
++pastesClient.getPaste = async () => { pasteReads++; return { ...PASTES[0] }; };
++pastesClient.listPastes = async (q = {}) => ({ pastes: (Number(q.offset) || 0) ? [] : PASTES.map((p) => ({ ...p })) });
++
++const express = require('express');
++const app = express();
++require('../server/web/paste-handover').register(app);
++const seo = require('../server/seo/seo');
++seo.register(app);
++app.get('*', (req, res) => res.status(200).type('html').send('<html>shell</html>'));
++
++function get(p, headers = {}) {
++ return new Promise((resolve, reject) => {
++ const req = http.request(base + p, { method: 'GET', headers }, (res) => {
++ let body = '';
++ res.on('data', (c) => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location || null, body }));
++ });
++ req.on('error', reject);
++ req.end();
++ });
++}
++
++let base;
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' ✓', name); }
++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.message); }
++}
++
++const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
++ base = `http://127.0.0.1:${server.address().port}`;
++ quiet('Paste canonical split');
++
++ await check('server/index.js mounts the paste handover before the SEO middleware', () => {
++ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
++ const handover = src.indexOf("require('./web/paste-handover').register(app)");
++ const seoAt = src.indexOf("require('./seo/seo').register(app)");
++ assert.ok(handover > 0, 'the handover is mounted');
++ assert.ok(seoAt > 0, 'SEO is mounted');
++ assert.ok(handover < seoAt, 'handover first');
++ assert.ok(!/app\.get\('\/p\/:slug',/.test(src), 'no second /p/:slug handler in index.js');
++ });
++
++ await check('/p/<slug> is a 301 to Community for crawlers, browsers and curl alike', async () => {
++ for (const accept of ['text/html,application/xhtml+xml', '*/*', undefined]) {
++ const r = await get('/p/abc123', accept ? { accept, 'user-agent': 'Googlebot/2.1' } : {});
++ assert.strictEqual(r.status, 301, `Accept ${accept}: ${r.status}`);
++ assert.strictEqual(r.location, 'https://openvibe.community/p/abc123');
++ }
++ assert.strictEqual(pasteReads, 0, 'Live never rendered its own copy');
++ });
++
++ await check('a signed-in visitor goes through Community\'s silent sign-in', async () => {
++ const r = await get('/p/abc123', { accept: 'text/html', cookie: 'ov_sso_hint=account' });
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.location, 'https://openvibe.community/auth/login?silent=1&next=%2Fp%2Fabc123');
++ });
++
++ await check('Live\'s sitemap lists no paste', async () => {
++ const xml = await seo.buildSitemap();
++ assert.ok(!/<loc>[^<]*\/p\//.test(xml), 'a /p/ URL in the sitemap');
++ assert.ok(xml.includes('/pastes<'), 'the Content feed\'s Pastes filter is still a Live page');
++ });
++
++ await check('paste links Live renders point at the canonical Community page', async () => {
++ const m = await seo._pageMeta('/pastes');
++ assert.ok(m.snapshot.includes('href="https://openvibe.community/p/abc123"'), m.snapshot.slice(0, 400));
++ assert.ok(!m.snapshot.includes('https://openvibe.live/p/'), 'no Live paste link');
++ });
++
++ server.close();
++ try { fs.unlinkSync(tmp); } catch { /* */ }
++ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
++ quiet(failures ? `\n${failures} check(s) failed` : '\npaste canonical: all checks passed');
++ process.exit(failures ? 1 : 0);
++});
ddiff --git a/test/seo-moments.test.js b/test/seo-moments.test.js
iindex 271ae8c4..5c7211e7 100644
---- a/test/seo-moments.test.js
++++ b/test/seo-moments.test.js
@@@ -197,7 +197,7 @@ async function check(name, fn) {
const xml = await seo.buildSitemap();
assert.ok(xml.includes('/clip/21<'), "a person's clip is listed");
assert.ok(!xml.includes('/clip/20<') && !xml.includes('/clip/22<'), 'no auto-clip');
-- assert.ok(!xml.includes('/p/ai-shot<') && !xml.includes('/p/ai-live<'), 'no AI paste');
++ assert.ok(!/<loc>[^<]*\/p\//.test(xml), 'no paste at all: Community is their canonical home');
assert.ok(xml.includes('/moments<'), 'the Moments collection is the indexable form');
assert.ok(clipQueries.some((q) => q.auto_generated === 0), 'asked Media for people\'s clips');
});