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
Changes
@@@ -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.@@@ -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 pageslet 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>`;}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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);++});@@@ -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');});
@@ -527,6 +527,9 @@ app.use('/fragments', assets.versionedStatic('/fragments'), express.static(path.
@@ -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
// 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
// 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.
// 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); }
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
// 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.
// 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) => {
@@ -626,23 +629,6 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
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)
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
// against THIS origin — bounce them to the Media public host, where the
// against THIS origin — bounce them to the Media public host, where the
// canonical paste page lives.
// canonical paste page lives.
@@ -45,7 +45,7 @@ const _feedList = (feed) => _cached(`feed:${feed}`, async () => {
@@ -45,7 +45,7 @@ const _feedList = (feed) => _cached(`feed:${feed}`, async () => {
const out = await require('../content/feed').page(feed, { limit: 24 });
const out = await require('../content/feed').page(feed, { limit: 24 });
const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
return (out.items || []).map((it) => ({
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(' · '),
meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
desc: it.excerpt,
desc: it.excerpt,
}));
}));
@@ -71,6 +71,13 @@ function _overlayAiState(row, kind) {
@@ -71,6 +71,13 @@ function _overlayAiState(row, kind) {
} catch { /* */ }
} 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 SITE_NAME = 'OpenVibe.Live';
const DEFAULT_OG_IMAGE = '/og-image.png';
const DEFAULT_OG_IMAGE = '/og-image.png';
@@ -129,7 +136,7 @@ async function _pageMeta(routePath) {
@@ -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 === '/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 === '/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 === '/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
// Detail pages
let m;
let m;
@@ -271,7 +278,7 @@ async function _homeMeta() {
@@ -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 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 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 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 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 =
const snapshot =
@@ -610,9 +617,6 @@ function _base() {
@@ -610,9 +617,6 @@ function _base() {
}
}
return doc.html;
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) {
function _headBlock(meta) {
const canonical = abs(meta.canonicalPath || '/');
const canonical = abs(meta.canonicalPath || '/');
@@ -702,6 +706,9 @@ async function middleware(req, res, next) {
@@ -702,6 +706,9 @@ async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
if (req.method !== 'GET') return next();
const p = (req.path || '/').replace(/\/+$/, '') || '/';
const p = (req.path || '/').replace(/\/+$/, '') || '/';
if (!SEO_ROUTE_RE.test(p)) return next();
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.
// Only rewrite HTML navigations, not fetch()/XHR/asset probes.
if (req.headers.accept && req.headers.accept.indexOf('text/html') === -1) return next();
if (req.headers.accept && req.headers.accept.indexOf('text/html') === -1) return next();
try {
try {
@@ -761,7 +768,7 @@ async function buildSitemap() {
@@ -761,7 +768,7 @@ async function buildSitemap() {
urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));
urls.push(_urlTag(`/vod/${v.id}`, isoDate(v.created_at), 'weekly', '0.6'));
if (v.username) seenChannels.add(v.username);
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
// 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.
// 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) => {
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() {
@@ -769,10 +776,7 @@ async function buildSitemap() {
urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
urls.push(_urlTag(`/clip/${c.id}`, isoDate(c.created_at), 'weekly', '0.6'));
if (c.username) seenChannels.add(c.username);
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'));
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>`;
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>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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);
+});
@@ -197,7 +197,7 @@ async function check(name, fn) {
@@ -197,7 +197,7 @@ async function check(name, fn) {
const xml = await seo.buildSitemap();
const xml = await seo.buildSitemap();
assert.ok(xml.includes('/clip/21<'), "a person's clip is listed");
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('/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(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');
assert.ok(clipQueries.some((q) => q.auto_generated === 0), 'asked Media for people\'s clips');
});
});