openvibers/openvibe.live Commits

SEO-29: noindex pastes and send X-Robots-Tag on noindex pages (#441)

OP OpenVibers committed 15 days ago · 28c9ffd4ae · 5 files, +53 −14

A paste is link-shared content, so its fallback page meta is now noindex
(public ones were index,follow). In production /p/:slug 301s to
OpenVibe.Community before this runs. Every noindex HTML response from the SEO
renderer or the SPA shell now also carries an X-Robots-Tag whose value
matches the page's robots meta tag.

Changes

ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 604f999d..8e332a65 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -17,6 +17,7 @@
'use strict';
const seo = require('openvibe-shared/seo');
const { esc } = seo;
++const { setRobotsHeader } = require('../web/robots-header');
const db = require('../db/database');
const media = require('../media-client');
let config = null; try { config = require('../config'); } catch { /* */ }
@@@ -618,8 +619,9 @@ function _authorLd(name) { return name ? { '@type': 'Person', name: clean(name,
// as AI-made, attributed to no person, kept out of search (noindex,follow: the page still passes
// its links on) and made canonical to its source: the VOD at the moment it came from, when that
// VOD is known and public. The store decides what is AI (Media's auto_generated, Community's
--// origin, the recap's own ai flag); nothing is inferred here. People's clips and pastes keep their
--// normal, indexable pages.
++// origin, the recap's own ai flag); nothing is inferred here. People's clips keep their normal,
++// indexable pages; pastes are link-shared and noindex (SEO-29), and their canonical page is
++// OpenVibe.Community's.
const AI_ROBOTS = 'noindex,follow';
const AI_KEYWORDS = 'AI-generated';
const VOD_MOMENT_RE = /^\/vod\/(\d+)(?:\?t=(\d+(?:\.\d+)?))?$/;
@@@ -656,8 +658,9 @@ async function _pasteMeta(slug) {
if (!p) return null;
if (isAiPaste(p)) return await _aiPasteMeta(p, slug);
const isScreenshot = p.type === 'screenshot';
-- // Only public, non-NSFW, non-burn pastes are indexable.
-- const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
++ // A paste is link-shared content, not a page meant to be found (SEO-29): noindex whether or
++ // not it is public. In production /p/:slug 301s to OpenVibe.Community before this runs; this
++ // keeps the answer right if the mount order ever changes.
const author = p.display_name || p.username;
const title = clean(p.title || (isScreenshot ? 'Screenshot' : 'Paste'), 80);
const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);
@@@ -676,7 +679,7 @@ async function _pasteMeta(slug) {
});
return {
title: `${title} — paste | ${SITE_NAME}`, description: desc,
-- canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: indexable ? 'index,follow' : 'noindex,follow',
++ canonicalPath, image, ogType: isScreenshot ? 'article' : 'article', robots: 'noindex',
jsonLd: [ld, _breadcrumb([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: canonicalPath }])],
snapshot,
};
@@@ -946,6 +949,7 @@ async function middleware(req, res, next) {
// This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
const send = (html, ttl) => {
++ setRobotsHeader(res, html);
res.set('Cache-Control', `public, max-age=${Math.max(0, Math.round(ttl / 1000))}, must-revalidate`);
res.set('Link', seo.linkHeaderTags(abs(key), `${baseUrl()}/llms.txt`).header);
res.set('Vary', 'Accept');
ddiff --git a/server/web/robots-header.js b/server/web/robots-header.js
nnew file mode 100644
iindex 00000000..16ac9302
---- /dev/null
++++ b/server/web/robots-header.js
@@@ -0,0 +1,28 @@
++'use strict';
++/**
++ * Copy a page's noindex rule into an X-Robots-Tag response header.
++ *
++ * Google's 2026-10-26 UGC guidance asks a platform to keep link-shared pages out of Search with
++ * "a robots meta tag or X-Robots-Tag HTTP response header"; sending both means a client that never
++ * parses the head (or a proxy that strips it) still sees the rule. The value mirrors the meta tag,
++ * so the page and the response never disagree.
++ */
++const ROBOTS_TAG_RE = /<meta[^>]*\bname=["']robots["'][^>]*>/i;
++const CONTENT_RE = /\bcontent=["']([^"']*)["']/i;
++const NOINDEX_RE = /(?:^|[\s,;])noindex(?:[\s,;]|$)/i;
++
++/** The page's robots content when it says noindex, else null. */
++function noindexOf(html) {
++ const tag = ROBOTS_TAG_RE.exec(String(html || ''));
++ const content = tag && CONTENT_RE.exec(tag[0]);
++ return content && NOINDEX_RE.test(content[1]) ? content[1].replace(/\s+/g, '') : null;
++}
++
++/** Set X-Robots-Tag on `res` when `html` carries a noindex robots meta tag. */
++function setRobotsHeader(res, html) {
++ const robots = noindexOf(html);
++ if (robots) res.set('X-Robots-Tag', robots);
++ return robots;
++}
++
++module.exports = { setRobotsHeader, noindexOf };
ddiff --git a/server/web/shell.js b/server/web/shell.js
iindex 0b213a9f..7797cd8e 100644
---- a/server/web/shell.js
++++ b/server/web/shell.js
@@@ -1,4 +1,5 @@
'use strict';
++const { setRobotsHeader } = require('./robots-header');
/**
* sendShell for the SPA fallback (server/web/page-status.js spaFallback): the page shell from seo.shellHtml for a URL,
@@@ -15,6 +16,7 @@ function createSendShell({ shellHtml, sendDocument, assets }) {
try { html = shellHtml ? await shellHtml(urlPath, res.statusCode) : null; } catch { html = null; }
if (typeof html !== 'string' || !html) return sendDocument(res, 'index.html', urlPath);
if (!res.getHeader('Cache-Control')) assets.setNoCache(res);
++ setRobotsHeader(res, html);
try { res.setHeader('Content-Security-Policy-Report-Only', assets.cspReportOnly(html)); } catch { /* the header is optional */ }
res.type('html').send(html);
return true;
ddiff --git a/test/seo-moments.test.js b/test/seo-moments.test.js
iindex 5cb0d2b6..36d23a35 100644
---- a/test/seo-moments.test.js
++++ b/test/seo-moments.test.js
@@@ -126,9 +126,9 @@ console.log('AI Moments pages');
assertNoPerson(m, 'live AI paste');
});
-- await check("a person's paste stays indexable and credited", async () => {
++ await check("a person's paste is still credited but noindex (link-shared, canonical at Community)", async () => {
const m = await seo._pageMeta('/p/human');
-- assert.strictEqual(m.robots, 'index,follow');
++ assert.strictEqual(m.robots, 'noindex');
assert.strictEqual(m.canonicalPath, '/p/human');
assert.deepStrictEqual(mainLd(m).author, { '@type': 'Person', name: 'Bob' });
});
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex 10e27a33..d8e17d3b 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -82,12 +82,12 @@ const pageStatus = require('../server/web/page-status');
const app = express();
require('../server/web/video-handover').register(app); // before SEO, as server/index.js mounts it
seo.register(app);
--app.get('*', pageStatus.spaFallback(async (res, urlPath) => {
-- const html = await seo.shellHtml(urlPath, res.statusCode);
-- if (!html) return false;
-- res.type('html').send(html);
-- return true;
--}));
++const sendShell = require('../server/web/shell').createSendShell({
++ shellHtml: seo.shellHtml,
++ sendDocument: async () => false,
++ assets: { setNoCache: (res) => res.setHeader('Cache-Control', 'no-cache'), cspReportOnly: () => '' },
++});
++app.get('*', pageStatus.spaFallback((res, urlPath) => sendShell(res, urlPath)));
let base;
function get(p, headers = {}) {
@@@ -143,6 +143,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(r.body.includes('<meta property="og:url" content="https://openvibe.live/@alice">'), c.name);
assert.ok(r.body.includes('<meta property="og:type" content="profile">'), c.name);
assert.deepStrictEqual(robotsOf(r.body), ['index,follow'], c.name);
++ assert.strictEqual(r.headers['x-robots-tag'], undefined, `${c.name}: an indexable page sends no X-Robots-Tag`);
assert.ok(ldOf(r.body).some((n) => n['@type'] === 'ProfilePage' && n.mainEntity.name === 'Alice'), c.name);
}
});
@@@ -184,8 +185,10 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(!b2.includes('<h2>AI Moments</h2>'), 'clips are on page 1 only');
const b3 = bodyOf((await get('/@alice?page=3')).body);
assert.ok(b3.includes('/v/129"') && !b3.includes('rel="next"'), 'the last page has no next link');
-- const past = (await get('/@alice?page=9')).body;
++ const pastRes = await get('/@alice?page=9');
++ const past = pastRes.body;
assert.deepStrictEqual(robotsOf(past), ['noindex,follow']);
++ assert.strictEqual(pastRes.headers['x-robots-tag'], 'noindex,follow');
assert.deepStrictEqual(canonicalsOf(past), ['https://openvibe.live/@alice']);
const junk = (await get('/@alice?page=abc')).body;
assert.deepStrictEqual(canonicalsOf(junk), ['https://openvibe.live/@alice'], 'a non-number is page 1');
@@@ -210,6 +213,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.strictEqual(r.status, 404, `${p} ${c.name}`);
assert.deepStrictEqual(canonicalsOf(r.body), [], `${p}: no canonical`);
assert.deepStrictEqual(robotsOf(r.body), ['noindex'], `${p}: noindex`);
++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex', `${p}: X-Robots-Tag`);
assert.strictEqual(titleOf(r.body), 'Page not found — OpenVibe.Live', p);
assert.ok(!/og:url|og:title|application\/ld\+json/.test(head(r.body)), `${p}: home OG/JSON-LD left in`);
assert.ok(bodyOf(r.body).includes('<h1>Page not found</h1>'), `${p}: a not-found body`);
@@@ -225,6 +229,7 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
const search = (await get('/search?q=minecraft')).body;
assert.deepStrictEqual(canonicalsOf(search), ['https://openvibe.live/search'], 'every query is the one search page');
assert.ok(/<meta name="robots" content="noindex,follow">/.test(head(search)));
++ assert.strictEqual((await get('/search?q=minecraft')).headers['x-robots-tag'], 'noindex,follow');
});
await check('/documentation and /updates have their own title, description and canonical (they repeated the home page\'s)', async () => {