openvibers/openvibe.space Commits

SEO-7: a gated space page answers on openvibe.space with noindex, not by redirecting to Network sign-in (#116)

OP OpenVibers committed 15 days ago · c874f92878 · 3 files, +38 −4

Anonymous GETs of /spaces/new, /spaces/mine and /spaces/:slug/edit (and the connect page) threw
401, which failPage turned into a 303 to /auth/login → openvibe.network/oauth/authorize. The
page a crawler ended on says <link rel="canonical" href="https://openvibe.network/login">, which
tools/seo-audit.js reported as openvibe.space's canonical.

failPage now renders render.signInPage(): 401, robots noindex,nofollow, a canonical to the page's
own address, and a "Sign in with OpenVibe" link to /auth/login?next=…. A form POST still
redirects, because its session ended and sign-in brings the person back.

test/spaces.test.js asserts the rule for one gated route (/spaces/new) and one public route
(/spaces keeps its self-canonical).

Changes

ddiff --git a/server/spaces/pages.js b/server/spaces/pages.js
iindex 5c7b167..b6583dd 100644
---- a/server/spaces/pages.js
++++ b/server/spaces/pages.js
@@@ -65,7 +65,12 @@ function createSpacesPages(ctx) {
const csrfOf = (req) => (signedIn(req) ? csrfToken(config, req.viewer) : '');
const html = (res, status, body) => res.status(status).type('html').send(body);
function failPage(req, res, err) {
-- if (err && err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ // A gated page answers on this origin with noindex, so a crawler is never handed Network sign-in's
++ // canonical (audit SEO-7). A form still redirects: its session ended, and sign-in brings the person back.
++ if (err && err.status === 401) {
++ if (req.method !== 'GET') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ return html(res, 401, render.signInPage({ path: req.path, next: req.originalUrl }));
++ }
if (!(err instanceof ApiError) && !(err && Number.isInteger(err.status) && err.code)) log.error('[Spaces]', err && err.stack ? err.stack : err);
const status = err && Number.isInteger(err.status) ? err.status : 500;
return html(res, status, render.errorBody(status, status === 500 ? 'Something went wrong on our side. Try again in a moment.' : err.message));
ddiff --git a/server/spaces/render.js b/server/spaces/render.js
iindex 29897f4..f5a06e3 100644
---- a/server/spaces/render.js
++++ b/server/spaces/render.js
@@@ -235,8 +235,22 @@ function connectPage({ viewer, space, csrf, challenge, state, returnPath }) {
return page({ title: `Sign in to ${space.name}`, path: `/spaces/${space.slug}/connect`, robots: 'noindex,nofollow', body });
}
++/**
++ * A gated page's answer on this origin (audit SEO-7): noindex, a canonical to the page itself, and a link
++ * to Network sign-in. Never a redirect to sign-in, which handed a crawler openvibe.network's canonical.
++ */
++function signInPage({ path, next, title = 'Sign in to continue' }) {
++ const body = `<div class="sp-wrap">
++ <p class="eyebrow">OpenVibe.Space</p>
++ <h1 class="sp-title">${esc(title)}</h1>
++ <p class="sp-lede">Spaces are made with your OpenVibe account. Sign in and you come straight back here.</p>
++ <p><a class="sp-btn" href="${esc(signInHref(next))}">Sign in with OpenVibe</a></p>
++</div>`;
++ return page({ title, path, robots: 'noindex,nofollow', body });
++}
++
function errorBody(status, message) {
return page({ title: status === 404 ? 'Space not found' : 'Spaces', path: '/spaces', robots: 'noindex,nofollow', body: `<div class="sp-wrap"><p class="eyebrow">Error ${Number(status)}</p><h1 class="sp-title">${status === 404 ? 'No such space' : 'That did not work'}</h1><p class="sp-lede">${esc(message)}</p><p><a class="sp-btn" href="/spaces">Back to spaces</a></p></div>` });
}
--module.exports = { galleryPage, newPage, minePage, spacePage, editorPage, connectPage, errorBody, CAP_TEXT };
++module.exports = { galleryPage, newPage, minePage, spacePage, editorPage, connectPage, signInPage, errorBody, CAP_TEXT };
ddiff --git a/test/spaces.test.js b/test/spaces.test.js
iindex 5dad38f..7e01ecb 100644
---- a/test/spaces.test.js
++++ b/test/spaces.test.js
@@@ -55,6 +55,19 @@ function fakeAi() {
assert.doesNotMatch(r.text, /name="template"/, 'no start form for someone signed out');
});
++ await check('SEO-7: a gated page answers here with noindex and never another origin as its canonical', async () => {
++ const gated = await t.api('GET', '/spaces/new');
++ assert.strictEqual(gated.status, 401);
++ assert.match(gated.text, /<meta name="robots" content="noindex,nofollow">/);
++ assert.match(gated.text, /href="\/auth\/login\?next=%2Fspaces%2Fnew"/, 'one click from signing in');
++ const canon = (/<link rel="canonical" href="([^"]+)"/.exec(gated.text) || [])[1];
++ assert.ok(!canon || new URL(canon).origin === 'https://openvibe.space', `canonical ${canon}`);
++ // The other half of the rule: a public page keeps its own canonical, on its own origin.
++ const pub = await t.api('GET', '/spaces');
++ assert.strictEqual(pub.status, 200);
++ assert.match(pub.text, /<link rel="canonical" href="https:\/\/openvibe\.space\/spaces">/);
++ });
++
await check('a person starts a space from a template and lands in its editor', async () => {
const r = await form(alice, '/spaces/new', { template: 'guestbook' });
assert.strictEqual(r.status, 303, r.text);
@@@ -75,7 +88,9 @@ function fakeAi() {
assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: bob })).status, 404, 'a stranger does not see a draft');
assert.strictEqual((await t.api('GET', `/spaces/${slug}/edit`, { session: bob })).status, 404);
assert.strictEqual((await t.api('GET', `/spaces/${slug}`, { session: alice })).status, 200);
-- assert.strictEqual((await t.api('GET', `/spaces/${slug}/edit`)).status, 303, 'signed out: to sign-in');
++ const gate = await t.api('GET', `/spaces/${slug}/edit`);
++ assert.strictEqual(gate.status, 401, 'signed out: a gate page here, noindex, not a redirect off the site (SEO-7)');
++ assert.match(gate.text, /<meta name="robots" content="noindex,nofollow">/);
const forged = await t.api('POST', '/spaces/new', { session: alice, form: { csrf: 'nope', template: 'poll' }, headers: { origin: ORIGIN } });
assert.strictEqual(forged.status, 403);
const crossSite = await t.api('PUT', `/spaces/${slug}/files`, { session: alice, json: { files: [] }, headers: { origin: 'https://evil.example', 'x-csrf-token': csrf(alice) } });
@@@ -159,7 +174,7 @@ function fakeAi() {
let bobToken = null;
await check('a visitor approves the space on the connect page and comes back with a one-time code', async () => {
const q = `challenge=${challenge}&state=st4te-abc&return=${encodeURIComponent('/game?x=1')}`;
-- assert.strictEqual((await t.api('GET', `/spaces/${slug}/connect?${q}`)).status, 303, 'signed out: to sign-in first');
++ assert.strictEqual((await t.api('GET', `/spaces/${slug}/connect?${q}`)).status, 401, 'signed out: sign in here first (a gate page, never a cross-origin redirect)');
const page = await t.api('GET', `/spaces/${slug}/connect?${q}`, { session: bob });
assert.strictEqual(page.status, 200);
assert.match(page.text, /Ask OpenVibe AI for you/);